mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84a7405bb5 | ||
|
|
8ef3195457 | ||
|
|
d088140c0d | ||
|
|
268babc257 | ||
|
|
3429de0020 | ||
|
|
f2232c3ed7 | ||
|
|
fc468a021d | ||
|
|
1d70ca47a8 |
No files matched your search
@@ -16,41 +16,42 @@ Horizon is a Rust rewrite of Minecraft Java **26.3** targeting one-to-one vanill
|
||||
|
||||
Parity is **not** complete. Still missing: the warden and the sculk family, the breeze, sniffer, creaking, happy ghast, llamas and a few other mobs, raids and pillager patrols, villager breeding and gossip, the loom, crafter and recipe book, scoreboards, subtitles, and spawned mob gear enchantments. Multiplayer joins vanilla servers but Horizon cannot host one. See [Rust rewrite status](docs/rust-parity.md).
|
||||
|
||||
### Java parity: 80%
|
||||
### Java parity: 84%
|
||||
|
||||
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
|
||||
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
|
||||
and verified against vanilla by differential tests). The percentage is the
|
||||
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
|
||||
2026-10-10 against the code, not the docs: of the 232 `tools/test_*_parity.py`
|
||||
differential harnesses, 223 pass, 8 fail and the item model harness runs out of
|
||||
memory; the failures are new harnesses that still find divergences.
|
||||
2026-10-10 against the code, not the docs. The harnesses that failed in the
|
||||
previous audit (block updates, random ticks, redstone, command parsing, menu
|
||||
clicks, whole chunks, entity models and item models) now pass; the save
|
||||
round-trip harness has not been rerun.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
| Window, input and game loop | 0.75 | Game loop and options match vanilla; camera, auto-jump, toggle sprint/sneak and the frame limiter not compared |
|
||||
| Assets and resources | 0.75 | Item model pipeline not yet used for all GUI icons; item model harness does not finish; no pack screen |
|
||||
| Assets and resources | 0.75 | Item models match vanilla over 196,290 stacks, but the client does not draw items through that pipeline yet; no pack screen |
|
||||
| Network protocol and multiplayer | 0.75 | Opens worlds to LAN, but hosting is not tested against a vanilla client; no dedicated server mode |
|
||||
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
|
||||
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
|
||||
| Rendering | 0.75 | Entity model harness fails; block entity renderers and HUD overlays not compared |
|
||||
| Rendering | 0.75 | Entity models match over 150 layers; block entity renderers and HUD overlays not compared |
|
||||
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
|
||||
| World generation | 0.75 | Whole-chunk harness still finds single-block differences near camps, shipwrecks and villages |
|
||||
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
|
||||
| Region files and saves | 0.75 | Save round-trip harness fails; older DataVersions are not upgraded |
|
||||
| Entities and physics | 0.75 | Attributes and dimensions match; AI, loot and spawn rules not compared; drowned, villagers, traders and the wither unverified |
|
||||
| Interaction and inventory | 0.75 | Menu click harness fails |
|
||||
| Menus, HUD and chat | 0.75 | Command parse harness fails; no /item modify, /spectate, /waypoint or /dialog; no tab list or world border rendering |
|
||||
| Interaction and inventory | 1 | None: menu clicks match over 6,000 click sequences |
|
||||
| Menus, HUD and chat | 0.75 | Commands parse as vanilla over 39,685 inputs, but /item modify, /spectate, /waypoint and /dialog do not run; no tab list or world border rendering |
|
||||
| Audio | 0.75 | Sound engine, music and ambience not compared with vanilla |
|
||||
| Block behaviours and ticking | 0.75 | Block update, random tick and redstone harnesses fail; a few dispenser behaviours missing |
|
||||
| Block behaviours and ticking | 0.75 | Block updates, random ticks and redstone match vanilla; dispensers lack several behaviours |
|
||||
|
||||
### Next steps
|
||||
|
||||
1. Fix the divergences the failing harnesses report: block updates, random ticks, redstone, command parsing, menu clicks, save round-trip, whole chunks and entity models.
|
||||
2. Let the item model harness finish, and draw every GUI icon through the item model pipeline.
|
||||
1. Rerun the save round-trip harness and upgrade saves from older DataVersions.
|
||||
2. Draw every item through the item model pipeline, and add the resource pack screen.
|
||||
3. An audio harness: sound engine attenuation, weighted sound events, music and ambience.
|
||||
4. Mob AI, loot and spawn-rule harnesses, and an oracle for drowned, villagers, traders and the wither.
|
||||
5. The missing commands, the tab list and world border rendering.
|
||||
6. Test LAN hosting with a vanilla client, and upgrade saves from older DataVersions.
|
||||
5. The missing commands and dispenser behaviours, the tab list and world border rendering.
|
||||
6. Test LAN hosting with a vanilla client, and add a dedicated server mode.
|
||||
|
||||
```sh
|
||||
python3 tools/prepare_native_client.py --prepare # needs JDK 25; extracts private vanilla resources
|
||||
|
||||
@@ -6,6 +6,7 @@ use horizon_assets::{
|
||||
item_model::*,
|
||||
math::Mat4,
|
||||
model::{CuboidModel, Direction},
|
||||
palette::{PalettedPermutations, apply_palette},
|
||||
resolve::{MISSING_MODEL, ModelLibrary},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -23,6 +24,8 @@ fn bits(v: f32) -> u32 {
|
||||
|
||||
struct Sprites {
|
||||
root: PathBuf,
|
||||
/// The items atlas's paletted permutations (armour trim sprites).
|
||||
permutations: Vec<PalettedPermutations>,
|
||||
cache: RefCell<BTreeMap<String, Option<SpriteMask>>>,
|
||||
}
|
||||
impl SpriteLookup for Sprites {
|
||||
@@ -38,27 +41,48 @@ impl SpriteLookup for Sprites {
|
||||
}
|
||||
}
|
||||
impl Sprites {
|
||||
fn load(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
let (namespace, path) = sprite.split_once(':')?;
|
||||
let png = self
|
||||
.root
|
||||
.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(&png).ok()?));
|
||||
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
|
||||
let (namespace, path) = sprite.split_once(':').unwrap_or(("minecraft", sprite));
|
||||
self.root
|
||||
.join(format!("assets/{namespace}/{directory}/{path}.png"))
|
||||
}
|
||||
/// The image as ARGB pixels in reading order, with its size.
|
||||
fn argb(png: &Path) -> Option<(usize, usize, Vec<u32>)> {
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(png).ok()?));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size()?];
|
||||
let info = reader.next_frame(&mut bytes).ok()?;
|
||||
let channels = info.color_type.samples();
|
||||
let alpha = |pixel: &[u8]| match info.color_type {
|
||||
png::ColorType::Rgba => pixel[3],
|
||||
png::ColorType::GrayscaleAlpha => pixel[1],
|
||||
_ => 255,
|
||||
};
|
||||
let (iw, ih) = (info.width as usize, info.height as usize);
|
||||
let pixels: Vec<u8> = bytes[..info.buffer_size()]
|
||||
let pixels = bytes[..info.buffer_size()]
|
||||
.chunks_exact(channels)
|
||||
.map(alpha)
|
||||
.map(|p| {
|
||||
let (r, g, b, a) = match info.color_type {
|
||||
png::ColorType::Rgba => (p[0], p[1], p[2], p[3]),
|
||||
png::ColorType::Rgb => (p[0], p[1], p[2], 255),
|
||||
png::ColorType::GrayscaleAlpha => (p[0], p[0], p[0], p[1]),
|
||||
_ => (p[0], p[0], p[0], 255),
|
||||
};
|
||||
u32::from_be_bytes([a, r, g, b])
|
||||
})
|
||||
.collect();
|
||||
Some((info.width as usize, info.height as usize, pixels))
|
||||
}
|
||||
fn load(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
let png = self.png(sprite, "textures");
|
||||
let (iw, ih, argb) = match Self::argb(&png) {
|
||||
Some(image) => image,
|
||||
None => {
|
||||
// PalettedPermutations: the base texture through PaletteMapping.
|
||||
let p = self.permutations.iter().find_map(|s| s.find(sprite))?;
|
||||
let (w, h, mut pixels) = Self::argb(&self.png(p.base, "textures"))?;
|
||||
let (_, _, key) = Self::argb(&self.png(p.palette_key, "textures/palettes"))?;
|
||||
let (_, _, target) = Self::argb(&self.png(p.palette, "textures/palettes"))?;
|
||||
apply_palette(&mut pixels, &key, &target)?;
|
||||
(w, h, pixels)
|
||||
}
|
||||
};
|
||||
let pixels: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
|
||||
let meta = fs::read_to_string(png.with_extension("png.mcmeta")).ok();
|
||||
let animation = meta
|
||||
.and_then(|m| serde_json::from_str::<Value>(&m).ok())
|
||||
@@ -215,6 +239,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let library = ModelLibrary::with_item_generator(cuboids)?;
|
||||
let sprites = Sprites {
|
||||
root: root.clone(),
|
||||
permutations: PalettedPermutations::from_atlas(
|
||||
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json")).unwrap_or_default(),
|
||||
),
|
||||
cache: RefCell::default(),
|
||||
};
|
||||
let grass = {
|
||||
|
||||
@@ -342,9 +342,11 @@ fn optional_transformation(m: &Map<String, Value>) -> Result<Option<Mat4>> {
|
||||
get(m, "transformation").map(transformation).transpose()
|
||||
}
|
||||
/// `Mth.floor(float)` as `ARGB.as8BitChannel` uses it.
|
||||
/// `ARGB.as8BitChannel`: `Mth.floor(value * 255.0F)`.
|
||||
fn channel(v: f32) -> i32 {
|
||||
let i = v as i32;
|
||||
if (i as f32) > v { i - 1 } else { i }
|
||||
let scaled = v * 255.0;
|
||||
let i = scaled as i32;
|
||||
if (i as f32) > scaled { i - 1 } else { i }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -207,9 +207,7 @@ impl SpecialModel {
|
||||
}
|
||||
SpecialKind::PlayerHead => "player_head",
|
||||
SpecialKind::ShulkerBox { texture, openness } => {
|
||||
if texture != "minecraft:shulker" {
|
||||
put("texture", json!(texture));
|
||||
}
|
||||
put("texture", json!(texture));
|
||||
if *openness != 0.0 {
|
||||
put("openness", json!(openness));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod item_model;
|
||||
mod json;
|
||||
pub mod math;
|
||||
pub mod model;
|
||||
pub mod palette;
|
||||
pub mod resolve;
|
||||
|
||||
pub use json::DecodeError;
|
||||
@@ -1,31 +1,88 @@
|
||||
//! Column-major `Matrix4f` arithmetic with the operand order of JOML's
|
||||
//! default (non-FMA) code paths, for composing item-model transformations.
|
||||
//! Column-major `Matrix4f` arithmetic as JOML 1.10.9 computes it, for composing
|
||||
//! item-model transformations: each operation takes the path JOML picks from
|
||||
//! the matrix properties (identity, translation, affine...), and `Math.fma` is
|
||||
//! `a * b + c` since Minecraft leaves `joml.useMathFma` off.
|
||||
|
||||
/// A JOML `Matrix4f`: `m[column][row]`.
|
||||
/// `Matrix4fc.PROPERTY_*` bits.
|
||||
const PERSPECTIVE: u8 = 1;
|
||||
const AFFINE: u8 = 2;
|
||||
const IDENTITY: u8 = 4;
|
||||
const TRANSLATION: u8 = 8;
|
||||
const ORTHONORMAL: u8 = 16;
|
||||
|
||||
/// JOML `Math.fma` without `joml.useMathFma`.
|
||||
#[inline]
|
||||
fn fma(a: f32, b: f32, c: f32) -> f32 {
|
||||
a * b + c
|
||||
}
|
||||
/// JOML `Math.absEqualsOne`.
|
||||
fn abs_equals_one(r: f32) -> bool {
|
||||
r.to_bits() & 0x7FFF_FFFF == 0x3F80_0000
|
||||
}
|
||||
|
||||
/// A JOML `Matrix4f`: `m[column][row]`, with its property bits.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Mat4(pub [[f32; 4]; 4]);
|
||||
pub struct Mat4(pub [[f32; 4]; 4], pub u8);
|
||||
|
||||
impl Mat4 {
|
||||
pub const IDENTITY: Self = Self([
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]);
|
||||
/// `new Matrix4f()`.
|
||||
pub const IDENTITY: Self = Self(
|
||||
[
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
],
|
||||
IDENTITY | AFFINE | TRANSLATION | ORTHONORMAL,
|
||||
);
|
||||
/// `Matrix4f.translation(x, y, z)`.
|
||||
pub fn translation(t: [f32; 3]) -> Self {
|
||||
let mut m = Self::IDENTITY;
|
||||
m.0[3][..3].copy_from_slice(&t);
|
||||
m.1 = AFFINE | TRANSLATION | ORTHONORMAL;
|
||||
m
|
||||
}
|
||||
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order.
|
||||
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order, then
|
||||
/// `determineProperties`.
|
||||
pub fn from_row_major(values: [f32; 16]) -> Self {
|
||||
let mut m = Self([[0.0; 4]; 4]);
|
||||
let mut m = Self([[0.0; 4]; 4], 0);
|
||||
for (i, v) in values.into_iter().enumerate() {
|
||||
m.0[i & 3][i >> 2] = v;
|
||||
}
|
||||
m.1 = m.determine_properties();
|
||||
m
|
||||
}
|
||||
/// `Matrix4f.determineProperties`.
|
||||
fn determine_properties(&self) -> u8 {
|
||||
let m = &self.0;
|
||||
let mut properties = 0;
|
||||
if m[0][3] == 0.0 && m[1][3] == 0.0 {
|
||||
if m[2][3] == 0.0 && m[3][3] == 1.0 {
|
||||
properties |= AFFINE;
|
||||
if m[0] == [1.0, 0.0, 0.0, 0.0] && m[1] == [0.0, 1.0, 0.0, 0.0] && m[2] == [0.0, 0.0, 1.0, 0.0] {
|
||||
properties |= TRANSLATION | ORTHONORMAL;
|
||||
if m[3][0] == 0.0 && m[3][1] == 0.0 && m[3][2] == 0.0 {
|
||||
properties |= IDENTITY;
|
||||
}
|
||||
}
|
||||
} else if m[0][1] == 0.0
|
||||
&& m[0][2] == 0.0
|
||||
&& m[1][0] == 0.0
|
||||
&& m[1][2] == 0.0
|
||||
&& m[2][0] == 0.0
|
||||
&& m[2][1] == 0.0
|
||||
&& m[3][0] == 0.0
|
||||
&& m[3][1] == 0.0
|
||||
&& m[3][3] == 0.0
|
||||
{
|
||||
properties |= PERSPECTIVE;
|
||||
}
|
||||
}
|
||||
properties
|
||||
}
|
||||
fn has(&self, property: u8) -> bool {
|
||||
self.1 & property != 0
|
||||
}
|
||||
/// The matrix as `[row][column]` entries, the order `getRowColumn` reads.
|
||||
pub fn rows(&self) -> [[f32; 4]; 4] {
|
||||
std::array::from_fn(|r| std::array::from_fn(|c| self.0[c][r]))
|
||||
@@ -33,37 +90,106 @@ impl Mat4 {
|
||||
/// `this * right` (`Matrix4f.mul`).
|
||||
pub fn mul(&self, right: &Self) -> Self {
|
||||
let (m, r) = (&self.0, &right.0);
|
||||
if self.has(IDENTITY) {
|
||||
return *right;
|
||||
}
|
||||
if right.has(IDENTITY) {
|
||||
return *self;
|
||||
}
|
||||
let mut out = [[0.0; 4]; 4];
|
||||
if self.has(TRANSLATION) && right.has(AFFINE) {
|
||||
// mulTranslationAffine
|
||||
for c in 0..4 {
|
||||
out[c] = [r[c][0], r[c][1], r[c][2], m[c][3]];
|
||||
}
|
||||
for row in 0..3 {
|
||||
out[3][row] = r[3][row] + m[3][row];
|
||||
}
|
||||
return Self(out, AFFINE | right.1 & ORTHONORMAL);
|
||||
}
|
||||
if self.has(AFFINE) && right.has(AFFINE) {
|
||||
// mulAffine
|
||||
for c in 0..3 {
|
||||
for row in 0..3 {
|
||||
out[c][row] = fma(m[0][row], r[c][0], fma(m[1][row], r[c][1], m[2][row] * r[c][2]));
|
||||
}
|
||||
out[c][3] = m[c][3];
|
||||
}
|
||||
for row in 0..3 {
|
||||
out[3][row] = fma(m[0][row], r[3][0], fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])));
|
||||
}
|
||||
out[3][3] = m[3][3];
|
||||
return Self(out, AFFINE | self.1 & right.1 & ORTHONORMAL);
|
||||
}
|
||||
if self.has(PERSPECTIVE) && right.has(AFFINE) {
|
||||
// mulPerspectiveAffine
|
||||
for c in 0..4 {
|
||||
out[c] = [m[0][0] * r[c][0], m[1][1] * r[c][1], m[2][2] * r[c][2], m[2][3] * r[c][2]];
|
||||
}
|
||||
out[3][2] = m[2][2] * r[3][2] + m[3][2];
|
||||
out[3][3] = m[2][3] * r[3][2];
|
||||
return Self(out, 0);
|
||||
}
|
||||
if right.has(AFFINE) {
|
||||
// mulAffineR
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out[c][row] = fma(m[0][row], r[c][0], fma(m[1][row], r[c][1], m[2][row] * r[c][2]));
|
||||
}
|
||||
}
|
||||
for row in 0..4 {
|
||||
out[3][row] = fma(m[0][row], r[3][0], fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])));
|
||||
}
|
||||
return Self(out, self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | ORTHONORMAL));
|
||||
}
|
||||
// mul0
|
||||
for c in 0..4 {
|
||||
for row in 0..4 {
|
||||
out[c][row] = m[0][row] * r[c][0]
|
||||
+ m[1][row] * r[c][1]
|
||||
+ m[2][row] * r[c][2]
|
||||
+ m[3][row] * r[c][3];
|
||||
out[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(m[1][row], r[c][1], fma(m[2][row], r[c][2], m[3][row] * r[c][3])),
|
||||
);
|
||||
}
|
||||
}
|
||||
Self(out)
|
||||
Self(out, 0)
|
||||
}
|
||||
/// `Matrix4f.translate(x, y, z)` (`translateGeneric`).
|
||||
/// `Matrix4f.translate(x, y, z)`.
|
||||
pub fn translate(&self, t: [f32; 3]) -> Self {
|
||||
if self.has(IDENTITY) {
|
||||
return Self::translation(t);
|
||||
}
|
||||
// translateGeneric
|
||||
let mut out = *self;
|
||||
for row in 0..4 {
|
||||
out.0[3][row] = self.0[0][row] * t[0]
|
||||
+ (self.0[1][row] * t[1] + (self.0[2][row] * t[2] + self.0[3][row]));
|
||||
out.0[3][row] = fma(self.0[0][row], t[0], fma(self.0[1][row], t[1], fma(self.0[2][row], t[2], self.0[3][row])));
|
||||
}
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY);
|
||||
out
|
||||
}
|
||||
/// `Matrix4f.scale(x, y, z)`: the first three columns are scaled.
|
||||
/// `Matrix4f.scale(x, y, z)`.
|
||||
pub fn scale(&self, s: [f32; 3]) -> Self {
|
||||
let one = s.iter().all(|&v| abs_equals_one(v));
|
||||
if self.has(IDENTITY) {
|
||||
// scaling
|
||||
let mut out = Self::IDENTITY;
|
||||
for (c, v) in s.into_iter().enumerate() {
|
||||
out.0[c][c] = v;
|
||||
}
|
||||
out.1 = AFFINE | if one { ORTHONORMAL } else { 0 };
|
||||
return out;
|
||||
}
|
||||
// scaleGeneric: the first three columns are scaled.
|
||||
let mut out = *self;
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = self.0[c][row] * s[c];
|
||||
}
|
||||
}
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | if one { 0 } else { ORTHONORMAL });
|
||||
out
|
||||
}
|
||||
/// `Matrix4f.rotate(Quaternionfc)` (`rotateGeneric`).
|
||||
/// `Matrix4f.rotate(Quaternionfc)`.
|
||||
pub fn rotate(&self, q: [f32; 4]) -> Self {
|
||||
let [x, y, z, w] = q;
|
||||
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
|
||||
@@ -71,14 +197,39 @@ impl Mat4 {
|
||||
let (dzw, dxy, dxz, dyw, dyz, dxw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
|
||||
let r = [
|
||||
[w2 + x2 - z2 - y2, dxy + dzw, dxz - dyw],
|
||||
[dxy - dzw, y2 - z2 + w2 - x2, dyz + dxw],
|
||||
[-dzw + dxy, y2 - z2 + w2 - x2, dyz + dxw],
|
||||
[dyw + dxz, dyz - dxw, z2 - y2 - x2 + w2],
|
||||
];
|
||||
let m = &self.0;
|
||||
let mut out = *self;
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
|
||||
if self.has(IDENTITY) {
|
||||
// rotation
|
||||
for c in 0..3 {
|
||||
out.0[c][..3].copy_from_slice(&r[c]);
|
||||
}
|
||||
out.1 = AFFINE | ORTHONORMAL;
|
||||
return out;
|
||||
}
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION);
|
||||
if self.has(TRANSLATION) {
|
||||
// rotateTranslation
|
||||
for c in 0..3 {
|
||||
out.0[c] = [r[c][0], r[c][1], r[c][2], 0.0];
|
||||
}
|
||||
} else if self.has(AFFINE) {
|
||||
// rotateAffine
|
||||
for c in 0..3 {
|
||||
for row in 0..3 {
|
||||
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
|
||||
}
|
||||
out.0[c][3] = 0.0;
|
||||
}
|
||||
} else {
|
||||
// rotateGeneric
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = fma(m[0][row], r[c][0], fma(m[1][row], r[c][1], m[2][row] * r[c][2]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -94,28 +245,46 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
|
||||
let inv = 1.0 / (x * x + (y * y + (z * z + w * w))).sqrt();
|
||||
[x * inv, y * inv, z * inv, w * inv]
|
||||
}
|
||||
/// JOML `Math.sin(float)` without fast math.
|
||||
fn joml_sin(rad: f32) -> f32 {
|
||||
f64::from(rad).sin() as f32
|
||||
}
|
||||
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
|
||||
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
|
||||
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let a = angle + std::f32::consts::FRAC_PI_2;
|
||||
let mut b = a - (a / tau) as i32 as f32 * tau;
|
||||
if b < 0.0 {
|
||||
b += tau;
|
||||
}
|
||||
if b >= std::f32::consts::PI { -cos } else { cos }
|
||||
}
|
||||
/// `Quaternionf.rotationXYZ` from angles in radians.
|
||||
pub fn quaternion_xyz(x: f32, y: f32, z: f32) -> [f32; 4] {
|
||||
let half = |a: f32| -> (f32, f32) {
|
||||
let h = a * 0.5;
|
||||
((f64::from(h).sin()) as f32, (f64::from(h).cos()) as f32)
|
||||
let s = joml_sin(h);
|
||||
(s, cos_from_sin(s, h))
|
||||
};
|
||||
let ((sx, cx), (sy, cy), (sz, cz)) = (half(x), half(y), half(z));
|
||||
let (cx_cy, sx_cy, cx_sy, sx_sy) = (cx * cy, sx * cy, cx * sy, sx * sy);
|
||||
let (cycz, sysz, sycz, cysz) = (cy * cz, sy * sz, sy * cz, cy * sz);
|
||||
[
|
||||
sx_cy * cz + cx_sy * sz,
|
||||
cx_sy * cz - sx_cy * sz,
|
||||
cx_cy * sz + sx_sy * cz,
|
||||
cx_cy * cz - sx_sy * sz,
|
||||
sx * cycz + cx * sysz,
|
||||
cx * sycz - sx * cysz,
|
||||
cx * cysz + sx * sycz,
|
||||
cx * cycz - sx * sysz,
|
||||
]
|
||||
}
|
||||
/// `new Quaternionf(new AxisAngle4f(angle, axis))`.
|
||||
/// `new Quaternionf(new AxisAngle4f(angle, axis))`: the angle is wrapped into
|
||||
/// [0, 2π) and the axis is used as given.
|
||||
pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
|
||||
let half = angle * 0.5;
|
||||
let (sin, cos) = (half.sin(), half.cos());
|
||||
let norm = 1.0 / (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
|
||||
let s = sin * norm;
|
||||
[axis[0] * s, axis[1] * s, axis[2] * s, cos]
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let angle = if angle < 0.0 { tau + angle % tau } else { angle } % tau;
|
||||
let sin = joml_sin(angle * 0.5);
|
||||
let cos = cos_from_sin(sin, angle * 0.5);
|
||||
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -137,4 +306,9 @@ mod tests {
|
||||
let m = Mat4::IDENTITY.rotate(q);
|
||||
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn half_turn_has_exact_zeros_like_joml() {
|
||||
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
|
||||
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! `PalettedPermutations` atlas sources: sprites such as
|
||||
//! `trims/items/boots_trim_quartz` are the base texture with its colours
|
||||
//! swapped through a palette pair (`PaletteMapping`), not files of their own.
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One `minecraft:paletted_permutations` source of an atlas definition.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PalettedPermutations {
|
||||
/// Base sprite names (`minecraft:trims/items/boots_trim`).
|
||||
pub textures: Vec<String>,
|
||||
/// The key palette (`minecraft:trim_base`).
|
||||
pub palette_key: String,
|
||||
/// Suffix to target palette (`quartz` → `minecraft:trim/quartz`).
|
||||
pub permutations: BTreeMap<String, String>,
|
||||
pub separator: String,
|
||||
}
|
||||
|
||||
/// A permuted sprite: its base texture and the palette pair to apply.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Permutation<'a> {
|
||||
pub base: &'a str,
|
||||
pub palette_key: &'a str,
|
||||
pub palette: &'a str,
|
||||
}
|
||||
|
||||
fn qualified(id: &str) -> String {
|
||||
if id.contains(':') { id.to_owned() } else { format!("minecraft:{id}") }
|
||||
}
|
||||
|
||||
impl PalettedPermutations {
|
||||
/// The `paletted_permutations` sources of an atlas JSON (`atlases/items.json`).
|
||||
pub fn from_atlas(atlas: &str) -> Vec<Self> {
|
||||
let Ok(json) = serde_json::from_str::<Value>(atlas) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let sources = json.get("sources").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
sources
|
||||
.iter()
|
||||
.filter(|s| s.get("type").and_then(Value::as_str).map(qualified).as_deref() == Some("minecraft:paletted_permutations"))
|
||||
.filter_map(|s| {
|
||||
Some(Self {
|
||||
textures: s.get("textures")?.as_array()?.iter().filter_map(Value::as_str).map(qualified).collect(),
|
||||
palette_key: qualified(s.get("palette_key")?.as_str()?),
|
||||
permutations: s
|
||||
.get("permutations")?
|
||||
.as_object()?
|
||||
.iter()
|
||||
.filter_map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
|
||||
.collect(),
|
||||
separator: s.get("separator").and_then(Value::as_str).unwrap_or("_").to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The permutation that produces `sprite` (`withSuffix(separator + key)`).
|
||||
pub fn find(&self, sprite: &str) -> Option<Permutation<'_>> {
|
||||
let sprite = qualified(sprite);
|
||||
self.textures.iter().find_map(|base| {
|
||||
let suffix = sprite.strip_prefix(base.as_str())?.strip_prefix(self.separator.as_str())?;
|
||||
Some(Permutation { base, palette_key: &self.palette_key, palette: self.permutations.get(suffix)? })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `PaletteMapping.create(key, target).apply` over ARGB pixels; the palettes
|
||||
/// are the pixels of their images in reading order (`Palette.from`).
|
||||
pub fn apply_palette(pixels: &mut [u32], key: &[u32], target: &[u32]) -> Option<()> {
|
||||
if key.len() != target.len() {
|
||||
return None;
|
||||
}
|
||||
let mut map = BTreeMap::new();
|
||||
for (&k, &t) in key.iter().zip(target) {
|
||||
if k >> 24 != 0 {
|
||||
map.insert(k | 0xFF00_0000, t);
|
||||
}
|
||||
}
|
||||
for pixel in pixels {
|
||||
let alpha = *pixel >> 24;
|
||||
if alpha == 0 {
|
||||
continue;
|
||||
}
|
||||
let rgb = *pixel | 0xFF00_0000;
|
||||
let target = map.get(&rgb).copied().unwrap_or(rgb);
|
||||
*pixel = (alpha * (target >> 24) / 255) << 24 | target & 0x00FF_FFFF;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn finds_trim_permutations_and_maps_colours() {
|
||||
let atlas = r#"{"sources":[{"type":"minecraft:paletted_permutations","palette_key":"minecraft:trim_base",
|
||||
"permutations":{"quartz":"minecraft:trim/quartz"},"textures":["minecraft:trims/items/boots_trim"]}]}"#;
|
||||
let sources = PalettedPermutations::from_atlas(atlas);
|
||||
let p = sources[0].find("minecraft:trims/items/boots_trim_quartz").unwrap();
|
||||
assert_eq!((p.base, p.palette), ("minecraft:trims/items/boots_trim", "minecraft:trim/quartz"));
|
||||
assert!(sources[0].find("minecraft:trims/items/boots_trim_gold").is_none());
|
||||
let mut pixels = [0xFF11_2233, 0x8011_2233, 0x0011_2233, 0xFF44_5566];
|
||||
apply_palette(&mut pixels, &[0xFF11_2233], &[0xFFAA_BBCC]).unwrap();
|
||||
assert_eq!(pixels, [0xFFAA_BBCC, 0x80AA_BBCC, 0x0011_2233, 0xFF44_5566]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Headless transport for the block-entity render parity gate: replays each
|
||||
//! case (block state, light, partial tick and ticked block-entity fields)
|
||||
//! through `BlockEntityRenderer` at the block origin and writes the quads
|
||||
//! (texture, sprite-local UVs, baked colour, light) and sign text calls.
|
||||
#![forbid(unsafe_code)]
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
banner::{self, Banners},
|
||||
block_entity_render::{BlockAnim, BlockEntityRenderer, Scene, TextCall},
|
||||
chunk::{Chunk, Light},
|
||||
hud::{HudAtlas, Rect},
|
||||
signs::{DYE_TEXT, SignData, SignText},
|
||||
world::World,
|
||||
};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashMap,
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
/// Every texture the vanilla oracle used gets its own unit square on a small
|
||||
/// grid (offsets stay small so f32 UVs keep their precision), so a vertex's
|
||||
/// texture and sprite-local UV can be recovered.
|
||||
fn fake_sprites(list: &Path) -> Result<(HashMap<String, Rect>, Vec<String>, usize)> {
|
||||
let names: Vec<String> = fs::read_to_string(list)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let grid = (names.len() as f64).sqrt().ceil().max(1.0) as usize;
|
||||
let map = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let (u0, v0) = ((i % grid) as f32 * 2.0, (i / grid) as f32 * 2.0);
|
||||
(
|
||||
n.clone(),
|
||||
Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1: u0 + 1.0,
|
||||
v1: v0 + 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok((map, names, grid))
|
||||
}
|
||||
|
||||
fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
|
||||
let (name, props) = match spec.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (spec, ""),
|
||||
};
|
||||
let wanted: Vec<(&str, &str)> = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.collect();
|
||||
let default = registry.state(registry.default_state(name)?)?.clone();
|
||||
(0..registry.len() as u32).find(|&id| {
|
||||
registry.state(BlockStateId(id)).is_some_and(|s| {
|
||||
s.name == name
|
||||
&& s.properties.iter().all(|(k, v)| {
|
||||
wanted
|
||||
.iter()
|
||||
.find(|(wk, _)| wk == k)
|
||||
.map_or(default.properties.get(k) == Some(v), |(_, wv)| wv == v)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_side(v: &Value) -> SignText {
|
||||
let mut side = SignText::default();
|
||||
for (line, text) in side
|
||||
.lines
|
||||
.iter_mut()
|
||||
.zip(v["lines"].as_array().into_iter().flatten())
|
||||
{
|
||||
*line = text.as_str().unwrap_or_default().to_owned();
|
||||
}
|
||||
let color = v["color"].as_str().unwrap_or("black");
|
||||
side.color = DYE_TEXT.iter().position(|(n, _)| *n == color).unwrap_or(15) as u8;
|
||||
side.glowing = v["glowing"].as_bool().unwrap_or(false);
|
||||
side
|
||||
}
|
||||
|
||||
fn intern(s: &str) -> &'static str {
|
||||
match s {
|
||||
"north" => "north",
|
||||
"south" => "south",
|
||||
"west" => "west",
|
||||
"east" => "east",
|
||||
"up" => "up",
|
||||
_ => "down",
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
|
||||
let [root, inputs, textures, output] = args.as_slice() else {
|
||||
return Err("usage: block_entity_render_fixture RESOURCES INPUTS TEXTURES OUTPUT".into());
|
||||
};
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let mut renderer = BlockEntityRenderer::load(root, registry)?;
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
let banners = Banners::load(root);
|
||||
let (sprites, names, grid) = fake_sprites(textures)?;
|
||||
let air = registry.default_state("minecraft:air").ok_or("no air")?;
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(inputs)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
let block = case["block"].as_str().unwrap_or_default();
|
||||
if block == "minecraft:beacon" {
|
||||
// BeaconRenderer: the beam sections from the case, as Horizon's beacon keeps them.
|
||||
let int = |v: &Value| v.as_i64().unwrap_or(0);
|
||||
let pos = [
|
||||
int(&case["pos"][0]),
|
||||
int(&case["pos"][1]),
|
||||
int(&case["pos"][2]),
|
||||
];
|
||||
let partial = case["partial"].as_f64().unwrap_or(0.0) as f32;
|
||||
let time = int(&case["gameTime"]).rem_euclid(40) as f32 + partial;
|
||||
let cam = &case["camera"];
|
||||
let dx = cam[0].as_f64().unwrap_or(0.0) - (pos[0] as f64 + 0.5);
|
||||
let dz = cam[2].as_f64().unwrap_or(0.0) - (pos[2] as f64 + 0.5);
|
||||
let scale = ((dx * dx + dz * dz).sqrt() as f32 / 96.0).max(1.0);
|
||||
let sections: Vec<(u32, i32)> = case["beam"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|s| (int(&s[0]) as u32 & 0xFF_FFFF, int(&s[1]) as i32))
|
||||
.collect();
|
||||
let texture = "entity/beacon/beacon_beam";
|
||||
let rect = sprites.get(texture).copied().ok_or("no beam sprite")?;
|
||||
let mut verts = Vec::new();
|
||||
horizon_client::block_entity_render::beacon_beam(
|
||||
Vec3::ZERO,
|
||||
§ions,
|
||||
time,
|
||||
scale,
|
||||
rect,
|
||||
&mut verts,
|
||||
);
|
||||
let quads: Vec<Value> = verts
|
||||
.chunks(4)
|
||||
.map(|q| {
|
||||
let v: Vec<Value> = q
|
||||
.iter()
|
||||
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0] - rect.u0, v.uv[1]]))
|
||||
.collect();
|
||||
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light, "custom": true})
|
||||
})
|
||||
.collect();
|
||||
writeln!(out, "{}", json!({"quads": quads, "texts": []}))?;
|
||||
continue;
|
||||
}
|
||||
let Some(state) = find_state(registry, block) else {
|
||||
writeln!(out, "{}", json!({"skip": "unknown block state"}))?;
|
||||
continue;
|
||||
};
|
||||
if !renderer.renders(state) {
|
||||
writeln!(out, "{}", json!({"skip": "no renderer"}))?;
|
||||
continue;
|
||||
}
|
||||
let int = |v: &Value| v.as_i64().unwrap_or(0) as i32;
|
||||
let float = |v: &Value| v.as_f64().unwrap_or(0.0) as f32;
|
||||
let pos = [
|
||||
int(&case["pos"][0]),
|
||||
int(&case["pos"][1]),
|
||||
int(&case["pos"][2]),
|
||||
];
|
||||
let packed = (int(&case["light"][0]) << 4 | int(&case["light"][1])) as u8;
|
||||
let mut world = World::new(air, 0);
|
||||
for dx in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
let (cx, cz) = ((pos[0] >> 4) + dx, (pos[2] >> 4) + dz);
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for section in &mut chunk.sections {
|
||||
Arc::make_mut(section).light = Light::Uniform(packed);
|
||||
}
|
||||
world.chunks.insert((cx, cz), Arc::new(chunk));
|
||||
}
|
||||
}
|
||||
if let Some(book) = case.get("book") {
|
||||
renderer.set_book(
|
||||
pos,
|
||||
int(&book["time"]),
|
||||
["flip", "oFlip", "open", "oOpen", "rot", "oRot"].map(|k| float(&book[k])),
|
||||
);
|
||||
}
|
||||
let sign = case.get("sign").map(|s| {
|
||||
let mut data = SignData {
|
||||
hanging: block.contains("hanging"),
|
||||
..SignData::default()
|
||||
};
|
||||
data.front = sign_side(&s["front"]);
|
||||
data.back = sign_side(&s["back"]);
|
||||
data
|
||||
});
|
||||
let layers: Vec<(String, u32)> = case["patterns"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|p| {
|
||||
(
|
||||
banners.banner_texture(p[0].as_str().unwrap_or_default()),
|
||||
banner::dye_rgb(p[1].as_str().unwrap_or_default()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let anim = BlockAnim {
|
||||
shulker: (float(&case["progress"]), float(&case["progressOld"])),
|
||||
bell_ticks: int(&case["bellTicks"]),
|
||||
bell_shake: case["shake"].as_str().map(intern),
|
||||
conduit: (
|
||||
int(&case["conduit"]["tickCount"]),
|
||||
case["conduit"]["active"].as_bool().unwrap_or(false),
|
||||
case["conduit"]["hunting"].as_bool().unwrap_or(false),
|
||||
float(&case["conduit"]["activeRotation"]),
|
||||
),
|
||||
skull: (
|
||||
case["skullAnimation"][0].as_bool().unwrap_or(false),
|
||||
int(&case["skullAnimation"][1]),
|
||||
),
|
||||
sherds: [0, 1, 2, 3].map(|i| {
|
||||
case["sherds"][i]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
}),
|
||||
wobble: case["wobble"]
|
||||
.as_array()
|
||||
.map(|w| (w[0].as_str() == Some("POSITIVE"), float(&w[1]))),
|
||||
};
|
||||
let open = float(&case["open"]);
|
||||
let near = case["sign"]["near"].as_bool().unwrap_or(false);
|
||||
let camera = DVec3::new(
|
||||
case["camera"][0].as_f64().unwrap_or(0.0),
|
||||
case["camera"][1].as_f64().unwrap_or(0.0),
|
||||
case["camera"][2].as_f64().unwrap_or(0.0),
|
||||
);
|
||||
// Signs pick the outline from the camera distance: put the camera where the case says.
|
||||
let camera = if case.get("sign").is_some() {
|
||||
let centre = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])) + 0.5;
|
||||
centre
|
||||
+ if near {
|
||||
DVec3::ZERO
|
||||
} else {
|
||||
DVec3::new(20.0, 0.0, 0.0)
|
||||
}
|
||||
} else {
|
||||
camera
|
||||
};
|
||||
let (yaw, pitch) = (float(&case["yaw"]), float(&case["pitch"]));
|
||||
let texts = RefCell::new(Vec::<TextCall>::new());
|
||||
let scene = Scene {
|
||||
camera,
|
||||
orientation: Quat::from_euler(
|
||||
glam::EulerRot::YXZ,
|
||||
std::f32::consts::PI - yaw * (std::f32::consts::PI / 180.0),
|
||||
-pitch * (std::f32::consts::PI / 180.0),
|
||||
0.0,
|
||||
),
|
||||
anim: &|_| anim.clone(),
|
||||
texts: Some(&texts),
|
||||
partial: float(&case["partial"]),
|
||||
game_time: 0,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| open,
|
||||
sign: &|_| sign.as_ref(),
|
||||
banner: &|_| layers.clone(),
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
|
||||
let quads: Vec<Value> = verts
|
||||
.chunks(4)
|
||||
.map(|q| {
|
||||
let (cu, cv) = ((q[0].uv[0] / 2.0).floor(), (q[0].uv[1] / 2.0).floor());
|
||||
let texture = names
|
||||
.get(cu as usize + cv as usize * grid)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let v: Vec<Value> = q
|
||||
.iter()
|
||||
.map(|v| {
|
||||
json!([
|
||||
v.pos[0],
|
||||
v.pos[1],
|
||||
v.pos[2],
|
||||
v.uv[0] - cu * 2.0,
|
||||
v.uv[1] - cv * 2.0
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light})
|
||||
})
|
||||
.collect();
|
||||
let texts: Vec<Value> = texts
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|t| !t.text.is_empty())
|
||||
.map(|t| {
|
||||
json!({"matrix": t.matrix.to_cols_array(), "x": t.x, "y": t.y, "text": t.text,
|
||||
"color": t.color, "outline": t.outline,
|
||||
"light": ((t.light & 0xFF) << 16 | (t.light >> 8 & 0xFF))})
|
||||
})
|
||||
.collect();
|
||||
writeln!(out, "{}", json!({"quads": quads, "texts": texts}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Headless transport for the HUD parity gate: replays each player state
|
||||
//! through `horizon_client::hud_layout` (crosshair, hotbar, health, mount
|
||||
//! hearts, experience bar and level) and writes the draw calls in vanilla's
|
||||
//! GuiGraphicsExtractor terms.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
hud_layout::{self, GuiDraw, HealthHud, HealthInputs, HeartKind, HotbarInputs},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
|
||||
let [root, inputs, output] = args.as_slice() else {
|
||||
return Err("usage: hud_fixture RESOURCES INPUTS OUTPUT".into());
|
||||
};
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(inputs)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
let c: Value = serde_json::from_str(line)?;
|
||||
let int = |k: &str, d: i64| c[k].as_i64().unwrap_or(d) as i32;
|
||||
let float = |k: &str, d: f64| c[k].as_f64().unwrap_or(d) as f32;
|
||||
let (w, h) = (int("width", 0), int("height", 0));
|
||||
let mode = c["gameMode"].as_str().unwrap_or("survival");
|
||||
let survival = mode == "survival" || mode == "adventure";
|
||||
let indicator = c["attackIndicator"].as_str().unwrap_or("OFF");
|
||||
let partial = float("partial", 0.0);
|
||||
let effects: Vec<&str> = c["effects"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
let has = |e: &str| effects.contains(&e);
|
||||
let mut draws = Vec::new();
|
||||
// Spectators only see it over a spectatable target (none here).
|
||||
if c["crosshair"].as_bool().unwrap_or(false) && mode != "spectator" {
|
||||
let attack = (indicator == "CROSSHAIR").then(|| float("attackScale", 1.0));
|
||||
draws.extend(hud_layout::crosshair(w, h, attack, false));
|
||||
}
|
||||
if mode != "spectator" {
|
||||
let pop = |v: &Value| v.as_f64().map(|p| p as f32 - partial);
|
||||
let slots: [Option<f32>; 9] = std::array::from_fn(|i| pop(&c["slots"][i]));
|
||||
let inputs = HotbarInputs {
|
||||
selected: int("selected", 0),
|
||||
right_handed: c["rightHanded"].as_bool().unwrap_or(true),
|
||||
offhand: !c["offhand"].is_null(),
|
||||
slots,
|
||||
offhand_pop: pop(&c["offhand"]).unwrap_or(0.0),
|
||||
attack: (indicator == "HOTBAR").then(|| float("attackScale", 1.0)),
|
||||
};
|
||||
draws.extend(hud_layout::hotbar(w, h, &inputs));
|
||||
}
|
||||
let vehicle_hearts = if c.get("vehicleMaxHealth").is_some() {
|
||||
hud_layout::vehicle_max_hearts(float("vehicleMaxHealth", 0.0))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if survival {
|
||||
let millis = 1_000_000_000i64;
|
||||
let mut hud = HealthHud {
|
||||
last_health: int("lastHealth", 0),
|
||||
display_health: int("displayHealth", 0),
|
||||
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
|
||||
health_blink_time: i64::from(int("healthBlinkTime", 0)),
|
||||
};
|
||||
let kind = if has("minecraft:poison") {
|
||||
HeartKind::Poisoned
|
||||
} else if has("minecraft:wither") {
|
||||
HeartKind::Withered
|
||||
} else if c["frozen"].as_bool().unwrap_or(false) {
|
||||
HeartKind::Frozen
|
||||
} else {
|
||||
HeartKind::Normal
|
||||
};
|
||||
let inputs = HealthInputs {
|
||||
tick: int("tick", 0),
|
||||
millis,
|
||||
health: float("health", 20.0),
|
||||
max_health: float("maxHealth", 20.0),
|
||||
absorption: float("absorption", 0.0),
|
||||
armor: int("armor", 0),
|
||||
food: int("food", 20),
|
||||
saturation: float("saturation", 5.0),
|
||||
kind,
|
||||
regeneration: has("minecraft:regeneration"),
|
||||
hunger: has("minecraft:hunger"),
|
||||
hardcore: c["hardcore"].as_bool().unwrap_or(false),
|
||||
damage_cooldown: int("damageCooldown", 0) > 0,
|
||||
air: int("air", 300),
|
||||
max_air: int("maxAir", 300),
|
||||
under_water: c["underWater"].as_bool().unwrap_or(false),
|
||||
vehicle_hearts,
|
||||
};
|
||||
draws.extend(hud.layout(&inputs, w, h));
|
||||
}
|
||||
if vehicle_hearts > 0 {
|
||||
draws.extend(hud_layout::vehicle_health(
|
||||
w,
|
||||
h,
|
||||
vehicle_hearts,
|
||||
float("vehicleHealth", 0.0),
|
||||
));
|
||||
}
|
||||
let width = |s: &str| HudBatch::text_width(&atlas, s) as i32;
|
||||
let level = if survival { int("xpLevel", 0) } else { 0 };
|
||||
draws.extend(hud_layout::experience(
|
||||
w,
|
||||
h,
|
||||
float("xpProgress", 0.0),
|
||||
level,
|
||||
c["experienceBar"].as_bool().unwrap_or(false),
|
||||
&width,
|
||||
));
|
||||
let draws: Vec<Value> = draws
|
||||
.iter()
|
||||
.map(|d| match d {
|
||||
GuiDraw::Sprite { sprite, x, y, w, h, region } => {
|
||||
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "region": region})
|
||||
}
|
||||
GuiDraw::Text { text, x, y, color, shadow } => {
|
||||
json!({"type": "text", "text": text, "x": x, "y": y, "color": *color as i32, "shadow": shadow})
|
||||
}
|
||||
GuiDraw::Item { slot, x, y, seed, scale, pivot } => {
|
||||
// translate(pivot) * scale * translate(-pivot) as a 3x2 matrix.
|
||||
let m = [scale[0], 0.0, 0.0, scale[1], pivot[0] - pivot[0] * scale[0], pivot[1] - pivot[1] * scale[1]];
|
||||
json!({"type": "item", "slot": slot, "x": x, "y": y, "seed": seed, "matrix": m})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
writeln!(out, "{}", json!({ "draws": draws }))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
|
||||
use crate::{hud::Rect, mesher::Vertex, world::World};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::trig::Mth,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, f32::consts::PI, path::Path};
|
||||
@@ -582,19 +585,19 @@ fn book_pose(openness: f32, flip1: f32, flip2: f32) -> impl Fn(&str, &mut [f32;
|
||||
"right_lid" => p[4] = -openness,
|
||||
"left_pages" => {
|
||||
p[4] = openness;
|
||||
p[0] = openness.sin();
|
||||
p[0] = openness.msin();
|
||||
}
|
||||
"right_pages" => {
|
||||
p[4] = -openness;
|
||||
p[0] = openness.sin();
|
||||
p[0] = openness.msin();
|
||||
}
|
||||
"flip_page1" => {
|
||||
p[4] = openness - openness * 2.0 * flip1;
|
||||
p[0] = openness.sin();
|
||||
p[0] = openness.msin();
|
||||
}
|
||||
"flip_page2" => {
|
||||
p[4] = openness - openness * 2.0 * flip2;
|
||||
p[0] = openness.sin();
|
||||
p[0] = openness.msin();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -603,94 +606,163 @@ fn frac(x: f32) -> f32 {
|
||||
x - x.floor()
|
||||
}
|
||||
|
||||
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
|
||||
/// wrapped at the last space that fits or mid-word when none does.
|
||||
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut width = 0.0;
|
||||
let mut last_space = None;
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
if c == '\n' {
|
||||
return chars[..i].iter().collect();
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
width += advance(c);
|
||||
if width > max_width {
|
||||
let end = match last_space {
|
||||
Some(s) if s > 0 => s,
|
||||
_ => i.max(1),
|
||||
};
|
||||
return chars[..end].iter().collect();
|
||||
}
|
||||
}
|
||||
line.to_owned()
|
||||
}
|
||||
|
||||
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
|
||||
/// full-bright with an 8x outline in the dark colour.
|
||||
fn sign_text(
|
||||
fn sign_text_calls(
|
||||
scene: &Scene,
|
||||
data: &crate::signs::SignData,
|
||||
text: &crate::signs::SignText,
|
||||
m: Mat4,
|
||||
packed: u8,
|
||||
near: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
) -> Vec<TextCall> {
|
||||
let line_height = data.line_height();
|
||||
let midpoint = (4.0 * line_height / 2.0).floor();
|
||||
let dark = text.dark_color();
|
||||
let dark = 0xFF00_0000 | text.dark_color();
|
||||
let (color, outline, light) = if text.glowing {
|
||||
let c = text.text_color();
|
||||
(c, (c == 0 || near).then_some(dark), 240 | 240 << 8)
|
||||
(
|
||||
0xFF00_0000 | c,
|
||||
if c == 0 || near { dark } else { 0 },
|
||||
240 | 240 << 8,
|
||||
)
|
||||
} else {
|
||||
(dark, None, vertex_light(packed))
|
||||
(dark, 0, vertex_light(packed))
|
||||
};
|
||||
let width = |s: &str| {
|
||||
s.chars()
|
||||
.map(|c| (scene.glyph)(c).map_or(6.0, |g| g.1))
|
||||
.sum::<f32>()
|
||||
};
|
||||
for (i, line) in text.lines.iter().enumerate() {
|
||||
// Font.split(line, maxWidth).get(0): keep what fits.
|
||||
let mut shown = String::new();
|
||||
for ch in line.chars() {
|
||||
shown.push(ch);
|
||||
if width(&shown) > data.max_line_width() {
|
||||
shown.pop();
|
||||
break;
|
||||
let advance = |c: char| (scene.glyph)(c).1;
|
||||
let width = |s: &str| s.chars().map(advance).sum::<f32>().ceil();
|
||||
text.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, line)| {
|
||||
let shown = first_line(line, data.max_line_width(), &advance);
|
||||
TextCall {
|
||||
matrix: m,
|
||||
x: -((width(&shown) as i32) / 2) as f32,
|
||||
y: i as f32 * line_height - midpoint,
|
||||
text: shown,
|
||||
color,
|
||||
outline,
|
||||
light,
|
||||
}
|
||||
}
|
||||
let x0 = -(width(&shown) as i32 / 2) as f32;
|
||||
let y0 = i as f32 * line_height - midpoint;
|
||||
let mut emit = |dx: f32, dy: f32, rgb: u32| {
|
||||
let mut x = x0 + dx;
|
||||
for ch in shown.chars() {
|
||||
let Some((r, advance)) = (scene.glyph)(ch) else {
|
||||
x += 6.0;
|
||||
continue;
|
||||
};
|
||||
if ch != ' ' {
|
||||
let corners = [
|
||||
(x, y0 + dy + 8.0),
|
||||
(x + 8.0, y0 + dy + 8.0),
|
||||
(x + 8.0, y0 + dy),
|
||||
(x, y0 + dy),
|
||||
];
|
||||
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
|
||||
let c =
|
||||
0xFF00_0000 | (rgb & 255) << 16 | (rgb >> 8 & 255) << 8 | (rgb >> 16 & 255);
|
||||
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
pos: m.transform_point3(Vec3::new(cx, cy, 0.0)).to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: c,
|
||||
light,
|
||||
});
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Draws a text call with the font atlas: 8x8 glyph cells, the outline as
|
||||
/// eight one-pixel offsets under the text.
|
||||
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut emit = |dx: f32, dy: f32, argb: u32| {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
let (cell, advance) = (scene.glyph)(ch);
|
||||
if let Some(r) = cell {
|
||||
let corners = [(x, y0 + 8.0), (x + 8.0, y0 + 8.0), (x + 8.0, y0), (x, y0)];
|
||||
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
|
||||
let c =
|
||||
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
pos: call
|
||||
.matrix
|
||||
.transform_point3(Vec3::new(cx, cy, 0.0))
|
||||
.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: c,
|
||||
light: call.light,
|
||||
});
|
||||
}
|
||||
x += advance;
|
||||
}
|
||||
};
|
||||
if let Some(o) = outline {
|
||||
for (dx, dy) in [
|
||||
(-1.0, -1.0),
|
||||
(0.0, -1.0),
|
||||
(1.0, -1.0),
|
||||
(-1.0, 0.0),
|
||||
(1.0, 0.0),
|
||||
(-1.0, 1.0),
|
||||
(0.0, 1.0),
|
||||
(1.0, 1.0),
|
||||
] {
|
||||
emit(dx, dy, o);
|
||||
}
|
||||
x += advance;
|
||||
}
|
||||
};
|
||||
if call.outline != 0 {
|
||||
for (dx, dy) in [
|
||||
(-1.0, -1.0),
|
||||
(0.0, -1.0),
|
||||
(1.0, -1.0),
|
||||
(-1.0, 0.0),
|
||||
(1.0, 0.0),
|
||||
(-1.0, 1.0),
|
||||
(0.0, 1.0),
|
||||
(1.0, 1.0),
|
||||
] {
|
||||
emit(dx, dy, call.outline);
|
||||
}
|
||||
emit(0.0, 0.0, color);
|
||||
}
|
||||
emit(0.0, 0.0, call.color);
|
||||
}
|
||||
|
||||
/// Block-entity fields the level ticks, at one position (vanilla's own field
|
||||
/// values; extraction to render state happens here as in each renderer).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BlockAnim {
|
||||
/// `ShulkerBoxBlockEntity` progress and progressOld.
|
||||
pub shulker: (f32, f32),
|
||||
/// `BellBlockEntity.ticks` and the click direction while `shaking`.
|
||||
pub bell_ticks: i32,
|
||||
pub bell_shake: Option<&'static str>,
|
||||
/// `ConduitBlockEntity` tickCount, isActive, isHunting and activeRotation.
|
||||
pub conduit: (i32, bool, bool, f32),
|
||||
/// `SkullBlockEntity` isAnimating and animationTickCount.
|
||||
pub skull: (bool, i32),
|
||||
/// `DecoratedPotBlockEntity` decorations as item ids: back, left, right, front.
|
||||
pub sherds: [Option<String>; 4],
|
||||
/// `DecoratedPotRenderState` wobble style (positive) and progress.
|
||||
pub wobble: Option<(bool, f32)>,
|
||||
}
|
||||
|
||||
/// One `submitText` call (`AbstractSignRenderer.submitSignText`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextCall {
|
||||
pub matrix: Mat4,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub text: String,
|
||||
/// ARGB.
|
||||
pub color: u32,
|
||||
/// ARGB outline colour, 0 for none.
|
||||
pub outline: u32,
|
||||
/// Horizon packed light (sky*16 | block*16 << 8).
|
||||
pub light: u32,
|
||||
}
|
||||
|
||||
/// Inputs from the game for one frame of block-entity rendering.
|
||||
pub struct Scene<'a> {
|
||||
pub camera: DVec3,
|
||||
/// `Camera.rotation` (conduit eyes face the camera).
|
||||
pub orientation: Quat,
|
||||
/// Ticked block-entity state at a position.
|
||||
pub anim: &'a dyn Fn([i32; 3]) -> BlockAnim,
|
||||
/// When set, sign text is recorded here as text calls instead of drawn.
|
||||
pub texts: Option<&'a std::cell::RefCell<Vec<TextCall>>>,
|
||||
pub partial: f32,
|
||||
pub game_time: i64,
|
||||
pub world: &'a World,
|
||||
@@ -702,8 +774,8 @@ pub struct Scene<'a> {
|
||||
pub sign: &'a dyn Fn([i32; 3]) -> Option<&'a crate::signs::SignData>,
|
||||
/// Banner layers at a position: pattern texture and RGB tint, bottom first.
|
||||
pub banner: &'a dyn Fn([i32; 3]) -> Vec<(String, u32)>,
|
||||
/// Font glyph cell and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> Option<(Rect, f32)>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
}
|
||||
|
||||
pub struct BlockEntityRenderer {
|
||||
@@ -711,8 +783,10 @@ pub struct BlockEntityRenderer {
|
||||
kinds: Vec<Kind>,
|
||||
books: HashMap<[i32; 3], Book>,
|
||||
random: LegacyRandom,
|
||||
/// Item id to its `PROVIDES_POTTERY_PATTERN` side texture (`Sheets.DECORATED_POT_MAPPER`).
|
||||
pot_patterns: HashMap<String, String>,
|
||||
}
|
||||
const MODELS: [(&str, &str); 24] = [
|
||||
const MODELS: [(&str, &str); 27] = [
|
||||
("chest", "minecraft:chest#main"),
|
||||
("double_chest_left", "minecraft:double_chest_left#main"),
|
||||
("double_chest_right", "minecraft:double_chest_right#main"),
|
||||
@@ -734,6 +808,9 @@ const MODELS: [(&str, &str); 24] = [
|
||||
("bell", "minecraft:bell#main"),
|
||||
("book", "minecraft:book#main"),
|
||||
("conduit_shell", "minecraft:conduit#shell"),
|
||||
("conduit_cage", "minecraft:conduit#cage"),
|
||||
("conduit_wind", "minecraft:conduit#wind"),
|
||||
("conduit_eye", "minecraft:conduit#eye"),
|
||||
("decorated_pot_base", "minecraft:decorated_pot_base#main"),
|
||||
("decorated_pot_sides", "minecraft:decorated_pot_sides#main"),
|
||||
("copper_golem", "minecraft:copper_golem#main"),
|
||||
@@ -757,7 +834,30 @@ impl BlockEntityRenderer {
|
||||
.iter()
|
||||
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
|
||||
.collect();
|
||||
Ok(Self::new(models, registry))
|
||||
let mut renderer = Self::new(models, registry);
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
|
||||
let items: Value = serde_json::from_slice(&bytes)?;
|
||||
for (item, components) in items.as_object().into_iter().flatten() {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
|
||||
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
|
||||
let Ok(def) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let def: Value = serde_json::from_slice(&def)?;
|
||||
if let Some(asset) = def["asset_id"].as_str() {
|
||||
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
renderer
|
||||
.pot_patterns
|
||||
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(renderer)
|
||||
}
|
||||
fn new(models: HashMap<&'static str, ModelPart>, registry: &BlockRegistry) -> Self {
|
||||
let kinds = (0..registry.len() as u32)
|
||||
@@ -773,6 +873,7 @@ impl BlockEntityRenderer {
|
||||
kinds,
|
||||
books: HashMap::new(),
|
||||
random: LegacyRandom::new(0x5EED_B00C),
|
||||
pot_patterns: HashMap::new(),
|
||||
}
|
||||
}
|
||||
/// The standing banner's pole-and-bar and flag models (`BannerRenderer.submitSpecial`).
|
||||
@@ -831,6 +932,41 @@ impl BlockEntityRenderer {
|
||||
self.render_one(scene, *pos, kind, base, packed, lights, out);
|
||||
}
|
||||
}
|
||||
/// One block entity at `pos` drawn under `base` with the given packed light
|
||||
/// (the parity fixture's entry point).
|
||||
pub fn render_block(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
pos: [i32; 3],
|
||||
state: u32,
|
||||
base: Mat4,
|
||||
packed: u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let kind = self.kind(state);
|
||||
let lights = diffuse_lights(scene.nether);
|
||||
self.render_one(scene, pos, kind, base, packed, lights, out);
|
||||
}
|
||||
/// Whether this block state has a block-entity renderer here.
|
||||
pub fn renders(&self, state: u32) -> bool {
|
||||
!matches!(self.kind(state), Kind::None)
|
||||
}
|
||||
/// Sets an enchanting table's book fields (`EnchantingTableBlockEntity`):
|
||||
/// time, flip, oFlip, open, oOpen, rot, oRot.
|
||||
pub fn set_book(&mut self, pos: [i32; 3], time: i32, fields: [f32; 6]) {
|
||||
let [flip, o_flip, open, o_open, rot, o_rot] = fields;
|
||||
let book = self.books.entry(pos).or_default();
|
||||
*book = Book {
|
||||
time,
|
||||
flip,
|
||||
o_flip,
|
||||
open,
|
||||
o_open,
|
||||
rot,
|
||||
o_rot,
|
||||
..*book
|
||||
};
|
||||
}
|
||||
/// One block entity of `state` drawn under `base` (minecart contents).
|
||||
pub fn render_state(
|
||||
&self,
|
||||
@@ -913,7 +1049,11 @@ impl BlockEntityRenderer {
|
||||
)
|
||||
.distance_squared(scene.camera)
|
||||
< 256.0;
|
||||
sign_text(scene, data, text, m, packed, near, out);
|
||||
let calls = sign_text_calls(scene, data, text, m, packed, near);
|
||||
match scene.texts {
|
||||
Some(sink) => sink.borrow_mut().extend(calls),
|
||||
None => calls.iter().for_each(|c| draw_text(scene, c, out)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::Chest {
|
||||
@@ -968,8 +1108,18 @@ impl BlockEntityRenderer {
|
||||
* Mat4::from_quat(direction_rotation(facing))
|
||||
* Mat4::from_scale(Vec3::new(1.0, -1.0, -1.0))
|
||||
* Mat4::from_translation(Vec3::new(0.0, -1.0, 0.0));
|
||||
// ShulkerBoxModel.setupAnim(progress = 0): closed lid.
|
||||
draw_part(model, "root", m, &none, paint, out);
|
||||
// ShulkerBoxModel.setupAnim(getProgress(partial)): the lid rises and turns.
|
||||
let (progress, old) = (scene.anim)(pos).shulker;
|
||||
let progress = old + scene.partial * (progress - old);
|
||||
let lid = move |name: &str, p: &mut [f32; 9]| {
|
||||
if name == "lid" {
|
||||
p[0] = 0.0;
|
||||
p[1] = 24.0 - progress * 0.5 * 16.0;
|
||||
p[2] = 0.0;
|
||||
p[4] = 270.0 * progress * (PI / 180.0);
|
||||
}
|
||||
};
|
||||
draw_part(model, "root", m, &lid, paint, out);
|
||||
}
|
||||
Kind::Banner { color, wall, angle } => {
|
||||
let (main, flag) = if *wall {
|
||||
@@ -995,7 +1145,7 @@ impl BlockEntityRenderer {
|
||||
/ 100.0;
|
||||
let wave = move |name: &str, p: &mut [f32; 9]| {
|
||||
if name == "flag" {
|
||||
p[3] = (-0.0125 + 0.01 * (2.0 * PI * phase).cos()) * PI;
|
||||
p[3] = (-0.0125 + 0.01 * (2.0 * PI * phase).mcos()) * PI;
|
||||
}
|
||||
};
|
||||
if let Some(paint) = paint("entity/banner/banner_base", 0xFFFFFF, packed) {
|
||||
@@ -1027,7 +1177,7 @@ impl BlockEntityRenderer {
|
||||
SkullType::Creeper => ("creeper_head", "entity/creeper/creeper"),
|
||||
SkullType::Dragon => ("dragon_skull", "entity/enderdragon/dragon"),
|
||||
SkullType::Piglin => ("piglin_head", "entity/piglin/piglin"),
|
||||
SkullType::Player => ("player_head", "entity/player/wide/steve"),
|
||||
SkullType::Player => ("player_head", "entity/player/slim/steve"),
|
||||
};
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
@@ -1050,11 +1200,16 @@ impl BlockEntityRenderer {
|
||||
* Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0))
|
||||
}
|
||||
};
|
||||
// SkullModelBase.State with animationPos 0 (unpowered).
|
||||
let anim = |name: &str, p: &mut [f32; 9]| match name {
|
||||
"jaw" => p[3] = (0.0f32.sin() + 1.0) * 0.2,
|
||||
"left_ear" => p[5] = -(1.0 + 2.5) * 0.2,
|
||||
"right_ear" => p[5] = (1.0 + 2.5) * 0.2,
|
||||
// SkullBlockEntity.getAnimation: the dragon jaw and piglin ears
|
||||
// (DragonHeadModel / PiglinHeadModel use double-precision Math).
|
||||
let (animating, count) = (scene.anim)(pos).skull;
|
||||
let t = count as f32 + if animating { scene.partial } else { 0.0 };
|
||||
let anim = move |name: &str, p: &mut [f32; 9]| match name {
|
||||
"jaw" => p[3] = (f64::from(t * PI * 0.2).sin() + 1.0) as f32 * 0.2,
|
||||
"left_ear" => {
|
||||
p[5] = -((f64::from(t * PI * 0.2 * 1.2).cos() + 2.5) as f32) * 0.2;
|
||||
}
|
||||
"right_ear" => p[5] = (f64::from(t * PI * 0.2).cos() + 2.5) as f32 * 0.2,
|
||||
_ => {}
|
||||
};
|
||||
draw_part(model, "root", m, &anim, paint, out);
|
||||
@@ -1066,7 +1221,27 @@ impl BlockEntityRenderer {
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
draw_part(model, "root", base, &none, paint, out);
|
||||
// BellModel.setupAnim(State(ticks + partial, shakeDirection)).
|
||||
let a = (scene.anim)(pos);
|
||||
let ticks = a.bell_ticks as f32 + scene.partial;
|
||||
let (mut x_rot, mut z_rot) = (0.0, 0.0);
|
||||
if let Some(dir) = a.bell_shake {
|
||||
let base_rot = (ticks / PI).msin() / (4.0 + ticks / 3.0);
|
||||
match dir {
|
||||
"north" => x_rot = -base_rot,
|
||||
"south" => x_rot = base_rot,
|
||||
"east" => z_rot = -base_rot,
|
||||
"west" => z_rot = base_rot,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let shake = move |name: &str, p: &mut [f32; 9]| {
|
||||
if name == "bell_body" {
|
||||
p[3] = x_rot;
|
||||
p[5] = z_rot;
|
||||
}
|
||||
};
|
||||
draw_part(model, "root", base, &shake, paint, out);
|
||||
}
|
||||
Kind::DecoratedPot { y_rot } => {
|
||||
let (Some(base_model), Some(sides)) = (
|
||||
@@ -1075,38 +1250,111 @@ impl BlockEntityRenderer {
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let m = base
|
||||
let a = (scene.anim)(pos);
|
||||
let mut m = base
|
||||
* around(
|
||||
Mat4::from_rotation_y((180.0 - y_rot).to_radians()),
|
||||
Vec3::splat(0.5),
|
||||
);
|
||||
// DecoratedPotRenderer.submit: the wobble tilts around the base centre.
|
||||
let pivot = Vec3::new(0.5, 0.0, 0.5);
|
||||
if let Some((positive, progress)) = a.wobble
|
||||
&& (0.0..=1.0).contains(&progress)
|
||||
{
|
||||
if positive {
|
||||
let delta = progress * (PI * 2.0);
|
||||
let tilt_x = -1.5 * (delta.mcos() + 0.5) * (delta / 2.0).msin();
|
||||
m *= around(Mat4::from_rotation_x(tilt_x * 0.015_625), pivot);
|
||||
let tilt_z = delta.msin();
|
||||
m *= around(Mat4::from_rotation_z(tilt_z * 0.015_625), pivot);
|
||||
} else {
|
||||
let turn = (-progress * 3.0 * PI).msin() * 0.125;
|
||||
let decay = 1.0 - progress;
|
||||
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
|
||||
}
|
||||
}
|
||||
if let Some(paint) =
|
||||
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
|
||||
{
|
||||
draw_part(base_model, "root", m, &none, paint, out);
|
||||
}
|
||||
if let Some(paint) =
|
||||
paint("entity/decorated_pot/decorated_pot_side", 0xFFFFFF, packed)
|
||||
{
|
||||
draw_part(sides, "root", m, &none, paint, out);
|
||||
// PotDecorations (back, left, right, front); items without a pattern use the blank side.
|
||||
for (side, item) in ["back", "left", "right", "front"].iter().zip(&a.sherds) {
|
||||
let texture = item
|
||||
.as_ref()
|
||||
.and_then(|i| self.pot_patterns.get(i))
|
||||
.map_or("entity/decorated_pot/decorated_pot_side", String::as_str);
|
||||
if let (Some(part), Some(paint)) =
|
||||
(sides.child(side), paint(texture, 0xFFFFFF, packed))
|
||||
{
|
||||
draw_part(part, side, m, &none, paint, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::Conduit => {
|
||||
// Inactive conduit (no frame scan yet): the shell at activeRotation 0.
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("conduit_shell"),
|
||||
paint("entity/conduit/base", 0xFFFFFF, packed),
|
||||
) else {
|
||||
let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit;
|
||||
// ConduitRenderer.extractRenderState.
|
||||
let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375;
|
||||
let tex = |name: &str| paint(name, 0xFFFFFF, packed);
|
||||
if !active {
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get("conduit_shell"), tex("entity/conduit/base"))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let m = base
|
||||
* Mat4::from_translation(Vec3::splat(0.5))
|
||||
* Mat4::from_rotation_y(rotation.to_radians());
|
||||
draw_part(model, "root", m, &none, paint, out);
|
||||
return;
|
||||
}
|
||||
let anim_time = ticks as f32 + scene.partial;
|
||||
let phase = ticks / 66 % 3;
|
||||
let degrees = rotation * (180.0 / PI);
|
||||
let hh = (anim_time * 0.1).msin() / 2.0 + 0.5;
|
||||
let hh = hh * hh + hh;
|
||||
let lift = Vec3::new(0.5, 0.3 + hh * 0.2, 0.5);
|
||||
if let (Some(model), Some(paint)) =
|
||||
(self.models.get("conduit_cage"), tex("entity/conduit/cage"))
|
||||
{
|
||||
let axis = Vec3::new(0.5, 1.0, 0.5).normalize();
|
||||
let m = base
|
||||
* Mat4::from_translation(lift)
|
||||
* Mat4::from_quat(Quat::from_axis_angle(axis, degrees * (PI / 180.0)));
|
||||
draw_part(model, "root", m, &none, paint, out);
|
||||
}
|
||||
let wind = if phase == 1 {
|
||||
"entity/conduit/wind_vertical"
|
||||
} else {
|
||||
"entity/conduit/wind"
|
||||
};
|
||||
draw_part(
|
||||
model,
|
||||
"root",
|
||||
base * Mat4::from_translation(Vec3::splat(0.5)),
|
||||
&none,
|
||||
paint,
|
||||
out,
|
||||
);
|
||||
if let (Some(model), Some(paint)) = (self.models.get("conduit_wind"), tex(wind)) {
|
||||
let turn = match phase {
|
||||
1 => Mat4::from_rotation_x(PI / 2.0),
|
||||
2 => Mat4::from_rotation_z(PI / 2.0),
|
||||
_ => Mat4::IDENTITY,
|
||||
};
|
||||
let center = base * Mat4::from_translation(Vec3::splat(0.5));
|
||||
draw_part(model, "root", center * turn, &none, paint, out);
|
||||
let inner = center
|
||||
* Mat4::from_scale(Vec3::splat(0.875))
|
||||
* Mat4::from_quat(Quat::from_euler(glam::EulerRot::XYZ, PI, 0.0, PI));
|
||||
draw_part(model, "root", inner, &none, paint, out);
|
||||
}
|
||||
let eye = if hunting {
|
||||
"entity/conduit/open_eye"
|
||||
} else {
|
||||
"entity/conduit/closed_eye"
|
||||
};
|
||||
if let (Some(model), Some(paint)) = (self.models.get("conduit_eye"), tex(eye)) {
|
||||
let m = base
|
||||
* Mat4::from_translation(lift)
|
||||
* Mat4::from_scale(Vec3::splat(0.5))
|
||||
* Mat4::from_quat(scene.orientation)
|
||||
* Mat4::from_quat(Quat::from_rotation_z(PI) * Quat::from_rotation_y(PI))
|
||||
* Mat4::from_scale(Vec3::splat(1.333_333_4));
|
||||
draw_part(model, "root", m, &none, paint, out);
|
||||
}
|
||||
}
|
||||
Kind::EnchantingTable => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
@@ -1123,12 +1371,13 @@ impl BlockEntityRenderer {
|
||||
let y_rot = b.o_rot + wrap(b.rot - b.o_rot) * a;
|
||||
let m = base
|
||||
* Mat4::from_translation(Vec3::new(0.5, 0.75, 0.5))
|
||||
* Mat4::from_translation(Vec3::new(0.0, 0.1 + (time * 0.1).sin() * 0.01, 0.0))
|
||||
* Mat4::from_translation(Vec3::new(0.0, 0.1 + (time * 0.1).msin() * 0.01, 0.0))
|
||||
* Mat4::from_rotation_y(-y_rot)
|
||||
* Mat4::from_rotation_z(80f32.to_radians());
|
||||
let ff1 = (frac(flip + 0.25) * 1.6 - 0.3).clamp(0.0, 1.0);
|
||||
let ff2 = (frac(flip + 0.75) * 1.6 - 0.3).clamp(0.0, 1.0);
|
||||
let openness = ((time * 0.02).sin() * 0.1 + 1.25) * open;
|
||||
// BookModel.State.forAnimation.
|
||||
let openness = ((time * 0.02).msin() * 0.1 + 1.25) * open;
|
||||
draw_part(model, "root", m, &book_pose(openness, ff1, ff2), paint, out);
|
||||
}
|
||||
Kind::Lectern { y_rot } => {
|
||||
@@ -1311,3 +1560,89 @@ mod tests {
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `BeaconRenderer.submit`: for each beam section (ARGB colour, height) the
|
||||
/// rotating solid beam and its translucent glow, the last section 2048
|
||||
/// blocks tall. `origin` is the beacon block's corner relative to the camera,
|
||||
/// `time` the animation time (game time mod 40 plus partial tick) and `scale`
|
||||
/// the radius scale. The sprite's v range rides in the light bytes (the
|
||||
/// entity shader wraps v for layer 6).
|
||||
pub fn beacon_beam(
|
||||
origin: Vec3,
|
||||
sections: &[(u32, i32)],
|
||||
time: f32,
|
||||
scale: f32,
|
||||
s: Rect,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let q = |v: f32| (v * 65535.0).round().clamp(0.0, 65535.0) as u32;
|
||||
let wrap = q(s.v0) | q(s.v1 - s.v0) << 16;
|
||||
let o = origin + Vec3::new(0.5, 0.0, 0.5);
|
||||
let mut start = 0i32;
|
||||
for (i, &(color, height)) in sections.iter().enumerate() {
|
||||
let height = if i == sections.len() - 1 {
|
||||
2048
|
||||
} else {
|
||||
height
|
||||
};
|
||||
let end = start + height;
|
||||
let scroll = if height < 0 { time } else { -time };
|
||||
let v_off = frac(scroll * 0.2 - (scroll * 0.1).floor());
|
||||
let abgr =
|
||||
|a: u32| a << 24 | (color & 255) << 16 | (color >> 8 & 255) << 8 | (color >> 16 & 255);
|
||||
// Solid beam: radius 0.2 * scale, rotated with time.
|
||||
let r = 0.2 * scale;
|
||||
let rot = glam::Mat3::from_rotation_y((time * 2.25 - 45.0).to_radians());
|
||||
let corners = [
|
||||
rot * Vec3::new(0.0, 0.0, r),
|
||||
rot * Vec3::new(r, 0.0, 0.0),
|
||||
rot * Vec3::new(-r, 0.0, 0.0),
|
||||
rot * Vec3::new(0.0, 0.0, -r),
|
||||
];
|
||||
let v2 = -1.0 + v_off;
|
||||
let v1 = height as f32 * (0.5 / r) + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], abgr(255), s, wrap);
|
||||
// Glow: radius 0.25 * scale, translucent.
|
||||
let g = 0.25 * scale;
|
||||
let corners = [
|
||||
Vec3::new(-g, 0.0, -g),
|
||||
Vec3::new(g, 0.0, -g),
|
||||
Vec3::new(-g, 0.0, g),
|
||||
Vec3::new(g, 0.0, g),
|
||||
];
|
||||
let v1 = height as f32 + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], abgr(32), s, wrap);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
/// `BeaconRenderer.renderPart`: four quads between the corners (wn, en, ws, es).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn beam_part(
|
||||
out: &mut Vec<Vertex>,
|
||||
o: Vec3,
|
||||
start: i32,
|
||||
end: i32,
|
||||
[wn, en, ws, es]: [Vec3; 4],
|
||||
[v1, v2]: [f32; 2],
|
||||
color: u32,
|
||||
s: Rect,
|
||||
wrap: u32,
|
||||
) {
|
||||
for (a, b) in [(wn, en), (es, ws), (en, es), (ws, wn)] {
|
||||
let at = |c: Vec3, y: i32| o + c + Vec3::new(0.0, y as f32, 0.0);
|
||||
for (p, uv) in [
|
||||
(at(a, end), [s.u1, v1]),
|
||||
(at(a, start), [s.u1, v2]),
|
||||
(at(b, start), [s.u0, v2]),
|
||||
(at(b, end), [s.u0, v1]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
color,
|
||||
light: wrap,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ mod game_mode;
|
||||
mod ghasts;
|
||||
mod golems;
|
||||
mod growth;
|
||||
mod health_hud;
|
||||
mod jukebox;
|
||||
mod lan;
|
||||
mod lan_actions;
|
||||
@@ -107,6 +106,7 @@ use horizon_client::{
|
||||
environment::Environment,
|
||||
gfx::{Frame, Renderer},
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
hud_layout,
|
||||
items::{ItemStack, Items},
|
||||
keymapping::KeyAction,
|
||||
light::LightTables,
|
||||
@@ -585,7 +585,7 @@ struct Game<'a> {
|
||||
fps_frames: u64,
|
||||
fps_at: Instant,
|
||||
ticks: u64,
|
||||
health_hud: health_hud::HealthHud,
|
||||
health_hud: hud_layout::HealthHud,
|
||||
error: Option<String>,
|
||||
message: Option<(String, Instant)>,
|
||||
save: Arc<SaveDir>,
|
||||
@@ -3098,49 +3098,39 @@ impl Game<'_> {
|
||||
return batch;
|
||||
}
|
||||
self.draw_spyglass(&mut batch);
|
||||
{
|
||||
let atlas = &self.res.atlas;
|
||||
// Crosshair (first person only).
|
||||
if self.camera_type.first_person() {
|
||||
batch.sprite(
|
||||
atlas,
|
||||
"gui/sprites/hud/crosshair",
|
||||
(w - 15.0) / 2.0,
|
||||
(h - 15.0) / 2.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Gui: spectators get the (closed) spectator menu instead of the
|
||||
// hotbar and no player health.
|
||||
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
|
||||
// extractVehicleHealth and the experience bar, laid out by hud_layout.
|
||||
// Horizon has no attack cooldown yet: the strength is always full.
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
let indicator = self.settings.attack_indicator;
|
||||
if self.camera_type.first_person() && !spectator {
|
||||
let attack = (indicator == 1).then_some(1.0);
|
||||
let draws = hud_layout::crosshair(wi, hi, attack, false);
|
||||
hud_layout::paint(&mut batch, &self.res.atlas, &draws);
|
||||
}
|
||||
if !spectator {
|
||||
let atlas = &self.res.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/hud/hotbar", w / 2.0 - 91.0, h - 22.0);
|
||||
batch.sprite(
|
||||
atlas,
|
||||
"gui/sprites/hud/hotbar_selection",
|
||||
w / 2.0 - 91.0 - 1.0 + self.selected as f32 * 20.0,
|
||||
h - 23.0,
|
||||
);
|
||||
}
|
||||
// Hud.extractItemHotbar: the off hand left of the hotbar (right-handed).
|
||||
if let Some(stack) = self.offhand.clone().filter(|_| !spectator) {
|
||||
batch.sprite(
|
||||
&self.res.atlas,
|
||||
"gui/sprites/hud/hotbar_offhand_left",
|
||||
w / 2.0 - 91.0 - 29.0,
|
||||
h - 23.0,
|
||||
);
|
||||
self.draw_item(&mut batch, &stack, w / 2.0 - 91.0 - 26.0, h - 19.0);
|
||||
}
|
||||
for i in 0..9 {
|
||||
if let Some(stack) = self.inventory[i].clone().filter(|_| !spectator) {
|
||||
self.draw_item(
|
||||
&mut batch,
|
||||
&stack,
|
||||
w / 2.0 - 90.0 + i as f32 * 20.0 + 2.0,
|
||||
h - 19.0,
|
||||
);
|
||||
let inputs = hud_layout::HotbarInputs {
|
||||
selected: self.selected as i32,
|
||||
right_handed: !self.settings.main_hand_left,
|
||||
offhand: self.offhand.is_some(),
|
||||
slots: std::array::from_fn(|i| self.inventory[i].as_ref().map(|_| 0.0)),
|
||||
offhand_pop: 0.0,
|
||||
attack: (indicator == 2).then_some(1.0),
|
||||
};
|
||||
let draws = hud_layout::hotbar(wi, hi, &inputs);
|
||||
hud_layout::paint(&mut batch, &self.res.atlas, &draws);
|
||||
for d in &draws {
|
||||
if let hud_layout::GuiDraw::Item { slot, x, y, .. } = *d {
|
||||
let stack = if slot == 40 {
|
||||
self.offhand.clone()
|
||||
} else {
|
||||
self.inventory[slot as usize].clone()
|
||||
};
|
||||
if let Some(stack) = stack {
|
||||
self.draw_item(&mut batch, &stack, x as f32, y as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.draw_effect_icons(&mut batch);
|
||||
@@ -3150,57 +3140,49 @@ impl Game<'_> {
|
||||
self.draw_titles(&mut batch);
|
||||
self.draw_scoreboard(&mut batch);
|
||||
self.draw_subtitles(&mut batch);
|
||||
// Gui.renderVehicleHealth / the jump bar while riding a mount.
|
||||
let mounted = !self.creative && !spectator && self.draw_mount_hud(&mut batch, w, h);
|
||||
let atlas = &self.res.atlas;
|
||||
// The mount's hearts (and the jump bar that replaces the experience bar).
|
||||
let (vehicle_hearts, jump_bar) = if spectator {
|
||||
(0, false)
|
||||
} else {
|
||||
self.draw_mount_hud(&mut batch, wi, hi)
|
||||
};
|
||||
if !self.creative
|
||||
&& !spectator
|
||||
&& let Some(p) = &mut self.player
|
||||
{
|
||||
// Experience bar and hearts/food (Gui.renderPlayerHealth).
|
||||
let saddled_mount = mounted
|
||||
&& self
|
||||
.riding
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.is_some_and(|m| m.extra.saddled);
|
||||
if !saddled_mount {
|
||||
batch.sprite(
|
||||
atlas,
|
||||
"gui/sprites/hud/experience_bar_background",
|
||||
w / 2.0 - 91.0,
|
||||
h - 29.0,
|
||||
);
|
||||
let filled = (self.experience.progress * 183.0).floor() / 182.0;
|
||||
if filled > 0.0 {
|
||||
batch.sprite_part(
|
||||
atlas,
|
||||
"gui/sprites/hud/experience_bar_progress",
|
||||
w / 2.0 - 91.0,
|
||||
h - 29.0,
|
||||
filled.min(1.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
if self.experience.level > 0 {
|
||||
// Gui.renderExperienceLevel: green text with a black outline.
|
||||
let text = self.experience.level.to_string();
|
||||
let tw = HudBatch::text_width(atlas, &text);
|
||||
let (x, y) = ((w - tw) / 2.0, h - 31.0 - 4.0);
|
||||
for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] {
|
||||
batch.text(atlas, &text, x + dx, y + dy, rgba(0, 0, 0, 255), false);
|
||||
}
|
||||
batch.text(atlas, &text, x, y, rgba(128, 255, 32, 255), false);
|
||||
}
|
||||
let atlas = &self.res.atlas;
|
||||
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
|
||||
let draws = hud_layout::experience(
|
||||
wi,
|
||||
hi,
|
||||
self.experience.progress,
|
||||
self.experience.level,
|
||||
!jump_bar,
|
||||
&width,
|
||||
);
|
||||
hud_layout::paint(&mut batch, atlas, &draws);
|
||||
let effects = &self.potions.effects;
|
||||
let kind = if effects.get("poison").is_some() {
|
||||
health_hud::HeartKind::Poisoned
|
||||
hud_layout::HeartKind::Poisoned
|
||||
} else if effects.get("wither").is_some() {
|
||||
health_hud::HeartKind::Withered
|
||||
hud_layout::HeartKind::Withered
|
||||
} else {
|
||||
health_hud::HeartKind::Normal
|
||||
hud_layout::HeartKind::Normal
|
||||
};
|
||||
let inputs = health_hud::HealthInputs {
|
||||
tick: self.ticks,
|
||||
let eye = p.position.y + f64::from(p.dimensions.eye_height);
|
||||
let under_water = self
|
||||
.res
|
||||
.facts
|
||||
.get(self.world.block(
|
||||
p.position.x.floor() as i32,
|
||||
eye.floor() as i32,
|
||||
p.position.z.floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
let inputs = hud_layout::HealthInputs {
|
||||
tick: self.ticks as i32,
|
||||
millis: self.started.elapsed().as_millis() as i64,
|
||||
health: p.health.health(),
|
||||
max_health: p.attribute("minecraft:max_health").unwrap_or(20.0) as f32,
|
||||
absorption: p.health.absorption(),
|
||||
@@ -3215,15 +3197,16 @@ impl Game<'_> {
|
||||
hunger: effects.get("hunger").is_some(),
|
||||
hardcore: self.level.hardcore,
|
||||
damage_cooldown: p.health.hurt.cooldown > 0,
|
||||
show_food: !mounted,
|
||||
// Horizon does not track the player's air supply yet.
|
||||
air: 300,
|
||||
max_air: 300,
|
||||
under_water,
|
||||
vehicle_hearts,
|
||||
};
|
||||
for (name, x, y) in
|
||||
self.health_hud
|
||||
.layout(&inputs, w as i32, h as i32, std::time::Instant::now())
|
||||
{
|
||||
batch.sprite(atlas, &name, x as f32, y as f32);
|
||||
}
|
||||
let draws = self.health_hud.layout(&inputs, wi, hi);
|
||||
hud_layout::paint(&mut batch, atlas, &draws);
|
||||
}
|
||||
let atlas = &self.res.atlas;
|
||||
if self.debug {
|
||||
let p = self
|
||||
.player
|
||||
@@ -4473,8 +4456,23 @@ impl Game<'_> {
|
||||
.unwrap_or((0.0, 0.0));
|
||||
prev + (cur - prev) * alpha as f32
|
||||
};
|
||||
let (view_yaw, view_pitch) =
|
||||
if self.camera_type == perspective::CameraType::ThirdPersonFront {
|
||||
(self.yaw + 180.0, -self.pitch)
|
||||
} else {
|
||||
(self.yaw, self.pitch)
|
||||
};
|
||||
let scene = horizon_client::block_entity_render::Scene {
|
||||
camera,
|
||||
// Camera.setRotation.
|
||||
orientation: glam::Quat::from_euler(
|
||||
glam::EulerRot::YXZ,
|
||||
std::f32::consts::PI - view_yaw.to_radians(),
|
||||
-view_pitch.to_radians(),
|
||||
0.0,
|
||||
),
|
||||
anim: &|_| horizon_client::block_entity_render::BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: alpha as f32,
|
||||
game_time: self.world.game_time,
|
||||
world: &self.world,
|
||||
@@ -4486,7 +4484,7 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
},
|
||||
banner: &|p| self.banner_layers_at(p),
|
||||
glyph: &|c| self.res.atlas.glyph(c),
|
||||
glyph: &|c| self.res.atlas.glyph_cell(c),
|
||||
};
|
||||
self.res
|
||||
.block_entity_render
|
||||
@@ -8468,7 +8466,7 @@ pub fn run() -> Result<()> {
|
||||
screen: Screen::Playing,
|
||||
window: None,
|
||||
renderer: None,
|
||||
health_hud: health_hud::HealthHud::default(),
|
||||
health_hud: hud_layout::HealthHud::default(),
|
||||
started: now,
|
||||
previous: now,
|
||||
last_input: now,
|
||||
|
||||
@@ -522,9 +522,6 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The sprite's v range rides in the light bytes (entity shader overlay 6).
|
||||
let q = |v: f32| (v * 65535.0).round().clamp(0.0, 65535.0) as u32;
|
||||
let wrap = q(s.v0) | q(s.v1 - s.v0) << 16;
|
||||
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
|
||||
let scoping = self.scoping();
|
||||
for (k, e) in &self.world.block_entities {
|
||||
@@ -539,75 +536,17 @@ impl Game<'_> {
|
||||
} else {
|
||||
(distance / 96.0).max(1.0)
|
||||
};
|
||||
let o = (base - camera).as_vec3();
|
||||
let mut start = 0i32;
|
||||
for (i, &(color, height)) in b.sections.iter().enumerate() {
|
||||
let height = if i == b.sections.len() - 1 {
|
||||
2048
|
||||
} else {
|
||||
height
|
||||
};
|
||||
let end = start + height;
|
||||
let scroll = -time;
|
||||
let v_off = (scroll * 0.2 - (scroll * 0.1).floor()).rem_euclid(1.0);
|
||||
let rgb = |a: u32| {
|
||||
a << 24 | (color & 255) << 16 | (color >> 8 & 255) << 8 | (color >> 16 & 255)
|
||||
};
|
||||
// Solid beam: radius 0.2, rotated with time.
|
||||
let r = 0.2 * scale;
|
||||
let rot = glam::Mat3::from_rotation_y((time * 2.25 - 45.0).to_radians());
|
||||
let corners = [
|
||||
rot * glam::Vec3::new(0.0, 0.0, r),
|
||||
rot * glam::Vec3::new(r, 0.0, 0.0),
|
||||
rot * glam::Vec3::new(-r, 0.0, 0.0),
|
||||
rot * glam::Vec3::new(0.0, 0.0, -r),
|
||||
];
|
||||
let v2 = -1.0 + v_off;
|
||||
let v1 = height as f32 * (0.5 / r) + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], rgb(255), s, wrap);
|
||||
// Glow: radius 0.25, translucent.
|
||||
let g = 0.25 * scale;
|
||||
let corners = [
|
||||
glam::Vec3::new(-g, 0.0, -g),
|
||||
glam::Vec3::new(g, 0.0, -g),
|
||||
glam::Vec3::new(-g, 0.0, g),
|
||||
glam::Vec3::new(g, 0.0, g),
|
||||
];
|
||||
let v1 = height as f32 + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], rgb(32), s, wrap);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `renderPart`: four quads between the corners (wn, en, ws, es).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn beam_part(
|
||||
out: &mut Vec<Vertex>,
|
||||
o: glam::Vec3,
|
||||
start: i32,
|
||||
end: i32,
|
||||
[wn, en, ws, es]: [glam::Vec3; 4],
|
||||
[v1, v2]: [f32; 2],
|
||||
color: u32,
|
||||
s: horizon_client::hud::Rect,
|
||||
wrap: u32,
|
||||
) {
|
||||
for (a, b) in [(wn, en), (es, ws), (en, es), (ws, wn)] {
|
||||
let at = |c: glam::Vec3, y: i32| o + c + glam::Vec3::new(0.0, y as f32, 0.0);
|
||||
for (p, uv) in [
|
||||
(at(a, end), [s.u1, v1]),
|
||||
(at(a, start), [s.u1, v2]),
|
||||
(at(b, start), [s.u0, v2]),
|
||||
(at(b, end), [s.u0, v1]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
color,
|
||||
light: wrap,
|
||||
});
|
||||
let origin = (glam::DVec3::new(f64::from(k.0), f64::from(k.1), f64::from(k.2))
|
||||
- camera)
|
||||
.as_vec3();
|
||||
horizon_client::block_entity_render::beacon_beam(
|
||||
origin,
|
||||
&b.sections,
|
||||
time,
|
||||
scale,
|
||||
s,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
//! `Hud.extractPlayerHealth`: armour, hearts, food. The layout is computed as
|
||||
//! a list of sprites so it can be tested without drawing.
|
||||
use std::time::Instant;
|
||||
|
||||
/// `java.util.Random`, for the HUD's per-tick jitter.
|
||||
pub(super) struct JavaRandom(i64);
|
||||
impl JavaRandom {
|
||||
pub fn new(seed: i64) -> Self {
|
||||
Self((seed ^ 0x5_DEEC_E66D) & ((1 << 48) - 1))
|
||||
}
|
||||
fn next(&mut self, bits: u32) -> i32 {
|
||||
self.0 = (self.0.wrapping_mul(0x5_DEEC_E66D).wrapping_add(0xB)) & ((1 << 48) - 1);
|
||||
(self.0 >> (48 - bits)) as i32
|
||||
}
|
||||
pub fn next_int(&mut self, bound: i32) -> i32 {
|
||||
let mut r = self.next(31);
|
||||
let m = bound - 1;
|
||||
if bound & m == 0 {
|
||||
return ((i64::from(bound) * i64::from(r)) >> 31) as i32;
|
||||
}
|
||||
let mut u = r;
|
||||
loop {
|
||||
r = u % bound;
|
||||
if u.wrapping_sub(r).wrapping_add(m) >= 0 {
|
||||
return r;
|
||||
}
|
||||
u = self.next(31);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum HeartKind {
|
||||
Normal,
|
||||
Poisoned,
|
||||
Withered,
|
||||
Absorbing,
|
||||
}
|
||||
impl HeartKind {
|
||||
fn prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "",
|
||||
Self::Poisoned => "poisoned_",
|
||||
Self::Withered => "withered_",
|
||||
Self::Absorbing => "absorbing_",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State kept between frames (`Hud.lastHealth`, `displayHealth`, ...).
|
||||
pub(super) struct HealthHud {
|
||||
last_health: i32,
|
||||
display_health: i32,
|
||||
last_health_time: Instant,
|
||||
health_blink_time: i64,
|
||||
}
|
||||
impl Default for HealthHud {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_health: 0,
|
||||
display_health: 0,
|
||||
last_health_time: Instant::now(),
|
||||
health_blink_time: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct HealthInputs {
|
||||
pub tick: u64,
|
||||
pub health: f32,
|
||||
pub max_health: f32,
|
||||
pub absorption: f32,
|
||||
pub armor: i32,
|
||||
pub food: i32,
|
||||
pub saturation: f32,
|
||||
pub kind: HeartKind,
|
||||
pub regeneration: bool,
|
||||
pub hunger: bool,
|
||||
pub hardcore: bool,
|
||||
/// `damageCooldownTime > 0`.
|
||||
pub damage_cooldown: bool,
|
||||
/// A mount with its own hearts replaces the food bar.
|
||||
pub show_food: bool,
|
||||
}
|
||||
|
||||
fn heart_sprite(kind: HeartKind, hardcore: bool, half: bool, blink: bool) -> String {
|
||||
let (hc, h) = (
|
||||
if hardcore { "hardcore_" } else { "" },
|
||||
if half { "half" } else { "full" },
|
||||
);
|
||||
let b = if blink { "_blinking" } else { "" };
|
||||
format!("gui/sprites/hud/heart/{}{hc}{h}{b}", kind.prefix())
|
||||
}
|
||||
|
||||
impl HealthHud {
|
||||
/// Sprites (name, x, y) in draw order for a screen of `w` x `h` GUI pixels.
|
||||
pub fn layout(
|
||||
&mut self,
|
||||
i: &HealthInputs,
|
||||
w: i32,
|
||||
h: i32,
|
||||
now: Instant,
|
||||
) -> Vec<(String, i32, i32)> {
|
||||
let mut out = Vec::new();
|
||||
let tick = i64::try_from(i.tick).unwrap_or(i64::MAX);
|
||||
let current = i.health.ceil() as i32;
|
||||
let blink = self.health_blink_time > tick && (self.health_blink_time - tick) / 3 % 2 == 1;
|
||||
if current < self.last_health && i.damage_cooldown {
|
||||
self.last_health_time = now;
|
||||
self.health_blink_time = tick + 20;
|
||||
} else if current > self.last_health && i.damage_cooldown {
|
||||
self.last_health_time = now;
|
||||
self.health_blink_time = tick + 10;
|
||||
}
|
||||
if now.duration_since(self.last_health_time).as_millis() > 1000 {
|
||||
self.display_health = current;
|
||||
self.last_health_time = now;
|
||||
}
|
||||
self.last_health = current;
|
||||
let old = self.display_health;
|
||||
let mut random = JavaRandom::new(i64::from((i.tick as i32).wrapping_mul(312_871)));
|
||||
let (x_left, x_right, y_base) = (w / 2 - 91, w / 2 + 91, h - 39);
|
||||
let max_health = i.max_health.max(old.max(current) as f32);
|
||||
let absorption = i.absorption.ceil() as i32;
|
||||
let rows = ((max_health + absorption as f32) / 2.0 / 10.0).ceil() as i32;
|
||||
let row_height = (10 - (rows - 2)).max(3);
|
||||
let offset_index = if i.regeneration {
|
||||
(i.tick as i32).rem_euclid((max_health + 5.0).ceil() as i32)
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
if i.armor > 0 {
|
||||
let y = y_base - (rows - 1) * row_height - 10;
|
||||
for n in 0..10 {
|
||||
let name = match (n * 2 + 1).cmp(&i.armor) {
|
||||
std::cmp::Ordering::Less => "armor_full",
|
||||
std::cmp::Ordering::Equal => "armor_half",
|
||||
std::cmp::Ordering::Greater => "armor_empty",
|
||||
};
|
||||
out.push((format!("gui/sprites/hud/{name}"), x_left + n * 8, y));
|
||||
}
|
||||
}
|
||||
let containers = (max_health / 2.0).ceil() as i32;
|
||||
let absorb_containers = (absorption as f32 / 2.0).ceil() as i32;
|
||||
let container = |hardcore: bool, blink: bool| {
|
||||
format!(
|
||||
"gui/sprites/hud/heart/container{}{}",
|
||||
if hardcore { "_hardcore" } else { "" },
|
||||
if blink { "_blinking" } else { "" }
|
||||
)
|
||||
};
|
||||
for c in (0..containers + absorb_containers).rev() {
|
||||
let (row, column) = (c / 10, c % 10);
|
||||
let x = x_left + column * 8;
|
||||
let mut y = y_base - row * row_height;
|
||||
if current + absorption <= 4 {
|
||||
y += random.next_int(2);
|
||||
}
|
||||
if c < containers && c == offset_index {
|
||||
y -= 2;
|
||||
}
|
||||
out.push((container(i.hardcore, blink), x, y));
|
||||
let halves = c * 2;
|
||||
if c >= containers {
|
||||
let ah = halves - containers * 2;
|
||||
if ah < absorption {
|
||||
let kind = if i.kind == HeartKind::Withered {
|
||||
i.kind
|
||||
} else {
|
||||
HeartKind::Absorbing
|
||||
};
|
||||
out.push((
|
||||
heart_sprite(kind, i.hardcore, ah + 1 == absorption, false),
|
||||
x,
|
||||
y,
|
||||
));
|
||||
}
|
||||
}
|
||||
if blink && halves < old {
|
||||
out.push((
|
||||
heart_sprite(i.kind, i.hardcore, halves + 1 == old, true),
|
||||
x,
|
||||
y,
|
||||
));
|
||||
}
|
||||
if halves < current {
|
||||
out.push((
|
||||
heart_sprite(i.kind, i.hardcore, halves + 1 == current, false),
|
||||
x,
|
||||
y,
|
||||
));
|
||||
}
|
||||
}
|
||||
if i.show_food {
|
||||
let suffix = if i.hunger { "_hunger" } else { "" };
|
||||
for n in 0..10 {
|
||||
let mut y = y_base;
|
||||
if i.saturation <= 0.0 && (i.tick as i32).rem_euclid(i.food * 3 + 1) == 0 {
|
||||
y += random.next_int(3) - 1;
|
||||
}
|
||||
let x = x_right - n * 8 - 9;
|
||||
out.push((format!("gui/sprites/hud/food_empty{suffix}"), x, y));
|
||||
if n * 2 + 1 < i.food {
|
||||
out.push((format!("gui/sprites/hud/food_full{suffix}"), x, y));
|
||||
}
|
||||
if n * 2 + 1 == i.food {
|
||||
out.push((format!("gui/sprites/hud/food_half{suffix}"), x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn java_random_matches_reference() {
|
||||
// new Random(42).nextInt(10) sequence: 0, 3, 8, 4, 0.
|
||||
let mut r = JavaRandom::new(42);
|
||||
let v: Vec<i32> = (0..5).map(|_| r.next_int(10)).collect();
|
||||
assert_eq!(v, [0, 3, 8, 4, 0]);
|
||||
}
|
||||
}
|
||||
@@ -1231,6 +1231,9 @@ impl Game<'_> {
|
||||
let lid = |_: [i32; 3]| 0.0;
|
||||
let scene = horizon_client::block_entity_render::Scene {
|
||||
camera: glam::DVec3::ZERO,
|
||||
orientation: glam::Quat::IDENTITY,
|
||||
anim: &|_| horizon_client::block_entity_render::BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: self.world.game_time,
|
||||
world: &self.world,
|
||||
@@ -1239,7 +1242,7 @@ impl Game<'_> {
|
||||
lid: &lid,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
glyph: &|c| self.res.atlas.glyph(c),
|
||||
glyph: &|c| self.res.atlas.glyph_cell(c),
|
||||
};
|
||||
self.res
|
||||
.block_entity_render
|
||||
|
||||
@@ -453,38 +453,21 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `Gui.renderVehicleHealth` and the jump bar (`JumpableVehicleBarRenderer`).
|
||||
pub(super) fn draw_mount_hud(&self, batch: &mut HudBatch, w: f32, h: f32) -> bool {
|
||||
/// `Hud.extractVehicleHealth` and the jump bar that replaces the
|
||||
/// experience bar on a saddled jumper. Returns the mount's heart count
|
||||
/// (`getVehicleMaxHearts`) and whether the jump bar shows.
|
||||
pub(super) fn draw_mount_hud(&self, batch: &mut HudBatch, w: i32, h: i32) -> (i32, bool) {
|
||||
let Some(i) = self.mount_index() else {
|
||||
return false;
|
||||
return (0, false);
|
||||
};
|
||||
let m = &self.mobs[i];
|
||||
let atlas = &self.res.atlas;
|
||||
// Hearts in rows of ten, right-aligned where the food bar sits.
|
||||
let max = (m.attrs.max_health + 0.5) as i32 / 2;
|
||||
let health = m.health.ceil() as i32;
|
||||
let right = w / 2.0 + 91.0;
|
||||
let mut y = h - 39.0;
|
||||
let mut drawn = 0;
|
||||
while drawn < max {
|
||||
let row = (max - drawn).min(10);
|
||||
for k in 0..row {
|
||||
let x = right - k as f32 * 8.0 - 9.0;
|
||||
batch.sprite(atlas, "gui/sprites/hud/heart/vehicle_container", x, y);
|
||||
let v = health - (drawn + k) * 2;
|
||||
if v >= 2 {
|
||||
batch.sprite(atlas, "gui/sprites/hud/heart/vehicle_full", x, y);
|
||||
} else if v == 1 {
|
||||
batch.sprite(atlas, "gui/sprites/hud/heart/vehicle_half", x, y);
|
||||
}
|
||||
}
|
||||
drawn += row;
|
||||
y -= 10.0;
|
||||
}
|
||||
// The jump bar replaces the experience bar on a saddled jumper.
|
||||
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health as f32);
|
||||
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health as f32);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
if m.extra.saddled {
|
||||
let x = w / 2.0 - 91.0;
|
||||
let y = h - 29.0;
|
||||
let x = (w / 2 - 91) as f32;
|
||||
let y = (h - 29) as f32;
|
||||
batch.sprite(atlas, "gui/sprites/hud/jump_bar_background", x, y);
|
||||
if self.mount.jump_ticks < 0 {
|
||||
batch.sprite(atlas, "gui/sprites/hud/jump_bar_cooldown", x, y);
|
||||
@@ -501,6 +484,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
(hearts, m.extra.saddled)
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,11 @@ impl HudAtlas {
|
||||
let g = self.font.glyph(ch);
|
||||
g.rect.map(|rect| (rect, g.advance))
|
||||
}
|
||||
/// A font glyph's atlas cell (none for blank glyphs such as the space) and advance.
|
||||
pub fn glyph_cell(&self, ch: char) -> (Option<Rect>, f32) {
|
||||
let g = self.font.glyph(ch);
|
||||
(g.rect, g.advance)
|
||||
}
|
||||
pub fn build(root: &Path) -> Result<Self> {
|
||||
let textures = root.join("assets/minecraft/textures");
|
||||
let mut files = Vec::new();
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
//! Layout of vanilla's `Hud` decorations as draw calls (`GuiGraphicsExtractor`
|
||||
//! blits and text): the hotbar with its selection, off-hand slot, items and
|
||||
//! attack indicator, the crosshair, armour, hearts, food, air bubbles, mount
|
||||
//! hearts and the experience bar and level. Computed without drawing so the
|
||||
//! parity fixture can compare them with the vanilla client.
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// One `GuiGraphicsExtractor` call.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum GuiDraw {
|
||||
/// `blitSprite(sprite, x, y, w, h)`; `region` = (spriteW, spriteH, u, v)
|
||||
/// for the partial overload.
|
||||
Sprite {
|
||||
sprite: &'static str,
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
region: Option<[i32; 4]>,
|
||||
},
|
||||
/// `text(font, text, x, y, argb, dropShadow)`.
|
||||
Text {
|
||||
text: String,
|
||||
x: i32,
|
||||
y: i32,
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
},
|
||||
/// `item(owner, stack, x, y, seed)` for a hotbar slot (0-8, 40 = off hand)
|
||||
/// under the pose's x/y scale about (cx, cy) (the pop squeeze).
|
||||
Item {
|
||||
slot: u32,
|
||||
x: i32,
|
||||
y: i32,
|
||||
seed: i32,
|
||||
scale: [f32; 2],
|
||||
pivot: [f32; 2],
|
||||
},
|
||||
}
|
||||
fn sprite(sprite: &'static str, x: i32, y: i32, w: i32, h: i32) -> GuiDraw {
|
||||
GuiDraw::Sprite {
|
||||
sprite,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
region: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hud.extractItemHotbar` inputs.
|
||||
pub struct HotbarInputs {
|
||||
pub selected: i32,
|
||||
/// The main arm is the right one (the off hand sits left of the hotbar).
|
||||
pub right_handed: bool,
|
||||
pub offhand: bool,
|
||||
/// Pop time minus partial tick (`ItemStack.getPopTime`) per hotbar slot
|
||||
/// that holds an item, then the off hand.
|
||||
pub slots: [Option<f32>; 9],
|
||||
pub offhand_pop: f32,
|
||||
/// `getAttackStrengthScale(0)` when the attack indicator is in HOTBAR mode.
|
||||
pub attack: Option<f32>,
|
||||
}
|
||||
|
||||
/// `Hud.extractItemHotbar`.
|
||||
pub fn hotbar(w: i32, h: i32, i: &HotbarInputs) -> Vec<GuiDraw> {
|
||||
let mut out = Vec::new();
|
||||
let center = w / 2;
|
||||
out.push(sprite("hud/hotbar", center - 91, h - 22, 182, 22));
|
||||
out.push(sprite(
|
||||
"hud/hotbar_selection",
|
||||
center - 91 - 1 + i.selected * 20,
|
||||
h - 22 - 1,
|
||||
24,
|
||||
23,
|
||||
));
|
||||
// The off hand is opposite the main arm.
|
||||
let offhand_left = i.right_handed;
|
||||
if i.offhand {
|
||||
if offhand_left {
|
||||
out.push(sprite(
|
||||
"hud/hotbar_offhand_left",
|
||||
center - 91 - 29,
|
||||
h - 23,
|
||||
29,
|
||||
24,
|
||||
));
|
||||
} else {
|
||||
out.push(sprite(
|
||||
"hud/hotbar_offhand_right",
|
||||
center + 91,
|
||||
h - 23,
|
||||
29,
|
||||
24,
|
||||
));
|
||||
}
|
||||
}
|
||||
let slot = |slot: u32, x: i32, y: i32, seed: i32, pop: f32, out: &mut Vec<GuiDraw>| {
|
||||
let (scale, pivot) = if pop > 0.0 {
|
||||
let squeeze = 1.0 + pop / 5.0;
|
||||
(
|
||||
[1.0 / squeeze, (squeeze + 1.0) / 2.0],
|
||||
[(x + 8) as f32, (y + 12) as f32],
|
||||
)
|
||||
} else {
|
||||
([1.0, 1.0], [0.0, 0.0])
|
||||
};
|
||||
out.push(GuiDraw::Item {
|
||||
slot,
|
||||
x,
|
||||
y,
|
||||
seed,
|
||||
scale,
|
||||
pivot,
|
||||
});
|
||||
};
|
||||
let mut seed = 1;
|
||||
for (n, pop) in i.slots.iter().enumerate() {
|
||||
if let Some(pop) = pop {
|
||||
slot(
|
||||
n as u32,
|
||||
center - 90 + n as i32 * 20 + 2,
|
||||
h - 16 - 3,
|
||||
seed,
|
||||
*pop,
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
seed += 1;
|
||||
}
|
||||
if i.offhand {
|
||||
let x = if offhand_left {
|
||||
center - 91 - 26
|
||||
} else {
|
||||
center + 91 + 10
|
||||
};
|
||||
slot(40, x, h - 16 - 3, seed, i.offhand_pop, &mut out);
|
||||
}
|
||||
if let Some(scale) = i.attack
|
||||
&& scale < 1.0
|
||||
{
|
||||
let y = h - 20;
|
||||
let x = if offhand_left {
|
||||
center + 91 + 6
|
||||
} else {
|
||||
center - 91 - 22
|
||||
};
|
||||
let progress = (scale * 19.0) as i32;
|
||||
out.push(sprite(
|
||||
"hud/hotbar_attack_indicator_background",
|
||||
x,
|
||||
y,
|
||||
18,
|
||||
18,
|
||||
));
|
||||
out.push(GuiDraw::Sprite {
|
||||
sprite: "hud/hotbar_attack_indicator_progress",
|
||||
x,
|
||||
y: y + 18 - progress,
|
||||
w: 18,
|
||||
h: progress,
|
||||
region: Some([18, 18, 0, 18 - progress]),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `Hud.extractCrosshair` (first person, not hidden for spectators):
|
||||
/// `attack` is `getAttackStrengthScale(0)` in CROSSHAIR indicator mode and
|
||||
/// `full` whether the full indicator shows for a target in reach.
|
||||
pub fn crosshair(w: i32, h: i32, attack: Option<f32>, full: bool) -> Vec<GuiDraw> {
|
||||
let mut out = vec![sprite("hud/crosshair", (w - 15) / 2, (h - 15) / 2, 15, 15)];
|
||||
if let Some(scale) = attack {
|
||||
let (x, y) = (w / 2 - 8, h / 2 - 7 + 16);
|
||||
if full {
|
||||
out.push(sprite("hud/crosshair_attack_indicator_full", x, y, 16, 16));
|
||||
} else if scale < 1.0 {
|
||||
let progress = (scale * 17.0) as i32;
|
||||
out.push(sprite(
|
||||
"hud/crosshair_attack_indicator_background",
|
||||
x,
|
||||
y,
|
||||
16,
|
||||
4,
|
||||
));
|
||||
out.push(GuiDraw::Sprite {
|
||||
sprite: "hud/crosshair_attack_indicator_progress",
|
||||
x,
|
||||
y,
|
||||
w: progress,
|
||||
h: 4,
|
||||
region: Some([16, 4, 0, 0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `ExperienceBar.extractBackground` and `ContextualBar.extractExperienceLevel`;
|
||||
/// `width` measures text with the font.
|
||||
pub fn experience(
|
||||
w: i32,
|
||||
h: i32,
|
||||
progress: f32,
|
||||
level: i32,
|
||||
bar: bool,
|
||||
width: &dyn Fn(&str) -> i32,
|
||||
) -> Vec<GuiDraw> {
|
||||
let mut out = Vec::new();
|
||||
if bar {
|
||||
let (left, top) = (w / 2 - 91, h - 32 + 3);
|
||||
let filled = (progress * 183.0) as i32;
|
||||
out.push(sprite("hud/experience_bar_background", left, top, 182, 5));
|
||||
if filled > 0 {
|
||||
out.push(GuiDraw::Sprite {
|
||||
sprite: "hud/experience_bar_progress",
|
||||
x: left,
|
||||
y: top,
|
||||
w: filled,
|
||||
h: 5,
|
||||
region: Some([182, 5, 0, 0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
if level > 0 {
|
||||
let text = level.to_string();
|
||||
let x = (w - width(&text)) / 2;
|
||||
let y = h - 24 - 9 - 2;
|
||||
for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
|
||||
out.push(GuiDraw::Text {
|
||||
text: text.clone(),
|
||||
x: x + dx,
|
||||
y: y + dy,
|
||||
color: 0xFF00_0000,
|
||||
shadow: false,
|
||||
});
|
||||
}
|
||||
out.push(GuiDraw::Text {
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color: 0xFF80_FF20,
|
||||
shadow: false,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum HeartKind {
|
||||
Normal,
|
||||
Poisoned,
|
||||
Withered,
|
||||
Frozen,
|
||||
}
|
||||
|
||||
/// State kept between frames (`Hud.lastHealth`, `displayHealth`, ...).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct HealthHud {
|
||||
pub last_health: i32,
|
||||
pub display_health: i32,
|
||||
/// `Util.getMillis()` of the last health change.
|
||||
pub last_health_time: i64,
|
||||
pub health_blink_time: i64,
|
||||
}
|
||||
|
||||
pub struct HealthInputs {
|
||||
/// `Hud.tickCount`.
|
||||
pub tick: i32,
|
||||
/// `Util.getMillis()` now.
|
||||
pub millis: i64,
|
||||
pub health: f32,
|
||||
pub max_health: f32,
|
||||
pub absorption: f32,
|
||||
pub armor: i32,
|
||||
pub food: i32,
|
||||
pub saturation: f32,
|
||||
pub kind: HeartKind,
|
||||
pub regeneration: bool,
|
||||
pub hunger: bool,
|
||||
pub hardcore: bool,
|
||||
/// `damageCooldownTime > 0`.
|
||||
pub damage_cooldown: bool,
|
||||
/// `getAirSupply`, `getMaxAirSupply`, `isEyeInFluid(WATER)`.
|
||||
pub air: i32,
|
||||
pub max_air: i32,
|
||||
pub under_water: bool,
|
||||
/// `getVehicleMaxHearts` of the ridden living vehicle (0 = none).
|
||||
pub vehicle_hearts: i32,
|
||||
}
|
||||
|
||||
/// `Hud.getVehicleMaxHearts` for a vehicle that shows its health.
|
||||
pub fn vehicle_max_hearts(max_health: f32) -> i32 {
|
||||
(((max_health + 0.5) as i32) / 2).min(30)
|
||||
}
|
||||
|
||||
/// `Hud.extractVehicleHealth`.
|
||||
pub fn vehicle_health(w: i32, h: i32, hearts: i32, health: f32) -> Vec<GuiDraw> {
|
||||
let mut out = Vec::new();
|
||||
let current = f64::from(health).ceil() as i32;
|
||||
let x_right = w / 2 + 91;
|
||||
let mut yo = h - 39;
|
||||
let mut hearts = hearts;
|
||||
let mut base = 0;
|
||||
while hearts > 0 {
|
||||
let row = hearts.min(10);
|
||||
hearts -= row;
|
||||
for i in 0..row {
|
||||
let xo = x_right - i * 8 - 9;
|
||||
out.push(sprite("hud/heart/vehicle_container", xo, yo, 9, 9));
|
||||
if i * 2 + 1 + base < current {
|
||||
out.push(sprite("hud/heart/vehicle_full", xo, yo, 9, 9));
|
||||
}
|
||||
if i * 2 + 1 + base == current {
|
||||
out.push(sprite("hud/heart/vehicle_half", xo, yo, 9, 9));
|
||||
}
|
||||
}
|
||||
yo -= 10;
|
||||
base += 20;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn heart(
|
||||
kind: Option<HeartKind>,
|
||||
absorbing: bool,
|
||||
hardcore: bool,
|
||||
half: bool,
|
||||
blink: bool,
|
||||
) -> &'static str {
|
||||
// Hud.HeartType sprites: [full, full_blinking, half, half_blinking] x [normal, hardcore].
|
||||
let table: [&'static str; 8] = match (kind, absorbing) {
|
||||
(None, _) => [
|
||||
"hud/heart/container",
|
||||
"hud/heart/container_blinking",
|
||||
"hud/heart/container",
|
||||
"hud/heart/container_blinking",
|
||||
"hud/heart/container_hardcore",
|
||||
"hud/heart/container_hardcore_blinking",
|
||||
"hud/heart/container_hardcore",
|
||||
"hud/heart/container_hardcore_blinking",
|
||||
],
|
||||
(_, true) => [
|
||||
"hud/heart/absorbing_full",
|
||||
"hud/heart/absorbing_full_blinking",
|
||||
"hud/heart/absorbing_half",
|
||||
"hud/heart/absorbing_half_blinking",
|
||||
"hud/heart/absorbing_hardcore_full",
|
||||
"hud/heart/absorbing_hardcore_full_blinking",
|
||||
"hud/heart/absorbing_hardcore_half",
|
||||
"hud/heart/absorbing_hardcore_half_blinking",
|
||||
],
|
||||
(Some(HeartKind::Normal), _) => [
|
||||
"hud/heart/full",
|
||||
"hud/heart/full_blinking",
|
||||
"hud/heart/half",
|
||||
"hud/heart/half_blinking",
|
||||
"hud/heart/hardcore_full",
|
||||
"hud/heart/hardcore_full_blinking",
|
||||
"hud/heart/hardcore_half",
|
||||
"hud/heart/hardcore_half_blinking",
|
||||
],
|
||||
(Some(HeartKind::Poisoned), _) => [
|
||||
"hud/heart/poisoned_full",
|
||||
"hud/heart/poisoned_full_blinking",
|
||||
"hud/heart/poisoned_half",
|
||||
"hud/heart/poisoned_half_blinking",
|
||||
"hud/heart/poisoned_hardcore_full",
|
||||
"hud/heart/poisoned_hardcore_full_blinking",
|
||||
"hud/heart/poisoned_hardcore_half",
|
||||
"hud/heart/poisoned_hardcore_half_blinking",
|
||||
],
|
||||
(Some(HeartKind::Withered), _) => [
|
||||
"hud/heart/withered_full",
|
||||
"hud/heart/withered_full_blinking",
|
||||
"hud/heart/withered_half",
|
||||
"hud/heart/withered_half_blinking",
|
||||
"hud/heart/withered_hardcore_full",
|
||||
"hud/heart/withered_hardcore_full_blinking",
|
||||
"hud/heart/withered_hardcore_half",
|
||||
"hud/heart/withered_hardcore_half_blinking",
|
||||
],
|
||||
(Some(HeartKind::Frozen), _) => [
|
||||
"hud/heart/frozen_full",
|
||||
"hud/heart/frozen_full_blinking",
|
||||
"hud/heart/frozen_half",
|
||||
"hud/heart/frozen_half_blinking",
|
||||
"hud/heart/frozen_hardcore_full",
|
||||
"hud/heart/frozen_hardcore_full_blinking",
|
||||
"hud/heart/frozen_hardcore_half",
|
||||
"hud/heart/frozen_hardcore_half_blinking",
|
||||
],
|
||||
};
|
||||
table[usize::from(hardcore) * 4 + usize::from(half) * 2 + usize::from(blink)]
|
||||
}
|
||||
|
||||
fn ceil(x: f32) -> i32 {
|
||||
// Mth.ceil.
|
||||
let i = x as i32;
|
||||
if x > i as f32 { i + 1 } else { i }
|
||||
}
|
||||
|
||||
impl HealthHud {
|
||||
/// `Hud.extractPlayerHealth` for a screen of `w` x `h` GUI pixels.
|
||||
pub fn layout(&mut self, i: &HealthInputs, w: i32, h: i32) -> Vec<GuiDraw> {
|
||||
let mut out = Vec::new();
|
||||
let tick = i64::from(i.tick);
|
||||
let current = ceil(i.health);
|
||||
let blink = self.health_blink_time > tick && (self.health_blink_time - tick) / 3 % 2 == 1;
|
||||
if current < self.last_health && i.damage_cooldown {
|
||||
self.last_health_time = i.millis;
|
||||
self.health_blink_time = tick + 20;
|
||||
} else if current > self.last_health && i.damage_cooldown {
|
||||
self.last_health_time = i.millis;
|
||||
self.health_blink_time = tick + 10;
|
||||
}
|
||||
if i.millis - self.last_health_time > 1000 {
|
||||
self.display_health = current;
|
||||
self.last_health_time = i.millis;
|
||||
}
|
||||
self.last_health = current;
|
||||
let old = self.display_health;
|
||||
let mut random = LegacyRandom::new(i64::from(i.tick.wrapping_mul(312_871)));
|
||||
let mut next = |bound: i32| random.next_int_bound(bound).unwrap_or(0);
|
||||
let (x_left, x_right, y_base) = (w / 2 - 91, w / 2 + 91, h - 39);
|
||||
let max_health = i.max_health.max(old.max(current) as f32);
|
||||
let absorption = ceil(i.absorption);
|
||||
let rows = ceil((max_health + absorption as f32) / 2.0 / 10.0);
|
||||
let row_height = (10 - (rows - 2)).max(3);
|
||||
let mut y_air = y_base - 10;
|
||||
let offset_index = if i.regeneration {
|
||||
i.tick % ceil(max_health + 5.0)
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
if i.armor > 0 {
|
||||
let y = y_base - (rows - 1) * row_height - 10;
|
||||
for n in 0..10 {
|
||||
let x = x_left + n * 8;
|
||||
if n * 2 + 1 < i.armor {
|
||||
out.push(sprite("hud/armor_full", x, y, 9, 9));
|
||||
}
|
||||
if n * 2 + 1 == i.armor {
|
||||
out.push(sprite("hud/armor_half", x, y, 9, 9));
|
||||
}
|
||||
if n * 2 + 1 > i.armor {
|
||||
out.push(sprite("hud/armor_empty", x, y, 9, 9));
|
||||
}
|
||||
}
|
||||
}
|
||||
let containers = f64::from(max_health / 2.0).ceil() as i32;
|
||||
let absorb_containers = (f64::from(absorption) / 2.0).ceil() as i32;
|
||||
for c in (0..containers + absorb_containers).rev() {
|
||||
let (row, column) = (c / 10, c % 10);
|
||||
let x = x_left + column * 8;
|
||||
let mut y = y_base - row * row_height;
|
||||
if current + absorption <= 4 {
|
||||
y += next(2);
|
||||
}
|
||||
if c < containers && c == offset_index {
|
||||
y -= 2;
|
||||
}
|
||||
out.push(sprite(
|
||||
heart(None, false, i.hardcore, false, blink),
|
||||
x,
|
||||
y,
|
||||
9,
|
||||
9,
|
||||
));
|
||||
let halves = c * 2;
|
||||
if c >= containers {
|
||||
let ah = halves - containers * 2;
|
||||
if ah < absorption {
|
||||
let withered = i.kind == HeartKind::Withered;
|
||||
let name = heart(
|
||||
Some(i.kind),
|
||||
!withered,
|
||||
i.hardcore,
|
||||
ah + 1 == absorption,
|
||||
false,
|
||||
);
|
||||
out.push(sprite(name, x, y, 9, 9));
|
||||
}
|
||||
}
|
||||
if blink && halves < old {
|
||||
let name = heart(Some(i.kind), false, i.hardcore, halves + 1 == old, true);
|
||||
out.push(sprite(name, x, y, 9, 9));
|
||||
}
|
||||
if halves < current {
|
||||
let name = heart(
|
||||
Some(i.kind),
|
||||
false,
|
||||
i.hardcore,
|
||||
halves + 1 == current,
|
||||
false,
|
||||
);
|
||||
out.push(sprite(name, x, y, 9, 9));
|
||||
}
|
||||
}
|
||||
if i.vehicle_hearts == 0 {
|
||||
let (empty, half, full) = if i.hunger {
|
||||
(
|
||||
"hud/food_empty_hunger",
|
||||
"hud/food_half_hunger",
|
||||
"hud/food_full_hunger",
|
||||
)
|
||||
} else {
|
||||
("hud/food_empty", "hud/food_half", "hud/food_full")
|
||||
};
|
||||
for n in 0..10 {
|
||||
let mut y = y_base;
|
||||
if i.saturation <= 0.0 && i.tick % (i.food * 3 + 1) == 0 {
|
||||
y += next(3) - 1;
|
||||
}
|
||||
let x = x_right - n * 8 - 9;
|
||||
out.push(sprite(empty, x, y, 9, 9));
|
||||
if n * 2 + 1 < i.food {
|
||||
out.push(sprite(full, x, y, 9, 9));
|
||||
}
|
||||
if n * 2 + 1 == i.food {
|
||||
out.push(sprite(half, x, y, 9, 9));
|
||||
}
|
||||
}
|
||||
y_air -= 10;
|
||||
}
|
||||
// Hud.extractAirBubbles.
|
||||
let max_air = i.max_air.max(1);
|
||||
let air = i.air.clamp(0, max_air);
|
||||
if i.under_water || air < max_air {
|
||||
let rows = (f64::from(i.vehicle_hearts) / 10.0).ceil() as i32;
|
||||
let y = y_air - (rows - 1) * 10;
|
||||
let bubble = |offset: i32| ceil(((air + offset) * 10) as f32 / max_air as f32);
|
||||
let full = bubble(-2);
|
||||
let popping = bubble(0);
|
||||
let delay = i32::from(air != 0 && i.under_water);
|
||||
let empty = 10 - bubble(delay);
|
||||
for b in 1..=10 {
|
||||
let x = x_right - (b - 1) * 8 - 9;
|
||||
if b <= full {
|
||||
out.push(sprite("hud/air", x, y, 9, 9));
|
||||
} else if full != popping && b == popping && i.under_water {
|
||||
out.push(sprite("hud/air_bursting", x, y, 9, 9));
|
||||
} else if b > 10 - empty {
|
||||
let wobble = if empty == 10 && i.tick % 2 == 0 {
|
||||
next(2)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out.push(sprite("hud/air_empty", x, y + wobble, 9, 9));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the sprite and text calls with the HUD batch; items are left to the
|
||||
/// caller (they need the item renderer).
|
||||
pub fn paint(batch: &mut crate::hud::HudBatch, atlas: &crate::hud::HudAtlas, draws: &[GuiDraw]) {
|
||||
for d in draws {
|
||||
match d {
|
||||
GuiDraw::Sprite {
|
||||
sprite,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
region,
|
||||
} => {
|
||||
let Some(mut r) = atlas.sprites.get(&format!("gui/sprites/{sprite}")).copied()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some([sw, sh, u, v]) = *region {
|
||||
let (du, dv) = (r.u1 - r.u0, r.v1 - r.v0);
|
||||
let u0 = r.u0 + du * u as f32 / sw as f32;
|
||||
let v0 = r.v0 + dv * v as f32 / sh as f32;
|
||||
r.u1 = u0 + du * *w as f32 / sw as f32;
|
||||
r.v1 = v0 + dv * *h as f32 / sh as f32;
|
||||
r.u0 = u0;
|
||||
r.v0 = v0;
|
||||
}
|
||||
batch.rect_uv(*x as f32, *y as f32, *w as f32, *h as f32, r, u32::MAX);
|
||||
}
|
||||
GuiDraw::Text {
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
shadow,
|
||||
} => {
|
||||
let [a, r, g, b] = color.to_be_bytes();
|
||||
let rgba = crate::hud::rgba(r, g, b, a);
|
||||
batch.text(atlas, text, *x as f32, *y as f32, rgba, *shadow);
|
||||
}
|
||||
GuiDraw::Item { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ pub mod gfx;
|
||||
pub mod gossip;
|
||||
pub mod health;
|
||||
pub mod hud;
|
||||
pub mod hud_layout;
|
||||
pub mod hunger;
|
||||
pub mod input;
|
||||
pub mod item_data;
|
||||
|
||||
@@ -160,6 +160,9 @@ fn main() -> Result<(), Error> {
|
||||
return Ok(());
|
||||
}
|
||||
for [x0, z0, x1, z1] in rects {
|
||||
// Each rectangle starts from scratch, like the oracle's fresh chunks:
|
||||
// structure pieces keep placement state (a shipwreck's adjusted height).
|
||||
worker.forget_structure_starts();
|
||||
let mut chunks: HashMap<(i32, i32), ProtoChunk> = HashMap::new();
|
||||
for z in z0 - 2..=z1 + 2 {
|
||||
for x in x0 - 2..=x1 + 2 {
|
||||
|
||||
@@ -563,10 +563,24 @@ impl ProtoChunk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build range (minimum Y, height) of the vanilla dimension types.
|
||||
fn dimension_type_height(name: &str) -> Option<(i32, i32)> {
|
||||
match name {
|
||||
"minecraft:overworld" | "minecraft:overworld_caves" => Some((-64, 384)),
|
||||
"minecraft:the_nether" | "minecraft:the_end" => Some((0, 256)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-noise-settings values resolved from the dimension's generator JSON.
|
||||
struct Settings {
|
||||
min_y: i32,
|
||||
height: i32,
|
||||
noise_min_y: i32,
|
||||
noise_height: i32,
|
||||
/// The dimension type's build range (LevelHeightAccessor): proto chunks,
|
||||
/// their heightmaps and the decoration region span it, while the noise
|
||||
/// only fills `noise_min_y..noise_min_y + noise_height`.
|
||||
level_min_y: i32,
|
||||
level_height: i32,
|
||||
sea_level: i32,
|
||||
default_block: BlockStateId,
|
||||
default_fluid: BlockStateId,
|
||||
@@ -635,16 +649,25 @@ where
|
||||
generator: &Value,
|
||||
settings_json: &Value,
|
||||
with_structures: bool,
|
||||
level: Option<(i32, i32)>,
|
||||
) -> Result<Self, GeneratorError> {
|
||||
let registry = data.registry;
|
||||
let noise = &settings_json["noise"];
|
||||
let min_y = noise["min_y"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| resource("noise min_y"))? as i32;
|
||||
let height = noise["height"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| resource("noise height"))? as i32;
|
||||
let (level_min_y, level_height) = level.unwrap_or((min_y, height));
|
||||
if min_y < level_min_y || min_y + height > level_min_y + level_height {
|
||||
return Err(resource("noise range outside the dimension's build height"));
|
||||
}
|
||||
let settings = Settings {
|
||||
min_y: noise["min_y"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| resource("noise min_y"))? as i32,
|
||||
height: noise["height"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| resource("noise height"))? as i32,
|
||||
noise_min_y: min_y,
|
||||
noise_height: height,
|
||||
level_min_y,
|
||||
level_height,
|
||||
sea_level: settings_json["sea_level"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| resource("sea_level"))? as i32,
|
||||
@@ -777,8 +800,8 @@ where
|
||||
}
|
||||
fn generation(&self) -> WorldGenerationContext {
|
||||
WorldGenerationContext::new(
|
||||
self.settings.min_y,
|
||||
self.settings.height,
|
||||
self.settings.noise_min_y,
|
||||
self.settings.noise_height,
|
||||
self.settings.sea_level,
|
||||
)
|
||||
}
|
||||
@@ -790,7 +813,7 @@ where
|
||||
if let Some(chunk) = self.biome_cache.lock().unwrap().get(&(pos.x, pos.z)) {
|
||||
return Ok(chunk.clone());
|
||||
}
|
||||
let (min, height) = (self.settings.min_y, self.settings.height);
|
||||
let (min, height) = (self.settings.level_min_y, self.settings.level_height);
|
||||
let mut chunk = BiomeChunk::new(pos, min, height, BiomeId(0)).map_err(stage("biomes"))?;
|
||||
let mut resolver = self.source.resolver(
|
||||
&self.climate,
|
||||
@@ -856,8 +879,8 @@ where
|
||||
) -> Result<ProtoChunk, GeneratorError> {
|
||||
let data = &self.data;
|
||||
let (min, height, sea) = (
|
||||
self.settings.min_y,
|
||||
self.settings.height,
|
||||
self.settings.noise_min_y,
|
||||
self.settings.noise_height,
|
||||
self.settings.sea_level,
|
||||
);
|
||||
let mut clock = std::time::Instant::now();
|
||||
@@ -869,8 +892,10 @@ where
|
||||
worker,
|
||||
ChunkPos::new(pos.x.wrapping_add(dx), pos.z.wrapping_add(dz)),
|
||||
)?;
|
||||
for section in 0..(height / 16) as usize {
|
||||
possible.extend(chunk.section(section).unwrap().iter().copied());
|
||||
let mut section = 0;
|
||||
while let Some(biomes) = chunk.section(section) {
|
||||
possible.extend(biomes.iter().copied());
|
||||
section += 1;
|
||||
}
|
||||
neighbors.push((*chunk).clone());
|
||||
}
|
||||
@@ -883,8 +908,14 @@ where
|
||||
.map(|id| data.biome_name(id).to_owned())
|
||||
.collect();
|
||||
worker.timings.biomes += lap(&mut clock);
|
||||
let mut chunk = TerrainChunk::new(pos, min, height, data.registry, data.states.clone())
|
||||
.map_err(stage("noise"))?;
|
||||
let mut chunk = TerrainChunk::new(
|
||||
pos,
|
||||
self.settings.level_min_y,
|
||||
self.settings.level_height,
|
||||
data.registry,
|
||||
data.states.clone(),
|
||||
)
|
||||
.map_err(stage("noise"))?;
|
||||
chunk.set_generation_stage(PreLightChunkStage::Biomes);
|
||||
let references = match &self.structures {
|
||||
Some(structures) => structures.references(
|
||||
@@ -1036,7 +1067,7 @@ where
|
||||
/// ChunkGenerator.getBaseHeight: the first free Y above the noise column
|
||||
/// (with aquifers, without terrain adaptation) for a heightmap type.
|
||||
fn base_height(&self, x: i32, z: i32, kind: HeightmapType) -> i32 {
|
||||
let min = self.settings.min_y;
|
||||
let min = self.settings.noise_min_y;
|
||||
let mut found = min;
|
||||
self.noise_column(x, z, &mut |y, state| {
|
||||
if kind.is_opaque(self.data.states.get(state)) {
|
||||
@@ -1054,7 +1085,7 @@ where
|
||||
}
|
||||
/// One noise-column pass, as opacities for each heightmap kind.
|
||||
fn base_columns(&self, x: i32, z: i32, kinds: &[HeightmapType]) -> Vec<Vec<bool>> {
|
||||
let (min, height) = (self.settings.min_y, self.settings.height);
|
||||
let (min, height) = (self.settings.noise_min_y, self.settings.noise_height);
|
||||
let mut columns = vec![vec![false; height as usize]; kinds.len()];
|
||||
self.noise_column(x, z, &mut |y, state| {
|
||||
let state = self.data.states.get(state);
|
||||
@@ -1067,7 +1098,7 @@ where
|
||||
}
|
||||
/// iterateNoiseColumn from the top down until `visit` returns true.
|
||||
fn noise_column(&self, x: i32, z: i32, visit: &mut dyn FnMut(i32, BlockStateId) -> bool) {
|
||||
let (min, height) = (self.settings.min_y, self.settings.height);
|
||||
let (min, height) = (self.settings.noise_min_y, self.settings.noise_height);
|
||||
let Ok(volume) = DensityVolume::new([1, height, 1], [x, min, z], [1; 3]) else {
|
||||
return;
|
||||
};
|
||||
@@ -1131,11 +1162,12 @@ impl<R: RandomSource> super::structure::jigsaw::JigsawContext for StructureQueri
|
||||
where
|
||||
R::Positional: Clone + Send + Sync,
|
||||
{
|
||||
// Structure.GenerationContext.heightAccessor: the chunk, so the level's range.
|
||||
fn min_y(&self) -> i32 {
|
||||
self.pipeline.settings.min_y
|
||||
self.pipeline.settings.level_min_y
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
self.pipeline.settings.min_y + self.pipeline.settings.height - 1
|
||||
self.pipeline.settings.level_min_y + self.pipeline.settings.level_height - 1
|
||||
}
|
||||
fn base_height(&mut self, x: i32, z: i32, kind: HeightmapType) -> i32 {
|
||||
if let Some(h) = self.worker.heights.get(&(x, z, kind as u8)) {
|
||||
@@ -1329,6 +1361,7 @@ trait SeededPipeline: Send + Sync {
|
||||
pos: ChunkPos,
|
||||
) -> Vec<Arc<super::structure::start::StructureStart>>;
|
||||
fn unsupported_features(&self) -> Vec<(String, String)>;
|
||||
fn forget_structure_starts(&self);
|
||||
fn ring_positions(&self, set: &str) -> Option<Vec<ChunkPos>>;
|
||||
fn locate(
|
||||
&self,
|
||||
@@ -1411,6 +1444,11 @@ where
|
||||
.map(super::generator_decoration::Decoration::unsupported)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn forget_structure_starts(&self) {
|
||||
if let Some(structures) = &self.structures {
|
||||
structures.forget_starts();
|
||||
}
|
||||
}
|
||||
fn ring_positions(&self, set: &str) -> Option<Vec<ChunkPos>> {
|
||||
self.structures.as_ref()?.cached_rings(set)
|
||||
}
|
||||
@@ -1485,6 +1523,9 @@ impl OverworldGenerator {
|
||||
.ok_or_else(|| resource(format!("missing world preset {preset}")))?,
|
||||
)?;
|
||||
let generator = preset_json["dimensions"][dimension]["generator"].clone();
|
||||
let level = preset_json["dimensions"][dimension]["type"]
|
||||
.as_str()
|
||||
.and_then(dimension_type_height);
|
||||
if generator["type"].as_str() != Some("minecraft:noise") {
|
||||
return Err(resource(format!("{dimension} is not a noise generator")));
|
||||
}
|
||||
@@ -1507,6 +1548,7 @@ impl OverworldGenerator {
|
||||
&generator,
|
||||
&settings_json,
|
||||
with_structures,
|
||||
level,
|
||||
)?)
|
||||
} else {
|
||||
Arc::new(Pipeline::new(
|
||||
@@ -1516,6 +1558,7 @@ impl OverworldGenerator {
|
||||
&generator,
|
||||
&settings_json,
|
||||
with_structures,
|
||||
level,
|
||||
)?)
|
||||
};
|
||||
Ok(Self { pipeline })
|
||||
@@ -1586,6 +1629,11 @@ impl GeneratorWorker {
|
||||
) -> Vec<Arc<super::structure::start::StructureStart>> {
|
||||
self.pipeline.structure_starts(&mut self.state, pos)
|
||||
}
|
||||
/// Forget the cached structure starts: chunks generated afterwards get
|
||||
/// fresh starts, as in a new level that has not loaded the old chunks.
|
||||
pub fn forget_structure_starts(&mut self) {
|
||||
self.pipeline.forget_structure_starts();
|
||||
}
|
||||
pub fn stage_timings(&self) -> StageTimings {
|
||||
self.state.timings
|
||||
}
|
||||
|
||||
@@ -329,18 +329,16 @@ impl Decoration {
|
||||
decoration: self,
|
||||
temperatures: &mut worker.temperatures,
|
||||
behaviours: data.behaviours.as_deref(),
|
||||
build: (generation.min_y, generation.min_y + generation.height),
|
||||
// WorldGenRegion.isOutsideBuildHeight: the level's range, not the noise's.
|
||||
build: (min_y, min_y + height),
|
||||
};
|
||||
let mut stats = DecorateStats::default();
|
||||
let blocks = self.blocks;
|
||||
let references = region[4].structure_starts.clone();
|
||||
// ChunkGenerator.getWritableArea: the chunk's minimum Y + 1 to its maximum Y.
|
||||
let chunk_box = BoundingBox::new(
|
||||
[center.x * 16, generation.min_y + 1, center.z * 16],
|
||||
[
|
||||
center.x * 16 + 15,
|
||||
generation.min_y + generation.height - 1,
|
||||
center.z * 16 + 15,
|
||||
],
|
||||
[center.x * 16, min_y + 1, center.z * 16],
|
||||
[center.x * 16 + 15, min_y + height - 1, center.z * 16 + 15],
|
||||
);
|
||||
let mut structure_errors = 0;
|
||||
let result = self.plan.decorate(
|
||||
@@ -497,6 +495,11 @@ impl crate::level::LevelAccess for ShapeLevel<'_, '_> {
|
||||
fn game_time(&self) -> i64 {
|
||||
0
|
||||
}
|
||||
/// Lighting has not run during FEATURES: the light engine has no data for
|
||||
/// these chunks, so sky light reads zero too (MushroomBlock.canSurvive).
|
||||
fn sky_light(&self, _: BlockPos) -> i32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
impl World<'_> {
|
||||
/// `BlockState.updateShape` of the state at `pos` against `neighbor`.
|
||||
@@ -670,6 +673,40 @@ impl PlacementContext for World<'_> {
|
||||
nbt.insert("LootTableSeed".into(), Tag::Long(random.next_long()));
|
||||
self.set_block_entity(position, nbt)
|
||||
}
|
||||
/// BeehiveDecorator: `getBlockEntity(pos, BEEHIVE)` then
|
||||
/// `storeBee(Occupant.create(ticks))` for each bee (min ticks in hive 600).
|
||||
fn store_beehive_occupants(
|
||||
&mut self,
|
||||
position: [i32; 3],
|
||||
ticks_in_hive: &[i32],
|
||||
) -> Result<(), PlacementQueryError> {
|
||||
use crate::nbt::Tag;
|
||||
let chunk = self.region.chunk(position[0] >> 4, position[2] >> 4)?;
|
||||
let state = chunk.block(position[0], position[1], position[2]);
|
||||
let registry = self.decoration.bindings.registry();
|
||||
if !matches!(
|
||||
registry.state(state).map(|s| s.name.as_str()),
|
||||
Some("minecraft:bee_nest" | "minecraft:beehive")
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
let bees = ticks_in_hive
|
||||
.iter()
|
||||
.map(|&ticks| {
|
||||
let mut entity = crate::nbt::Compound::new();
|
||||
entity.insert("id".into(), Tag::String("minecraft:bee".into()));
|
||||
let mut bee = crate::nbt::Compound::new();
|
||||
bee.insert("entity_data".into(), Tag::Compound(entity));
|
||||
bee.insert("ticks_in_hive".into(), Tag::Int(ticks));
|
||||
bee.insert("min_ticks_in_hive".into(), Tag::Int(600));
|
||||
Tag::Compound(bee)
|
||||
})
|
||||
.collect();
|
||||
let mut nbt = crate::nbt::Compound::new();
|
||||
nbt.insert("id".into(), Tag::String("minecraft:beehive".into()));
|
||||
nbt.insert("bees".into(), Tag::List(bees));
|
||||
self.set_block_entity(position, nbt)
|
||||
}
|
||||
/// SpawnerBlockEntity.setEntityId: the next spawn data and no potentials.
|
||||
fn configure_spawner(
|
||||
&mut self,
|
||||
|
||||
@@ -96,6 +96,11 @@ impl StructureState {
|
||||
pub(super) fn cached_rings(&self, set: &str) -> Option<Vec<ChunkPos>> {
|
||||
self.rings.get().and_then(|rings| rings.get(set).cloned())
|
||||
}
|
||||
/// Drop the cached starts, so that later chunks build fresh pieces (the
|
||||
/// pieces keep per-start placement state such as a shipwreck's height).
|
||||
pub(super) fn forget_starts(&self) {
|
||||
self.starts.lock().unwrap().clear();
|
||||
}
|
||||
/// The (cached) structure starts owned by a chunk.
|
||||
pub(super) fn starts_at(
|
||||
&self,
|
||||
|
||||
@@ -277,7 +277,8 @@ impl IcebergFeature {
|
||||
let off = 10.0f32 * random.next_float().clamp(0.2, 0.8) / radius as f32;
|
||||
f64::from(off) + pow2(xo) + pow2(zo) - pow2(radius)
|
||||
};
|
||||
if signed >= 0.0 {
|
||||
// `signedDist < 0.0` fails for NaN too (a zero-width ellipse layer at its centre).
|
||||
if signed.is_nan() || signed >= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let p = tb::offset(origin, xo, y, zo);
|
||||
|
||||
@@ -296,7 +296,12 @@ impl StructureResources {
|
||||
{
|
||||
sources.insert(id);
|
||||
}
|
||||
if state.properties.get("waterlogged").map(String::as_str) == Some("false") {
|
||||
// SlabBlock.placeLiquid refuses double slabs.
|
||||
let double_slab = state.name.ends_with("_slab")
|
||||
&& state.properties.get("type").map(String::as_str) == Some("double");
|
||||
if !double_slab
|
||||
&& state.properties.get("waterlogged").map(String::as_str) == Some("false")
|
||||
{
|
||||
let mut wet = state.clone();
|
||||
wet.properties.insert("waterlogged".into(), "true".into());
|
||||
if let Some(wet) = registry.id(&wet) {
|
||||
|
||||
@@ -230,6 +230,8 @@ impl PieceContext<'_> {
|
||||
let mut nbt = Compound::new();
|
||||
nbt.insert("id".into(), Tag::String("minecraft:mob_spawner".into()));
|
||||
nbt.insert("SpawnData".into(), Tag::Compound(data));
|
||||
// SpawnerBlockEntity.setEntityId on a fresh spawner: no potentials.
|
||||
nbt.insert("SpawnPotentials".into(), Tag::List(Vec::new()));
|
||||
self.world.block_entity(pos, nbt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.blaze3d.font.GlyphInfo;
|
||||
import com.mojang.blaze3d.font.GlyphProvider;
|
||||
import com.mojang.blaze3d.font.UnbakedGlyph;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.minecraft.client.gui.Font;
|
||||
import net.minecraft.client.gui.GlyphSource;
|
||||
import net.minecraft.client.gui.font.glyphs.BakedGlyph;
|
||||
import net.minecraft.client.gui.font.providers.GlyphProviderDefinition;
|
||||
import net.minecraft.client.gui.font.TextRenderable;
|
||||
import net.minecraft.network.chat.FontDescription;
|
||||
import net.minecraft.network.chat.Style;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.resources.MultiPackResourceManager;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraft.util.RandomSource;
|
||||
|
||||
/**
|
||||
* The vanilla default font's glyph metrics without a GPU: the `space` and
|
||||
* `include/default` providers of the client jar loaded by vanilla's own
|
||||
* provider definitions (BitmapProvider scans ascii.png etc. for advances).
|
||||
* Glyphs carry metrics only; nothing is stitched.
|
||||
*/
|
||||
public final class ParityFont {
|
||||
public static ResourceManager resources() throws Exception {
|
||||
var method = net.minecraft.client.resources.ClientPackSource.class.getDeclaredMethod("createVanillaPackSource", Path.class);
|
||||
method.setAccessible(true);
|
||||
var pack = (net.minecraft.server.packs.VanillaPackResources) method.invoke(null, Path.of("/nonexistent-horizon-assets"));
|
||||
return pack.asResourceManager();
|
||||
}
|
||||
|
||||
public static Font create(ResourceManager resources) throws Exception {
|
||||
List<GlyphProvider> providers = new ArrayList<>();
|
||||
for (String include : new String[] {"space", "default"}) {
|
||||
var id = Identifier.withDefaultNamespace("font/include/" + include + ".json");
|
||||
try (var reader = new InputStreamReader(resources.open(id), StandardCharsets.UTF_8)) {
|
||||
var json = JsonParser.parseReader(reader).getAsJsonObject();
|
||||
for (var element : json.getAsJsonArray("providers")) {
|
||||
var conditional = GlyphProviderDefinition.Conditional.CODEC.parse(JsonOps.INSTANCE, element).getOrThrow();
|
||||
var loader = conditional.definition().unpack().left().orElseThrow();
|
||||
providers.add(loader.load(resources));
|
||||
}
|
||||
}
|
||||
}
|
||||
GlyphSource source = new GlyphSource() {
|
||||
@Override
|
||||
public BakedGlyph getGlyph(int codepoint) {
|
||||
for (GlyphProvider provider : providers) {
|
||||
UnbakedGlyph glyph = provider.getGlyph(codepoint);
|
||||
if (glyph != null) return baked(glyph.info());
|
||||
}
|
||||
return baked(GlyphInfo.simple(6.0F));
|
||||
}
|
||||
|
||||
@Override
|
||||
public BakedGlyph getRandomGlyph(RandomSource random, int width) {
|
||||
return getGlyph('?');
|
||||
}
|
||||
};
|
||||
return new Font(new Font.Provider() {
|
||||
@Override
|
||||
public GlyphSource glyphs(FontDescription font) {
|
||||
return source;
|
||||
}
|
||||
|
||||
@Override
|
||||
public net.minecraft.client.gui.font.glyphs.EffectGlyph effect() {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static BakedGlyph baked(GlyphInfo info) {
|
||||
return new BakedGlyph() {
|
||||
@Override
|
||||
public GlyphInfo info() {
|
||||
return info;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TextRenderable.Styled createGlyph(float x, float y, int color, int shadowColor, Style style, float boldOffset, float shadowOffset) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import java.lang.reflect.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.client.gui.Font;
|
||||
import net.minecraft.client.model.Model;
|
||||
import net.minecraft.client.model.geom.EntityModelSet;
|
||||
import net.minecraft.client.model.geom.LayerDefinitions;
|
||||
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||
import net.minecraft.client.renderer.blockentity.*;
|
||||
import net.minecraft.client.renderer.blockentity.state.*;
|
||||
import net.minecraft.client.renderer.rendertype.RenderType;
|
||||
import net.minecraft.client.renderer.state.level.CameraRenderState;
|
||||
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
|
||||
import net.minecraft.client.renderer.texture.UvMapping;
|
||||
import net.minecraft.client.resources.model.sprite.SpriteGetter;
|
||||
import net.minecraft.client.resources.model.sprite.SpriteId;
|
||||
import net.minecraft.commands.arguments.blocks.BlockStateParser;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
import net.minecraft.world.item.DyeColor;
|
||||
import net.minecraft.world.item.ItemStackTemplate;
|
||||
import net.minecraft.world.level.block.EntityBlock;
|
||||
import net.minecraft.world.level.block.entity.*;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.ChestType;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Quaternionf;
|
||||
|
||||
/**
|
||||
* Original oracle for block-entity rendering: each case builds a vanilla block
|
||||
* entity for a block state, sets its animation fields, runs the real
|
||||
* renderer's extractRenderState and submit, and captures every submitted
|
||||
* model (vertices after setupAnim and the pose stack, sprite-local UVs, tint,
|
||||
* light), text call and item. Level-backed inputs (light, chest lid
|
||||
* openness, pot wobble) come from the case because the entity has no level.
|
||||
*
|
||||
* Input lines: case JSON. Output lines: {case, submits:[...]} or {case, error}.
|
||||
*/
|
||||
public final class VanillaBlockEntityRenderOracle {
|
||||
private static final Map<TextureAtlasSprite, SpriteId> SPRITES = new IdentityHashMap<>();
|
||||
private static Object unsafe;
|
||||
private static Method allocate;
|
||||
|
||||
static String textureName(Identifier id) {
|
||||
String path = id.getPath();
|
||||
if (path.startsWith("textures/")) path = path.substring(9);
|
||||
if (path.endsWith(".png")) path = path.substring(0, path.length() - 4);
|
||||
return id.getNamespace().equals("minecraft") ? path : id.getNamespace() + ":" + path;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static Identifier renderTypeTexture(RenderType type) throws Exception {
|
||||
Field state = RenderType.class.getDeclaredField("state");
|
||||
state.setAccessible(true);
|
||||
Object setup = state.get(type);
|
||||
Field textures = setup.getClass().getDeclaredField("textures");
|
||||
textures.setAccessible(true);
|
||||
var map = (Map<String, Object>) textures.get(setup);
|
||||
Object binding = map.get("Sampler0");
|
||||
if (binding == null) return null;
|
||||
Method location = binding.getClass().getDeclaredMethod("location");
|
||||
location.setAccessible(true);
|
||||
return (Identifier) location.invoke(binding);
|
||||
}
|
||||
|
||||
static JsonArray matrix(Matrix4f m) {
|
||||
var a = new JsonArray();
|
||||
float[] f = new float[16];
|
||||
m.get(f);
|
||||
for (float x : f) a.add(x);
|
||||
return a;
|
||||
}
|
||||
|
||||
/** A vertex consumer recording [x,y,z,color,u,v,overlay,light,nx,ny,nz]. */
|
||||
static VertexConsumer recorder(List<float[]> out) {
|
||||
InvocationHandler handler = new InvocationHandler() {
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
|
||||
if (method.isDefault()) return InvocationHandler.invokeDefault(proxy, method, args);
|
||||
float[] v = out.isEmpty() ? null : out.get(out.size() - 1);
|
||||
switch (method.getName()) {
|
||||
case "addVertex" -> out.add(new float[] {(float) args[0], (float) args[1], (float) args[2], 0, 0, 0, 0, 0, 0, 0, 0});
|
||||
case "setColor" -> v[3] = Float.intBitsToFloat(args.length == 1 ? (int) args[0]
|
||||
: ((int) args[3] << 24) | ((int) args[0] << 16) | ((int) args[1] << 8) | (int) args[2]);
|
||||
case "setUv" -> { v[4] = (float) args[0]; v[5] = (float) args[1]; }
|
||||
case "setUv1" -> v[6] = Float.intBitsToFloat(((int) args[0]) | ((int) args[1] << 16));
|
||||
case "setUv2" -> v[7] = Float.intBitsToFloat(((int) args[0]) | ((int) args[1] << 16));
|
||||
case "setNormal" -> { v[8] = (float) args[0]; v[9] = (float) args[1]; v[10] = (float) args[2]; }
|
||||
default -> { }
|
||||
}
|
||||
return proxy;
|
||||
}
|
||||
};
|
||||
return (VertexConsumer) Proxy.newProxyInstance(VertexConsumer.class.getClassLoader(), new Class<?>[] {VertexConsumer.class}, handler);
|
||||
}
|
||||
|
||||
static JsonArray vertices(List<float[]> verts) {
|
||||
var out = new JsonArray();
|
||||
for (float[] v : verts) {
|
||||
var a = new JsonArray();
|
||||
a.add(v[0]); a.add(v[1]); a.add(v[2]);
|
||||
a.add(Float.floatToRawIntBits(v[3]));
|
||||
a.add(v[4]); a.add(v[5]);
|
||||
a.add(Float.floatToRawIntBits(v[6])); a.add(Float.floatToRawIntBits(v[7]));
|
||||
a.add(v[8]); a.add(v[9]); a.add(v[10]);
|
||||
out.add(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static String text(FormattedCharSequence sequence) {
|
||||
var sb = new StringBuilder();
|
||||
sequence.accept((index, style, codepoint) -> {
|
||||
sb.appendCodePoint(codepoint);
|
||||
return true;
|
||||
});
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
static SubmitNodeCollector collector(JsonArray submits) {
|
||||
InvocationHandler handler = new InvocationHandler() {
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
|
||||
String name = method.getName();
|
||||
if (name.equals("order")) return proxy;
|
||||
if (method.isDefault()) return InvocationHandler.invokeDefault(proxy, method, args);
|
||||
var submit = new JsonObject();
|
||||
submit.addProperty("type", name);
|
||||
switch (name) {
|
||||
case "submitModel" -> {
|
||||
var model = (Model) args[0];
|
||||
var pose = (PoseStack) args[2];
|
||||
var renderType = (RenderType) args[3];
|
||||
var uv = (UvMapping) args[7];
|
||||
String texture;
|
||||
if (uv instanceof TextureAtlasSprite sprite && SPRITES.containsKey(sprite))
|
||||
texture = textureName(SPRITES.get(sprite).texture());
|
||||
else {
|
||||
Identifier t = renderTypeTexture(renderType);
|
||||
texture = t == null ? "?" : textureName(t);
|
||||
}
|
||||
submit.addProperty("texture", texture);
|
||||
submit.addProperty("renderType", renderType.toString().split("\\[")[0]);
|
||||
submit.addProperty("light", (int) args[4]);
|
||||
submit.addProperty("overlay", (int) args[5]);
|
||||
submit.addProperty("tint", (int) args[6]);
|
||||
model.setupAnim(args[1]);
|
||||
List<float[]> verts = new ArrayList<>();
|
||||
model.renderToBuffer(pose, recorder(verts), (int) args[4], (int) args[5], (int) args[6]);
|
||||
submit.add("vertices", vertices(verts));
|
||||
}
|
||||
case "submitText" -> {
|
||||
var pose = (PoseStack) args[0];
|
||||
submit.add("matrix", matrix(pose.last().pose()));
|
||||
submit.addProperty("x", (float) args[1]);
|
||||
submit.addProperty("y", (float) args[2]);
|
||||
submit.addProperty("text", text((FormattedCharSequence) args[3]));
|
||||
submit.addProperty("shadow", (boolean) args[4]);
|
||||
submit.addProperty("mode", args[5].toString());
|
||||
submit.addProperty("light", (int) args[6]);
|
||||
submit.addProperty("color", (int) args[7]);
|
||||
submit.addProperty("background", (int) args[8]);
|
||||
submit.addProperty("outline", (int) args[9]);
|
||||
}
|
||||
case "submitCustomGeometry" -> {
|
||||
var pose = (PoseStack) args[0];
|
||||
var renderType = (RenderType) args[1];
|
||||
Identifier t = renderTypeTexture(renderType);
|
||||
submit.addProperty("texture", t == null ? "?" : textureName(t));
|
||||
submit.addProperty("renderType", renderType.toString().split("\\[")[0]);
|
||||
List<float[]> verts = new ArrayList<>();
|
||||
((SubmitNodeCollector.CustomGeometryRenderer) args[2]).render(pose.last(), recorder(verts));
|
||||
submit.add("vertices", vertices(verts));
|
||||
}
|
||||
case "submitItem" -> {
|
||||
var pose = (PoseStack) args[0];
|
||||
submit.add("matrix", matrix(pose.last().pose()));
|
||||
submit.addProperty("context", args[1].toString());
|
||||
submit.addProperty("light", (int) args[2]);
|
||||
}
|
||||
case "submitMovingBlock", "submitBlockModel" -> {
|
||||
var pose = (PoseStack) args[0];
|
||||
submit.add("matrix", matrix(pose.last().pose()));
|
||||
}
|
||||
case "submitCrumblingOverlay" -> { return null; }
|
||||
default -> { }
|
||||
}
|
||||
submits.add(submit);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return (SubmitNodeCollector) Proxy.newProxyInstance(SubmitNodeCollector.class.getClassLoader(), new Class<?>[] {SubmitNodeCollector.class}, handler);
|
||||
}
|
||||
|
||||
static Field field(Class<?> type, String name) throws NoSuchFieldException {
|
||||
for (Class<?> c = type; c != null; c = c.getSuperclass()) {
|
||||
try {
|
||||
Field f = c.getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
return f;
|
||||
} catch (NoSuchFieldException e) {
|
||||
}
|
||||
}
|
||||
throw new NoSuchFieldException(name + " on " + type);
|
||||
}
|
||||
|
||||
static DyeColor dye(String name) {
|
||||
return DyeColor.byName(name, DyeColor.BLACK);
|
||||
}
|
||||
|
||||
static SignText signText(JsonObject side) {
|
||||
var lines = side.getAsJsonArray("lines");
|
||||
var messages = new ArrayList<Component>();
|
||||
for (int i = 0; i < 4; i++) messages.add(Component.literal(i < lines.size() ? lines.get(i).getAsString() : ""));
|
||||
return new SignText(messages, messages, dye(side.get("color").getAsString()), side.get("glowing").getAsBoolean());
|
||||
}
|
||||
|
||||
/** Block-entity fields that vanilla ticks; set from the case before extraction. */
|
||||
static void apply(BlockEntity be, JsonObject c) throws Exception {
|
||||
if (be instanceof BellBlockEntity bell) {
|
||||
bell.ticks = c.has("bellTicks") ? c.get("bellTicks").getAsInt() : 0;
|
||||
bell.shaking = c.has("shake");
|
||||
if (bell.shaking) bell.clickDirection = Direction.byName(c.get("shake").getAsString());
|
||||
} else if (be instanceof ShulkerBoxBlockEntity) {
|
||||
field(ShulkerBoxBlockEntity.class, "progressOld").setFloat(be, c.has("progressOld") ? c.get("progressOld").getAsFloat() : 0.0F);
|
||||
field(ShulkerBoxBlockEntity.class, "progress").setFloat(be, c.has("progress") ? c.get("progress").getAsFloat() : 0.0F);
|
||||
} else if (be instanceof ConduitBlockEntity conduit && c.has("conduit")) {
|
||||
var k = c.getAsJsonObject("conduit");
|
||||
conduit.tickCount = k.get("tickCount").getAsInt();
|
||||
field(ConduitBlockEntity.class, "isActive").setBoolean(be, k.get("active").getAsBoolean());
|
||||
field(ConduitBlockEntity.class, "isHunting").setBoolean(be, k.get("hunting").getAsBoolean());
|
||||
field(ConduitBlockEntity.class, "activeRotation").setFloat(be, k.get("activeRotation").getAsFloat());
|
||||
} else if (be instanceof EnchantingTableBlockEntity table && c.has("book")) {
|
||||
var b = c.getAsJsonObject("book");
|
||||
table.time = b.get("time").getAsInt();
|
||||
table.flip = b.get("flip").getAsFloat(); table.oFlip = b.get("oFlip").getAsFloat();
|
||||
table.open = b.get("open").getAsFloat(); table.oOpen = b.get("oOpen").getAsFloat();
|
||||
table.rot = b.get("rot").getAsFloat(); table.oRot = b.get("oRot").getAsFloat();
|
||||
} else if (be instanceof SkullBlockEntity && c.has("skullAnimation")) {
|
||||
var a = c.getAsJsonArray("skullAnimation");
|
||||
field(SkullBlockEntity.class, "isAnimating").setBoolean(be, a.get(0).getAsBoolean());
|
||||
field(SkullBlockEntity.class, "animationTickCount").setInt(be, a.get(1).getAsInt());
|
||||
} else if (be instanceof DecoratedPotBlockEntity && c.has("sherds")) {
|
||||
var s = c.getAsJsonArray("sherds");
|
||||
var items = new ArrayList<Optional<ItemStackTemplate>>();
|
||||
for (var e : s) {
|
||||
var id = e.getAsString();
|
||||
items.add(id.isEmpty() ? Optional.empty()
|
||||
: Optional.of(new ItemStackTemplate(BuiltInRegistries.ITEM.getValue(Identifier.parse(id)))));
|
||||
}
|
||||
field(DecoratedPotBlockEntity.class, "decorations").set(be, new PotDecorations(items.get(0), items.get(1), items.get(2), items.get(3)));
|
||||
} else if (be instanceof BannerBlockEntity && c.has("patterns")) {
|
||||
var builder = new net.minecraft.world.level.block.entity.BannerPatternLayers.Builder();
|
||||
var registry = ParityData.access.lookupOrThrow(Registries.BANNER_PATTERN);
|
||||
for (var e : c.getAsJsonArray("patterns")) {
|
||||
var p = e.getAsJsonArray();
|
||||
var holder = registry.getOrThrow(ResourceKey.create(Registries.BANNER_PATTERN, Identifier.parse(p.get(0).getAsString())));
|
||||
builder.add(holder, dye(p.get(1).getAsString()));
|
||||
}
|
||||
field(BannerBlockEntity.class, "patterns").set(be, builder.build());
|
||||
}
|
||||
}
|
||||
|
||||
/** Render-state values vanilla reads from the level (absent here). */
|
||||
static void patch(BlockEntityRenderState rs, BlockState state, JsonObject c) {
|
||||
if (rs instanceof ChestRenderState chest) {
|
||||
chest.type = state.hasProperty(net.minecraft.world.level.block.ChestBlock.TYPE)
|
||||
? state.getValue(net.minecraft.world.level.block.ChestBlock.TYPE) : ChestType.SINGLE;
|
||||
chest.facing = state.getValue(net.minecraft.world.level.block.ChestBlock.FACING);
|
||||
chest.open = c.has("open") ? c.get("open").getAsFloat() : 0.0F;
|
||||
} else if (rs instanceof DecoratedPotRenderState pot && c.has("wobble")) {
|
||||
var w = c.getAsJsonArray("wobble");
|
||||
pot.wobbleStyle = DecoratedPotBlockEntity.WobbleStyle.valueOf(w.get(0).getAsString());
|
||||
pot.wobbleProgress = w.get(1).getAsFloat();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
var unsafeField = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe");
|
||||
unsafeField.setAccessible(true);
|
||||
unsafe = unsafeField.get(null);
|
||||
allocate = unsafe.getClass().getMethod("allocateInstance", Class.class);
|
||||
Font font = ParityFont.create(ParityFont.resources());
|
||||
var models = new EntityModelSet(LayerDefinitions.createRoots());
|
||||
SpriteGetter sprites = id -> {
|
||||
try {
|
||||
var sprite = (TextureAtlasSprite) allocate.invoke(unsafe, TextureAtlasSprite.class);
|
||||
SPRITES.put(sprite, id);
|
||||
return sprite;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
};
|
||||
var context = new BlockEntityRendererProvider.Context(null, null, null, null, models, font, sprites, null);
|
||||
Map<Object, BlockEntityRenderer> renderers = (Map) BlockEntityRenderers.createEntityRenderers(context);
|
||||
var gson = new Gson();
|
||||
var out = new StringBuilder();
|
||||
for (String line : Files.readAllLines(Path.of(args[0]))) {
|
||||
if (line.isBlank()) continue;
|
||||
var c = JsonParser.parseString(line).getAsJsonObject();
|
||||
var result = new JsonObject();
|
||||
result.add("case", c);
|
||||
try {
|
||||
BlockState state = BlockStateParser.parseForBlock(BuiltInRegistries.BLOCK, c.get("block").getAsString(), false).blockState();
|
||||
var p = c.getAsJsonArray("pos");
|
||||
var pos = new BlockPos(p.get(0).getAsInt(), p.get(1).getAsInt(), p.get(2).getAsInt());
|
||||
var be = ((EntityBlock) state.getBlock()).newBlockEntity(pos, state);
|
||||
if (be == null) throw new IllegalStateException("no block entity");
|
||||
apply(be, c);
|
||||
var renderer = renderers.get(be.getType());
|
||||
if (renderer == null) throw new IllegalStateException("no renderer");
|
||||
float partial = c.get("partial").getAsFloat();
|
||||
var cam = c.getAsJsonArray("camera");
|
||||
var cameraPos = new Vec3(cam.get(0).getAsDouble(), cam.get(1).getAsDouble(), cam.get(2).getAsDouble());
|
||||
var rs = (BlockEntityRenderState) renderer.createRenderState();
|
||||
if (rs instanceof BeaconRenderState beacon) {
|
||||
// BeaconRenderer.extract reads Minecraft.getInstance().player (scoping); its level-free part:
|
||||
BlockEntityRenderState.extractBase(be, rs, null);
|
||||
beacon.animationTime = Math.floorMod(c.get("gameTime").getAsLong(), 40) + partial;
|
||||
var sections = new ArrayList<BeaconRenderState.Section>();
|
||||
for (var e : c.getAsJsonArray("beam")) sections.add(new BeaconRenderState.Section(e.getAsJsonArray().get(0).getAsInt(), e.getAsJsonArray().get(1).getAsInt()));
|
||||
beacon.sections = sections;
|
||||
float distance = (float) cameraPos.subtract(Vec3.atCenterOf(pos)).horizontalDistance();
|
||||
beacon.beamRadiusScale = Math.max(1.0F, distance / 96.0F);
|
||||
} else
|
||||
if (rs instanceof SignRenderState sign) {
|
||||
// AbstractSignRenderer.extractRenderState reads Minecraft.getInstance(); its level-free part:
|
||||
var signBe = (SignBlockEntity) be;
|
||||
BlockEntityRenderState.extractBase(be, rs, null);
|
||||
sign.maxTextLineWidth = signBe.getMaxTextLineWidth();
|
||||
sign.textLineHeight = signBe.getTextLineHeight();
|
||||
var s = c.getAsJsonObject("sign");
|
||||
sign.frontText = signText(s.getAsJsonObject("front"));
|
||||
sign.backText = signText(s.getAsJsonObject("back"));
|
||||
sign.drawOutline = s.get("near").getAsBoolean();
|
||||
if (rs instanceof StandingSignRenderState standing) {
|
||||
standing.attachmentType = net.minecraft.world.level.block.PlainSignBlock.getAttachmentPoint(state);
|
||||
sign.transformations = state.getBlock() instanceof net.minecraft.world.level.block.WallSignBlock
|
||||
? StandingSignRenderer.TRANSFORMATIONS.wallTransformation(state.getValue(net.minecraft.world.level.block.WallSignBlock.FACING))
|
||||
: StandingSignRenderer.TRANSFORMATIONS.freeTransformations(state.getValue(net.minecraft.world.level.block.StandingSignBlock.ROTATION));
|
||||
} else {
|
||||
((HangingSignRenderState) rs).attachmentType = net.minecraft.world.level.block.HangingSignBlock.getAttachmentPoint(state);
|
||||
sign.transformations = state.getBlock() instanceof net.minecraft.world.level.block.WallHangingSignBlock
|
||||
? HangingSignRenderer.TRANSFORMATIONS.wallTransformation(state.getValue(net.minecraft.world.level.block.WallHangingSignBlock.FACING))
|
||||
: HangingSignRenderer.TRANSFORMATIONS.freeTransformations(state.getValue(net.minecraft.world.level.block.CeilingHangingSignBlock.ROTATION));
|
||||
}
|
||||
} else {
|
||||
renderer.extractRenderState(be, rs, partial, cameraPos, null);
|
||||
}
|
||||
var light = c.getAsJsonArray("light");
|
||||
rs.lightCoords = light.get(1).getAsInt() << 4 | light.get(0).getAsInt() << 20;
|
||||
patch(rs, state, c);
|
||||
var camera = new CameraRenderState();
|
||||
camera.pos = cameraPos;
|
||||
float yaw = c.has("yaw") ? c.get("yaw").getAsFloat() : 0.0F, pitch = c.has("pitch") ? c.get("pitch").getAsFloat() : 0.0F;
|
||||
// Camera.setRotation.
|
||||
camera.orientation = new Quaternionf().rotationYXZ((float) Math.PI - yaw * (float) (Math.PI / 180.0), -pitch * (float) (Math.PI / 180.0), 0.0F);
|
||||
var submits = new JsonArray();
|
||||
renderer.submit(rs, new PoseStack(), collector(submits), camera);
|
||||
result.add("submits", submits);
|
||||
} catch (Throwable error) {
|
||||
var cause = error instanceof InvocationTargetException && error.getCause() != null ? error.getCause() : error;
|
||||
var sw = new java.io.StringWriter();
|
||||
cause.printStackTrace(new java.io.PrintWriter(sw));
|
||||
result.addProperty("error", sw.toString().lines().limit(6).reduce("", (a, b) -> a + b + " | "));
|
||||
}
|
||||
out.append(gson.toJson(result)).append('\n');
|
||||
}
|
||||
Files.writeString(Path.of(args[1]), out.toString(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -411,9 +411,17 @@ public final class VanillaChunkGenerationOracle {
|
||||
var tag = target.getBlockEntityNbtForSaving(pos, registry);
|
||||
if (tag == null) continue;
|
||||
var st = target.getBlockState(pos);
|
||||
var fresh = ((net.minecraft.world.level.block.EntityBlock) st.getBlock()).newBlockEntity(pos, st).saveWithFullMetadata(registry);
|
||||
if ("DUMMY".equals(tag.getStringOr("id", ""))) tag = fresh;
|
||||
w.println("be " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + "\t" + canonicalOf(tag) + "\t" + canonicalOf(fresh));
|
||||
var block = (net.minecraft.world.level.block.EntityBlock) st.getBlock();
|
||||
var fresh = block.newBlockEntity(pos, st).saveWithFullMetadata(registry);
|
||||
// The value of each key Horizon leaves out: what loading a tag without
|
||||
// it gives (BrewingStandBlockEntity reads a missing total_brew_time as 400).
|
||||
var empty = block.newBlockEntity(pos, st);
|
||||
empty.loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create(
|
||||
net.minecraft.util.ProblemReporter.DISCARDING, registry, new net.minecraft.nbt.CompoundTag()));
|
||||
var absent = empty.saveWithFullMetadata(registry);
|
||||
// A DUMMY pending entry becomes a fresh block entity, not a loaded one.
|
||||
if ("DUMMY".equals(tag.getStringOr("id", ""))) tag = absent = fresh;
|
||||
w.println("be " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + "\t" + canonicalOf(tag) + "\t" + canonicalOf(absent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.mojang.renderpearl.api.pipeline.RenderPipeline;
|
||||
import java.lang.reflect.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.client.AttackIndicatorStatus;
|
||||
import net.minecraft.client.DeltaTracker;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.OptionInstance;
|
||||
import net.minecraft.client.Options;
|
||||
import net.minecraft.client.gui.Font;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.Hud;
|
||||
import net.minecraft.client.gui.contextualbar.ExperienceBar;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.multiplayer.MultiPlayerGameMode;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.tags.FluidTags;
|
||||
import net.minecraft.tags.TagKey;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraft.world.effect.MobEffect;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntityEquipment;
|
||||
import net.minecraft.world.entity.HumanoidArm;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.ai.attributes.Attribute;
|
||||
import net.minecraft.world.entity.ai.attributes.Attributes;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.food.FoodData;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.level.GameType;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.material.Fluid;
|
||||
import org.joml.Matrix3x2f;
|
||||
import org.joml.Matrix3x2fStack;
|
||||
|
||||
/**
|
||||
* Original oracle for the in-game HUD decorations: runs vanilla's own Hud
|
||||
* methods (extractItemHotbar, extractPlayerHealth, extractVehicleHealth,
|
||||
* extractCrosshair) and ExperienceBar / ContextualBar on a stand-in client
|
||||
* (Unsafe-allocated Minecraft, options, game mode, window and level) and a
|
||||
* player whose state getters answer from the case, and records every
|
||||
* GuiGraphicsExtractor blit, text and item call.
|
||||
*
|
||||
* Input lines: case JSON. Output lines: {case, draws:[...]} or {case, error}.
|
||||
*/
|
||||
public final class VanillaHudOracle {
|
||||
static Object unsafe;
|
||||
static Method allocate;
|
||||
static JsonObject c;
|
||||
static IdentityHashMap<ItemStack, Integer> slots = new IdentityHashMap<>();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static <T> T alloc(Class<T> type) throws Exception {
|
||||
return (T) allocate.invoke(unsafe, type);
|
||||
}
|
||||
|
||||
static Field field(Class<?> type, String name) throws NoSuchFieldException {
|
||||
for (Class<?> k = type; k != null; k = k.getSuperclass()) {
|
||||
try {
|
||||
Field f = k.getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
return f;
|
||||
} catch (NoSuchFieldException e) {
|
||||
}
|
||||
}
|
||||
throw new NoSuchFieldException(name + " on " + type);
|
||||
}
|
||||
|
||||
static void put(Object target, Class<?> owner, String name, Object value) throws Exception {
|
||||
Field f = field(owner, name);
|
||||
long offset = (long) unsafe.getClass().getMethod("objectFieldOffset", Field.class).invoke(unsafe, f);
|
||||
Class<?> t = f.getType();
|
||||
String setter = t == int.class ? "putInt" : t == boolean.class ? "putBoolean" : t == float.class ? "putFloat" : t == long.class ? "putLong" : "putObject";
|
||||
Class<?> param = t.isPrimitive() ? t : Object.class;
|
||||
unsafe.getClass().getMethod(setter, Object.class, long.class, param).invoke(unsafe, target, offset, value);
|
||||
}
|
||||
|
||||
static float f(String k, float d) { return c.has(k) && !c.get(k).isJsonNull() ? c.get(k).getAsFloat() : d; }
|
||||
static int i(String k, int d) { return c.has(k) && !c.get(k).isJsonNull() ? c.get(k).getAsInt() : d; }
|
||||
static boolean b(String k) { return c.has(k) && c.get(k).getAsBoolean(); }
|
||||
|
||||
/** A player whose HUD-facing getters answer from the case. */
|
||||
public static final class Actor extends LocalPlayer {
|
||||
Level level;
|
||||
Entity vehicle;
|
||||
FoodData food;
|
||||
ItemStack offhand = ItemStack.EMPTY;
|
||||
private Actor() { super(null, null, null, null, null, null, false, null, null); }
|
||||
@Override public float getHealth() { return f("health", 20); }
|
||||
@Override public double getAttributeValue(Holder<Attribute> a) { return a == Attributes.MAX_HEALTH ? f("maxHealth", 20) : 0.0; }
|
||||
@Override public float getAbsorptionAmount() { return f("absorption", 0); }
|
||||
@Override public int getArmorValue() { return i("armor", 0); }
|
||||
@Override public FoodData getFoodData() { return food; }
|
||||
@Override public boolean hasEffect(Holder<MobEffect> effect) {
|
||||
for (var e : c.getAsJsonArray("effects")) if (effect.is(Identifier.parse(e.getAsString()))) return true;
|
||||
return false;
|
||||
}
|
||||
@Override public boolean isFullyFrozen() { return b("frozen"); }
|
||||
@Override public int getAirSupply() { return i("air", 300); }
|
||||
@Override public int getMaxAirSupply() { return i("maxAir", 300); }
|
||||
@Override public boolean isEyeInFluid(TagKey<Fluid> tag) { return tag == FluidTags.WATER && b("underWater"); }
|
||||
@Override public Entity getVehicle() { return vehicle; }
|
||||
@Override public ItemStack getOffhandItem() { return offhand; }
|
||||
@Override public HumanoidArm getMainArm() { return b("rightHanded") ? HumanoidArm.RIGHT : HumanoidArm.LEFT; }
|
||||
@Override public float getAttackStrengthScale(float a) { return f("attackScale", 1); }
|
||||
@Override public Level level() { return level; }
|
||||
@Override public boolean isSpectator() { return false; }
|
||||
@Override public void playSound(net.minecraft.sounds.SoundEvent sound, float volume, float pitch) { }
|
||||
}
|
||||
|
||||
/** A ridden mount answering health from the case. */
|
||||
public static final class Mount extends net.minecraft.world.entity.animal.equine.Horse {
|
||||
private Mount() { super(null, null); }
|
||||
@Override public float getHealth() { return f("vehicleHealth", 0); }
|
||||
@Override public double getAttributeValue(Holder<Attribute> a) { return a == Attributes.MAX_HEALTH ? f("vehicleMaxHealth", 0) : 0.0; }
|
||||
@Override public boolean showVehicleHealth() { return true; }
|
||||
}
|
||||
|
||||
/** Records GuiGraphicsExtractor calls. */
|
||||
public static final class Capture extends GuiGraphicsExtractor {
|
||||
JsonArray draws;
|
||||
Matrix3x2fStack stack;
|
||||
private Capture() { super(null, null, 0, 0); }
|
||||
@Override public int guiWidth() { return i("width", 0); }
|
||||
@Override public int guiHeight() { return i("height", 0); }
|
||||
@Override public Matrix3x2fStack pose() { return stack; }
|
||||
@Override public void nextStratum() { }
|
||||
void sprite(Identifier id, int x, int y, int w, int h, int[] region, int color) {
|
||||
var o = new JsonObject();
|
||||
o.addProperty("type", "sprite");
|
||||
o.addProperty("sprite", id.getPath());
|
||||
o.addProperty("x", x); o.addProperty("y", y); o.addProperty("w", w); o.addProperty("h", h);
|
||||
if (region != null) {
|
||||
var r = new JsonArray();
|
||||
for (int v : region) r.add(v);
|
||||
o.add("region", r);
|
||||
}
|
||||
o.addProperty("color", color);
|
||||
draws.add(o);
|
||||
}
|
||||
@Override public void blitSprite(RenderPipeline p, Identifier id, int x, int y, int w, int h, int color) { sprite(id, x, y, w, h, null, color); }
|
||||
@Override public void blitSprite(RenderPipeline p, Identifier id, int sw, int sh, int u, int v, int x, int y, int w, int h, int color) {
|
||||
sprite(id, x, y, w, h, new int[] {sw, sh, u, v}, color);
|
||||
}
|
||||
@Override public void text(Font font, FormattedCharSequence str, int x, int y, int color, boolean shadow) {
|
||||
var o = new JsonObject();
|
||||
o.addProperty("type", "text");
|
||||
var sb = new StringBuilder();
|
||||
str.accept((index, style, cp) -> { sb.appendCodePoint(cp); return true; });
|
||||
o.addProperty("text", sb.toString());
|
||||
o.addProperty("x", x); o.addProperty("y", y); o.addProperty("color", color); o.addProperty("shadow", shadow);
|
||||
draws.add(o);
|
||||
}
|
||||
@Override public void item(LivingEntity owner, ItemStack stack, int x, int y, int seed) {
|
||||
var o = new JsonObject();
|
||||
o.addProperty("type", "item");
|
||||
o.addProperty("slot", slots.getOrDefault(stack, -1));
|
||||
o.addProperty("x", x); o.addProperty("y", y); o.addProperty("seed", seed);
|
||||
var m = new Matrix3x2f(stack.isEmpty() ? new Matrix3x2f() : this.stack);
|
||||
var a = new JsonArray();
|
||||
for (float v : new float[] {m.m00, m.m01, m.m10, m.m11, m.m20, m.m21}) a.add(v);
|
||||
o.add("matrix", a);
|
||||
draws.add(o);
|
||||
}
|
||||
@Override public void itemDecorations(Font font, ItemStack stack, int x, int y, String count) { }
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
var unsafeField = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe");
|
||||
unsafeField.setAccessible(true);
|
||||
unsafe = unsafeField.get(null);
|
||||
allocate = unsafe.getClass().getMethod("allocateInstance", Class.class);
|
||||
Font font = ParityFont.create(ParityFont.resources());
|
||||
Method hotbar = Hud.class.getDeclaredMethod("extractItemHotbar", GuiGraphicsExtractor.class, DeltaTracker.class);
|
||||
Method health = Hud.class.getDeclaredMethod("extractPlayerHealth", GuiGraphicsExtractor.class);
|
||||
Method vehicle = Hud.class.getDeclaredMethod("extractVehicleHealth", GuiGraphicsExtractor.class);
|
||||
Method crosshair = Hud.class.getDeclaredMethod("extractCrosshair", GuiGraphicsExtractor.class, DeltaTracker.class);
|
||||
crosshair.setAccessible(true);
|
||||
for (Method m : new Method[] {hotbar, health, vehicle}) m.setAccessible(true);
|
||||
Method level = Class.forName("net.minecraft.client.gui.contextualbar.ContextualBar")
|
||||
.getDeclaredMethod("extractExperienceLevel", GuiGraphicsExtractor.class, Font.class, int.class);
|
||||
level.setAccessible(true);
|
||||
var gson = new Gson();
|
||||
var out = new StringBuilder();
|
||||
for (String line : Files.readAllLines(Path.of(args[0]))) {
|
||||
if (line.isBlank()) continue;
|
||||
c = JsonParser.parseString(line).getAsJsonObject();
|
||||
var result = new JsonObject();
|
||||
result.add("case", c);
|
||||
try {
|
||||
slots.clear();
|
||||
var mc = alloc(Minecraft.class);
|
||||
var actor = alloc(Actor.class);
|
||||
var clientLevel = alloc(ClientLevel.class);
|
||||
var levelData = alloc(ClientLevel.ClientLevelData.class);
|
||||
put(levelData, ClientLevel.ClientLevelData.class, "hardcore", b("hardcore"));
|
||||
put(clientLevel, ClientLevel.class, "clientLevelData", levelData);
|
||||
actor.level = clientLevel;
|
||||
actor.offhand = ItemStack.EMPTY;
|
||||
actor.food = new FoodData();
|
||||
actor.food.setFoodLevel(i("food", 20));
|
||||
actor.food.setSaturation(f("saturation", 5));
|
||||
actor.damageCooldownTime = i("damageCooldown", 0);
|
||||
actor.experienceProgress = f("xpProgress", 0);
|
||||
actor.experienceLevel = i("xpLevel", 0);
|
||||
var inventory = new Inventory(actor, new EntityEquipment());
|
||||
put(actor, net.minecraft.world.entity.player.Player.class, "inventory", inventory);
|
||||
inventory.setSelectedSlot(i("selected", 0));
|
||||
var stacks = c.getAsJsonArray("slots");
|
||||
var items = new net.minecraft.world.item.Item[] {Items.STONE, Items.DIAMOND_SWORD, Items.APPLE, Items.TORCH, Items.BOW,
|
||||
Items.OAK_LOG, Items.BREAD, Items.SHIELD, Items.COMPASS};
|
||||
for (int n = 0; n < 9; n++) {
|
||||
if (stacks.get(n).isJsonNull()) continue;
|
||||
var stack = new ItemStack(items[n]);
|
||||
stack.setPopTime(stacks.get(n).getAsInt());
|
||||
inventory.setItem(n, stack);
|
||||
slots.put(stack, n);
|
||||
}
|
||||
if (c.has("offhand") && !c.get("offhand").isJsonNull()) {
|
||||
actor.offhand = new ItemStack(Items.TOTEM_OF_UNDYING);
|
||||
actor.offhand.setPopTime(c.get("offhand").getAsInt());
|
||||
slots.put(actor.offhand, 40);
|
||||
}
|
||||
if (c.has("vehicleMaxHealth")) actor.vehicle = alloc(Mount.class);
|
||||
put(mc, Minecraft.class, "player", actor);
|
||||
var camera = alloc(net.minecraft.client.Camera.class);
|
||||
put(camera, net.minecraft.client.Camera.class, "entity", actor);
|
||||
var renderer = alloc(net.minecraft.client.renderer.GameRenderer.class);
|
||||
put(renderer, net.minecraft.client.renderer.GameRenderer.class, "mainCamera", camera);
|
||||
put(mc, Minecraft.class, "gameRenderer", renderer);
|
||||
put(mc, Minecraft.class, "font", font);
|
||||
var gameMode = alloc(MultiPlayerGameMode.class);
|
||||
put(gameMode, MultiPlayerGameMode.class, "localPlayerMode", GameType.byName(c.get("gameMode").getAsString()));
|
||||
put(mc, Minecraft.class, "gameMode", gameMode);
|
||||
var options = alloc(Options.class);
|
||||
var indicator = alloc(OptionInstance.class);
|
||||
put(indicator, OptionInstance.class, "value", AttackIndicatorStatus.valueOf(c.get("attackIndicator").getAsString()));
|
||||
put(options, Options.class, "attackIndicator", indicator);
|
||||
put(mc, Minecraft.class, "options", options);
|
||||
var window = alloc(com.mojang.blaze3d.platform.Window.class);
|
||||
put(window, com.mojang.blaze3d.platform.Window.class, "guiScaledWidth", i("width", 0));
|
||||
put(window, com.mojang.blaze3d.platform.Window.class, "guiScaledHeight", i("height", 0));
|
||||
put(mc, Minecraft.class, "window", window);
|
||||
var hud = alloc(Hud.class);
|
||||
put(hud, Hud.class, "minecraft", mc);
|
||||
put(hud, Hud.class, "random", net.minecraft.util.RandomSource.create());
|
||||
put(hud, Hud.class, "tickCount", i("tick", 0));
|
||||
put(hud, Hud.class, "lastHealth", i("lastHealth", 0));
|
||||
put(hud, Hud.class, "displayHealth", i("displayHealth", 0));
|
||||
put(hud, Hud.class, "lastHealthTime", Util.getMillis() - (long) i("lastHealthAgo", 0));
|
||||
put(hud, Hud.class, "healthBlinkTime", (long) i("healthBlinkTime", 0));
|
||||
var capture = alloc(Capture.class);
|
||||
capture.draws = new JsonArray();
|
||||
capture.stack = new Matrix3x2fStack(16);
|
||||
float partial = f("partial", 0);
|
||||
DeltaTracker delta = (DeltaTracker) Proxy.newProxyInstance(DeltaTracker.class.getClassLoader(), new Class<?>[] {DeltaTracker.class},
|
||||
(proxy, method, a) -> method.getReturnType() == float.class ? partial : null);
|
||||
var mode = GameType.byName(c.get("gameMode").getAsString());
|
||||
if (c.has("crosshair") && c.get("crosshair").getAsBoolean()) {
|
||||
put(options, Options.class, "cameraType", net.minecraft.client.CameraType.FIRST_PERSON);
|
||||
var debug = alloc(net.minecraft.client.gui.components.debug.DebugScreenEntryList.class);
|
||||
put(debug, net.minecraft.client.gui.components.debug.DebugScreenEntryList.class, "currentlyEnabled", new ArrayList<Identifier>());
|
||||
put(mc, Minecraft.class, "debugEntries", debug);
|
||||
crosshair.invoke(hud, capture, delta);
|
||||
}
|
||||
if (mode != GameType.SPECTATOR) hotbar.invoke(hud, capture, delta);
|
||||
if (mode.isSurvival()) health.invoke(hud, capture);
|
||||
vehicle.invoke(hud, capture);
|
||||
if (c.has("experienceBar") && c.get("experienceBar").getAsBoolean()) new ExperienceBar(mc).extractBackground(capture, delta);
|
||||
if (mode.isSurvival() && actor.experienceLevel > 0) level.invoke(null, capture, font, actor.experienceLevel);
|
||||
result.add("draws", capture.draws);
|
||||
} catch (Throwable error) {
|
||||
var cause = error instanceof InvocationTargetException && error.getCause() != null ? error.getCause() : error;
|
||||
var sw = new java.io.StringWriter();
|
||||
cause.printStackTrace(new java.io.PrintWriter(sw));
|
||||
result.addProperty("error", sw.toString().lines().limit(8).reduce("", (x, y) -> x + y + " | "));
|
||||
}
|
||||
out.append(gson.toJson(result)).append('\n');
|
||||
}
|
||||
Files.writeString(Path.of(args[1]), out.toString(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,11 @@ public final class VanillaItemModelOracle {
|
||||
static TextureAtlasSprite load(Identifier id, Identifier atlas) {
|
||||
try {
|
||||
var png = root.resolve("assets").resolve(id.getNamespace()).resolve("textures").resolve(id.getPath() + ".png");
|
||||
if (!Files.isRegularFile(png)) return null;
|
||||
if (!Files.isRegularFile(png)) {
|
||||
// Armour trim textures come from the atlas's paletted_permutations source.
|
||||
var generated = generatedSprites(atlas).get(id);
|
||||
return generated == null ? null : new Sprite(atlas, generated.get(null));
|
||||
}
|
||||
NativeImage image;
|
||||
try (var in = Files.newInputStream(png)) { image = NativeImage.read(in); }
|
||||
var mcmeta = png.resolveSibling(png.getFileName() + ".mcmeta");
|
||||
@@ -100,6 +104,40 @@ public final class VanillaItemModelOracle {
|
||||
} catch (IOException e) { throw new UncheckedIOException(e); }
|
||||
}
|
||||
|
||||
static final Map<Identifier, Map<Identifier, net.minecraft.client.renderer.texture.atlas.SpriteSource.DiscardableLoader>> GENERATED = new HashMap<>();
|
||||
/** The atlas's paletted_permutations sprites, run by vanilla's own source over the extracted resources. */
|
||||
static Map<Identifier, net.minecraft.client.renderer.texture.atlas.SpriteSource.DiscardableLoader> generatedSprites(Identifier atlas) {
|
||||
return GENERATED.computeIfAbsent(atlas, a -> {
|
||||
Map<Identifier, net.minecraft.client.renderer.texture.atlas.SpriteSource.DiscardableLoader> out = new HashMap<>();
|
||||
var file = "/assets/" + a.getNamespace() + "/atlases/" + a.getPath().replaceFirst("^textures/atlas/", "").replaceFirst("\\.png$", "") + ".json";
|
||||
try (var in = VanillaItemModelOracle.class.getResourceAsStream(file)) {
|
||||
if (in == null) return out;
|
||||
var json = JsonParser.parseString(new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8));
|
||||
var sources = net.minecraft.client.renderer.texture.atlas.SpriteSources.FILE_CODEC.parse(JsonOps.INSTANCE, json).getOrThrow();
|
||||
var output = new net.minecraft.client.renderer.texture.atlas.SpriteSource.Output() {
|
||||
@Override public void add(Identifier id, net.minecraft.client.renderer.texture.atlas.SpriteSource.DiscardableLoader sprite) { out.put(id, sprite); }
|
||||
@Override public void removeAll(java.util.function.Predicate<Identifier> predicate) { out.keySet().removeIf(predicate); }
|
||||
};
|
||||
for (var source : sources)
|
||||
if (source instanceof net.minecraft.client.renderer.texture.atlas.sources.PalettedPermutations) source.run(FILES, output);
|
||||
} catch (IOException e) { throw new UncheckedIOException(e); }
|
||||
return out;
|
||||
});
|
||||
}
|
||||
/** The extracted resources as a single-pack resource manager. */
|
||||
static final net.minecraft.server.packs.resources.ResourceManager FILES = new net.minecraft.server.packs.resources.ResourceManager() {
|
||||
Path path(Identifier id) { return root.resolve("assets").resolve(id.getNamespace()).resolve(id.getPath()); }
|
||||
@Override public Optional<net.minecraft.server.packs.resources.Resource> getResource(Identifier id) {
|
||||
var file = path(id);
|
||||
return Files.isRegularFile(file) ? Optional.of(new net.minecraft.server.packs.resources.Resource(null, () -> Files.newInputStream(file))) : Optional.empty();
|
||||
}
|
||||
@Override public java.util.Set<String> getNamespaces() { return java.util.Set.of("minecraft"); }
|
||||
@Override public List<net.minecraft.server.packs.resources.Resource> getResourceStack(Identifier id) { return getResource(id).stream().toList(); }
|
||||
@Override public Map<Identifier, net.minecraft.server.packs.resources.Resource> listResources(String directory, net.minecraft.server.packs.resources.ResourceManager.Selector selector) { throw new UnsupportedOperationException(); }
|
||||
@Override public Map<Identifier, List<net.minecraft.server.packs.resources.Resource>> listResourceStacks(String directory, net.minecraft.server.packs.resources.ResourceManager.Selector selector) { throw new UnsupportedOperationException(); }
|
||||
@Override public java.util.stream.Stream<net.minecraft.server.packs.PackResources> listPacks() { return java.util.stream.Stream.empty(); }
|
||||
};
|
||||
|
||||
// --- Tagging of special renderers so the layer carries its codec form. ---
|
||||
static final class Tagged implements SpecialModelRenderer.Unbaked<Object> {
|
||||
final SpecialModelRenderer.Unbaked<?> delegate;
|
||||
@@ -196,6 +234,15 @@ public final class VanillaItemModelOracle {
|
||||
models.getOrDefault(id, missing).update(output, item, this, context, level instanceof ClientLevel c ? c : null, owner, seed);
|
||||
}
|
||||
}
|
||||
// ModelManager.getItemProperties, from the same baked properties.
|
||||
@Override public boolean shouldPlaySwapAnimation(ItemStack stack) {
|
||||
Identifier id = stack.get(DataComponents.ITEM_MODEL);
|
||||
return id == null || properties.getOrDefault(id, ClientItem.Properties.DEFAULT).handAnimationOnSwap();
|
||||
}
|
||||
@Override public float swapAnimationScale(ItemStack stack) {
|
||||
Identifier id = stack.get(DataComponents.ITEM_MODEL);
|
||||
return id == null ? 1.0F : properties.getOrDefault(id, ClientItem.Properties.DEFAULT).swapAnimationScale();
|
||||
}
|
||||
}
|
||||
|
||||
static Object field(Object target, Class<?> type, String name) throws Exception {
|
||||
@@ -251,12 +298,22 @@ public final class VanillaItemModelOracle {
|
||||
public static void main(String[] args) throws Exception {
|
||||
net.minecraft.SharedConstants.tryDetectVersion();
|
||||
net.minecraft.server.Bootstrap.bootStrap();
|
||||
// Item tags (compasses get the special foil) come from the vanilla data pack.
|
||||
var repo = net.minecraft.server.packs.repository.ServerPacksSource.createVanillaTrustedRepository();
|
||||
repo.reload();
|
||||
repo.setSelected(List.of("vanilla"));
|
||||
try (var data = new net.minecraft.server.packs.resources.MultiPackResourceManager(net.minecraft.server.packs.PackType.SERVER_DATA, repo.openAllSelected())) {
|
||||
var layers = net.minecraft.server.RegistryLayer.createRegistryAccess();
|
||||
net.minecraft.tags.TagLoader.loadTagsForExistingRegistries(data, layers.getLayer(net.minecraft.server.RegistryLayer.STATIC))
|
||||
.forEach(net.minecraft.core.Registry.PendingTags::apply);
|
||||
}
|
||||
ItemModels.bootstrap();
|
||||
SpecialModelRenderers.bootstrap();
|
||||
net.minecraft.client.color.item.ItemTintSources.bootstrap();
|
||||
net.minecraft.client.renderer.item.properties.select.SelectItemModelProperties.bootstrap();
|
||||
net.minecraft.client.renderer.item.properties.conditional.ConditionalItemModelProperties.bootstrap();
|
||||
net.minecraft.client.renderer.item.properties.numeric.RangeSelectItemModelProperties.bootstrap();
|
||||
net.minecraft.client.renderer.texture.atlas.SpriteSources.bootstrap();
|
||||
var u = Class.forName("sun.misc.Unsafe");
|
||||
var uf = u.getDeclaredField("theUnsafe"); uf.setAccessible(true); unsafe = uf.get(null);
|
||||
allocate = u.getMethod("allocateInstance", Class.class);
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare block-entity rendering with the vanilla 26.3 client renderers.
|
||||
|
||||
The Java oracle (VanillaBlockEntityRenderOracle) builds each case's vanilla
|
||||
block entity, sets its animation fields, runs the real BlockEntityRenderer's
|
||||
extractRenderState and submit, and captures what it submits: model vertices
|
||||
after setupAnim and the pose stack (sprite-local UVs, tint, normal, light),
|
||||
text calls and items. `examples/block_entity_render_fixture` replays the case
|
||||
through Horizon's BlockEntityRenderer. Quads are matched by texture and
|
||||
position; colours compare vanilla's tint times the entity shader's
|
||||
`minecraft_mix_light` for the vanilla normal with Horizon's baked colour.
|
||||
|
||||
test_block_entity_render_parity.py [--filter SUBSTRING] [--detail N] [--tolerance EPS]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
from . import lab, test_rust_parity as base, test_model_baking_parity as baking
|
||||
except ImportError:
|
||||
import lab, test_rust_parity as base, test_model_baking_parity as baking
|
||||
|
||||
PARITY = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity'
|
||||
DYES = ['white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', 'pink', 'gray', 'light_gray', 'cyan', 'purple',
|
||||
'blue', 'brown', 'green', 'red', 'black']
|
||||
HORIZONTAL = ['north', 'south', 'west', 'east']
|
||||
ALL = HORIZONTAL + ['up', 'down']
|
||||
NO_OVERLAY = 10 << 16
|
||||
|
||||
|
||||
def cases():
|
||||
rng = random.Random(0x42454e44)
|
||||
out = []
|
||||
|
||||
def add(block, **extra):
|
||||
case = {'block': block, 'pos': [rng.randint(-40, 40), rng.randint(-20, 90), rng.randint(-40, 40)],
|
||||
'light': [rng.randint(0, 15), rng.randint(0, 15)], 'partial': round(rng.random(), 4),
|
||||
'camera': [0.5, 70.0, 0.5]}
|
||||
case.update(extra)
|
||||
out.append(case)
|
||||
|
||||
for block in ['chest', 'trapped_chest', 'copper_chest', 'exposed_copper_chest', 'weathered_copper_chest',
|
||||
'oxidized_copper_chest', 'waxed_copper_chest', 'waxed_oxidized_copper_chest']:
|
||||
for facing in HORIZONTAL:
|
||||
for kind in ['single', 'left', 'right']:
|
||||
add(f'minecraft:{block}[facing={facing},type={kind}]', open=rng.choice([0.0, 0.25, 0.7, 1.0]))
|
||||
for facing in HORIZONTAL:
|
||||
add(f'minecraft:ender_chest[facing={facing}]', open=rng.choice([0.0, 0.5, 1.0]))
|
||||
texts = [['Hello', '', 'World', ''], ['A much longer line that cannot fit', 'mid', 'iii', 'WWWWWWWWWWWWWWW'],
|
||||
['', '', '', ''], ['x', 'Sign text', '#1 (test)', '!']]
|
||||
for wood in ['oak', 'spruce', 'cherry', 'crimson', 'bamboo', 'pale_oak']:
|
||||
signs = [f'minecraft:{wood}_sign[rotation={r}]' for r in (0, 3, 8, 13)] + \
|
||||
[f'minecraft:{wood}_wall_sign[facing={f}]' for f in HORIZONTAL] + \
|
||||
[f'minecraft:{wood}_hanging_sign[rotation={r},attached={a}]' for r, a in ((0, 'false'), (6, 'true'), (11, 'false'))] + \
|
||||
[f'minecraft:{wood}_wall_hanging_sign[facing={f}]' for f in HORIZONTAL]
|
||||
for block in signs:
|
||||
side = lambda: {'lines': rng.choice(texts), 'color': rng.choice(DYES), 'glowing': rng.random() < 0.4}
|
||||
add(block, sign={'front': side(), 'back': side(), 'near': rng.random() < 0.5})
|
||||
patterns = ['minecraft:stripe_bottom', 'minecraft:creeper', 'minecraft:gradient', 'minecraft:flower',
|
||||
'minecraft:border', 'minecraft:skull', 'minecraft:globe', 'minecraft:piglin']
|
||||
for color in ['white', 'red', 'light_gray', 'black']:
|
||||
for r in (0, 5, 12):
|
||||
add(f'minecraft:{color}_banner[rotation={r}]',
|
||||
patterns=[[rng.choice(patterns), rng.choice(DYES)] for _ in range(rng.randint(0, 3))])
|
||||
for f in HORIZONTAL:
|
||||
add(f'minecraft:{color}_wall_banner[facing={f}]',
|
||||
patterns=[[rng.choice(patterns), rng.choice(DYES)] for _ in range(rng.randint(0, 2))])
|
||||
add('minecraft:blue_banner[rotation=2]', patterns=[[rng.choice(patterns), rng.choice(DYES)] for _ in range(17)])
|
||||
for skull in ['skeleton_skull', 'wither_skeleton_skull', 'zombie_head', 'creeper_head', 'dragon_head',
|
||||
'piglin_head', 'player_head']:
|
||||
wall = skull.replace('_skull', '_wall_skull').replace('_head', '_wall_head')
|
||||
for r in (0, 4, 9):
|
||||
add(f'minecraft:{skull}[rotation={r}]', skullAnimation=[rng.random() < 0.5, rng.randint(0, 80)])
|
||||
for f in HORIZONTAL:
|
||||
add(f'minecraft:{wall}[facing={f}]', skullAnimation=[rng.random() < 0.5, rng.randint(0, 80)])
|
||||
for color in [None, 'white', 'light_gray', 'purple', 'black']:
|
||||
name = 'shulker_box' if color is None else f'{color}_shulker_box'
|
||||
for f in ALL:
|
||||
p = rng.choice([0.0, 0.0, 0.3, 1.0])
|
||||
add(f'minecraft:{name}[facing={f}]', progress=p, progressOld=max(0.0, p - 0.1))
|
||||
for attach in ['floor', 'ceiling', 'single_wall', 'double_wall']:
|
||||
for f in HORIZONTAL:
|
||||
shake = rng.choice([None] + HORIZONTAL)
|
||||
extra = {'bellTicks': rng.randint(0, 50)}
|
||||
if shake:
|
||||
extra['shake'] = shake
|
||||
add(f'minecraft:bell[attachment={attach},facing={f}]', **extra)
|
||||
sherds = ['', 'minecraft:brick', 'minecraft:angler_pottery_sherd', 'minecraft:skull_pottery_sherd',
|
||||
'minecraft:heart_pottery_sherd', 'minecraft:flow_pottery_sherd']
|
||||
for f in HORIZONTAL:
|
||||
for wobble in [None, ['POSITIVE', 0.3], ['NEGATIVE', 0.6], ['POSITIVE', 1.2]]:
|
||||
extra = {'sherds': [rng.choice(sherds) for _ in range(4)]}
|
||||
if wobble:
|
||||
extra['wobble'] = wobble
|
||||
add(f'minecraft:decorated_pot[facing={f}]', **extra)
|
||||
for active in (False, True):
|
||||
for _ in range(4):
|
||||
add('minecraft:conduit', conduit={'tickCount': rng.randint(0, 400), 'active': active,
|
||||
'hunting': rng.random() < 0.5, 'activeRotation': rng.uniform(0, 300)},
|
||||
yaw=round(rng.uniform(-180, 180), 2), pitch=round(rng.uniform(-90, 90), 2))
|
||||
for _ in range(6):
|
||||
o = rng.random()
|
||||
add('minecraft:enchanting_table', book={'time': rng.randint(0, 2000), 'flip': rng.uniform(-3, 3),
|
||||
'oFlip': rng.uniform(-3, 3), 'open': o, 'oOpen': max(0.0, o - 0.1),
|
||||
'rot': rng.uniform(-3.1, 3.1), 'oRot': rng.uniform(-3.1, 3.1)})
|
||||
for f in HORIZONTAL:
|
||||
add(f'minecraft:lectern[facing={f},has_book=true]')
|
||||
add(f'minecraft:lectern[facing={f},has_book=false]')
|
||||
for prefix in ['', 'exposed_', 'weathered_', 'oxidized_', 'waxed_']:
|
||||
for pose in ['standing', 'running', 'sitting', 'star']:
|
||||
add(f'minecraft:{prefix}copper_golem_statue[facing={rng.choice(HORIZONTAL)},copper_golem_pose={pose}]')
|
||||
for _ in range(8):
|
||||
add('minecraft:beacon', gameTime=rng.randint(0, 10 ** 6),
|
||||
camera=[rng.uniform(-300, 300), 70.0, rng.uniform(-300, 300)],
|
||||
beam=[[0xFF000000 | rng.randint(0, 0xFFFFFF), rng.randint(1, 30)] for _ in range(rng.randint(1, 3))])
|
||||
for block in ['end_portal', 'end_gateway', 'campfire[facing=north]', 'spawner',
|
||||
'trial_spawner', 'vault', 'suspicious_sand', 'oak_shelf[facing=east]']:
|
||||
add('minecraft:' + block)
|
||||
return out
|
||||
|
||||
|
||||
def light_coords(light):
|
||||
"""Horizon light (sky*16 low byte, block*16 next) as vanilla light coords."""
|
||||
return ((light & 0xFF) << 16) | ((light >> 8) & 0xFF)
|
||||
|
||||
|
||||
def lights():
|
||||
def norm(v):
|
||||
n = math.sqrt(sum(x * x for x in v))
|
||||
return [x / n for x in v]
|
||||
return norm([0.2, 1.0, -0.7]), norm([-0.2, 1.0, 0.7])
|
||||
|
||||
|
||||
L0, L1 = lights()
|
||||
|
||||
|
||||
def mix(normal):
|
||||
d = lambda l: max(0.0, sum(a * b for a, b in zip(l, normal)))
|
||||
return min(1.0, (d(L0) + d(L1)) * 0.6 + 0.4)
|
||||
|
||||
|
||||
def vanilla_quads(submits):
|
||||
quads, other = [], []
|
||||
for s in submits:
|
||||
custom = s['type'] == 'submitCustomGeometry'
|
||||
if s['type'] != 'submitModel' and not custom:
|
||||
other.append(s)
|
||||
continue
|
||||
verts = s['vertices']
|
||||
for i in range(0, len(verts), 4):
|
||||
q = verts[i:i + 4]
|
||||
tint = q[0][3] & 0xFFFFFFFF
|
||||
# Custom geometry (beacon beams) is unlit; models get the entity shader's mix light.
|
||||
b = 1.0 if custom else mix(q[0][8:11])
|
||||
ch = lambda shift: int(((tint >> shift) & 255) * b)
|
||||
quads.append({'texture': s['texture'], 'v': [v[:3] + v[4:6] for v in q],
|
||||
'rgb': [ch(16), ch(8), ch(0)], 'alpha': tint >> 24, 'light': q[0][7],
|
||||
'overlay': q[0][6], 'type': s['renderType'], 'custom': custom})
|
||||
return quads, other
|
||||
|
||||
|
||||
def area(q):
|
||||
import itertools
|
||||
p = [v[:3] for v in q]
|
||||
a = [p[1][i] - p[0][i] for i in range(3)]
|
||||
b = [p[2][i] - p[0][i] for i in range(3)]
|
||||
c = [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
a2 = [p[3][i] - p[0][i] for i in range(3)]
|
||||
d = [b[1] * a2[2] - b[2] * a2[1], b[2] * a2[0] - b[0] * a2[2], b[0] * a2[1] - b[1] * a2[0]]
|
||||
return math.sqrt(sum(x * x for x in c)) + math.sqrt(sum(x * x for x in d))
|
||||
|
||||
|
||||
def pair(vq, rq):
|
||||
"""Matches quads of the same texture globally by ascending vertex distance."""
|
||||
candidates = []
|
||||
for i, a in enumerate(vq):
|
||||
for j, b in enumerate(rq):
|
||||
if a['texture'] == b['texture']:
|
||||
d = sum((a['v'][k][c] - b['v'][k][c]) ** 2 for k in range(4) for c in range(5))
|
||||
candidates.append((d, i, j))
|
||||
candidates.sort()
|
||||
match, used = {}, set()
|
||||
for d, i, j in candidates:
|
||||
if i not in match and j not in used:
|
||||
match[i] = j
|
||||
used.add(j)
|
||||
return [(a, rq[match[i]] if i in match else None) for i, a in enumerate(vq)], \
|
||||
[b for j, b in enumerate(rq) if j not in used]
|
||||
|
||||
|
||||
def compare_case(v, r, tol):
|
||||
problems = []
|
||||
vq, other = vanilla_quads(v['submits'])
|
||||
for s in other:
|
||||
if s['type'] == 'submitText':
|
||||
continue
|
||||
problems.append(f'unsupported vanilla submit {s["type"]}')
|
||||
rq = r.get('quads', [])
|
||||
if len(vq) != len(rq):
|
||||
tex = lambda qs: sorted({q['texture'] for q in qs})
|
||||
problems.append(f'{len(vq)} vanilla quads {tex(vq)} vs {len(rq)} Horizon quads {tex(rq)}')
|
||||
pairs, extra = pair(vq, rq)
|
||||
for b in extra[:1]:
|
||||
problems.append(f'extra Horizon quad {b["texture"]} {b["v"]}')
|
||||
for a, b in pairs:
|
||||
if b is None:
|
||||
problems.append(f'missing {a["texture"]} quad {a["v"]}')
|
||||
break
|
||||
bad = [(i, j) for i in range(4) for j in range(5) if abs(a['v'][i][j] - b['v'][i][j]) > tol * (1 + abs(a['v'][i][j]))]
|
||||
if bad:
|
||||
problems.append(f'{a["texture"]} vertices {a["v"]} != {b["v"]}')
|
||||
break
|
||||
if a['custom']:
|
||||
# Horizon packs the beam sprite's v range into the light bytes (its shader wraps v).
|
||||
got = b['color']
|
||||
if [got & 255, (got >> 8) & 255, (got >> 16) & 255, got >> 24] != a['rgb'] + [a['alpha']]:
|
||||
problems.append(f'{a["texture"]} colour {a["rgb"]}/{a["alpha"]} != {got:#x}')
|
||||
break
|
||||
continue
|
||||
if a['overlay'] != NO_OVERLAY:
|
||||
problems.append(f'overlay {a["overlay"]}')
|
||||
got = b['color']
|
||||
rgb = [got & 255, (got >> 8) & 255, (got >> 16) & 255]
|
||||
# A zero-area quad shows nothing: vanilla shades it by its face normal, Horizon cannot derive one.
|
||||
if area(a['v']) > 1e-9 and (any(abs(x - y) > 1 for x, y in zip(a['rgb'], rgb)) or (got >> 24) != a['alpha']):
|
||||
problems.append(f'{a["texture"]} colour {a["rgb"]}/{a["alpha"]} != {rgb}/{got >> 24} at {a["v"]}')
|
||||
break
|
||||
if a['light'] != light_coords(b['light']):
|
||||
problems.append(f'{a["texture"]} light {a["light"]:#x} != {light_coords(b["light"]):#x}')
|
||||
break
|
||||
vt = [s for s in v['submits'] if s['type'] == 'submitText' and s['text']]
|
||||
rt = r.get('texts', [])
|
||||
if len(vt) != len(rt):
|
||||
problems.append(f'{len(vt)} vanilla texts {[t["text"] for t in vt]} vs {len(rt)} Horizon texts {[t["text"] for t in rt]}')
|
||||
for a, b in zip(vt, rt):
|
||||
for k in ('text', 'x', 'y', 'color', 'outline', 'light'):
|
||||
av, bv = a[k], b[k]
|
||||
if k in ('color', 'outline'):
|
||||
av &= 0xFFFFFFFF
|
||||
if (abs(av - bv) > 1e-4) if isinstance(av, float) else av != bv:
|
||||
problems.append(f'text {a["text"]!r} {k} {av!r} != {bv!r}')
|
||||
if any(abs(x - y) > tol * (1 + abs(x)) for x, y in zip(a['matrix'], b['matrix'])):
|
||||
problems.append(f'text {a["text"]!r} matrix {a["matrix"]} != {b["matrix"]}')
|
||||
return problems
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--filter', default='')
|
||||
parser.add_argument('--detail', type=int, default=2)
|
||||
parser.add_argument('--tolerance', type=float, default=2e-5)
|
||||
args = parser.parse_args()
|
||||
lock, jars, _ = base.verify_artifacts()
|
||||
libraries = baking.client_libraries()
|
||||
build = lab.LOCAL / 'rust-block-entity-render-parity'; build.mkdir(parents=True, exist_ok=True)
|
||||
resources = lab.LOCAL / 'horizon-client' / '26.3'
|
||||
chosen = [c for c in cases() if args.filter in c['block']]
|
||||
inputs = build / 'inputs.jsonl'; inputs.write_text(''.join(json.dumps(c) + '\n' for c in chosen))
|
||||
classes = build / 'classes'; classes.mkdir(exist_ok=True)
|
||||
dependencies = os.pathsep.join(map(str, [jars['client'], *libraries]))
|
||||
|
||||
def run(command, name):
|
||||
with (build / (name + '.log')).open('w') as log:
|
||||
result = subprocess.run(list(map(str, command)), cwd=lab.ROOT, timeout=3600, stdout=log, stderr=subprocess.STDOUT)
|
||||
if result.returncode:
|
||||
print((build / (name + '.log')).read_text()[-4000:]); sys.exit(f'{name} failed')
|
||||
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-nowarn', '-cp', dependencies, '-d', classes,
|
||||
*(PARITY / f'{n}.java' for n in ('ParityData', 'ParityFont', 'VanillaBlockEntityRenderOracle'))], 'compile-java')
|
||||
run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaBlockEntityRenderOracle',
|
||||
inputs, build / 'vanilla.jsonl'], 'vanilla')
|
||||
vanilla = [json.loads(l) for l in (build / 'vanilla.jsonl').read_text().splitlines()]
|
||||
textures = sorted({s['texture'] for v in vanilla for s in v.get('submits', []) if 'texture' in s})
|
||||
(build / 'textures.txt').write_text(''.join(t + '\n' for t in textures))
|
||||
run(['cargo', 'run', '--quiet', '--profile', 'fast', '-p', 'horizon-client', '--example', 'block_entity_render_fixture', '--',
|
||||
resources, inputs, build / 'textures.txt', build / 'rust.jsonl'], 'rust')
|
||||
rust = [json.loads(l) for l in (build / 'rust.jsonl').read_text().splitlines()]
|
||||
assert len(vanilla) == len(rust), (len(vanilla), len(rust))
|
||||
groups, errors, skipped, passed = {}, {}, {}, 0
|
||||
for v, r in zip(vanilla, rust):
|
||||
block = v['case']['block'].split('[')[0]
|
||||
if 'error' in v:
|
||||
errors.setdefault(block, v['error']); continue
|
||||
if 'skip' in r:
|
||||
if not v['submits']:
|
||||
passed += 1 # vanilla submits nothing either (e.g. a lectern without a book)
|
||||
else:
|
||||
skipped.setdefault(r['skip'], set()).add(block)
|
||||
continue
|
||||
problems = compare_case(v, r, args.tolerance)
|
||||
if problems:
|
||||
groups.setdefault(block, []).append((v['case'], problems))
|
||||
else:
|
||||
passed += 1
|
||||
for reason, blocks in sorted(skipped.items()):
|
||||
print(f'NOT RENDERED by Horizon ({reason}): {", ".join(sorted(blocks))}')
|
||||
for block, error in sorted(errors.items()):
|
||||
print(f'ORACLE ERROR {block}: {error}')
|
||||
for block, items in sorted(groups.items()):
|
||||
print(f'MISMATCH {block}: {len(items)} cases')
|
||||
for case, problems in items[:args.detail]:
|
||||
print(' ', json.dumps(case))
|
||||
for p in problems[:4]:
|
||||
print(' ', p[:600])
|
||||
total = len(vanilla)
|
||||
print(f'{passed}/{total} cases match; {sum(len(b) for b in skipped.values())} not rendered; {len(errors)} oracle errors')
|
||||
if groups or errors or skipped:
|
||||
sys.exit(1)
|
||||
print('block entity render parity ok')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -141,7 +141,8 @@ def parse_nbt(text, i=0):
|
||||
|
||||
def nbt_matches(vanilla, rust, default):
|
||||
"""Horizon stores the placed data only; vanilla saves defaults too. Every key
|
||||
Horizon sets must match, and every other vanilla key must be the fresh default."""
|
||||
Horizon sets must match, and every other vanilla key must be what vanilla
|
||||
loads for an absent key (the oracle's second form)."""
|
||||
if isinstance(vanilla, dict) and isinstance(rust, dict):
|
||||
default = default if isinstance(default, dict) else {}
|
||||
if 'LootTableSeed' in rust and 'LootTable' not in rust:
|
||||
@@ -154,7 +155,11 @@ def nbt_matches(vanilla, rust, default):
|
||||
return False
|
||||
elif value != default.get(key):
|
||||
return False
|
||||
return all(key in vanilla or key in ('x', 'y', 'z') or nbt_matches(default.get(key), rust[key], None) or (key, rust[key]) == VAULT_REWARD for key in rust)
|
||||
return all(key in vanilla or key in ('x', 'y', 'z') or nbt_matches(default.get(key), rust[key], None) or (key, rust[key]) == VAULT_REWARD
|
||||
or omitted_on_save(key, rust) for key in rust)
|
||||
if isinstance(vanilla, tuple) and vanilla[0] == 'str' and isinstance(rust, dict) and set(rust) == {'text'}:
|
||||
# A literal text component saves as its plain string: {text:"a"} is "a".
|
||||
return vanilla == rust['text']
|
||||
if isinstance(vanilla, list) and isinstance(rust, list):
|
||||
return len(vanilla) == len(rust) and all(nbt_matches(a, b, None) for a, b in zip(vanilla, rust))
|
||||
if isinstance(vanilla, tuple) and isinstance(rust, tuple) and vanilla[0] in 'bsil' and rust[0] in 'bsil':
|
||||
@@ -163,6 +168,14 @@ def nbt_matches(vanilla, rust, default):
|
||||
return vanilla == rust
|
||||
|
||||
|
||||
def omitted_on_save(key, rust):
|
||||
"""Template keys that vanilla's codecs leave out when re-saving the loaded block entity."""
|
||||
if key == 'patterns' and rust[key] == []:
|
||||
return True # BannerBlockEntity saves its patterns only when there are some
|
||||
# SignText saves filtered_messages only when some line differs from messages.
|
||||
return key == 'filtered_messages' and rust[key] == rust.get('messages')
|
||||
|
||||
|
||||
def entity_matches(vanilla, rust):
|
||||
if vanilla is None:
|
||||
return False
|
||||
@@ -258,6 +271,7 @@ def pick_structures(starts, per_kind, rng):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--seeds', type=int, default=3)
|
||||
parser.add_argument('--seed', type=int, action='append', help='run only this seed (repeatable)')
|
||||
parser.add_argument('--per-structure', type=int, default=2)
|
||||
parser.add_argument('--sites', type=int, default=6, help='terrain sites per overworld seed')
|
||||
parser.add_argument('--radius', type=int, default=1, help='chunk radius around each site')
|
||||
@@ -281,7 +295,7 @@ def main():
|
||||
biome_seen = set()
|
||||
structure_seen = Counter()
|
||||
started = time.time()
|
||||
for seed in SEEDS[:args.seeds]:
|
||||
for seed in args.seed or SEEDS[:args.seeds]:
|
||||
rng = random.Random(seed)
|
||||
for dimension in dimensions:
|
||||
rects, labels = [], []
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare the in-game HUD decorations with the vanilla 26.3 client's Hud.
|
||||
|
||||
The Java oracle (VanillaHudOracle) runs vanilla's own Hud.extractCrosshair,
|
||||
extractItemHotbar, extractPlayerHealth, extractVehicleHealth, ExperienceBar
|
||||
and ContextualBar.extractExperienceLevel on a stand-in client whose player
|
||||
answers from the case, and records every blit (sprite, position, size,
|
||||
partial region), text call (string, position, colour, shadow) and hotbar
|
||||
item (slot, position, seed, pose). `examples/hud_fixture` lays out the same
|
||||
state with `horizon_client::hud_layout`; the ordered call lists must agree.
|
||||
|
||||
test_hud_parity.py [--count N] [--detail N]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
from . import lab, test_rust_parity as base, test_model_baking_parity as baking
|
||||
except ImportError:
|
||||
import lab, test_rust_parity as base, test_model_baking_parity as baking
|
||||
|
||||
PARITY = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity'
|
||||
EFFECTS = ['minecraft:poison', 'minecraft:wither', 'minecraft:regeneration', 'minecraft:hunger', 'minecraft:speed']
|
||||
|
||||
|
||||
def cases(count):
|
||||
rng = random.Random(0x485544)
|
||||
out = []
|
||||
for n in range(count):
|
||||
max_health = rng.choice([20.0, 20.0, 6.0, 40.0, 33.0, 100.0])
|
||||
health = rng.choice([max_health, round(rng.uniform(0, max_health), 2), 1.0, 0.5, 3.0])
|
||||
air_max = 300
|
||||
under = rng.random() < 0.3
|
||||
air = rng.choice([air_max, rng.randint(-20, air_max), 0, 1, 29, 30, 31, 299]) if under or rng.random() < 0.2 else air_max
|
||||
case = {
|
||||
'width': rng.choice([427, 320, 480, 640, 961]), 'height': rng.choice([240, 255, 270, 360, 501]),
|
||||
'gameMode': rng.choice(['survival'] * 5 + ['adventure', 'creative', 'spectator']),
|
||||
'crosshair': rng.random() < 0.7,
|
||||
'attackIndicator': rng.choice(['OFF', 'CROSSHAIR', 'HOTBAR']),
|
||||
'attackScale': rng.choice([1.0, round(rng.random(), 3), 0.0, 0.999]),
|
||||
'selected': rng.randint(0, 8), 'rightHanded': rng.random() < 0.7,
|
||||
'slots': [rng.choice([None, 0, 0, 3, 5]) if rng.random() < 0.6 else None for _ in range(9)],
|
||||
'offhand': rng.choice([None, None, 0, 4]), 'partial': round(rng.random(), 3),
|
||||
'health': health, 'maxHealth': max_health,
|
||||
'absorption': rng.choice([0.0, 0.0, 4.0, 3.5, 17.0, 40.0]),
|
||||
'armor': rng.choice([0, 0, 1, 7, 20, 13]), 'food': rng.randint(0, 20),
|
||||
'saturation': rng.choice([0.0, 5.0, 0.0]),
|
||||
'effects': [e for e in EFFECTS if rng.random() < 0.15], 'frozen': rng.random() < 0.08,
|
||||
'hardcore': rng.random() < 0.2, 'damageCooldown': rng.choice([0, 0, 10]),
|
||||
'air': air, 'maxAir': air_max, 'underWater': under,
|
||||
'tick': rng.randint(0, 100000), 'lastHealth': rng.randint(0, 40), 'displayHealth': rng.randint(0, 40),
|
||||
'lastHealthAgo': rng.choice([0, 500, 5000]), 'healthBlinkTime': 0,
|
||||
'xpProgress': rng.choice([0.0, round(rng.random(), 4), 0.999]), 'xpLevel': rng.choice([0, 1, 7, 30, 123, 4000]),
|
||||
'experienceBar': rng.random() < 0.8,
|
||||
}
|
||||
if case['damageCooldown'] and rng.random() < 0.5:
|
||||
case['healthBlinkTime'] = case['tick'] + rng.randint(1, 20)
|
||||
if rng.random() < 0.15:
|
||||
case['vehicleMaxHealth'] = rng.choice([15.0, 30.0, 53.0, 100.0])
|
||||
case['vehicleHealth'] = round(rng.uniform(0, case['vehicleMaxHealth']), 1)
|
||||
out.append(case)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--count', type=int, default=600)
|
||||
parser.add_argument('--detail', type=int, default=3)
|
||||
args = parser.parse_args()
|
||||
lock, jars, _ = base.verify_artifacts()
|
||||
libraries = baking.client_libraries()
|
||||
build = lab.LOCAL / 'rust-hud-parity'; build.mkdir(parents=True, exist_ok=True)
|
||||
resources = lab.LOCAL / 'horizon-client' / '26.3'
|
||||
chosen = cases(args.count)
|
||||
inputs = build / 'inputs.jsonl'; inputs.write_text(''.join(json.dumps(c) + '\n' for c in chosen))
|
||||
classes = build / 'classes'; classes.mkdir(exist_ok=True)
|
||||
dependencies = os.pathsep.join(map(str, [jars['client'], *libraries]))
|
||||
|
||||
def run(command, name):
|
||||
with (build / (name + '.log')).open('w') as log:
|
||||
result = subprocess.run(list(map(str, command)), cwd=lab.ROOT, timeout=3600, stdout=log, stderr=subprocess.STDOUT)
|
||||
if result.returncode:
|
||||
print((build / (name + '.log')).read_text()[-4000:]); sys.exit(f'{name} failed')
|
||||
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-nowarn', '-cp', dependencies, '-d', classes,
|
||||
*(PARITY / f'{n}.java' for n in ('ParityData', 'ParityFont', 'VanillaHudOracle'))], 'compile-java')
|
||||
run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaHudOracle',
|
||||
inputs, build / 'vanilla.jsonl'], 'vanilla')
|
||||
run(['cargo', 'run', '--quiet', '--profile', 'fast', '-p', 'horizon-client', '--example', 'hud_fixture', '--',
|
||||
resources, inputs, build / 'rust.jsonl'], 'rust')
|
||||
vanilla = [json.loads(l) for l in (build / 'vanilla.jsonl').read_text().splitlines()]
|
||||
rust = [json.loads(l) for l in (build / 'rust.jsonl').read_text().splitlines()]
|
||||
assert len(vanilla) == len(rust)
|
||||
errors, bad = [], []
|
||||
for v, r in zip(vanilla, rust):
|
||||
if 'error' in v:
|
||||
errors.append(v['error']); continue
|
||||
a = [{k: x for k, x in d.items() if k != 'color' or d['type'] == 'text'} for d in v['draws']]
|
||||
for d in a:
|
||||
if d['type'] == 'sprite':
|
||||
d.setdefault('region', None)
|
||||
b = r['draws']
|
||||
def same(x, y):
|
||||
if x.keys() != y.keys():
|
||||
return False
|
||||
return all(
|
||||
(all(abs(p - q) <= 1e-4 * (1 + abs(p)) for p, q in zip(x[k], y[k])) if k == 'matrix' else x[k] == y[k]) for k in x)
|
||||
if len(a) != len(b) or not all(same(x, y) for x, y in zip(a, b)):
|
||||
first = next((i for i, (x, y) in enumerate(zip(a, b)) if not same(x, y)), min(len(a), len(b)))
|
||||
bad.append((v['case'], first, a[first:first + 2], b[first:first + 2], len(a), len(b)))
|
||||
for e in sorted(set(errors))[:5]:
|
||||
print('ORACLE ERROR', e)
|
||||
for case, i, a, b, la, lb in bad[:args.detail]:
|
||||
print('MISMATCH', json.dumps(case))
|
||||
print(f' at call {i} ({la} vanilla, {lb} Horizon calls)\n vanilla {a}\n horizon {b}')
|
||||
print(f'{len(vanilla) - len(bad) - len(errors)}/{len(vanilla)} HUD states match; {len(errors)} oracle errors')
|
||||
if bad or errors:
|
||||
sys.exit(1)
|
||||
print('hud parity ok')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -26,7 +26,8 @@ TRIM_MATERIALS = ['quartz', 'iron', 'netherite', 'redstone', 'copper', 'gold', '
|
||||
'amethyst', 'resin']
|
||||
NEEDED = ('assets/minecraft/items/', 'assets/minecraft/models/', 'assets/minecraft/textures/item/',
|
||||
'assets/minecraft/textures/block/', 'assets/minecraft/textures/trims/',
|
||||
'assets/minecraft/textures/colormap/')
|
||||
'assets/minecraft/textures/colormap/', 'assets/minecraft/textures/palettes/',
|
||||
'assets/minecraft/atlases/')
|
||||
|
||||
|
||||
def extract(jar, destination):
|
||||
@@ -293,6 +294,12 @@ def matrix(bits):
|
||||
return [0 if b == 0x80000000 else b for b in bits]
|
||||
|
||||
|
||||
def rows_of(path):
|
||||
with path.open('rb') as rows:
|
||||
for line in rows:
|
||||
yield json.loads(line)
|
||||
|
||||
|
||||
def main():
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
build = lab.LOCAL / 'item-model-parity'; build.mkdir(parents=True, exist_ok=True)
|
||||
@@ -324,8 +331,11 @@ def main():
|
||||
run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaItemModelOracle',
|
||||
resources, inputs, build / 'client.jsonl', prototypes_path], 'client')
|
||||
# The host inputs vanilla derives from the stack (use duration, charge time).
|
||||
client = [json.loads(line) for line in (build / 'client.jsonl').read_text().splitlines()]
|
||||
derived = {r['id']: r['derived'] for r in client if r.get('row') == 'case' and 'derived' in r}
|
||||
# Both outputs are gigabytes of quads, so rows are streamed one at a time.
|
||||
derived = {}
|
||||
for r in rows_of(build / 'client.jsonl'):
|
||||
if r.get('row') == 'case' and 'derived' in r:
|
||||
derived[r['id']] = r['derived']
|
||||
rust_cases = []
|
||||
for case in cases:
|
||||
case = dict(case)
|
||||
@@ -335,35 +345,60 @@ def main():
|
||||
rust_inputs.write_text(''.join(json.dumps(c) + '\n' for c in rust_cases))
|
||||
(build / 'rust-cases.jsonl.definitions').write_text((build / 'cases.jsonl.definitions').read_text())
|
||||
run([binary, resources, prototypes_path, rust_inputs, build / 'rust.jsonl'], 'rust')
|
||||
rust = [json.loads(line) for line in (build / 'rust.jsonl').read_text().splitlines()]
|
||||
mismatches, compared, skipped, decoded = [], 0, 0, 0
|
||||
vanilla_by_key = {(r['row'], r.get('id') or r.get('name')): r for r in client if r['row'] in ('case', 'decode', 'components')}
|
||||
rust_by_key = {(r['row'], r.get('id') or r.get('name')): r for r in rust}
|
||||
assert vanilla_by_key.keys() == rust_by_key.keys(), 'The two sides produced different rows'
|
||||
assert vanilla_by_key[('components', None)]['names'] == rust_by_key[('components', None)]['names'], 'Data component registry differs'
|
||||
unbaked = [r['name'] for r in client if r['row'] == 'unbaked']
|
||||
for key, left in vanilla_by_key.items():
|
||||
right = rust_by_key[key]
|
||||
if key[0] == 'components': continue
|
||||
mismatches, mismatch_count, compared, skipped, decoded = [], 0, 0, 0, 0
|
||||
rust_offsets = {}
|
||||
with (build / 'rust.jsonl').open('rb') as rust_file:
|
||||
offset = 0
|
||||
for line in rust_file:
|
||||
r = json.loads(line)
|
||||
rust_offsets[(r['row'], r.get('id') or r.get('name'))] = offset
|
||||
offset += len(line)
|
||||
vanilla_keys, unbaked = set(), []
|
||||
rust_file = (build / 'rust.jsonl').open('rb')
|
||||
|
||||
def rust_row(key):
|
||||
rust_file.seek(rust_offsets[key])
|
||||
return json.loads(rust_file.readline())
|
||||
|
||||
for left in rows_of(build / 'client.jsonl'):
|
||||
if left['row'] == 'unbaked':
|
||||
unbaked.append(left['name'])
|
||||
if left['row'] not in ('case', 'decode', 'components'):
|
||||
continue
|
||||
key = (left['row'], left.get('id') or left.get('name'))
|
||||
vanilla_keys.add(key)
|
||||
assert key in rust_offsets, f'Horizon produced no row for {key}'
|
||||
right = rust_row(key)
|
||||
if key[0] == 'components':
|
||||
assert left['names'] == right['names'], 'Data component registry differs'
|
||||
continue
|
||||
if key[0] == 'decode':
|
||||
decoded += 1
|
||||
if left['ok'] != right['ok']: mismatches.append(('decode', key[1], left, right))
|
||||
if left['ok'] != right['ok']:
|
||||
mismatch_count += 1
|
||||
if len(mismatches) < 5:
|
||||
mismatches.append(('decode', key[1], left, right))
|
||||
continue
|
||||
if 'error' in left:
|
||||
skipped += 1
|
||||
continue
|
||||
compared += 1
|
||||
left.pop('derived', None) # an input Horizon received, not an output
|
||||
for side in (left, right):
|
||||
for layer in side['layers']:
|
||||
layer['matrix'] = matrix(layer['matrix'])
|
||||
layer.pop('has_transform', None)
|
||||
if layer.get('quads') is not None: layer['quads'] = canonical_quads(layer['quads'])
|
||||
if left != right:
|
||||
mismatches.append(('case', key[1], left, right))
|
||||
if mismatches:
|
||||
for kind, name, left, right in mismatches[:5]:
|
||||
mismatch_count += 1
|
||||
if len(mismatches) < 5:
|
||||
mismatches.append(('case', key[1], left, right))
|
||||
rust_file.close()
|
||||
assert vanilla_keys == rust_offsets.keys(), 'The two sides produced different rows'
|
||||
if mismatch_count:
|
||||
for kind, name, left, right in mismatches:
|
||||
print(kind, name, '\nvanilla:', json.dumps(left)[:1500], '\nrust: ', json.dumps(right)[:1500])
|
||||
raise AssertionError(f'{len(mismatches)} item model mismatches')
|
||||
raise AssertionError(f'{mismatch_count} item model mismatches')
|
||||
base.ensure_sources_unchanged(sources, tested)
|
||||
assert compared > 1000, 'Too few cases compared'
|
||||
assert skipped * 20 < len(cases), f'Too many cases rejected by vanilla: {skipped}'
|
||||
|
||||
Reference in new issue
Block a user