Compare commits

...
Author SHA1 Message Date
Ludovic Marie 84a7405bb5 Merge branch 'parity-base' into parity/input 2026-10-10 22:21:42 +02:00
Ludovic MarieandClaude Opus 5.5 17ac24f4db Drive toggle sprint/sneak as vanilla's key handlers do and compare
ToggleKeys holds the sneak, sprint, attack and use ToggleKeyMappings with
the dispatch of KeyboardHandler.keyPress (releases always reach them;
presses and SDL key repeats only without a screen), MouseHandler.onButton,
Gui.setScreen (releaseAll / restoreToggleStatesOnScreenClosed) and
LocalPlayer.respawn (resetToggleKeys, which Horizon lacked). Presses behind
a screen no longer flip a toggled key. tools/test_toggle_keys_parity.py
drives the real Options mappings through the real static KeyMapping
functions (8,000 sequences).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:12:23 +02:00
Ludovic MarieandClaude Opus 5.5 538347007a Add auto-jump and compare it with LocalPlayer.updateAutoJump
Horizon had the Auto-Jump option but never jumped. MotionWorld::move_self
now runs LocalPlayer.updateAutoJump after the player's own move (behind
canAutoJump: option, autoJumpTime, ground, shift, input, block jump
factor), the next aiStep presses jump for one tick (input.makeJump) without
toggling creative flight, and the game feeds the option each tick.
tools/test_auto_jump_parity.py runs the real updateAutoJump on a LocalPlayer
shell in an independently built copy of the resident test scene with real
block states (10,568 cases, 1,200 jumps).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:04:04 +02:00
Ludovic Marie 8ef3195457 Merge remote-tracking branch 'desktop/parity-rendering' into t3/achieve-full-parity 2026-10-10 22:03:24 +02:00
Ludovic MarieandClaude Opus 5.5 d088140c0d Compare beacon beams with BeaconRenderer
Move the beam geometry into block_entity_render::beacon_beam so the
block entity render harness can replay it against vanilla submit calls
(positions, UVs and colours of the solid beam and glow per section).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:02:40 +02:00
Ludovic MarieandClaude Opus 5.5 268babc257 Lay out the HUD decorations exactly as vanilla Hud does
Add horizon_client::hud_layout (crosshair and attack indicator, hotbar
with selection, off hand by main arm, item slots with the pop squeeze,
armour, hearts including frozen and absorbing kinds, food, air bubbles,
mount hearts capped at 30, experience bar and level) and the
test_hud_parity harness whose oracle runs vanilla Hud methods on a
stand-in client. The game now draws these through hud_layout; the
experience level is centred with integer division like vanilla,
spectators get no crosshair and mount hearts show in creative too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:56:23 +02:00
Ludovic MarieandClaude Opus 5.5 ebb66e451f Pace frames with vanilla's FramerateLimiter and compare it
The game now ends each frame with FramerateLimiter.limitDisplayFPS below
260 fps (sleep, overshoot averaging, spin-wait) instead of waking the event
loop at a computed deadline, and the limiter honours Thread.interrupted.
tools/test_frame_limiter_parity.py runs the real limiter bytecode with its
clock calls rewritten to a scripted virtual clock (5,288 cases).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:51:04 +02:00
Ludovic MarieandClaude Opus 5.5 f85411f2fb Port the camera, field of view and view/hurt bobbing exactly
Camera.tick, tickFov, alignWithEntity with getMaxZoom over visual-shape
clipping, setRotation and move with JOML quaternion arithmetic,
calculateFov/calculateHudFov, getFieldOfViewModifier (now including
flight), ClientAvatarState walk/bob history (the backwards-extrapolated
walk distance and velocity-based bob) and GameRenderer.bobHurt/bobView as
the JOML pose matrix with property flags. The game renders through them,
adding hurt tilt, the camera distance and scale attributes, the mirrored
front camera and the sleeping camera. tools/test_camera_parity.py compares
18,502 cases against the real classes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:45:35 +02:00
Ludovic MarieandClaude Opus 5.5 3429de0020 Compare block entity rendering with vanilla renderers
Add a differential harness (test_block_entity_render_parity) whose Java
oracle runs each vanilla BlockEntityRenderer on a real block entity and
captures the submitted model vertices, textures, light and text calls.
Horizon now animates shulker lids, bell shakes, active conduits (cage,
wind and the camera-facing eye), skull jaws and ears, pot wobble and
sherd sides; signs wrap lines like StringSplitter and measure spaces
with the font; books use the Mth sine table and player heads the slim
default skin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:35:45 +02:00
Ludovic MarieandClaude Opus 5.5 f2232c3ed7 Rescore the Java parity table at 84%
World generation and interaction now have every covering harness passing and
score 1. The other rows keep 0.75, with their gaps rewritten to name what is
still missing now that the block, command, entity model and item model
harnesses pass; the next steps follow the open gaps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:22:38 +02:00
Ludovic MarieandClaude Opus 5.5 fc468a021d Resolve item models exactly as the vanilla client does
The item model harness ran out of memory, so it now streams both outputs and
looks Horizon's rows up by offset. With the vanilla side no longer crashing on
swap-animation lookups (properties now come from the baked set), on armour
(trim sprites are generated by the items atlas's paletted_permutations source
once SpriteSources is bootstrapped) or on compasses (item tags are bound from
the data pack), 197,000 more stacks were compared and 121,841 differed.

Horizon now composes transformations as JOML 1.10.9 does: rotationXYZ and
axis-angle quaternions with cosFromSin, and Matrix4f operations that take the
identity, translation, affine or generic path by matrix properties, with
unfused a * b + c. Float RGB tint constants are scaled by 255, the shulker box
renderer always encodes its texture, and a new palette module ports
PalettedPermutations and PaletteMapping so trim sprites exist on the Horizon
side too.

196,290 resolved stacks match; 2,582 cases are still rejected by vanilla's
stand-in client and not compared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:21:50 +02:00
Ludovic MarieandClaude Opus 5.5 1d70ca47a8 Generate whole chunks block for block as vanilla does
The chunk generation harness found 70 differing chunks across three seeds in
the overworld, Nether and End. Proto chunks, biomes and decoration now span
the dimension type's build range rather than the noise height, so features
above y=128 in the Nether and End city upper floors survive; the decoration
level reports no sky light, as unlit vanilla chunks do, so mushrooms and crops
pass or fail their light checks the same way; beehive decorators store their
bees; iceberg ellipses skip NaN distances; double slabs never waterlog;
mineshaft spawners save an empty SpawnPotentials list; and the dump example
forgets structure starts between rectangles, as the oracle starts each with
fresh chunks.

The oracle prints absent block entity keys as vanilla loads them from an empty
tag, and the harness treats vanilla's save-format equivalences (plain sign
text, absent empty banner patterns, unfiltered sign messages) as equal. All 2,352
chunks over 225 structure sites match with zero block, biome or block entity
differences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:20:51 +02:00
57 changed files with 7081 additions and 849 deletions

No files matched your search

+15 -14
View File
@@ -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 = {
+4 -2
View File
@@ -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));
}
+1
View File
@@ -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;
+212 -38
View File
@@ -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]);
}
}
+105
View File
@@ -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,111 @@
#![forbid(unsafe_code)]
//! Runs tools/test_auto_jump_parity.py's cases through the local player's
//! `MotionWorld::update_auto_jump` over the client's resident test scene
//! (actual resources, collision registry and block-collision scan).
use horizon_client::{
assets::Assets,
collisions,
motion::{MotionResources, MotionWorld, PlayerMotion},
world::World,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
fs,
io::{BufRead, BufReader, BufWriter, Write},
path::PathBuf,
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn fl(v: &Value) -> f32 {
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn db(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
let [root, input, output, scene_json] = &args[..] else {
return Err("usage: auto_jump_probe RESOURCES INPUT OUTPUT SCENE_JSON".into());
};
let root = PathBuf::from(root);
let assets = Assets::load(&root)?;
let mut scene = World::test_scene(&assets, 1)?;
let binding = collisions::load(&root, assets.registry)?;
let resources = MotionResources::load(&root, &assets)?;
fs::write(
scene_json,
serde_json::to_vec(&serde_json::json!({
"radius": 1, "resident_chunks": scene.chunks.len(), "model_gallery": assets.model_gallery,
}))?,
)?;
let mut out = BufWriter::new(fs::File::create(output)?);
for (n, line) in BufReader::new(fs::File::open(input)?).lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let mut restore = Vec::new();
for b in c["blocks"].as_array().unwrap() {
let (x, y, z) = (
b[0].as_i64().unwrap() as i32,
b[1].as_i64().unwrap() as i32,
b[2].as_i64().unwrap() as i32,
);
let mut state = assets
.registry
.default_state(b[3].as_str().unwrap())
.ok_or("missing fixture block")?;
if let Some(properties) = b[4].as_object()
&& !properties.is_empty()
{
let mut definition = assets.registry.state(state).unwrap().clone();
for (name, value) in properties {
definition
.properties
.insert(name.clone(), value.as_str().unwrap().into());
}
state = assets.registry.id(&definition).ok_or("missing fixture state")?;
}
restore.push((x, y, z, scene.block(x, y, z)));
scene.set(x, y, z, state)?;
}
let p = &c["player"];
let position = Vec3::new(db(&p["pos"][0]), db(&p["pos"][1]), db(&p["pos"][2]));
let mut player = PlayerMotion::new(&resources, position)?;
player.ground.on_ground = p["ground"].as_bool().unwrap();
player.shift_key_down = p["shift"].as_bool().unwrap();
player.yaw = fl(&p["yr"]);
player.pitch = fl(&p["xr"]);
let key = player.attributes.key("minecraft:movement_speed").ok_or("speed")?;
let handle = player
.attributes
.get_instance(Some(key))
.map_err(|e| format!("{e:?}"))?
.ok_or("speed instance")?;
let speed = f64::from(fl(&p["speed"]));
player
.attributes
.edit(&handle, |a, o| a.set_base_value(speed, o))
.map_err(|e| format!("{e:?}"))?;
player.move_vector = [fl(&p["move"][0]), fl(&p["move"][1])];
player.dimensions.width = fl(&p["width"]);
player.dimensions.height = fl(&p["height"]);
player.status.jump_boost = p["boost"].as_i64().map(|a| a as i32);
player.auto_jump_enabled = p["enabled"].as_bool().unwrap();
player.auto_jump_time = p["time"].as_i64().unwrap() as i32;
MotionWorld {
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
}
.update_auto_jump(fl(&c["xa"]), fl(&c["za"]))?;
writeln!(out, "jump {}", player.auto_jump_time)?;
for (x, y, z, state) in restore.into_iter().rev() {
scene.set(x, y, z, state)?;
}
}
out.flush()?;
Ok(())
}
@@ -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,
&sections,
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,338 @@
#![forbid(unsafe_code)]
//! Runs the JSON-lines scenarios of tools/test_camera_parity.py through the
//! Rust camera, field-of-view, avatar bob and view/hurt bobbing code.
use horizon_client::{
Result,
camera::{
AvatarBob, BobState, Camera, CameraEntity, CameraFluid, CameraType, FovPlayer, HurtBob,
LivingCamera, UseItem, bob_matrix, field_of_view_modifier,
},
};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
position::BlockPos,
};
use horizon_world::world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world};
use serde_json::Value;
use std::{
collections::HashMap,
convert::Infallible,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn fl(v: &Value) -> f32 {
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn db(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn fb(v: f32) -> String {
format!("{:08x}", if v.is_nan() { 0x7fc0_0000 } else { v.to_bits() })
}
fn dbs(v: f64) -> String {
format!(
"{:016x}",
if v.is_nan() {
0x7ff8_0000_0000_0000
} else {
v.to_bits()
}
)
}
fn b(v: &Value) -> bool {
v.as_bool().unwrap()
}
fn vec3(v: &Value) -> [f64; 3] {
[db(&v[0]), db(&v[1]), db(&v[2])]
}
fn fov_player(c: &Value) -> FovPlayer {
FovPlayer {
flying: b(&c["flying"]),
walking_speed: fl(&c["ws"]),
movement_speed: db(&c["speed"]),
using: c["use"].as_array().map(|u| {
let item = match u[0].as_str().unwrap() {
"bow" => UseItem::Bow,
"spyglass" => UseItem::Spyglass,
_ => UseItem::Other,
};
(item, u[1].as_i64().unwrap() as i32)
}),
}
}
/// A level of visual shapes keyed by cell, without fluids.
struct Cells {
shapes: Vec<VoxelShape>,
index: HashMap<BlockPos, usize>,
}
impl RayWorld for Cells {
type Error = Infallible;
type Block = usize;
type Fluid = ();
fn block_state(&mut self, p: BlockPos) -> std::result::Result<usize, Infallible> {
Ok(self.index.get(&p).copied().unwrap_or(0))
}
fn fluid_state(&mut self, _: BlockPos) -> std::result::Result<(), Infallible> {
Ok(())
}
fn block_shape(
&mut self,
s: usize,
_: BlockPos,
_: RayBlockMode,
) -> std::result::Result<VoxelShape, Infallible> {
Ok(self.shapes[s].clone())
}
fn interaction_shape(&mut self, _: usize, _: BlockPos) -> std::result::Result<VoxelShape, Infallible> {
Ok(VoxelShape::empty())
}
fn fluid_is_source(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
Ok(false)
}
fn fluid_is_empty(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
Ok(true)
}
fn fluid_is_water(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
Ok(false)
}
fn fluid_shape(&mut self, _: (), _: BlockPos) -> std::result::Result<VoxelShape, Infallible> {
Ok(VoxelShape::empty())
}
}
fn cells(c: &Value) -> Result<Cells> {
let mut world = Cells {
shapes: vec![VoxelShape::empty()],
index: HashMap::new(),
};
for (key, boxes) in c["cells"].as_object().unwrap() {
let at: Vec<i32> = key.split(' ').map(|v| v.parse().unwrap()).collect();
let mut shape = VoxelShape::empty();
for a in boxes.as_array().unwrap() {
shape = shape.join(
&VoxelShape::from_box(Aabb::new(
Vec3::new(db(&a[0]), db(&a[1]), db(&a[2])),
Vec3::new(db(&a[3]), db(&a[4]), db(&a[5])),
))?,
ShapeOp::OR,
)?;
}
world.index.insert(BlockPos::new(at[0], at[1], at[2]), world.shapes.len());
world.shapes.push(shape);
}
Ok(world)
}
fn camera_type(v: &Value) -> CameraType {
[
CameraType::FirstPerson,
CameraType::ThirdPersonBack,
CameraType::ThirdPersonFront,
][v.as_u64().unwrap() as usize]
}
/// The camera entity and, for players, what the field of view reads.
fn entity(s: &Value) -> (CameraEntity, Option<FovPlayer>) {
let player = s["kind"] == "player";
let living = player.then(|| LivingCamera {
scale: db(&s["scale_attr"]) as f32,
camera_distance: db(&s["dist"]),
sleeping: s["sleeping"].as_array().map(|a| {
a[0].as_u64()
.map(|d| [0.0f32, 90.0, 180.0, 270.0][d as usize])
}),
dead: s["dead"].as_i64().map(|t| t as i32),
});
let mount = if player {
s["mount"]
.as_array()
.map(|a| (db(&a[0]) as f32, db(&a[1])))
} else {
None
};
(
CameraEntity {
position: vec3(&s["pos"]),
previous: vec3(&s["prev"]),
x_rot: fl(&s["xr"]),
y_rot: fl(&s["yr"]),
x_rot_o: fl(&s["xro"]),
y_rot_o: fl(&s["yro"]),
eye_height: fl(&s["eye"]),
living,
mount,
},
player.then(|| fov_player(s)),
)
}
fn camera(c: &Value, out: &mut impl Write) -> Result<()> {
let mut world = cells(c)?;
let fov_option = c["fov"].as_i64().unwrap() as i32;
let effect = db(&c["effect"]) as f32;
let mut kind = camera_type(&c["type"]);
let mut camera = Camera::default();
let mut current: Option<(CameraEntity, Option<FovPlayer>)> = None;
for op in c["ops"].as_array().unwrap() {
match op[0].as_str().unwrap() {
"entity" => {
current = Some(entity(&op[1]));
writeln!(out, "entity")?;
}
"type" => {
kind = camera_type(&op[1]);
writeln!(out, "type")?;
}
"tick" => {
let target = current
.as_ref()
.and_then(|(_, p)| p.as_ref())
.map_or(1.0, |p| {
field_of_view_modifier(p, kind.is_first_person(), effect)
});
camera.tick(current.as_ref().map(|(e, _)| e.eye_height), target);
writeln!(
out,
"tick {} {} {} {}",
fb(camera.eye_height),
fb(camera.eye_height_old),
fb(camera.fov_modifier),
fb(camera.old_fov_modifier)
)?;
}
"frame" => {
let pt = fl(&op[1]);
let fluid = [
CameraFluid::None,
CameraFluid::Water,
CameraFluid::Lava,
CameraFluid::PowderSnow,
][op[2].as_u64().unwrap() as usize];
let panoramic = b(&op[3]);
let (e, _) = current.as_ref().expect("entity");
camera.align_with_entity(e, pt, kind, |from, to| {
let Ok(hit) = clip_world(
&mut world,
Vec3::new(from[0], from[1], from[2]),
Vec3::new(to[0], to[1], to[2]),
RayBlockMode::Visual,
RayFluidMode::None,
usize::MAX,
) else {
panic!("ray")
};
(!hit.miss).then(|| {
let l = hit.hit.location;
[l.x, l.y, l.z]
})
});
let dead = e.living.and_then(|l| l.dead);
let fov = camera.calculate_fov(pt, fov_option, effect, dead, fluid, panoramic);
let hud = camera.calculate_hud_fov(pt, effect, dead, fluid);
let p = camera.position;
let q = camera.rotation;
let v = |a: [f32; 3]| format!("{} {} {}", fb(a[0]), fb(a[1]), fb(a[2]));
writeln!(
out,
"frame {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
dbs(p[0]),
dbs(p[1]),
dbs(p[2]),
fb(camera.x_rot),
fb(camera.y_rot),
fb(q.x),
fb(q.y),
fb(q.z),
fb(q.w),
v(camera.forwards),
v(camera.up),
v(camera.left),
camera.detached,
fb(fov),
fb(hud)
)?;
}
other => panic!("{other}"),
}
}
Ok(())
}
fn bob(c: &Value, out: &mut impl Write) -> Result<()> {
let state = BobState {
living: (!c["living"].is_null()).then(|| {
let l = &c["living"];
HurtBob {
hurt_time: fl(&l["hurt"]),
hurt_duration: l["duration"].as_i64().unwrap() as i32,
hurt_dir: fl(&l["dir"]),
death_time: (!l["death"].is_null()).then(|| fl(&l["death"])),
}
}),
player: c["player"].as_array().map(|p| (fl(&p[0]), fl(&p[1]))),
};
let m = bob_matrix(&state, db(&c["tilt"]), b(&c["view"]));
let mut line = String::from("bob");
for v in m.m {
line.push(' ');
line.push_str(&fb(v));
}
writeln!(out, "{line} {}", m.properties)?;
Ok(())
}
fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
let mut state = AvatarBob::default();
for op in c["ops"].as_array().unwrap() {
let name = op[0].as_str().unwrap();
match name {
"tick" => state.tick(),
"move" => state.add_walked(fl(&op[1]), fl(&op[2])),
"bob" => state.update_bob(b(&op[1]) && !b(&op[2]) && !b(&op[3]), vec3(&op[4])),
"reset" => state.reset_bob(),
"frame" => {
let pt = fl(&op[1]);
writeln!(
out,
"frame {} {}",
fb(state.interpolated_bob(pt)),
fb(state.backwards_interpolated_walk_distance(pt))
)?;
continue;
}
other => panic!("{other}"),
}
writeln!(out, "{name}")?;
}
Ok(())
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
match c["t"].as_str().unwrap() {
"fov" => writeln!(
out,
"{}",
fb(field_of_view_modifier(
&fov_player(&c),
b(&c["fp"]),
fl(&c["scale"])
))
)?,
"camera" => camera(&c, &mut out)?,
"bob" => bob(&c, &mut out)?,
"avatar" => avatar(&c, &mut out)?,
other => panic!("{other}"),
}
}
out.flush()?;
Ok(())
}
@@ -0,0 +1,96 @@
#![forbid(unsafe_code)]
//! Runs the JSON-lines scenarios of tools/test_frame_limiter_parity.py through
//! the Rust `FramerateLimiter` with the same scripted virtual clock.
use horizon_client::{
Result,
window::{FramerateLimiter, LimiterClock, end_frame},
};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
/// The scripted clock of the case: every query costs `call` ns, parking
/// sleeps the request plus the next overshoot (at least 1 ns), spinning
/// costs `spin` ns and interruptions follow their script.
struct Script {
now: i64,
call: i64,
spin: i64,
overshoot: Vec<i64>,
next_overshoot: usize,
interrupts: Vec<bool>,
next_interrupt: usize,
parks: Vec<i64>,
spins: u64,
queries: u64,
}
impl LimiterClock for Script {
fn nanos(&mut self) -> i64 {
self.queries += 1;
let t = self.now;
self.now = self.now.wrapping_add(self.call);
t
}
fn park(&mut self, nanos: i64) {
self.parks.push(nanos);
let o = self.overshoot[self.next_overshoot % self.overshoot.len()];
self.next_overshoot += 1;
self.now = self.now.wrapping_add(nanos.max(0).wrapping_add(o).max(1));
}
fn spin(&mut self) {
self.spins += 1;
self.now = self.now.wrapping_add(self.spin);
}
fn interrupted(&mut self) -> bool {
let i = self.interrupts[self.next_interrupt % self.interrupts.len()];
self.next_interrupt += 1;
i
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let i = |k: &str| c[k].as_i64().unwrap();
let mut clock = Script {
now: i("now"),
call: i("call"),
spin: i("spin"),
overshoot: c["overshoot"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect(),
next_overshoot: 0,
interrupts: c["interrupts"].as_array().unwrap().iter().map(|v| v.as_bool().unwrap()).collect(),
next_interrupt: 0,
parks: Vec::new(),
spins: 0,
queries: 0,
};
let mut limiter = FramerateLimiter::with_state(i("last"), i("average"), i("last_limit") as i32);
for frame in c["frames"].as_array().unwrap() {
clock.now = clock.now.wrapping_add(frame[0].as_i64().unwrap());
clock.parks.clear();
clock.spins = 0;
clock.queries = 0;
end_frame(&mut limiter, frame[1].as_i64().unwrap() as i32, &mut clock);
let parks: Vec<String> = clock.parks.iter().map(i64::to_string).collect();
writeln!(
out,
"frame {} {} {} {} queries {} spins {} parks [{}]",
clock.now,
limiter.last_frame_time(),
limiter.average_overshoot_ns(),
limiter.last_framerate_limit(),
clock.queries,
clock.spins,
parks.join(",")
)?;
}
}
out.flush()?;
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(())
}
@@ -0,0 +1,56 @@
#![forbid(unsafe_code)]
//! Runs tools/test_toggle_keys_parity.py's event sequences through
//! `ToggleKeys`, the sneak/sprint/attack/use toggle mappings.
use horizon_client::{
Result,
keymapping::{KeyAction, ToggleKeys},
};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let mut keys = ToggleKeys::default();
let mut options = [false; 4];
for op in c["ops"].as_array().unwrap() {
let name = op[0].as_str().unwrap();
let slot = || op[1].as_u64().unwrap() as usize;
match name {
"option" => options[slot()] = op[2].as_bool().unwrap(),
"key" => {
let action = match op[2].as_i64().unwrap() {
0 => KeyAction::Release,
1 => KeyAction::Press,
_ => KeyAction::Repeat,
};
keys.key(slot(), action, op[3].as_bool().unwrap(), options[slot()]);
}
"button" => keys.button(
slot(),
op[2].as_bool().unwrap(),
op[3].as_bool().unwrap(),
options[slot()],
),
"open" => keys.screen_opened(options),
"close" => keys.screen_closed(options),
"respawn" => keys.respawn(),
other => panic!("{other}"),
}
let mut line = name.to_string();
for k in &keys.0 {
line += &format!(" {} {}", k.is_down, k.pending_clicks());
}
writeln!(out, "{line}")?;
}
}
out.flush()?;
Ok(())
}
+227
View File
@@ -0,0 +1,227 @@
//! Auto-jump (`LocalPlayer.updateAutoJump` and `canAutoJump`): after the
//! local player's own move, look ahead along the movement for a step that
//! the player can clear with a jump and is not walled in above; if found, the
//! next `aiStep` presses jump for one tick (`autoJumpTime = 1`).
use horizon_core::{
geometry::{Aabb, Axis, Vec3, VoxelShape, trig},
position::BlockPos,
};
/// `Float.MIN_VALUE`, the "no obstacle" marker.
const NO_OBSTACLE: f32 = f32::from_bits(1);
/// What `updateAutoJump` reads from the player.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AutoJumper {
pub position: Vec3,
/// `getBoundingBox().maxY`.
pub max_y: f64,
pub x_rot: f32,
pub y_rot: f32,
/// `getSpeed()`.
pub speed: f32,
/// `input.getMoveVector()` (x: strafe, y: forward).
pub move_vector: [f32; 2],
/// `getBbWidth()`, `getBbHeight()`.
pub width: f32,
pub height: f32,
/// The jump boost amplifier, when the effect is active.
pub jump_boost: Option<i32>,
}
/// The level queries, with the player's collision context.
pub trait AutoJumpWorld {
type Error;
/// `getBlockState(p).getCollisionShape(level, p, CollisionContext.of(player))`.
fn collision_shape(&mut self, p: BlockPos) -> Result<VoxelShape, Self::Error>;
/// `level.getCollisions(player, bounds)` flattened with `toAabbs`, in order.
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>, Self::Error>;
}
/// `LocalPlayer.canAutoJump`. `staying_on_surface` is
/// `isStayingOnGroundSurface()` (the shift key).
#[allow(clippy::too_many_arguments)]
pub fn can_auto_jump(
enabled: bool,
auto_jump_time: i32,
on_ground: bool,
staying_on_surface: bool,
passenger: bool,
move_vector: [f32; 2],
block_jump_factor: f32,
) -> bool {
enabled
&& auto_jump_time <= 0
&& on_ground
&& !staying_on_surface
&& !passenger
&& move_vector[0] * move_vector[0] + move_vector[1] * move_vector[1] > 0.0
&& f64::from(block_jump_factor) >= 1.0
}
/// `Vec3.directionFromRotation(rotX, rotY)`.
pub fn direction_from_rotation(rot_x: f32, rot_y: f32) -> Vec3 {
let rad = trig::DEG_TO_RAD;
let pi = std::f32::consts::PI;
let y_cos = trig::cos(f64::from(-rot_y * rad - pi));
let y_sin = trig::sin(f64::from(-rot_y * rad - pi));
let x_cos = -trig::cos(f64::from(-rot_x * rad));
let x_sin = trig::sin(f64::from(-rot_x * rad));
Vec3::new(
f64::from(y_sin * x_cos),
f64::from(x_sin),
f64::from(y_cos * x_cos),
)
}
/// `AABB.intersects(Vec3, Vec3)`: the box overlaps the segment's bounds.
fn intersects_segment(b: Aabb, from: Vec3, to: Vec3) -> bool {
b.intersects(Aabb::new(from, to))
}
/// `LocalPlayer.updateAutoJump(xa, za)` once `canAutoJump` holds: whether
/// it sets `autoJumpTime = 1`. `xa`/`za` are the float horizontal
/// displacement of the move.
pub fn update_auto_jump<W: AutoJumpWorld>(
world: &mut W,
p: &AutoJumper,
xa: f32,
za: f32,
) -> Result<bool, W::Error> {
let move_begin = p.position;
let move_end = move_begin.add_vector(Vec3::new(f64::from(xa), 0.0, f64::from(za)));
let mut move_diff = Vec3::new(f64::from(xa), 0.0, f64::from(za));
let current_speed = p.speed;
let mut move_dist_sq = move_diff.length_squared() as f32;
if move_dist_sq <= 0.001 {
let input_xa = current_speed * p.move_vector[0];
let input_za = current_speed * p.move_vector[1];
let sin = trig::sin(f64::from(p.y_rot * trig::DEG_TO_RAD));
let cos = trig::cos(f64::from(p.y_rot * trig::DEG_TO_RAD));
move_diff = Vec3::new(
f64::from(input_xa * cos - input_za * sin),
move_diff.y,
f64::from(input_za * cos + input_xa * sin),
);
move_dist_sq = move_diff.length_squared() as f32;
if move_dist_sq <= 0.001 {
return Ok(false);
}
}
// Mth.invSqrt: 1.0F / (float) Math.sqrt(x).
let inverted = 1.0 / move_dist_sq.sqrt();
let move_dir = move_diff.scale(f64::from(inverted));
let facing = direction_from_rotation(p.x_rot, p.y_rot);
let facing_vs_moving = (facing.x * move_dir.x + facing.z * move_dir.z) as f32;
if facing_vs_moving < -0.15 {
return Ok(false);
}
let mut ceiling = BlockPos::containing(Vec3::new(move_begin.x, p.max_y, move_begin.z));
if !world.collision_shape(ceiling)?.is_empty() {
return Ok(false);
}
ceiling = ceiling.offset(0, 1, 0);
if !world.collision_shape(ceiling)?.is_empty() {
return Ok(false);
}
let mut jump_height = 1.2f32;
if let Some(amplifier) = p.jump_boost {
jump_height += (amplifier + 1) as f32 * 0.75;
}
let look_ahead = crate::camera::java_max(current_speed * 7.0, 1.0 / inverted);
let seg_begin = move_begin;
let seg_end = move_end.add_vector(move_dir.scale(f64::from(look_ahead)));
let (width, height) = (p.width, p.height);
let test_box = Aabb::new(
seg_begin,
seg_end.add_vector(Vec3::new(0.0, f64::from(height), 0.0)),
)
.inflate(Vec3::new(f64::from(width), 0.0, f64::from(width)));
let lift = Vec3::new(0.0, f64::from(0.51f32), 0.0);
let seg_begin = seg_begin.add_vector(lift);
let seg_end = seg_end.add_vector(lift);
let right_dir = move_dir.cross(Vec3::new(0.0, 1.0, 0.0));
let right_offset = right_dir.scale(f64::from(width * 0.5));
let left_begin = seg_begin.subtract(right_offset);
let left_end = seg_end.subtract(right_offset);
let right_begin = seg_begin.add_vector(right_offset);
let right_end = seg_end.add_vector(right_offset);
let mut obstacle = NO_OBSTACLE;
for b in world.collision_boxes(test_box)? {
if intersects_segment(b, left_begin, left_end) || intersects_segment(b, right_begin, right_end)
{
obstacle = b.max_position().y as f32;
let obstacle_pos = BlockPos::containing(b.center());
let mut steps = 1i32;
while (steps as f32) < jump_height {
let above = obstacle_pos.offset(0, steps, 0);
let shape = world.collision_shape(above)?;
if !shape.is_empty() {
obstacle = shape.max(Axis::Y) as f32 + above.y as f32;
if f64::from(obstacle) - p.position.y > f64::from(jump_height) {
return Ok(false);
}
}
if steps > 1 {
ceiling = ceiling.offset(0, 1, 0);
if !world.collision_shape(ceiling)?.is_empty() {
return Ok(false);
}
}
steps += 1;
}
break;
}
}
if obstacle != NO_OBSTACLE {
let y_delta = (f64::from(obstacle) - p.position.y) as f32;
if !(y_delta <= 0.5) && !(y_delta > jump_height) {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
struct Step;
impl AutoJumpWorld for Step {
type Error = ();
fn collision_shape(&mut self, p: BlockPos) -> Result<VoxelShape, ()> {
Ok(if p.x == 1 && p.y == 0 {
VoxelShape::block()
} else {
VoxelShape::empty()
})
}
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>, ()> {
let b = Aabb::new(Vec3::new(1.0, 0.0, -5.0), Vec3::new(2.0, 1.0, 5.0));
Ok(if b.intersects(bounds) { vec![b] } else { Vec::new() })
}
}
#[test]
fn walking_into_a_one_block_step_jumps() {
let p = AutoJumper {
position: Vec3::new(0.6, 0.0, 0.5),
max_y: 1.8,
x_rot: 0.0,
y_rot: -90.0,
speed: 0.1,
move_vector: [0.0, 1.0],
width: 0.6,
height: 1.8,
jump_boost: None,
};
assert!(update_auto_jump(&mut Step, &p, 0.1, 0.0).unwrap());
// Moving away from the step does not.
assert!(!update_auto_jump(&mut Step, &p, -0.1, 0.0).unwrap());
}
#[test]
fn sneaking_or_standing_still_cannot_auto_jump() {
assert!(can_auto_jump(true, 0, true, false, false, [0.0, 1.0], 1.0));
assert!(!can_auto_jump(true, 0, true, true, false, [0.0, 1.0], 1.0));
assert!(!can_auto_jump(true, 0, true, false, false, [0.0, 0.0], 1.0));
assert!(!can_auto_jump(true, 0, true, false, false, [0.0, 1.0], 0.5));
}
}
+439 -104
View File
@@ -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,
});
}
}
}
+776
View File
@@ -0,0 +1,776 @@
//! The camera (`Camera.tick`, `tickFov`, `calculateFov`, `alignWithEntity`,
//! `getMaxZoom`, `setRotation`, `move`), view and hurt bobbing
//! (`GameRenderer.bobView`, `bobHurt`), the walk and bob history they read
//! (`ClientAvatarState`), and `AbstractClientPlayer.getFieldOfViewModifier`.
//!
//! The float arithmetic follows vanilla and JOML's default (non-FMA,
//! non-fast-math) code paths operation by operation.
use horizon_core::geometry::trig;
/// `(float) Math.PI`.
const PI: f32 = std::f32::consts::PI;
/// `(float) (Math.PI / 180.0)`.
const DEG_TO_RAD: f32 = trig::DEG_TO_RAD;
/// `Math.min(float, float)`: NaN wins and -0.0 is below 0.0.
pub fn java_min(a: f32, b: f32) -> f32 {
if a.is_nan() || b.is_nan() {
f32::NAN
} else if a == 0.0 && b == 0.0 {
f32::from_bits(a.to_bits() | b.to_bits())
} else if a <= b {
a
} else {
b
}
}
/// `Math.max(float, float)`.
pub fn java_max(a: f32, b: f32) -> f32 {
if a.is_nan() || b.is_nan() {
f32::NAN
} else if a == 0.0 && b == 0.0 {
f32::from_bits(a.to_bits() & b.to_bits())
} else if a >= b {
a
} else {
b
}
}
/// `Mth.lerp(float, float, float)`.
pub fn lerp(alpha: f32, p0: f32, p1: f32) -> f32 {
p0 + alpha * (p1 - p0)
}
/// `Mth.lerp(double, double, double)`.
pub fn lerp_d(alpha: f64, p0: f64, p1: f64) -> f64 {
p0 + alpha * (p1 - p0)
}
/// `Mth.wrapDegrees(float)`.
pub fn wrap_degrees(angle: f32) -> f32 {
let mut normalized = angle % 360.0;
if normalized >= 180.0 {
normalized -= 360.0;
}
if normalized < -180.0 {
normalized += 360.0;
}
normalized
}
/// `Mth.clamp(float, float, float)`.
pub fn clamp(value: f32, min: f32, max: f32) -> f32 {
if value < min { min } else { java_min(value, max) }
}
/// JOML's float trigonometry (`org.joml.Math` without fast math).
pub mod joml {
use super::PI;
const TAU: f32 = std::f32::consts::TAU;
const HALF_PI: f32 = std::f32::consts::FRAC_PI_2;
/// `Math.sin(float)`: `java.lang.Math.sin` narrowed.
pub fn sin(rad: f32) -> f32 {
f64::from(rad).sin() as f32
}
/// `Math.cosFromSin(float, float)`.
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
let cos = (1.0 - sin * sin).sqrt();
let a = angle + HALF_PI;
let mut b = a - (a / TAU) as i32 as f32 * TAU;
if f64::from(b) < 0.0 {
b += TAU;
}
if b >= PI { -cos } else { cos }
}
/// A JOML `Quaternionf` (x, y, z, w).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Quat {
pub x: f32,
pub y: f32,
pub z: f32,
pub w: f32,
}
impl Quat {
pub const IDENTITY: Self = Self {
x: 0.0,
y: 0.0,
z: 0.0,
w: 1.0,
};
/// `Quaternionf.rotationYXZ(angleY, angleX, angleZ)`.
pub fn rotation_yxz(angle_y: f32, angle_x: f32, angle_z: f32) -> Self {
let sx = sin(angle_x * 0.5);
let cx = cos_from_sin(sx, angle_x * 0.5);
let sy = sin(angle_y * 0.5);
let cy = cos_from_sin(sy, angle_y * 0.5);
let sz = sin(angle_z * 0.5);
let cz = cos_from_sin(sz, angle_z * 0.5);
let x = cy * sx;
let y = sy * cx;
let z = sy * sx;
let w = cy * cx;
Self {
x: x * cz + y * sz,
y: y * cz - x * sz,
z: w * sz - z * cz,
w: w * cz + z * sz,
}
}
/// `Quaternionf.transform(x, y, z, dest)` (`Vector3f.rotate`).
pub fn transform(&self, v: [f32; 3]) -> [f32; 3] {
let [x, y, z] = v;
let xx = self.x * self.x;
let yy = self.y * self.y;
let zz = self.z * self.z;
let ww = self.w * self.w;
let xy = self.x * self.y;
let xz = self.x * self.z;
let yz = self.y * self.z;
let xw = self.x * self.w;
let zw = self.z * self.w;
let yw = self.y * self.w;
let k = 1.0 / (xx + yy + zz + ww);
[
(xx - yy - zz + ww) * k * x
+ (2.0 * (xy - zw) * k * y + 2.0 * (xz + yw) * k * z),
2.0 * (xy + zw) * k * x
+ ((yy - xx - zz + ww) * k * y + 2.0 * (yz - xw) * k * z),
2.0 * (xz - yw) * k * x
+ (2.0 * (yz + xw) * k * y + (zz - xx - yy + ww) * k * z),
]
}
}
const PERSPECTIVE: u8 = 1;
const IDENTITY: u8 = 4;
const TRANSLATION: u8 = 8;
/// A JOML `Matrix4f` with its property flags, which select the code
/// paths (and so the rounding) of later operations. `m[column * 4 + row]`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Mat4 {
pub m: [f32; 16],
pub properties: u8,
}
impl Default for Mat4 {
fn default() -> Self {
Self::IDENTITY
}
}
impl Mat4 {
/// `new Matrix4f()`.
pub const IDENTITY: Self = Self {
m: [
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,
],
properties: 30,
};
fn get(&self, column: usize, row: usize) -> f32 {
self.m[column * 4 + row]
}
fn set(&mut self, column: usize, row: usize, v: f32) {
self.m[column * 4 + row] = v;
}
fn reset_unless_identity(&mut self) {
if self.properties & IDENTITY == 0 {
self.m = Self::IDENTITY.m;
}
}
/// `Matrix4f.translation(x, y, z)`.
pub fn translation(&mut self, x: f32, y: f32, z: f32) {
self.reset_unless_identity();
self.set(3, 0, x);
self.set(3, 1, y);
self.set(3, 2, z);
self.properties = 26;
}
/// `Matrix4f.setTranslation(x, y, z)`.
pub fn set_translation(&mut self, x: f32, y: f32, z: f32) {
self.set(3, 0, x);
self.set(3, 1, y);
self.set(3, 2, z);
self.properties &= !(PERSPECTIVE | IDENTITY);
}
/// `Matrix4f.translate(x, y, z)`.
pub fn translate(&mut self, x: f32, y: f32, z: f32) {
if self.properties & IDENTITY != 0 {
self.translation(x, y, z);
return;
}
for row in 0..4 {
let v = self.get(0, row) * x
+ (self.get(1, row) * y + (self.get(2, row) * z + self.get(3, row)));
self.set(3, row, v);
}
self.properties &= !(PERSPECTIVE | IDENTITY);
}
fn rotation_axis(&mut self, axis: usize, ang: f32) {
let sin = sin(ang);
let cos = cos_from_sin(sin, ang);
self.reset_unless_identity();
match axis {
0 => {
self.set(1, 1, cos);
self.set(1, 2, sin);
self.set(2, 1, -sin);
self.set(2, 2, cos);
}
1 => {
self.set(0, 0, cos);
self.set(0, 2, -sin);
self.set(2, 0, sin);
self.set(2, 2, cos);
}
_ => {
self.set(0, 0, cos);
self.set(0, 1, sin);
self.set(1, 0, -sin);
self.set(1, 1, cos);
}
}
self.properties = 18;
}
/// `Matrix4f.rotateX/rotateY/rotateZ(ang)`, with the identity and
/// pure-translation shortcuts.
fn rotate_axis(&mut self, axis: usize, ang: f32) {
if self.properties & IDENTITY != 0 {
self.rotation_axis(axis, ang);
return;
}
if self.properties & TRANSLATION != 0 {
let (x, y, z) = (self.get(3, 0), self.get(3, 1), self.get(3, 2));
self.rotation_axis(axis, ang);
self.set_translation(x, y, z);
return;
}
let sin = sin(ang);
let cos = cos_from_sin(sin, ang);
let s = *self;
match axis {
0 => {
for row in 0..4 {
let (l1, l2) = (s.get(1, row), s.get(2, row));
self.set(2, row, l1 * -sin + l2 * cos);
self.set(1, row, l1 * cos + l2 * sin);
}
}
1 => {
for row in 0..4 {
let (l0, l2) = (s.get(0, row), s.get(2, row));
self.set(0, row, l0 * cos + l2 * -sin);
self.set(2, row, l0 * sin + l2 * cos);
}
}
_ => {
// rotateTowardsXY(sin, cos).
let (dx, dy) = (sin, cos);
for row in 0..4 {
let (l0, l1) = (s.get(0, row), s.get(1, row));
self.set(0, row, l0 * dy + l1 * dx);
self.set(1, row, l0 * -dx + l1 * dy);
}
}
}
self.properties &= !(PERSPECTIVE | IDENTITY | TRANSLATION);
}
pub fn rotate_x(&mut self, ang: f32) {
self.rotate_axis(0, ang);
}
pub fn rotate_y(&mut self, ang: f32) {
self.rotate_axis(1, ang);
}
pub fn rotate_z(&mut self, ang: f32) {
// rotateTowardsXY also short-cuts on identity, but rotateZ has
// already taken that branch.
self.rotate_axis(2, ang);
}
}
}
use joml::{Mat4, Quat};
/// `CameraType`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CameraType {
#[default]
FirstPerson,
ThirdPersonBack,
ThirdPersonFront,
}
impl CameraType {
pub fn is_first_person(self) -> bool {
self == Self::FirstPerson
}
pub fn is_mirrored(self) -> bool {
self == Self::ThirdPersonFront
}
/// `CameraType.cycle` (F5).
pub fn cycle(self) -> Self {
match self {
Self::FirstPerson => Self::ThirdPersonBack,
Self::ThirdPersonBack => Self::ThirdPersonFront,
Self::ThirdPersonFront => Self::FirstPerson,
}
}
}
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FovPlayer {
/// `Abilities.flying`.
pub flying: bool,
/// `Abilities.getWalkingSpeed()`.
pub walking_speed: f32,
/// `getAttributeValue(MOVEMENT_SPEED)`.
pub movement_speed: f64,
/// The item in use and its use ticks, when using one.
pub using: Option<(UseItem, i32)>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UseItem {
Bow,
Spyglass,
Other,
}
/// `AbstractClientPlayer.getFieldOfViewModifier(firstPerson, effectScale)`.
pub fn field_of_view_modifier(p: &FovPlayer, first_person: bool, effect_scale: f32) -> f32 {
let mut modifier = 1.0f32;
if p.flying {
modifier *= 1.1;
}
let walking_speed = p.walking_speed;
if walking_speed != 0.0 {
let speed_factor = p.movement_speed as f32 / walking_speed;
modifier *= (speed_factor + 1.0) / 2.0;
}
if let Some((item, ticks)) = p.using {
match item {
UseItem::Bow => {
let scale = java_min(ticks as f32 / 20.0, 1.0);
modifier *= 1.0 - scale * scale * 0.15;
}
// isScoping: using a spyglass.
UseItem::Spyglass if first_person => return 0.1,
_ => {}
}
}
lerp(effect_scale, 1.0, modifier)
}
/// The camera entity as `Camera.alignWithEntity` and the field-of-view
/// death and hurt effects see it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CameraEntity {
pub position: [f64; 3],
/// `xo`, `yo`, `zo`.
pub previous: [f64; 3],
pub x_rot: f32,
pub y_rot: f32,
pub x_rot_o: f32,
pub y_rot_o: f32,
/// `Entity.getEyeHeight()`.
pub eye_height: f32,
/// A living entity's `getScale()` and camera distance attribute.
pub living: Option<LivingCamera>,
/// The vehicle's scale and camera distance when it is living.
pub mount: Option<(f32, f64)>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LivingCamera {
pub scale: f32,
pub camera_distance: f64,
/// `isSleeping()` with `getBedOrientation()` as its `toYRot()`.
pub sleeping: Option<Option<f32>>,
/// `isDeadOrDying()` and `deathTime`.
pub dead: Option<i32>,
}
impl CameraEntity {
/// `Entity.getXRot(partialTicks)`.
pub fn view_x_rot(&self, partial: f32) -> f32 {
if partial == 1.0 {
self.x_rot
} else {
lerp(partial, self.x_rot_o, self.x_rot)
}
}
/// `LocalPlayer.getViewYRot` (`getYRot(partialTicks)`, a `Mth.rotLerp`).
pub fn view_y_rot(&self, partial: f32) -> f32 {
if partial == 1.0 {
self.y_rot
} else {
self.y_rot_o + partial * wrap_degrees(self.y_rot - self.y_rot_o)
}
}
}
/// The fluid `Camera.getFluidInCamera` reports, as the field of view sees it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CameraFluid {
#[default]
None,
Water,
Lava,
PowderSnow,
}
/// `Camera`: the eye-height and field-of-view easing and the view placement.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
pub eye_height: f32,
pub eye_height_old: f32,
pub fov_modifier: f32,
pub old_fov_modifier: f32,
pub position: [f64; 3],
pub x_rot: f32,
pub y_rot: f32,
pub rotation: Quat,
pub forwards: [f32; 3],
pub up: [f32; 3],
pub left: [f32; 3],
pub detached: bool,
}
impl Default for Camera {
fn default() -> Self {
Self {
eye_height: 0.0,
eye_height_old: 0.0,
fov_modifier: 0.0,
old_fov_modifier: 0.0,
position: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
rotation: Quat::IDENTITY,
forwards: [0.0, 0.0, -1.0],
up: [0.0, 1.0, 0.0],
left: [-1.0, 0.0, 0.0],
detached: false,
}
}
}
impl Camera {
/// `Camera.tick`: the eye height eases halfway toward the entity's
/// (with a level and an entity), then `tickFov` with the camera entity's
/// field-of-view modifier target (1 when it is not a player).
pub fn tick(&mut self, entity_eye_height: Option<f32>, target_fov_modifier: f32) {
if let Some(eye) = entity_eye_height {
self.eye_height_old = self.eye_height;
self.eye_height += (eye - self.eye_height) * 0.5;
}
self.old_fov_modifier = self.fov_modifier;
self.fov_modifier += (target_fov_modifier - self.fov_modifier) * 0.5;
self.fov_modifier = clamp(self.fov_modifier, 0.1, 1.5);
}
/// `Camera.setRotation`.
pub fn set_rotation(&mut self, y_rot: f32, x_rot: f32) {
self.x_rot = x_rot;
self.y_rot = y_rot;
self.rotation = Quat::rotation_yxz(PI - y_rot * DEG_TO_RAD, -x_rot * DEG_TO_RAD, 0.0);
self.forwards = self.rotation.transform([0.0, 0.0, -1.0]);
self.up = self.rotation.transform([0.0, 1.0, 0.0]);
self.left = self.rotation.transform([-1.0, 0.0, 0.0]);
}
/// `Camera.move(forwards, up, right)`.
pub fn move_by(&mut self, forwards: f32, up: f32, right: f32) {
let offset = self.rotation.transform([right, up, -forwards]);
self.position = [
self.position[0] + f64::from(offset[0]),
self.position[1] + f64::from(offset[1]),
self.position[2] + f64::from(offset[2]),
];
}
/// `Camera.alignWithEntity` (not riding a minecart with the new
/// movement): the entity's view rotation and interpolated eye, then a
/// detached camera backed off along the view (`getMaxZoom`) or a sleeping
/// one turned to the bed. `clip` is `Level.clip` with
/// `ClipContext.Block.VISUAL` and no fluids; it returns the hit location
/// unless the ray misses.
pub fn align_with_entity(
&mut self,
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
let p = f64::from(partial);
self.position = [
lerp_d(p, entity.previous[0], entity.position[0]),
lerp_d(p, entity.previous[1], entity.position[1])
+ f64::from(lerp(partial, self.eye_height_old, self.eye_height)),
lerp_d(p, entity.previous[2], entity.position[2]),
];
self.detached = !camera_type.is_first_person();
if self.detached {
if camera_type.is_mirrored() {
self.set_rotation(self.y_rot + 180.0, -self.x_rot);
}
let (mut distance, mut scale) = (4.0f32, 1.0f32);
if let Some(living) = &entity.living {
scale = living.scale;
distance = living.camera_distance as f32;
}
let (mut mount_scale, mut mount_distance) = (scale, distance);
if let Some((s, d)) = entity.mount {
mount_scale = s;
mount_distance = d as f32;
}
let zoom = self.max_zoom(
java_max(scale * distance, mount_scale * mount_distance),
&mut clip,
);
self.move_by(-zoom, 0.0, 0.0);
} else if let Some(LivingCamera {
sleeping: Some(bed),
..
}) = entity.living
{
self.set_rotation(bed.map_or(0.0, |yaw| yaw - 180.0), 0.0);
self.move_by(0.0, 0.15, 0.0);
}
}
/// `Camera.getMaxZoom`: eight rays from the corners of a 0.2 cube about
/// the eye, shortening the distance to the nearest visual-shape hit.
pub fn max_zoom(
&self,
mut distance: f32,
clip: &mut impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) -> f32 {
let at = self.position;
for i in 0..8 {
let offset = [
((i & 1) * 2 - 1) as f32,
((i >> 1 & 1) * 2 - 1) as f32,
((i >> 2 & 1) * 2 - 1) as f32,
];
let from: [f64; 3] = std::array::from_fn(|a| at[a] + f64::from(offset[a] * 0.1));
let back = f64::from(-distance);
let to: [f64; 3] =
std::array::from_fn(|a| from[a] + f64::from(self.forwards[a]) * back);
if let Some(hit) = clip(from, to) {
let d = [hit[0] - at[0], hit[1] - at[1], hit[2] - at[2]];
let dist_sq = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]) as f32;
if dist_sq < distance * distance {
distance = dist_sq.sqrt();
}
}
}
distance
}
/// `Camera.calculateFov`: the option scaled by the eased modifier, then
/// the death and fluid effects.
pub fn calculate_fov(
&self,
partial: f32,
fov_option: i32,
effect_scale: f32,
dead: Option<i32>,
fluid: CameraFluid,
panoramic: bool,
) -> f32 {
if panoramic {
return 90.0;
}
let fov =
fov_option as f32 * lerp(partial, self.old_fov_modifier, self.fov_modifier);
modify_fov_based_on_death_or_fluid(partial, fov, effect_scale, dead, fluid)
}
/// `Camera.calculateHudFov`.
pub fn calculate_hud_fov(
&self,
partial: f32,
effect_scale: f32,
dead: Option<i32>,
fluid: CameraFluid,
) -> f32 {
modify_fov_based_on_death_or_fluid(partial, 70.0, effect_scale, dead, fluid)
}
}
/// `Camera.modifyFovBasedOnDeathOrFluid`. `dead` is the camera entity's
/// `deathTime` while it is a living entity that is dead or dying.
pub fn modify_fov_based_on_death_or_fluid(
partial: f32,
mut fov: f32,
effect_scale: f32,
dead: Option<i32>,
fluid: CameraFluid,
) -> f32 {
if let Some(death_time) = dead {
let duration = java_min(death_time as f32 + partial, 20.0);
fov /= (1.0 - 500.0 / (duration + 500.0)) * 2.0 + 1.0;
}
if matches!(fluid, CameraFluid::Lava | CameraFluid::Water) {
fov *= lerp(effect_scale, 1.0, 0.857_142_87);
}
fov
}
/// `ClientAvatarState`: the walked distance and view bob of a player.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct AvatarBob {
pub walk_dist: f32,
pub walk_dist_o: f32,
pub bob: f32,
pub bob_o: f32,
}
impl AvatarBob {
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`).
pub fn tick(&mut self) {
self.walk_dist_o = self.walk_dist;
}
/// `LocalPlayer.move`: `addWalkedDistance(Mth.length(dx, dz) * 0.6F)`
/// with the float horizontal displacement.
pub fn add_walked(&mut self, dx: f32, dz: f32) {
self.walk_dist += (dx * dx + dz * dz).sqrt() * 0.6;
}
/// `AbstractClientPlayer.updateBob`: toward the horizontal speed (at most
/// 0.1) on the ground, toward 0 otherwise.
pub fn update_bob(&mut self, on_ground_alive_not_swimming: bool, velocity: [f64; 3]) {
let target = if on_ground_alive_not_swimming {
java_min(
0.1,
(velocity[0] * velocity[0] + velocity[2] * velocity[2]).sqrt() as f32,
)
} else {
0.0
};
self.bob_o = self.bob;
self.bob += (target - self.bob) * 0.4;
}
/// `ClientAvatarState.resetBob` (`AbstractClientPlayer.rideTick`).
pub fn reset_bob(&mut self) {
self.bob_o = self.bob;
self.bob = 0.0;
}
pub fn interpolated_bob(&self, partial: f32) -> f32 {
lerp(partial, self.bob_o, self.bob)
}
pub fn backwards_interpolated_walk_distance(&self, partial: f32) -> f32 {
let wda = self.walk_dist - self.walk_dist_o;
-(self.walk_dist + wda * partial)
}
}
/// The parts of `CameraRenderState.entityRenderState` the bobbing reads.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct BobState {
/// A living camera entity: `hurtTime - partial`, `hurtDuration`,
/// `getHurtDir()`, and `deathTime + partial` when dead or dying.
pub living: Option<HurtBob>,
/// A player camera entity: the backwards-interpolated walk distance and
/// the interpolated bob.
pub player: Option<(f32, f32)>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct HurtBob {
pub hurt_time: f32,
pub hurt_duration: i32,
pub hurt_dir: f32,
pub death_time: Option<f32>,
}
/// `PoseStack.rotateDegrees` on the pose with an `Axis`.
fn rotate_degrees(m: &mut Mat4, axis: usize, degrees: f32) {
let ang = degrees * DEG_TO_RAD;
match axis {
0 => m.rotate_x(ang),
1 => m.rotate_y(ang),
_ => m.rotate_z(ang),
}
}
/// `GameRenderer.bobHurt`.
pub fn bob_hurt(state: &BobState, damage_tilt_strength: f64, pose: &mut Mat4) {
let Some(living) = state.living else { return };
let mut hurt = living.hurt_time;
if let Some(death_time) = living.death_time {
let duration = java_min(death_time, 20.0);
rotate_degrees(pose, 2, 40.0 - 8000.0 / (duration + 200.0));
}
if hurt < 0.0 {
return;
}
hurt /= living.hurt_duration as f32;
hurt = trig::sin(f64::from(hurt * hurt * hurt * hurt * PI));
let rr = living.hurt_dir;
rotate_degrees(pose, 1, -rr);
let tilt = (f64::from(-hurt) * 14.0 * damage_tilt_strength) as f32;
rotate_degrees(pose, 2, tilt);
rotate_degrees(pose, 1, rr);
}
/// `GameRenderer.bobView`.
pub fn bob_view(state: &BobState, pose: &mut Mat4) {
let Some((walk, bob)) = state.player else {
return;
};
pose.translate(
trig::sin(f64::from(walk * PI)) * bob * 0.5,
-(trig::cos(f64::from(walk * PI)) * bob).abs(),
0.0,
);
rotate_degrees(pose, 2, trig::sin(f64::from(walk * PI)) * bob * 3.0);
rotate_degrees(pose, 0, (trig::cos(f64::from(walk * PI - 0.2)) * bob).abs() * 5.0);
}
/// The bob matrix `GameRenderer.renderLevel` multiplies into the projection
/// (and `renderItemInHand` into the hand's pose).
pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option: bool) -> Mat4 {
let mut pose = Mat4::IDENTITY;
bob_hurt(state, damage_tilt_strength, &mut pose);
if bob_view_option {
bob_view(state, &mut pose);
}
pose
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn camera_eases_eye_height_and_fov() {
let mut camera = Camera::default();
camera.tick(Some(1.62), 1.0);
assert_eq!(camera.eye_height, 0.81);
assert_eq!(camera.fov_modifier, 0.5);
camera.tick(None, 1.0);
assert_eq!(camera.eye_height, 0.81);
assert_eq!(camera.fov_modifier, 0.75);
}
#[test]
fn rotation_faces_south_at_zero_yaw() {
let mut camera = Camera::default();
camera.set_rotation(0.0, 0.0);
assert!((camera.forwards[2] - 1.0).abs() < 1e-6);
camera.set_rotation(90.0, 0.0);
assert!((camera.forwards[0] + 1.0).abs() < 1e-6);
}
#[test]
fn detached_camera_backs_off_until_blocked() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: None,
mount: None,
};
let mut camera = Camera::default();
camera.align_with_entity(&entity, 1.0, CameraType::ThirdPersonBack, |_, _| None);
assert!((camera.position[2] + 4.0).abs() < 1e-6);
camera.align_with_entity(&entity, 1.0, CameraType::ThirdPersonBack, |_, _| {
Some([0.0, 0.0, -1.0])
});
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
}
#[test]
fn fov_modifier_combines_speed_flight_and_bow() {
let mut p = FovPlayer {
flying: false,
walking_speed: 0.1,
movement_speed: 0.13,
using: None,
};
assert_eq!(field_of_view_modifier(&p, true, 1.0), (0.13f32 / 0.1 + 1.0) / 2.0);
assert_eq!(field_of_view_modifier(&p, true, 0.0), 1.0);
p.using = Some((UseItem::Spyglass, 3));
assert_eq!(field_of_view_modifier(&p, true, 1.0), 0.1);
assert_ne!(field_of_view_modifier(&p, false, 1.0), 0.1);
}
}
+147 -170
View File
@@ -34,7 +34,6 @@ mod game_mode;
mod ghasts;
mod golems;
mod growth;
mod health_hud;
mod jukebox;
mod lan;
mod lan_actions;
@@ -107,8 +106,9 @@ use horizon_client::{
environment::Environment,
gfx::{Frame, Renderer},
hud::{HudAtlas, HudBatch, rgba},
hud_layout,
items::{ItemStack, Items},
keymapping::KeyState,
keymapping::KeyAction,
light::LightTables,
mesher::{MeshTables, Vertex},
mobs::{Mob, MobType, MobTypes},
@@ -567,7 +567,7 @@ struct Game<'a> {
/// `Options.smoothCamera` (cinematic camera), not persisted.
smooth_camera: bool,
/// Sneak, sprint, attack and use as ToggleKeyMappings.
toggles: [KeyState; 4],
toggles: horizon_client::keymapping::ToggleKeys,
/// A world screen with the pointer captured was open last frame.
was_playing: bool,
/// Minecraft.pause: a single-player screen that pauses the game is open.
@@ -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>,
@@ -615,7 +615,11 @@ struct Game<'a> {
/// Item being used (eaten): hotbar slot and ticks used.
using: Option<(usize, u32)>,
/// Camera.oldFovModifier / fovModifier.
fov_modifier: [f32; 2],
/// `Camera` (eye height and field-of-view easing, view placement).
view: horizon_client::camera::Camera,
/// `FramerateLimiter` and its clock.
limiter: horizon_client::window::FramerateLimiter,
frame_clock: horizon_client::window::SystemClock,
/// Hud.scopeScale (spyglass overlay size).
scope_scale: f32,
/// Duration of the last frame in ticks (GUI easing).
@@ -645,8 +649,8 @@ struct Game<'a> {
/// Where the options screen returns: true = title, false = pause menu.
options_from_title: bool,
dragging: bool,
walk: (f32, f32),
bob: (f32, f32),
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
flash: u32,
/// Sleeping in a bed: (bed head position, ticks asleep).
sleeping: Option<([i32; 3], u32)>,
@@ -2513,6 +2517,8 @@ impl Game<'_> {
player.yaw = self.yaw;
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
player.may_fly = self.creative || spectator;
// LocalPlayer.sendPosition: autoJumpEnabled follows the option.
player.auto_jump_enabled = self.settings.auto_jump;
player.invulnerable = self.creative || spectator;
player.spectator = spectator;
player.pitch = self.pitch;
@@ -2536,6 +2542,11 @@ impl Game<'_> {
.and_then(|s| self.res.items.get(&s.item))
.map(|i| i.use_speed);
let grounded = player.ground.on_ground;
let before = (
grounded,
[player.velocity.x, player.velocity.y, player.velocity.z],
[player.position.x, player.position.y, player.position.z],
);
if !riding {
MotionWorld {
player,
@@ -2562,6 +2573,7 @@ impl Game<'_> {
self.previous_eye = self.eye;
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
self.tick_avatar(before, riding);
self.record_combat(&events);
for event in events {
match event {
@@ -2706,23 +2718,6 @@ impl Game<'_> {
self.tick_falling()?;
self.tick_tnt()?;
}
if let Some(p) = &self.player {
// Player.aiStep bob and Entity.walkDist (0.6 per block).
let (dx, dz) = (
p.position.x - self.previous_position.x,
p.position.z - self.previous_position.z,
);
let speed = ((dx * dx + dz * dz).sqrt() as f32).min(0.1);
self.walk.0 = self.walk.1;
self.walk.1 += (dx * dx + dz * dz).sqrt() as f32 * 0.6;
self.bob.0 = self.bob.1;
let target = if p.ground.on_ground && !p.health.dead_or_dying() {
speed
} else {
0.0
};
self.bob.1 += (target - self.bob.1) * 0.4;
}
if running {
self.tick_block_entities()?;
self.tick_sculk()?;
@@ -2750,7 +2745,7 @@ impl Game<'_> {
/// Keep the held-key state of toggled sneak and sprint in step with their mapping.
fn sync_toggle(&mut self, slot: usize) {
let key = [KeyCode::ShiftLeft, KeyCode::ControlLeft][slot];
if self.toggles[slot].is_down {
if self.toggles.is_down(slot) {
self.keys.insert(key);
} else {
self.keys.remove(&key);
@@ -2770,20 +2765,15 @@ impl Game<'_> {
self.settings.toggle_attack,
self.settings.toggle_use,
];
for (slot, option) in options.into_iter().enumerate() {
if playing {
if self.toggles[slot].should_restore_state_on_screen_closed(option) {
self.toggles[slot].set_down(true, option);
}
} else {
self.toggles[slot].release(option);
}
match slot {
0 | 1 => self.sync_toggle(slot),
2 => self.left = self.toggles[2].is_down,
_ => self.right = self.toggles[3].is_down,
}
if playing {
self.toggles.screen_closed(options);
} else {
self.toggles.screen_opened(options);
}
self.sync_toggle(0);
self.sync_toggle(1);
self.left = self.toggles.is_down(2);
self.right = self.toggles.is_down(3);
}
/// `MouseHandler.handleAccumulatedMovement` and `Entity.turn`, once per frame.
fn apply_mouse_look(&mut self) {
@@ -3108,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);
@@ -3160,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(),
@@ -3225,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
@@ -4483,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,
@@ -4496,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
@@ -7387,16 +7375,8 @@ impl Game<'_> {
return Ok(false);
};
let alpha = self.clock.fraction();
let (camera, underwater, view_yaw, view_pitch) = match &self.player {
Some(p) => {
let (a, b) = (self.previous_position, p.position);
let eye = self.previous_eye + alpha as f32 * (self.eye - self.previous_eye);
let c = glam::DVec3::new(
a.x + (b.x - a.x) * alpha,
a.y + (b.y - a.y) * alpha + f64::from(eye),
a.z + (b.z - a.z) * alpha,
);
let (c, yaw, pitch) = self.camera_view(c);
let (camera, underwater, view_yaw, view_pitch) = match self.camera_view(alpha as f32) {
Some((c, yaw, pitch)) => {
let s =
self.world
.block(c.x.floor() as i32, c.y.floor() as i32, c.z.floor() as i32);
@@ -7534,13 +7514,11 @@ impl Game<'_> {
} else {
self.fov_degrees(alpha as f32, camera.to_array())
},
bob: (self.settings.view_bobbing && !title && self.player.is_some()).then(|| {
let a = alpha as f32;
(
self.walk.0 + (self.walk.1 - self.walk.0) * a,
self.bob.0 + (self.bob.1 - self.bob.0) * a,
)
}),
bob: if title {
glam::Mat4::IDENTITY
} else {
self.bob_matrix(alpha as f32)
},
sky,
distance: (self.streaming.radius * 16) as f32,
underwater,
@@ -7696,15 +7674,16 @@ impl Game<'_> {
if pressed && self.attack() {
return Ok(());
}
self.toggles[2].set_down(pressed, self.settings.toggle_attack);
self.left = self.toggles[2].is_down;
self.toggles
.button(2, pressed, false, self.settings.toggle_attack);
self.left = self.toggles.is_down(2);
if pressed {
self.destroy_delay = 0;
}
}
MouseButton::Right => {
self.toggles[3].set_down(pressed, self.settings.toggle_use);
self.right = self.toggles[3].is_down;
self.toggles.button(3, pressed, false, self.settings.toggle_use);
self.right = self.toggles.is_down(3);
if pressed {
self.use_item()?;
self.use_delay = 4;
@@ -8156,18 +8135,23 @@ impl ApplicationHandler for Game<'_> {
}
None => return Ok(()),
};
// Sneak and sprint are ToggleKeyMappings: a press flips them when toggling.
// Sneak and sprint are ToggleKeyMappings: a press (or a key
// repeat) flips them when toggling; only releases reach them
// behind a screen (KeyboardHandler.keyPress).
let toggle = match key {
KeyCode::ShiftLeft => Some((0, self.settings.toggle_crouch)),
KeyCode::ControlLeft => Some((1, self.settings.toggle_sprint)),
_ => None,
};
if let Some((slot, option)) = toggle {
if !event.repeat {
self.toggles[slot]
.set_down(event.state == ElementState::Pressed, option);
self.sync_toggle(slot);
}
let action = match (event.state, event.repeat) {
(ElementState::Released, _) => KeyAction::Release,
(_, false) => KeyAction::Press,
(_, true) => KeyAction::Repeat,
};
let screen_open = self.screen != Screen::Playing || self.title.is_some();
self.toggles.key(slot, action, screen_open, option);
self.sync_toggle(slot);
if event.state != ElementState::Pressed {
return Ok(());
}
@@ -8216,6 +8200,7 @@ impl ApplicationHandler for Game<'_> {
}
self.apply_mouse_look();
let rendered = self.render()?;
self.limit_frame();
self.pause = self.pauses_game();
self.clock.end_frame(self.pause);
let finished = self
@@ -8281,10 +8266,6 @@ impl ApplicationHandler for Game<'_> {
event_loop.exit();
return;
}
if let Some(due) = self.next_frame_due() {
event_loop.set_control_flow(ControlFlow::WaitUntil(due));
return;
}
if let Some(w) = &self.window {
w.request_redraw();
}
@@ -8478,19 +8459,14 @@ pub fn run() -> Result<()> {
focused: true,
mouse: MouseLook::default(),
smooth_camera: false,
toggles: [
KeyState::toggle(true, true),
KeyState::toggle(true, true),
KeyState::toggle(true, false),
KeyState::toggle(false, false),
],
toggles: Default::default(),
was_playing: false,
pause: false,
debug: false,
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,
@@ -8523,7 +8499,9 @@ pub fn run() -> Result<()> {
last_hurt_by_player: HashSet::new(),
chest_open: HashMap::new(),
using: None,
fov_modifier: [1.0, 1.0],
view: Default::default(),
limiter: horizon_client::window::FramerateLimiter::new(0),
frame_clock: Default::default(),
scope_scale: 0.5,
frame_ticks: 0.0,
title: None,
@@ -8544,8 +8522,7 @@ pub fn run() -> Result<()> {
settings,
options_from_title: false,
dragging: false,
walk: (0.0, 0.0),
bob: (0.0, 0.0),
avatar: Default::default(),
flash: 0,
sleeping: None,
dim: Dim::Overworld,
+11 -72
View File
@@ -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,
);
}
}
}
+109 -46
View File
@@ -1,16 +1,18 @@
//! Field of view (`Camera.tickFov`, `calculateFov`,
//! `AbstractClientPlayer.getFieldOfViewModifier`) and the spyglass
//! (`SpyglassItem`, `Hud.extractSpyglassOverlay`).
//! `AbstractClientPlayer.getFieldOfViewModifier`), view and hurt bobbing
//! (`GameRenderer.bobHurt`, `bobView`) and the spyglass (`SpyglassItem`,
//! `Hud.extractSpyglassOverlay`). The arithmetic is `horizon_client::camera`.
use super::Game;
use horizon_client::{
camera::{
BobState, CameraFluid, FovPlayer, HurtBob, UseItem, bob_matrix, field_of_view_modifier,
},
fog::{FogType, fog_type},
hud::{HudBatch, rgba},
};
/// `Abilities.walkingSpeed`.
const WALKING_SPEED: f32 = 0.1;
/// `options.fovEffectScale` (vanilla default 1).
const FOV_EFFECT_SCALE: f32 = 1.0;
impl Game<'_> {
/// `Player.isScoping`: using a spyglass.
@@ -19,54 +21,115 @@ impl Game<'_> {
.and_then(|(slot, _)| self.stack_in(slot))
.is_some_and(|s| &*s.item == "minecraft:spyglass")
}
/// `AbstractClientPlayer.getFieldOfViewModifier` (first person).
fn fov_target(&self) -> f32 {
let Some(p) = &self.player else { return 1.0 };
let mut modifier = 1.0f32;
// Movement speed (sprinting, speed and slowness) relative to walking.
modifier *= (p.speed / WALKING_SPEED + 1.0) / 2.0;
if let Some((slot, ticks)) = self.using
&& let Some(stack) = self.stack_in(slot)
{
match &*stack.item {
"minecraft:bow" => {
let scale = (ticks as f32 / 20.0).min(1.0);
modifier *= 1.0 - scale * scale * 0.15;
}
"minecraft:spyglass" => return 0.1,
_ => {}
}
}
1.0 + (modifier - 1.0) * FOV_EFFECT_SCALE
/// `options.fovEffectScale`.
pub(super) fn fov_effect_scale(&self) -> f32 {
self.settings.file.double("fovEffectScale") as f32
}
/// `Camera.tickFov`.
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
fn fov_player(&self) -> Option<FovPlayer> {
let p = self.player.as_ref()?;
let using = self.using.and_then(|(slot, ticks)| {
let stack = self.stack_in(slot)?;
let item = match &*stack.item {
"minecraft:bow" => UseItem::Bow,
"minecraft:spyglass" => UseItem::Spyglass,
_ => UseItem::Other,
};
Some((item, ticks as i32))
});
Some(FovPlayer {
flying: p.flying,
walking_speed: WALKING_SPEED,
// Player.aiStep keeps `speed` as the float movement speed attribute.
movement_speed: f64::from(p.speed),
using,
})
}
/// `Camera.tick`: the eye height and the field-of-view modifier ease
/// halfway toward the player's each tick.
pub(super) fn tick_fov(&mut self) {
let target = self.fov_target();
let [_, current] = self.fov_modifier;
let next = (current + (target - current) * 0.5).clamp(0.1, 1.5);
self.fov_modifier = [current, next];
let first_person = self.camera_type.first_person();
let effect = self.fov_effect_scale();
let target = self
.fov_player()
.map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect));
let eye = self.player.as_ref().map(|p| p.dimensions.eye_height);
self.view.tick(eye, target);
}
/// The player's `deathTime` while dead or dying.
fn death_time(&self) -> Option<i32> {
let p = self.player.as_ref()?;
p.health.dead_or_dying().then(|| {
self.ui
.died_at
.map_or(0, |t| (t.elapsed().as_secs_f32() * 20.0) as i32)
})
}
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
match fog_type(&self.world, &self.res.facts, camera, self.forward()) {
FogType::Water => CameraFluid::Water,
FogType::Lava => CameraFluid::Lava,
FogType::PowderSnow => CameraFluid::PowderSnow,
FogType::Atmospheric => CameraFluid::None,
}
}
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
let [old, new] = self.fov_modifier;
let mut fov = self.settings.fov * (old + (new - old) * partial);
if let Some(p) = &self.player
&& p.health.dead_or_dying()
{
let ticks = self
.ui
.died_at
.map_or(0.0, |t| t.elapsed().as_secs_f32() * 20.0);
let duration = (ticks + partial).min(20.0);
fov /= (1.0 - 500.0 / (duration + 500.0)) * 2.0 + 1.0;
self.view.calculate_fov(
partial,
self.settings.fov as i32,
self.fov_effect_scale(),
self.death_time(),
self.camera_fluid(camera),
false,
)
}
/// The pose `GameRenderer.renderLevel` multiplies into the projection:
/// `bobHurt`, then `bobView` with the View Bobbing option.
pub(super) fn bob_matrix(&self, partial: f32) -> glam::Mat4 {
let Some(p) = self.player.as_ref() else {
return glam::Mat4::IDENTITY;
};
let hurt = &p.health.hurt;
let state = BobState {
living: Some(HurtBob {
hurt_time: hurt.hurt_time as f32 - partial,
hurt_duration: hurt.hurt_duration,
hurt_dir: 0.0,
death_time: self.death_time().map(|t| t as f32 + partial),
}),
player: Some((
self.avatar.backwards_interpolated_walk_distance(partial),
self.avatar.interpolated_bob(partial),
)),
};
let tilt = self.settings.file.double("damageTiltStrength");
glam::Mat4::from_cols_array(&bob_matrix(&state, tilt, self.settings.view_bobbing).m)
}
/// `AbstractClientPlayer.tick` and `aiStep` (`ClientAvatarState.tick`,
/// `updateBob`), `rideTick`'s `resetBob` and `LocalPlayer.move`'s walked
/// distance, after the player's movement this tick. `before` is the
/// ground state, velocity and position at the start of the tick.
pub(super) fn tick_avatar(&mut self, before: (bool, [f64; 3], [f64; 3]), riding: bool) {
let Some(p) = self.player.as_ref() else {
return;
};
let (ground, velocity, start) = before;
let swimming = p.pose == horizon_client::pose::Pose::Swimming;
let alive = !p.health.dead_or_dying();
let (dx, dz) = (
(p.position.x - start[0]) as f32,
(p.position.z - start[2]) as f32,
);
self.avatar.tick();
self.avatar
.update_bob(ground && alive && !swimming, velocity);
if riding {
self.avatar.reset_bob();
} else {
self.avatar.add_walked(dx, dz);
}
if matches!(
fog_type(&self.world, &self.res.facts, camera, self.forward()),
FogType::Water | FogType::Lava
) {
fov *= 1.0 + (0.857_142_87 - 1.0) * FOV_EFFECT_SCALE;
}
fov
}
/// `SpyglassItem.use`: start scoping.
pub(super) fn use_spyglass(&mut self) {
@@ -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]);
}
}
+4 -1
View File
@@ -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
+11 -28
View File
@@ -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)
}
}
+13 -12
View File
@@ -176,13 +176,9 @@ impl Game<'_> {
self.settings.save(&Self::options_path());
self.apply_ui_settings();
}
/// Frame limiter for the Max Framerate option: when the next frame is due.
pub(super) fn next_frame_due(&self) -> Option<std::time::Instant> {
if self.options.frames.is_some() {
return None;
}
// FramerateLimitTracker.getFramerateLimit: the option, throttled when
// minimised, idle, or in a menu without a world.
/// `FramerateLimitTracker.getFramerateLimit`: the Max Framerate option,
/// throttled when minimised, idle, or in a menu without a world.
pub(super) fn framerate_limit(&self) -> i32 {
let context = FrameContext {
inactivity: if self.settings.inactivity_fps_limit == "minimized" {
InactivityFpsLimit::Minimized
@@ -201,11 +197,16 @@ impl Game<'_> {
has_screen: self.title.is_some() || self.screen != super::Screen::Playing,
has_overlay: false,
};
let fps = framerate_limit(self.settings.max_fps as i32, context);
if fps >= 260 {
return None;
framerate_limit(self.settings.max_fps as i32, context)
}
/// `Minecraft.runTick`'s frame limiter: `FramerateLimiter.limitDisplayFPS`
/// below 260 frames per second, sleeping then spin-waiting until the
/// frame's slot ends (not in scripted frame-count runs).
pub(super) fn limit_frame(&mut self) {
if self.options.frames.is_some() {
return;
}
let due = self.previous + std::time::Duration::from_secs_f64(1.0 / f64::from(fps));
(due > std::time::Instant::now()).then_some(due)
let limit = self.framerate_limit();
horizon_client::window::end_frame(&mut self.limiter, limit, &mut self.frame_clock);
}
}
+182 -29
View File
@@ -8,8 +8,100 @@ use horizon_client::{
mobs::{Anim, Brain, Mob, MobType},
pose::Pose,
};
use horizon_core::geometry::Vec3;
use std::{path::Path, sync::Arc};
use horizon_client::{
blocks::BlockFacts,
camera::{CameraEntity, LivingCamera},
collisions::SceneCollisions,
};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
position::BlockPos,
};
use horizon_world::{
collision_context::{CollisionContext, CollisionEntity, CollisionItem},
entity_obstruction::EntityIdentity,
registry::BlockStateId,
world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world},
};
use std::{io, path::Path, sync::Arc};
/// `Level.clip` with `ClipContext.Block.VISUAL` and no fluids:
/// `BlockBehaviour.getVisualShape` is the collision shape except for the
/// blocks that override it.
struct VisualRays<'a, 'r> {
scene: SceneCollisions<'a, 'r>,
facts: &'a BlockFacts,
context: CollisionContext<'static>,
}
impl RayWorld for VisualRays<'_, '_> {
type Error = io::Error;
type Block = BlockStateId;
type Fluid = ();
fn block_state(&mut self, p: BlockPos) -> io::Result<BlockStateId> {
Ok(self.scene.world.block(p.x, p.y, p.z))
}
fn fluid_state(&mut self, _: BlockPos) -> io::Result<()> {
Ok(())
}
fn block_shape(
&mut self,
state: BlockStateId,
p: BlockPos,
_: RayBlockMode,
) -> io::Result<VoxelShape> {
let facts = self.facts.get(state);
let name = &*facts.name;
// TransparentBlock (glass, stained and tinted glass, copper grates),
// IronBarsBlock (bars and panes) and PowderSnowBlock: nothing;
// SoulSandBlock and MudBlock: a full block; SnowLayerBlock and
// FenceBlock: their outline.
if name.ends_with("glass")
|| name.ends_with("glass_pane")
|| name.ends_with("_bars")
|| name.ends_with("copper_grate")
|| name == "minecraft:powder_snow"
{
return Ok(VoxelShape::empty());
}
if name == "minecraft:soul_sand" || name == "minecraft:mud" {
return Ok(VoxelShape::block());
}
if name == "minecraft:snow" || name.ends_with("_fence") {
let mut shape = VoxelShape::empty();
for b in facts.outline.iter() {
let b = b.map(f64::from);
let part = VoxelShape::from_box(Aabb::new(
Vec3::new(b[0], b[1], b[2]),
Vec3::new(b[3], b[4], b[5]),
))
.map_err(|e| io::Error::other(format!("{e:?}")))?;
shape = shape
.join(&part, ShapeOp::OR)
.map_err(|e| io::Error::other(format!("{e:?}")))?;
}
return Ok(shape);
}
let binding = self.scene.binding;
binding
.collision_shape_with_context(&mut self.scene, state, p, &self.context)
.map_err(|e| io::Error::other(format!("{e:?}")))
}
fn interaction_shape(&mut self, _: BlockStateId, _: BlockPos) -> io::Result<VoxelShape> {
Ok(VoxelShape::empty())
}
fn fluid_is_source(&mut self, _: ()) -> io::Result<bool> {
Ok(false)
}
fn fluid_is_empty(&mut self, _: ()) -> io::Result<bool> {
Ok(true)
}
fn fluid_is_water(&mut self, _: ()) -> io::Result<bool> {
Ok(false)
}
fn fluid_shape(&mut self, _: (), _: BlockPos) -> io::Result<VoxelShape> {
Ok(VoxelShape::empty())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum CameraType {
@@ -95,34 +187,95 @@ impl Game<'_> {
pub(super) fn cycle_camera(&mut self) {
self.camera_type = self.camera_type.cycle();
}
/// `Camera.setup`: the eye, or a point up to four blocks behind (or in
/// front of) it, pulled in where blocks are in the way. Returns the
/// `Camera.update` (`alignWithEntity` and `getMaxZoom`): the
/// interpolated eye, or a point up to the camera distance behind (or in
/// front of) it, pulled in where visual shapes are in the way. Returns the
/// position, yaw and pitch to render from.
pub(super) fn camera_view(&self, eye: glam::DVec3) -> (glam::DVec3, f32, f32) {
let (mut yaw, mut pitch) = (self.yaw, self.pitch);
if self.camera_type.first_person() || self.player.is_none() || self.sleeping.is_some() {
return (eye, yaw, pitch);
}
if self.camera_type == CameraType::ThirdPersonFront {
yaw += 180.0;
pitch = -pitch;
}
let f = super::view_vector(yaw, pitch);
let mut dist = 4.0f64;
for i in 0..8 {
let o = [
f64::from((i & 1) * 2 - 1) * 0.1,
f64::from((i >> 1 & 1) * 2 - 1) * 0.1,
f64::from((i >> 2 & 1) * 2 - 1) * 0.1,
];
let from = [eye.x + o[0], eye.y + o[1], eye.z + o[2]];
let back = [-f[0], -f[1], -f[2]];
if let Some(hit) = self.clip_ray(from, back, dist) {
let d = glam::DVec3::from_array(hit.point) - eye;
dist = dist.min(d.length());
}
}
(eye - glam::DVec3::from_array(f) * dist, yaw, pitch)
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
let (scale, distance) = {
let p = self.player.as_mut()?;
(
p.attribute("minecraft:scale").unwrap_or(1.0) as f32,
p.attribute("minecraft:camera_distance").unwrap_or(4.0),
)
};
let p = self.player.as_ref()?;
let sleeping = self.sleeping.map(|(head, _)| {
let state = self.world.block(head[0], head[1], head[2]);
let props = self.res.assets.registry.state(state).map(|s| &s.properties);
// BedBlock.getBedOrientation as Direction.toYRot.
props.and_then(|props| match props.get("facing").map(String::as_str) {
Some("south") => Some(0.0),
Some("west") => Some(90.0),
Some("north") => Some(180.0),
Some("east") => Some(270.0),
_ => None,
})
});
// A living mount at the default scale and camera distance.
let mount = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|_| (1.0, 4.0));
let entity = CameraEntity {
position: [p.position.x, p.position.y, p.position.z],
previous: [
self.previous_position.x,
self.previous_position.y,
self.previous_position.z,
],
// Entity.turn moves the old rotation with the new: the mouse
// turns the view every frame without interpolation.
x_rot: self.pitch,
y_rot: self.yaw,
x_rot_o: self.pitch,
y_rot_o: self.yaw,
eye_height: p.dimensions.eye_height,
living: Some(LivingCamera {
scale,
camera_distance: distance,
sleeping,
dead: None,
}),
mount,
};
let mut view = self.view;
let kind = match self.camera_type {
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
CameraType::ThirdPersonFront => horizon_client::camera::CameraType::ThirdPersonFront,
};
let context = CollisionContext::for_entity(
CollisionEntity {
identity: EntityIdentity(0),
y: p.position.y,
descending: p.shift_key_down,
main_hand: Some(CollisionItem::EMPTY),
},
false,
);
let mut rays = VisualRays {
scene: SceneCollisions {
world: &self.world,
binding: self.collision,
},
facts: &self.res.facts,
context,
};
view.align_with_entity(&entity, partial, kind, |from, to| {
let hit = clip_world(
&mut rays,
Vec3::new(from[0], from[1], from[2]),
Vec3::new(to[0], to[1], to[2]),
RayBlockMode::Visual,
RayFluidMode::None,
4096,
)
.ok()?;
(!hit.miss).then(|| [hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
});
self.view = view;
Some((glam::DVec3::from_array(view.position), view.y_rot, view.x_rot))
}
/// Keep the player's model in step with the player (`LivingEntity.tick`
/// body turning, `WalkAnimationState`, the swing and the pose).
+6 -12
View File
@@ -1309,6 +1309,11 @@ impl Game<'_> {
| u8::from(keys.shift) << 5
| u8::from(keys.sprint) << 6;
self.previous_position = player.position;
let before = (
player.ground.on_ground,
[player.velocity.x, player.velocity.y, player.velocity.z],
[player.position.x, player.position.y, player.position.z],
);
player.yaw = self.yaw;
let dead = self.screen == Screen::Dead;
if !dead {
@@ -1337,18 +1342,7 @@ impl Game<'_> {
}
// The server owns health and hunger: undo local food/fall changes.
self.apply_remote_health();
if let Some(p) = &self.player {
let (dx, dz) = (
p.position.x - self.previous_position.x,
p.position.z - self.previous_position.z,
);
let speed = ((dx * dx + dz * dz).sqrt() as f32).min(0.1);
self.walk.0 = self.walk.1;
self.walk.1 += (dx * dx + dz * dz).sqrt() as f32 * 0.6;
self.bob.0 = self.bob.1;
let target = if p.ground.on_ground { speed } else { 0.0 };
self.bob.1 += (target - self.bob.1) * 0.4;
}
self.tick_avatar(before, false);
if !dead {
self.remote_mining();
if self.right && self.use_delay == 0 && playing {
@@ -213,9 +213,12 @@ impl Game<'_> {
match hit {
Some(0) if self.remote.is_some() => {
self.ui.death_text = None;
self.toggles.respawn();
self.remote_respawn();
}
Some(0) => {
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
self.toggles.respawn();
self.respawn_out_of_end()?;
self.player = None;
self.screen = Screen::Playing;
+4 -10
View File
@@ -40,8 +40,9 @@ pub struct Frame<'a> {
pub yaw: f32,
pub pitch: f32,
pub fov_degrees: f32,
/// GameRenderer.bobView: (walk distance, bob amount), interpolated.
pub bob: Option<(f32, f32)>,
/// The `GameRenderer.bobHurt`/`bobView` pose multiplied into the
/// projection (`crate::camera::bob_matrix`).
pub bob: Mat4,
pub sky: Sky,
/// Render distance in blocks (fog end).
pub distance: f32,
@@ -1074,14 +1075,7 @@ impl Renderer {
yaw.cos() * pitch.cos(),
);
let far = (frame.distance * 4.0).max(1024.0);
let bob = frame.bob.map_or(Mat4::IDENTITY, |(walk, bob)| {
let f = -walk * std::f32::consts::PI;
Mat4::from_translation(Vec3::new(f.sin() * bob * 0.5, -(f.cos() * bob).abs(), 0.0))
* Mat4::from_rotation_z((f.sin() * bob * 3.0).to_radians())
* Mat4::from_rotation_x(
((f - 0.2).cos() * bob).abs().mul_add(5.0, 0.0).to_radians(),
)
});
let bob = frame.bob;
Mat4::perspective_rh(frame.fov_degrees.to_radians(), aspect, 0.05, far)
* bob
* Mat4::look_to_rh(Vec3::ZERO, forward, Vec3::Y)
+5
View File
@@ -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();
+599
View File
@@ -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 { .. } => {}
}
}
}
+101
View File
@@ -74,6 +74,10 @@ impl KeyState {
pub fn reset(&mut self) {
self.is_down = false;
}
/// Clicks not yet consumed.
pub fn pending_clicks(&self) -> i32 {
self.click_count
}
/// `shouldRestoreStateOnScreenClosed` (clears the remembered release).
pub fn should_restore_state_on_screen_closed(&mut self, needs_toggle: bool) -> bool {
let Some(restore) = self.toggle else {
@@ -85,6 +89,90 @@ impl KeyState {
}
}
/// A keyboard event (`KeyEvent.Action`): SDL reports a held key's repeats
/// as their own action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyAction {
Release,
Press,
Repeat,
}
/// Sneak.
pub const SNEAK: usize = 0;
/// Sprint.
pub const SPRINT: usize = 1;
/// Attack (mouse).
pub const ATTACK: usize = 2;
/// Use (mouse).
pub const USE: usize = 3;
/// The `ToggleKeyMapping`s `Options` builds: sneak and sprint (keyboard,
/// restored after a screen), attack (mouse, restored) and use (mouse, not
/// restored), and the handlers that drive them. `needs_toggle` is each
/// mapping's toggle option, read at every event as vanilla does.
#[derive(Clone, Debug)]
pub struct ToggleKeys(pub [KeyState; 4]);
impl Default for ToggleKeys {
fn default() -> Self {
Self([
KeyState::toggle(true, true),
KeyState::toggle(true, true),
KeyState::toggle(true, false),
KeyState::toggle(false, false),
])
}
}
impl ToggleKeys {
pub fn is_down(&self, slot: usize) -> bool {
self.0[slot].is_down
}
/// `KeyboardHandler.keyPress` for a key bound to `slot`: a release sets
/// the mapping up (`KeyMapping.set(key, false)`); a press or a repeat
/// with no screen open sets it down and clicks it.
pub fn key(&mut self, slot: usize, action: KeyAction, screen_open: bool, needs_toggle: bool) {
match action {
KeyAction::Release => self.0[slot].set_down(false, needs_toggle),
KeyAction::Press | KeyAction::Repeat if !screen_open => {
self.0[slot].set_down(true, needs_toggle);
self.0[slot].click();
}
_ => {}
}
}
/// `MouseHandler.onButton` for a button bound to `slot`: only without a
/// screen or overlay.
pub fn button(&mut self, slot: usize, pressed: bool, screen_open: bool, needs_toggle: bool) {
if !screen_open {
self.0[slot].set_down(pressed, needs_toggle);
if pressed {
self.0[slot].click();
}
}
}
/// `Gui.setScreen` with a screen: `KeyMapping.releaseAll`.
pub fn screen_opened(&mut self, needs_toggle: [bool; 4]) {
for (key, option) in self.0.iter_mut().zip(needs_toggle) {
key.release(option);
}
}
/// `Gui.setScreen(null)` in a level:
/// `KeyMapping.restoreToggleStatesOnScreenClosed`.
pub fn screen_closed(&mut self, needs_toggle: [bool; 4]) {
for (key, option) in self.0.iter_mut().zip(needs_toggle) {
if key.should_restore_state_on_screen_closed(option) {
key.set_down(true, option);
}
}
}
/// `LocalPlayer.respawn`: `KeyMapping.resetToggleKeys`.
pub fn respawn(&mut self) {
for key in &mut self.0 {
key.reset();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -99,6 +187,19 @@ mod tests {
assert!(!k.is_down);
}
#[test]
fn screens_block_presses_but_not_releases() {
let mut keys = ToggleKeys::default();
keys.key(SNEAK, KeyAction::Press, true, false);
assert!(!keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Press, false, false);
assert!(keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Release, true, false);
assert!(!keys.is_down(SNEAK));
keys.key(SPRINT, KeyAction::Press, false, true);
keys.respawn();
assert!(!keys.is_down(SPRINT));
}
#[test]
fn screen_release_restores_toggled_state() {
let mut k = KeyState::toggle(true, true);
k.set_down(true, true);
+3
View File
@@ -7,6 +7,7 @@ pub mod animate_tick;
pub mod assets;
pub mod attribute_updates;
pub mod audio;
pub mod auto_jump;
pub mod banner;
pub mod biome;
pub mod block_entity_nbt;
@@ -14,6 +15,7 @@ pub mod block_entity_render;
pub mod block_transform;
pub mod blocks;
pub mod bossbar;
pub mod camera;
pub mod brewing;
pub mod cauldron;
pub mod chat;
@@ -41,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;
+83 -2
View File
@@ -147,6 +147,13 @@ pub struct PlayerMotion {
pub flying: bool,
/// The jump key last tick (LocalPlayer.aiStep's wasJumping).
pub was_jumping: bool,
/// `LocalPlayer.autoJumpEnabled` (the Auto-Jump option) and
/// `autoJumpTime`; `was_auto_jump` is this tick's `wasAutoJump`.
pub auto_jump_enabled: bool,
pub auto_jump_time: i32,
pub was_auto_jump: bool,
/// `input.getMoveVector()` this tick (before `modifyInput`).
pub move_vector: [f32; 2],
/// A usable glider (elytra) is worn; the game sets it each tick.
pub glider: bool,
/// The view pitch (xRot), for gliding.
@@ -230,6 +237,10 @@ impl PlayerMotion {
spectator: false,
flying: false,
was_jumping: false,
auto_jump_enabled: false,
auto_jump_time: 0,
was_auto_jump: false,
move_vector: [0.0, 0.0],
glider: false,
pitch: 0.0,
fall_flying: false,
@@ -473,7 +484,7 @@ impl MotionWorld<'_, '_, '_> {
self.player.ground.on_ground = false;
} else if !self.player.may_fly {
self.player.flying = false;
} else if !was_jumping && jumping {
} else if !was_jumping && jumping && !self.player.was_auto_jump {
if self.player.jump_trigger_time == 0 {
self.player.jump_trigger_time = 7;
} else {
@@ -545,9 +556,18 @@ impl MotionWorld<'_, '_, '_> {
}
pub fn controlled_simulation_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
self.update_local_crouching()?;
let mut keys = keys;
// LocalPlayer.aiStep: a pending auto-jump presses jump for this tick
// (input.makeJump) and keeps the jump from toggling flight.
self.player.was_auto_jump = self.player.auto_jump_time > 0;
if self.player.was_auto_jump {
self.player.auto_jump_time -= 1;
keys.jump = true;
}
self.player.sprint_input_tick(keys)?;
self.player.shift_key_down = keys.shift;
let direction = keys.direction();
self.player.move_vector = direction;
let sneak = if direction[0] * direction[0] + direction[1] * direction[1] != 0.0
&& self.player.moving_slowly()
{
@@ -668,8 +688,69 @@ impl MotionWorld<'_, '_, '_> {
let movement = self.player.velocity;
self.move_self(movement)
}
/// `LocalPlayer.move`: the move, then `updateAutoJump` with the float
/// horizontal displacement.
pub fn move_self(&mut self, movement: Vec3) -> Result<()> {
entity_move::move_entity(self, MoverType::SelfMovement, movement)
let before = self.player.position;
entity_move::move_entity(self, MoverType::SelfMovement, movement)?;
let dx = (self.player.position.x - before.x) as f32;
let dz = (self.player.position.z - before.z) as f32;
self.update_auto_jump(dx, dz)
}
/// `LocalPlayer.updateAutoJump` behind `canAutoJump`.
pub fn update_auto_jump(&mut self, xa: f32, za: f32) -> Result<()> {
let p = &*self.player;
// canAutoJump; MotionWorld drives an unmounted player (not a passenger).
let ready = crate::auto_jump::can_auto_jump(
p.auto_jump_enabled,
p.auto_jump_time,
p.ground.on_ground,
p.shift_key_down,
false,
p.move_vector,
1.0,
);
if !ready || f64::from(self.block_jump_factor()?) < 1.0 {
return Ok(());
}
// Player.getSpeed: the movement speed attribute as it is now.
let speed = self.player.attribute("minecraft:movement_speed")? as f32;
let p = &*self.player;
let jumper = crate::auto_jump::AutoJumper {
position: p.position,
max_y: p.bounds().max_position().y,
x_rot: p.pitch,
y_rot: p.yaw,
speed,
move_vector: p.move_vector,
width: p.dimensions.width,
height: p.dimensions.height,
jump_boost: p.status.jump_boost,
};
if crate::auto_jump::update_auto_jump(self, &jumper, xa, za)? {
self.player.auto_jump_time = 1;
}
Ok(())
}
}
impl crate::auto_jump::AutoJumpWorld for MotionWorld<'_, '_, '_> {
type Error = Box<dyn std::error::Error>;
fn collision_shape(&mut self, p: BlockPos) -> Result<horizon_core::geometry::VoxelShape> {
let context = self.context();
let state = self.block(p);
let binding = self.collision_binding;
Ok(binding.collision_shape_with_context(&mut self.collisions(), state, p, &context)?)
}
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>> {
let context = self.context();
let mut scene = self.collisions();
let mut boxes = Vec::new();
for hit in horizon_world::block_collision_scan::BlockCollisionScan::new(
&mut scene, context, bounds, false, 1_000_000,
)? {
boxes.extend_from_slice(hit?.shape.boxes());
}
Ok(boxes)
}
}
// Current resident movement domain: dry, standing, no vehicles or gliding.
+61 -10
View File
@@ -143,16 +143,64 @@ pub trait LimiterClock {
fn park(&mut self, nanos: i64);
/// `Thread.onSpinWait`.
fn spin(&mut self);
/// `Thread.interrupted`: an interrupted limiter skips that sleep.
fn interrupted(&mut self) -> bool {
false
}
}
/// The real clock: `System.nanoTime` from a fixed origin, sleeping for
/// `LockSupport.parkNanos` and a CPU spin hint for `Thread.onSpinWait`.
#[derive(Clone, Copy, Debug)]
pub struct SystemClock {
origin: std::time::Instant,
}
impl Default for SystemClock {
fn default() -> Self {
Self {
origin: std::time::Instant::now(),
}
}
}
impl LimiterClock for SystemClock {
fn nanos(&mut self) -> i64 {
i64::try_from(self.origin.elapsed().as_nanos()).unwrap_or(i64::MAX)
}
fn park(&mut self, nanos: i64) {
std::thread::sleep(std::time::Duration::from_nanos(nanos.max(0) as u64));
}
fn spin(&mut self) {
std::hint::spin_loop();
}
}
/// `Minecraft.runTick`'s frame-limiter step: only limits below 260
/// (the "unlimited" setting) pace frames.
pub fn end_frame(limiter: &mut FramerateLimiter, framerate_limit: i32, clock: &mut impl LimiterClock) {
if framerate_limit < 260 {
limiter.limit_display_fps(framerate_limit, clock);
}
}
impl FramerateLimiter {
pub fn new(now_ns: i64) -> Self {
Self::with_state(now_ns, 0, 0)
}
/// The limiter's static state: `lastFrameTime`, `averageOvershootNs`
/// and `lastFramerateLimit`.
pub fn with_state(last_frame_time: i64, average_overshoot_ns: i64, last_framerate_limit: i32) -> Self {
Self {
last_frame_time: now_ns,
average_overshoot_ns: 0,
last_framerate_limit: 0,
last_frame_time,
average_overshoot_ns,
last_framerate_limit,
}
}
pub fn average_overshoot_ns(&self) -> i64 {
self.average_overshoot_ns
}
pub fn last_framerate_limit(&self) -> i32 {
self.last_framerate_limit
}
pub fn limit_display_fps(&mut self, framerate_limit: i32, clock: &mut impl LimiterClock) {
let target_time_per_frame = 1_000_000_000 / i64::from(framerate_limit);
let target_time_ns = self.last_frame_time + target_time_per_frame;
@@ -168,13 +216,16 @@ impl FramerateLimiter {
if remaining > self.average_overshoot_ns + 500_000 {
let sleep_start = clock.nanos();
let expected = remaining - self.average_overshoot_ns - 500_000;
clock.park(expected);
let duration = clock.nanos() - sleep_start;
let overshoot = duration - expected;
if overshoot > 0 && overshoot < 25_000_000 {
self.average_overshoot_ns =
(0.1 * overshoot as f64 + 0.9 * self.average_overshoot_ns as f64) as i64;
self.average_overshoot_ns = self.average_overshoot_ns.min(2_000_000);
if !clock.interrupted() {
clock.park(expected);
let duration = clock.nanos() - sleep_start;
let overshoot = duration - expected;
if overshoot > 0 && overshoot < 25_000_000 {
self.average_overshoot_ns = (0.1 * overshoot as f64
+ 0.9 * self.average_overshoot_ns as f64)
as i64;
self.average_overshoot_ns = self.average_overshoot_ns.min(2_000_000);
}
}
} else {
clock.spin();
@@ -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 {
+70 -22
View File
@@ -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,
+2 -1
View File
@@ -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,159 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import java.io.*;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import net.minecraft.client.player.ClientInput;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.core.*;
import net.minecraft.world.effect.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.player.Input;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.Property;
import net.minecraft.world.phys.*;
import net.minecraft.world.phys.shapes.VoxelShape;
import static dev.horizonlab.parity.VanillaRegistryCollisionScanOracle.*;
/**
* Runs the real LocalPlayer.updateAutoJump (with canAutoJump, getBlockJumpFactor,
* CollisionGetter.getCollisions/BlockCollisions and the player's collision
* context) on a LocalPlayer shell standing in an independently constructed copy
* of the client's resident test scene, with each case's blocks placed.
*/
public final class VanillaAutoJumpOracle {
static Object unsafe;
static Method allocate;
@SuppressWarnings("unchecked")
static <T> T make(Class<T> type) throws Exception {
return (T) allocate.invoke(unsafe, type);
}
static Field field(Class<?> type, String name) throws Exception {
Field f = type.getDeclaredField(name);
f.setAccessible(true);
return f;
}
static float f(JsonElement v) {
return Float.intBitsToFloat(Integer.parseUnsignedInt(v.getAsString(), 16));
}
static double d(JsonElement v) {
return Double.longBitsToDouble(Long.parseUnsignedLong(v.getAsString(), 16));
}
static Map<Long, Chunk> chunks;
static final class AutoLevel extends net.minecraft.server.level.ServerLevel {
AutoLevel() {
super(null, null, null, null, null, null, false, 0L, List.of(), false);
}
@Override public BlockState getBlockState(BlockPos p) { return chunks.get(key(p.getX() >> 4, p.getZ() >> 4)).getBlockState(p); }
@Override public BlockGetter getChunkForCollisions(int x, int z) { return chunks.get(key(x, z)); }
@Override public List<VoxelShape> getEntityCollisions(Entity entity, AABB bounds) { return List.of(); }
}
static final class Jumper extends LocalPlayer {
boolean ground;
net.minecraft.world.entity.ai.attributes.AttributeMap attributes;
Jumper() {
super(null, null, null, null, null, Input.EMPTY, false, null, null);
}
@Override public boolean onGround() { return ground; }
@Override public net.minecraft.world.entity.ai.attributes.AttributeMap getAttributes() { return attributes; }
@Override public ItemStack getMainHandItem() { return ItemStack.EMPTY; }
@Override public ItemStack getItemBySlot(EquipmentSlot slot) { return ItemStack.EMPTY; }
}
static <T extends Comparable<T>> BlockState with(BlockState state, Property<T> key, String value) {
return state.setValue(key, key.getValue(value).orElseThrow());
}
public static void main(String[] args) throws Exception {
net.minecraft.SharedConstants.tryDetectVersion();
net.minecraft.server.Bootstrap.bootStrap();
var u = Class.forName("sun.misc.Unsafe");
unsafe = field(u, "theUnsafe").get(null);
allocate = u.getMethod("allocateInstance", Class.class);
Path resources = Path.of(args[2]);
VanillaHeightmapOracle.bindTags(JsonParser.parseString(Files.readString(resources.resolve("block-tags.json"))).getAsJsonObject());
var description = JsonParser.parseString(Files.readString(Path.of(args[3]))).getAsJsonObject();
var trace = new Trace();
trace.out = new PrintWriter(OutputStream.nullOutputStream());
trace.fail = -1;
chunks = VanillaSceneCollisionOracle.scene(description, trace);
AutoLevel level = make(AutoLevel.class);
var update = LocalPlayer.class.getDeclaredMethod("updateAutoJump", float.class, float.class);
update.setAccessible(true);
var enabled = field(LocalPlayer.class, "autoJumpEnabled");
var time = field(LocalPlayer.class, "autoJumpTime");
var moveVector = field(ClientInput.class, "moveVector");
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
var lines = Files.lines(Path.of(args[0]))) {
int n = 0;
for (var line : (Iterable<String>) lines::iterator) {
var c = JsonParser.parseString(line).getAsJsonObject();
out.println("== " + n++);
var restore = new ArrayList<Object[]>();
for (var e : c.getAsJsonArray("blocks")) {
var b = e.getAsJsonArray();
var pos = new BlockPos(b.get(0).getAsInt(), b.get(1).getAsInt(), b.get(2).getAsInt());
BlockState state = VanillaSceneCollisionOracle.state(b.get(3).getAsString());
for (var p : b.get(4).getAsJsonObject().entrySet()) {
state = with(state, Objects.requireNonNull(state.getBlock().getStateDefinition().getProperty(p.getKey())), p.getValue().getAsString());
}
restore.add(new Object[] {pos, level.getBlockState(pos)});
VanillaSceneCollisionOracle.put(chunks, pos.getX(), pos.getY(), pos.getZ(), state);
}
var p = c.getAsJsonObject("player");
Jumper player = make(Jumper.class);
field(Entity.class, "level").set(player, level);
var position = new Vec3(d(p.getAsJsonArray("pos").get(0)), d(p.getAsJsonArray("pos").get(1)), d(p.getAsJsonArray("pos").get(2)));
field(Entity.class, "position").set(player, position);
field(Entity.class, "blockPosition").set(player, BlockPos.containing(position));
player.mainSupportingBlockPos = Optional.empty();
var dimensions = EntityDimensions.scalable(f(p.get("width")), f(p.get("height")));
field(Entity.class, "dimensions").set(player, dimensions);
player.setBoundingBox(dimensions.makeBoundingBox(position));
player.ground = p.get("ground").getAsBoolean();
field(Entity.class, "yRot").setFloat(player, f(p.get("yr")));
field(Entity.class, "xRot").setFloat(player, f(p.get("xr")));
// Player.getSpeed reads the movement speed attribute.
player.attributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(Player.createAttributes().build());
player.attributes.getInstance(net.minecraft.world.entity.ai.attributes.Attributes.MOVEMENT_SPEED).setBaseValue(f(p.get("speed")));
var input = new ClientInput();
boolean shift = p.get("shift").getAsBoolean();
input.keyPresses = new Input(false, false, false, false, false, shift, false);
var move = p.getAsJsonArray("move");
moveVector.set(input, new Vec2(f(move.get(0)), f(move.get(1))));
player.input = input;
var effects = new HashMap<net.minecraft.core.Holder<MobEffect>, MobEffectInstance>();
if (!p.get("boost").isJsonNull()) {
effects.put(MobEffects.JUMP_BOOST, new MobEffectInstance(MobEffects.JUMP_BOOST, 100, p.get("boost").getAsInt()));
}
field(LivingEntity.class, "activeEffects").set(player, effects);
enabled.setBoolean(player, p.get("enabled").getAsBoolean());
time.setInt(player, p.get("time").getAsInt());
update.invoke(player, f(c.get("xa")), f(c.get("za")));
out.println("jump " + time.getInt(player));
Collections.reverse(restore);
for (var r : restore) {
var pos = (BlockPos) r[0];
VanillaSceneCollisionOracle.put(chunks, pos.getX(), pos.getY(), pos.getZ(), (BlockState) r[1]);
}
}
}
System.exit(0);
}
}
@@ -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);
}
}
@@ -0,0 +1,419 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import com.mojang.blaze3d.vertex.PoseStack;
import java.io.*;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import net.minecraft.client.*;
import net.minecraft.client.entity.ClientAvatarState;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.client.renderer.state.GameRenderState;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.core.*;
import net.minecraft.util.Mth;
import net.minecraft.world.attribute.EnvironmentAttributeProbe;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.*;
import net.minecraft.world.phys.*;
import net.minecraft.world.phys.shapes.*;
import org.joml.Matrix4f;
/**
* Drives the real Camera (tick, tickFov, alignWithEntity with getMaxZoom over
* Level.clip, calculateFov, calculateHudFov), AbstractClientPlayer
* .getFieldOfViewModifier and updateBob, ClientAvatarState and
* GameRenderer.bobHurt/bobView with scripted entity states, and prints every
* observable bit-exactly. Entities, the level and the renderer are
* uninitialised shells (Unsafe) with controlled queries; block visual shapes
* come from the case.
*/
public final class VanillaCameraOracle {
static Object unsafe;
static Method allocate;
@SuppressWarnings("unchecked")
static <T> T make(Class<T> type) throws Exception {
return (T) allocate.invoke(unsafe, type);
}
static Field field(Class<?> type, String name) throws Exception {
Field f = type.getDeclaredField(name);
f.setAccessible(true);
return f;
}
static Method method(Class<?> type, String name, Class<?>... args) throws Exception {
Method m = type.getDeclaredMethod(name, args);
m.setAccessible(true);
return m;
}
static float f(JsonElement v) {
return Float.intBitsToFloat(Integer.parseUnsignedInt(v.getAsString(), 16));
}
static double d(JsonElement v) {
return Double.longBitsToDouble(Long.parseUnsignedLong(v.getAsString(), 16));
}
static String fb(float v) {
return String.format("%08x", Float.isNaN(v) ? 0x7fc00000 : Float.floatToRawIntBits(v));
}
static String db(double v) {
return String.format("%016x", Double.isNaN(v) ? 0x7ff8000000000000L : Double.doubleToRawLongBits(v));
}
static Vec3 vec(JsonElement v) {
var a = v.getAsJsonArray();
return new Vec3(d(a.get(0)), d(a.get(1)), d(a.get(2)));
}
// ---- level with controlled visual shapes ----------------------------------------------
static final class State extends BlockState {
VoxelShape visual;
State() {
super(null, null, null);
}
@Override public VoxelShape getVisualShape(BlockGetter w, BlockPos p, CollisionContext c) { return visual; }
@Override public VoxelShape getInteractionShape(BlockGetter w, BlockPos p) { return Shapes.empty(); }
}
static final class Level extends net.minecraft.server.level.ServerLevel {
Map<BlockPos, State> cells = new HashMap<>();
State air;
Level() {
super(null, null, null, null, null, null, false, 0L, List.of(), false);
}
@Override public BlockState getBlockState(BlockPos p) { return cells.getOrDefault(p, air); }
@Override public FluidState getFluidState(BlockPos p) { return Fluids.EMPTY.defaultFluidState(); }
}
static final class NoProbe extends EnvironmentAttributeProbe {
@Override public void tick(net.minecraft.world.level.Level level, Vec3 position) { }
}
static final class StubCamera extends Camera {
net.minecraft.world.level.material.FogType fluid = net.minecraft.world.level.material.FogType.NONE;
@Override public net.minecraft.world.level.material.FogType getFluidInCamera() { return fluid; }
}
// ---- camera entities ----------------------------------------------------------------
static final class StubPlayer extends LocalPlayer {
AttributeMap attributes;
boolean using, dead, sleeping, swimming, ground;
ItemStack useItem = ItemStack.EMPTY;
int useTicks;
Direction bed;
Vec3 motion = Vec3.ZERO;
StubPlayer() {
super(null, null, null, null, null, Input.EMPTY, false, null, null);
}
@Override public AttributeMap getAttributes() { return attributes; }
@Override public boolean isUsingItem() { return using; }
@Override public ItemStack getUseItem() { return useItem; }
@Override public int getTicksUsingItem() { return useTicks; }
@Override public boolean isDeadOrDying() { return dead; }
@Override public boolean isSleeping() { return sleeping; }
@Override public Direction getBedOrientation() { return bed; }
@Override public boolean isDescending() { return false; }
@Override public ItemStack getMainHandItem() { return ItemStack.EMPTY; }
@Override public boolean isSwimming() { return swimming; }
@Override public boolean onGround() { return ground; }
@Override public Vec3 getDeltaMovement() { return motion; }
@Override public boolean isSpectator() { return false; }
}
static final class StubMarker extends Marker {
StubMarker() {
super(null, null);
}
@Override public boolean isDescending() { return false; }
}
static Minecraft shell;
static Options options;
static Field position, xRot, yRot, eyeHeight, vehicle, abilities;
static Constructor<ItemStack> stack;
public static void main(String[] args) throws Exception {
net.minecraft.SharedConstants.tryDetectVersion();
net.minecraft.server.Bootstrap.bootStrap();
var u = Class.forName("sun.misc.Unsafe");
unsafe = field(u, "theUnsafe").get(null);
allocate = u.getMethod("allocateInstance", Class.class);
shell = make(Minecraft.class);
field(Minecraft.class, "fixerUpper").set(shell, net.minecraft.util.datafix.DataFixers.getDataFixer());
field(Minecraft.class, "instance").set(null, shell);
options = new Options(shell, Files.createTempDirectory("opts").toFile());
field(Minecraft.class, "options").set(shell, options);
position = field(Entity.class, "position");
xRot = field(Entity.class, "xRot");
yRot = field(Entity.class, "yRot");
eyeHeight = field(Entity.class, "eyeHeight");
vehicle = field(Entity.class, "vehicle");
abilities = field(Player.class, "abilities");
stack = ItemStack.class.getDeclaredConstructor(Holder.class, int.class, net.minecraft.core.component.PatchedDataComponentMap.class);
stack.setAccessible(true);
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
var lines = Files.lines(Path.of(args[0]))) {
int n = 0;
for (var line : (Iterable<String>) lines::iterator) {
var c = JsonParser.parseString(line).getAsJsonObject();
out.println("== " + n++);
switch (c.get("t").getAsString()) {
case "fov" -> out.println(fb(player(c).getFieldOfViewModifier(c.get("fp").getAsBoolean(), f(c.get("scale")))));
case "camera" -> camera(c, out);
case "bob" -> bob(c, out);
case "avatar" -> avatar(c, out);
default -> throw new IllegalArgumentException(line);
}
}
}
System.exit(0);
}
static AttributeMap attributes(double scale, double distance, double speed) {
var map = new AttributeMap(Player.createAttributes().build());
map.getInstance(Attributes.SCALE).setBaseValue(scale);
map.getInstance(Attributes.CAMERA_DISTANCE).setBaseValue(distance);
map.getInstance(Attributes.MOVEMENT_SPEED).setBaseValue(speed);
return map;
}
/** A player shell from the "fov" fields of a case or an entity op. */
static StubPlayer player(JsonObject c) throws Exception {
StubPlayer p = make(StubPlayer.class);
fill(p, c);
return p;
}
static void fill(StubPlayer p, JsonObject c) throws Exception {
var a = new Abilities();
a.flying = c.get("flying").getAsBoolean();
a.setWalkingSpeed(f(c.get("ws")));
abilities.set(p, a);
double scale = c.has("scale_attr") ? d(c.get("scale_attr")) : 1.0;
double distance = c.has("dist") ? d(c.get("dist")) : 4.0;
p.attributes = attributes(scale, distance, d(c.get("speed")));
var use = c.get("use");
p.using = !use.isJsonNull();
if (p.using) {
var u = use.getAsJsonArray();
Item item = switch (u.get(0).getAsString()) {
case "bow" -> Items.BOW;
case "spyglass" -> Items.SPYGLASS;
default -> Items.STICK;
};
p.useItem = stack.newInstance(Holder.direct(item), 1,
new net.minecraft.core.component.PatchedDataComponentMap(net.minecraft.core.component.DataComponentMap.EMPTY));
p.useTicks = u.get(1).getAsInt();
}
}
static void camera(JsonObject c, PrintWriter out) throws Exception {
var level = make(Level.class);
level.cells = new HashMap<>();
level.air = make(State.class);
level.air.visual = Shapes.empty();
for (var e : c.getAsJsonObject("cells").entrySet()) {
var at = e.getKey().split(" ");
var s = make(State.class);
VoxelShape shape = Shapes.empty();
for (var b : e.getValue().getAsJsonArray()) {
var a = b.getAsJsonArray();
shape = Shapes.or(shape, Shapes.box(d(a.get(0)), d(a.get(1)), d(a.get(2)), d(a.get(3)), d(a.get(4)), d(a.get(5))));
}
s.visual = shape;
level.cells.put(new BlockPos(Integer.parseInt(at[0]), Integer.parseInt(at[1]), Integer.parseInt(at[2])), s);
}
options.fov().set(c.get("fov").getAsInt());
options.fovEffectScale().set(d(c.get("effect")));
options.setCameraType(CameraType.values()[c.get("type").getAsInt()]);
var camera = new StubCamera();
field(Camera.class, "attributeProbe").set(camera, new NoProbe());
field(Camera.class, "level").set(camera, level);
GameRenderer renderer = make(GameRenderer.class);
field(GameRenderer.class, "mainCamera").set(renderer, camera);
field(Minecraft.class, "gameRenderer").set(shell, renderer);
var align = method(Camera.class, "alignWithEntity", float.class);
var fov = method(Camera.class, "calculateFov", float.class);
var hud = method(Camera.class, "calculateHudFov", float.class);
var panoramic = field(Camera.class, "isPanoramicMode");
var eye = field(Camera.class, "eyeHeight");
var eyeOld = field(Camera.class, "eyeHeightOld");
var mod = field(Camera.class, "fovModifier");
var modOld = field(Camera.class, "oldFovModifier");
Entity entity = null;
for (var e : c.getAsJsonArray("ops")) {
var op = e.getAsJsonArray();
switch (op.get(0).getAsString()) {
case "entity" -> {
var s = op.get(1).getAsJsonObject();
entity = entity(s, entity);
camera.setEntity(entity);
out.println("entity");
}
case "type" -> {
options.setCameraType(CameraType.values()[op.get(1).getAsInt()]);
out.println("type");
}
case "tick" -> {
camera.tick();
out.println("tick " + fb(eye.getFloat(camera)) + " " + fb(eyeOld.getFloat(camera)) + " "
+ fb(mod.getFloat(camera)) + " " + fb(modOld.getFloat(camera)));
}
case "frame" -> {
float pt = f(op.get(1));
camera.fluid = net.minecraft.world.level.material.FogType.valueOf(
new String[] {"NONE", "WATER", "LAVA", "POWDER_SNOW"}[op.get(2).getAsInt()]);
panoramic.setBoolean(camera, op.get(3).getAsBoolean());
align.invoke(camera, pt);
float fv = (float) fov.invoke(camera, pt);
float hv = (float) hud.invoke(camera, pt);
var p = camera.position();
var q = camera.rotation();
var fw = camera.forwardVector();
var up = camera.upVector();
var left = camera.leftVector();
out.println("frame " + db(p.x) + " " + db(p.y) + " " + db(p.z) + " " + fb(camera.xRot()) + " " + fb(camera.yRot())
+ " " + fb(q.x) + " " + fb(q.y) + " " + fb(q.z) + " " + fb(q.w)
+ " " + fb(fw.x()) + " " + fb(fw.y()) + " " + fb(fw.z())
+ " " + fb(up.x()) + " " + fb(up.y()) + " " + fb(up.z())
+ " " + fb(left.x()) + " " + fb(left.y()) + " " + fb(left.z())
+ " " + camera.isDetached() + " " + fb(fv) + " " + fb(hv));
}
default -> throw new IllegalArgumentException(op.toString());
}
}
}
/** Applies an entity op: a player (with fov fields) or a non-living marker. */
static Entity entity(JsonObject s, Entity previous) throws Exception {
Entity entity;
if (s.get("kind").getAsString().equals("player")) {
StubPlayer p = previous instanceof StubPlayer old ? old : make(StubPlayer.class);
fill(p, s);
p.dead = !s.get("dead").isJsonNull();
if (p.dead) {
p.deathTime = s.get("dead").getAsInt();
}
p.sleeping = !s.get("sleeping").isJsonNull();
p.bed = null;
if (p.sleeping && !s.get("sleeping").getAsJsonArray().get(0).isJsonNull()) {
p.bed = Direction.from2DDataValue(s.get("sleeping").getAsJsonArray().get(0).getAsInt());
}
var mount = s.get("mount");
if (mount.isJsonNull()) {
vehicle.set(p, null);
} else {
StubPlayer m = make(StubPlayer.class);
var a = mount.getAsJsonArray();
m.attributes = attributes(d(a.get(0)), d(a.get(1)), 0.1);
vehicle.set(p, m);
}
entity = p;
} else {
entity = previous instanceof StubMarker old ? old : make(StubMarker.class);
}
position.set(entity, vec(s.get("pos")));
var prev = vec(s.get("prev"));
entity.xo = prev.x;
entity.yo = prev.y;
entity.zo = prev.z;
xRot.setFloat(entity, f(s.get("xr")));
yRot.setFloat(entity, f(s.get("yr")));
entity.xRotO = f(s.get("xro"));
entity.yRotO = f(s.get("yro"));
eyeHeight.setFloat(entity, f(s.get("eye")));
return entity;
}
static void bob(JsonObject c, PrintWriter out) throws Exception {
GameRenderer renderer = make(GameRenderer.class);
var state = new GameRenderState();
field(GameRenderer.class, "gameRenderState").set(renderer, state);
state.optionsRenderState.damageTiltStrength = d(c.get("tilt"));
state.optionsRenderState.bobView = c.get("view").getAsBoolean();
var camera = new CameraRenderState();
var es = camera.entityRenderState;
var living = c.get("living");
es.isLiving = !living.isJsonNull();
if (es.isLiving) {
var l = living.getAsJsonObject();
es.hurtTime = f(l.get("hurt"));
es.hurtDuration = l.get("duration").getAsInt();
es.hurtDir = f(l.get("dir"));
es.isDeadOrDying = !l.get("death").isJsonNull();
if (es.isDeadOrDying) {
es.deathTime = f(l.get("death"));
}
}
var player = c.get("player");
es.isPlayer = !player.isJsonNull();
if (es.isPlayer) {
es.backwardsInterpolatedWalkDistance = f(player.getAsJsonArray().get(0));
es.bob = f(player.getAsJsonArray().get(1));
}
var stack = new PoseStack();
method(GameRenderer.class, "bobHurt", CameraRenderState.class, PoseStack.class).invoke(renderer, camera, stack);
if (state.optionsRenderState.bobView) {
method(GameRenderer.class, "bobView", CameraRenderState.class, PoseStack.class).invoke(renderer, camera, stack);
}
Matrix4f m = stack.last().pose();
var sb = new StringBuilder("bob");
for (int col = 0; col < 4; col++) {
for (int row = 0; row < 4; row++) {
sb.append(' ').append(fb(m.get(col, row)));
}
}
sb.append(' ').append(m.properties());
out.println(sb);
}
static void avatar(JsonObject c, PrintWriter out) throws Exception {
StubPlayer p = make(StubPlayer.class);
var state = new ClientAvatarState();
field(net.minecraft.client.player.AbstractClientPlayer.class, "clientAvatarState").set(p, state);
var updateBob = method(net.minecraft.client.player.AbstractClientPlayer.class, "updateBob");
for (var e : c.getAsJsonArray("ops")) {
var op = e.getAsJsonArray();
switch (op.get(0).getAsString()) {
case "tick" -> state.tick(Vec3.ZERO, Vec3.ZERO);
case "move" -> state.addWalkDistance(Mth.length(f(op.get(1)), f(op.get(2))) * 0.6F);
case "bob" -> {
p.ground = op.get(1).getAsBoolean();
p.dead = op.get(2).getAsBoolean();
p.swimming = op.get(3).getAsBoolean();
p.motion = vec(op.get(4));
updateBob.invoke(p);
}
case "reset" -> state.resetBob();
case "frame" -> {
float pt = f(op.get(1));
out.println("frame " + fb(state.getInterpolatedBob(pt)) + " " + fb(state.getBackwardsInterpolatedWalkDistance(pt)));
continue;
}
default -> throw new IllegalArgumentException(op.toString());
}
out.println(op.get(0).getAsString());
}
}
}
@@ -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,171 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import java.io.*;
import java.lang.classfile.*;
import java.lang.classfile.instruction.InvokeInstruction;
import java.lang.constant.ClassDesc;
import java.lang.constant.MethodTypeDesc;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
/**
* Runs the real FramerateLimiter.limitDisplayFPS from the client jar with its
* clock calls (System.nanoTime, LockSupport.parkNanos, Thread.interrupted,
* Thread.onSpinWait) redirected by a ClassFile rewrite to a scripted virtual
* clock, and the Minecraft.runTick condition (limits below 260), printing the
* limiter state and clock interaction of every frame.
*/
public final class VanillaFrameLimiterOracle {
/** The scripted clock the rewritten limiter calls. */
public static final class Clock {
static long now, call, spin;
static long[] overshoot;
static int nextOvershoot;
static boolean[] interrupts;
static int nextInterrupt;
static List<Long> parks = new ArrayList<>();
static long spins, queries;
public static long nanoTime() {
queries++;
long t = now;
now += call;
return t;
}
public static void parkNanos(long nanos) {
parks.add(nanos);
long o = overshoot[nextOvershoot++ % overshoot.length];
now += Math.max(Math.max(nanos, 0) + o, 1);
}
public static void onSpinWait() {
spins++;
now += spin;
}
public static boolean interrupted() {
return interrupts[nextInterrupt++ % interrupts.length];
}
}
static final ClassDesc CLOCK = ClassDesc.of(Clock.class.getName());
/** Loads FramerateLimiter itself, rewritten; everything else from the parent. */
static final class Rewriting extends ClassLoader {
Rewriting(ClassLoader parent) {
super(parent);
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (!name.equals("net.minecraft.client.FramerateLimiter")) {
return super.loadClass(name, resolve);
}
synchronized (getClassLoadingLock(name)) {
Class<?> loaded = findLoadedClass(name);
if (loaded == null) {
try (var in = getParent().getResourceAsStream("net/minecraft/client/FramerateLimiter.class")) {
byte[] bytes = rewrite(in.readAllBytes());
loaded = defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
return loaded;
}
}
}
static int rewrites;
static byte[] rewrite(byte[] bytes) {
var cf = ClassFile.of();
ClassModel model = cf.parse(bytes);
return cf.transformClass(model, ClassTransform.transformingMethodBodies((builder, element) -> {
if (element instanceof InvokeInstruction call && call.opcode() == Opcode.INVOKESTATIC) {
String owner = call.owner().asInternalName();
String name = call.name().stringValue();
String target = switch (owner + "." + name) {
case "java/lang/System.nanoTime" -> "nanoTime";
case "java/util/concurrent/locks/LockSupport.parkNanos" -> "parkNanos";
case "java/lang/Thread.interrupted" -> "interrupted";
case "java/lang/Thread.onSpinWait" -> "onSpinWait";
default -> null;
};
if (target != null) {
rewrites++;
builder.invokestatic(CLOCK, target, MethodTypeDesc.ofDescriptor(call.type().stringValue()));
return;
}
}
builder.with(element);
}));
}
public static void main(String[] args) throws Exception {
Clock.overshoot = new long[] {0};
Clock.interrupts = new boolean[] {false};
Class<?> limiter = Class.forName("net.minecraft.client.FramerateLimiter", true,
new Rewriting(VanillaFrameLimiterOracle.class.getClassLoader()));
// System.nanoTime x4 (and once in the static initialiser), parkNanos, interrupted, onSpinWait.
if (rewrites != 8) {
throw new AssertionError("unexpected clock calls in FramerateLimiter: " + rewrites);
}
Method limit = limiter.getMethod("limitDisplayFPS", int.class);
Field last = limiter.getDeclaredField("lastFrameTime");
Field average = limiter.getDeclaredField("averageOvershootNs");
Field lastLimit = limiter.getDeclaredField("lastFramerateLimit");
for (Field f : List.of(last, average, lastLimit)) {
f.setAccessible(true);
}
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
var lines = Files.lines(Path.of(args[0]))) {
int n = 0;
for (var line : (Iterable<String>) lines::iterator) {
var c = JsonParser.parseString(line).getAsJsonObject();
out.println("== " + n++);
Clock.now = c.get("now").getAsLong();
Clock.call = c.get("call").getAsLong();
Clock.spin = c.get("spin").getAsLong();
var o = c.getAsJsonArray("overshoot");
Clock.overshoot = new long[o.size()];
for (int i = 0; i < o.size(); i++) {
Clock.overshoot[i] = o.get(i).getAsLong();
}
var in = c.getAsJsonArray("interrupts");
Clock.interrupts = new boolean[in.size()];
for (int i = 0; i < in.size(); i++) {
Clock.interrupts[i] = in.get(i).getAsBoolean();
}
Clock.nextOvershoot = 0;
Clock.nextInterrupt = 0;
last.setLong(null, c.get("last").getAsLong());
average.setLong(null, c.get("average").getAsLong());
lastLimit.setInt(null, c.get("last_limit").getAsInt());
for (var e : c.getAsJsonArray("frames")) {
var frame = e.getAsJsonArray();
Clock.now += frame.get(0).getAsLong();
Clock.parks.clear();
Clock.spins = 0;
Clock.queries = 0;
int framerateLimit = frame.get(1).getAsInt();
// Minecraft.runTick: "if (framerateLimit < 260) FramerateLimiter.limitDisplayFPS(framerateLimit)".
if (framerateLimit < 260) {
limit.invoke(null, framerateLimit);
}
var parks = new StringJoiner(",");
for (long p : Clock.parks) {
parks.add(Long.toString(p));
}
out.println("frame " + Clock.now + " " + last.getLong(null) + " " + average.getLong(null) + " "
+ lastLimit.getInt(null) + " queries " + Clock.queries + " spins " + Clock.spins + " parks [" + parks + "]");
}
}
}
System.exit(0);
}
}
@@ -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,102 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import com.mojang.blaze3d.platform.InputConstants;
import java.io.*;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import net.minecraft.client.*;
/**
* Drives the real Options key mappings (keyShift, keySprint, keyAttack and
* keyUse are ToggleKeyMappings reading toggleCrouch/toggleSprint/toggleAttack/
* toggleUse) through the real static KeyMapping.set/click, releaseAll,
* restoreToggleStatesOnScreenClosed and resetToggleKeys, dispatched as
* KeyboardHandler.keyPress, MouseHandler.onButton, Gui.setScreen and
* LocalPlayer.respawn call them, and prints each mapping's down state and
* pending clicks after every event.
*/
public final class VanillaToggleKeysOracle {
static Field field(Class<?> type, String name) throws Exception {
Field f = type.getDeclaredField(name);
f.setAccessible(true);
return f;
}
public static void main(String[] args) throws Exception {
net.minecraft.SharedConstants.tryDetectVersion();
net.minecraft.server.Bootstrap.bootStrap();
var u = Class.forName("sun.misc.Unsafe");
var unsafe = field(u, "theUnsafe").get(null);
Minecraft shell = (Minecraft) u.getMethod("allocateInstance", Class.class).invoke(unsafe, Minecraft.class);
field(Minecraft.class, "fixerUpper").set(shell, net.minecraft.util.datafix.DataFixers.getDataFixer());
field(Minecraft.class, "instance").set(null, shell);
Options options = new Options(shell, Files.createTempDirectory("opts").toFile());
KeyMapping[] mappings = {options.keyShift, options.keySprint, options.keyAttack, options.keyUse};
List<OptionInstance<Boolean>> toggles = List.of(options.toggleCrouch(), options.toggleSprint(), options.toggleAttack(), options.toggleUse());
Field keyField = field(KeyMapping.class, "key");
Field clicks = field(KeyMapping.class, "clickCount");
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
var lines = Files.lines(Path.of(args[0]))) {
int n = 0;
for (var line : (Iterable<String>) lines::iterator) {
var c = JsonParser.parseString(line).getAsJsonObject();
out.println("== " + n++);
// A fresh session: everything up, no pending clicks or remembered releases.
for (int i = 0; i < 4; i++) {
toggles.get(i).set(false);
}
KeyMapping.resetToggleKeys();
KeyMapping.releaseAll();
KeyMapping.restoreToggleStatesOnScreenClosed();
for (var m : mappings) {
while (m.consumeClick()) {
}
}
for (var e : c.getAsJsonArray("ops")) {
var op = e.getAsJsonArray();
String name = op.get(0).getAsString();
switch (name) {
case "option" -> toggles.get(op.get(1).getAsInt()).set(op.get(2).getAsBoolean());
case "key" -> {
// KeyboardHandler.keyPress: action 0 releases; a press (1) or an SDL
// repeat (-1) sets and clicks only with no screen open.
var key = (InputConstants.Key) keyField.get(mappings[op.get(1).getAsInt()]);
int action = op.get(2).getAsInt();
boolean screen = op.get(3).getAsBoolean();
if (action == 0) {
KeyMapping.set(key, false);
} else if (!screen) {
KeyMapping.set(key, true);
KeyMapping.click(key);
}
}
case "button" -> {
// MouseHandler.onButton: only with no screen or overlay.
var key = (InputConstants.Key) keyField.get(mappings[op.get(1).getAsInt()]);
boolean pressed = op.get(2).getAsBoolean();
if (!op.get(3).getAsBoolean()) {
KeyMapping.set(key, pressed);
if (pressed) {
KeyMapping.click(key);
}
}
}
case "open" -> KeyMapping.releaseAll();
case "close" -> KeyMapping.restoreToggleStatesOnScreenClosed();
case "respawn" -> KeyMapping.resetToggleKeys();
default -> throw new IllegalArgumentException(name);
}
var sb = new StringBuilder(name);
for (var m : mappings) {
sb.append(' ').append(m.isDown()).append(' ').append(clicks.getInt(m));
}
out.println(sb);
}
}
}
System.exit(0);
}
}
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Compare auto-jump with the pinned client: the real LocalPlayer.updateAutoJump
(and canAutoJump) on a player standing in an independently built copy of the
client's resident test scene, against MotionWorld::update_auto_jump over the
client's actual resources, collision registry and block-collision scan, for
steps, slabs, stairs, fences, walls, snow, carpets, honey, low ceilings and
overhangs approached from every angle with varied speeds, input vectors,
sizes, jump boost and the canAutoJump preconditions."""
import json
import math
import os
from pathlib import Path
import platform
import random
import struct
import subprocess
try:
from . import lab, oracle_harness as harness, test_rust_parity as base, test_model_baking_parity as baking, \
test_native_client as client
except ImportError:
import lab, oracle_harness as harness, test_rust_parity as base, test_model_baking_parity as baking
import test_native_client as client
def fbits(v):
return struct.pack('>f', v).hex()
def dbits(v):
return struct.pack('>d', v).hex()
BLOCKS = [
('stone', {}), ('stone', {}), ('stone', {}),
('stone_slab', {'type': 'bottom'}), ('stone_slab', {'type': 'top'}), ('stone_slab', {'type': 'double'}),
('oak_stairs', {'facing': 'north', 'half': 'bottom'}), ('oak_stairs', {'facing': 'east', 'half': 'bottom'}),
('oak_stairs', {'facing': 'south', 'half': 'top'}), ('oak_stairs', {'facing': 'west', 'half': 'bottom', 'shape': 'outer_left'}),
('oak_fence', {}), ('oak_fence', {'north': 'true', 'south': 'true'}), ('oak_fence_gate', {}),
('cobblestone_wall', {}), ('cobblestone_wall', {'up': 'true', 'east': 'tall'}),
('snow', {'layers': '1'}), ('snow', {'layers': '4'}), ('snow', {'layers': '8'}),
('white_carpet', {}), ('honey_block', {}), ('soul_sand', {}), ('farmland', {}), ('dirt_path', {}),
('chest', {}), ('enchanting_table', {}), ('oak_trapdoor', {}), ('oak_trapdoor', {'half': 'top'}),
('oak_trapdoor', {'open': 'true', 'facing': 'east'}), ('daylight_detector', {}), ('glass', {}), ('iron_bars', {}),
('lantern', {}), ('cake', {}), ('anvil', {}), ('hopper', {}), ('oak_leaves', {}), ('cobweb', {}), ('ladder', {}),
('red_bed', {'part': 'foot'}),
]
ORIGIN = (20, 4, -4)
def placed(rng, count):
blocks = {}
ox, oy, oz = ORIGIN
for _ in range(count):
x, y, z = ox + rng.randint(-3, 3), oy + rng.choice([0, 0, 0, 1, 1, 2, 3]), oz + rng.randint(-3, 3)
name, props = rng.choice(BLOCKS)
blocks[(x, y, z)] = [x, y, z, 'minecraft:' + name, props]
return list(blocks.values())
DIMS = [(0.6, 1.8), (0.6, 1.8), (0.6, 1.5), (0.6, 0.6), (0.3, 0.9), (1.2, 3.6), (0.6000000238418579, 1.7999999523162842)]
def player(rng, x, y, z, yaw):
w, h = rng.choice(DIMS)
return dict(pos=[dbits(x), dbits(y), dbits(z)], ground=rng.random() < 0.9, shift=rng.random() < 0.08,
yr=fbits(yaw), xr=fbits(rng.choice([0.0, 30.0, -45.0, 90.0, rng.uniform(-90, 90)])),
speed=fbits(rng.choice([0.1, 0.13000001, 0.03, 0.0, 0.2, rng.uniform(0, 0.3)])),
move=[fbits(v) for v in rng.choice([(0.0, 1.0), (0.0, 1.0), (1.0, 0.0), (-1.0, 0.0), (0.70710677, 0.70710677),
(0.0, -1.0), (0.0, 0.0), (0.3, 0.3)])],
width=fbits(w), height=fbits(h), boost=rng.choice([None] * 6 + [0, 1, 4, 255]),
enabled=rng.random() < 0.93, time=rng.choice([0] * 12 + [1, -3, 2]))
def cases(rng):
ox, oy, oz = ORIGIN
# Walk straight at each block from the four sides, at several speeds.
for name, props in BLOCKS:
for yaw, (dx, dz) in [(0.0, (0, 1)), (90.0, (-1, 0)), (180.0, (0, -1)), (-90.0, (1, 0)), (45.0, (-0.7, 0.7))]:
for step in (0.1, 0.215, 0.03, 0.0):
for lift in (0, 1):
blocks = [[ox, oy + lift, oz, 'minecraft:' + name, props]]
if lift:
blocks.append([ox, oy, oz, 'minecraft:stone', {}])
x = ox + 0.5 - dx * 1.1
z = oz + 0.5 - dz * 1.1
p = player(rng, x, float(oy), z, yaw)
p.update(ground=True, shift=False, enabled=True, time=0, boost=None, move=[fbits(0.0), fbits(1.0)],
width=fbits(0.6), height=fbits(1.8), xr=fbits(0.0))
yield dict(blocks=blocks, player=p, xa=fbits(dx * step), za=fbits(dz * step))
# Ceilings above the player and above the step.
for ceiling in range(2, 6):
for step_height in (0, 1):
blocks = [[ox, oy + step_height, oz, 'minecraft:stone', {}], [ox - 1, oy + ceiling, oz, 'minecraft:stone', {}],
[ox, oy + ceiling + step_height, oz, 'minecraft:stone_slab', {'type': 'top'}]]
p = player(rng, ox - 0.5, float(oy), oz + 0.5, -90.0)
p.update(ground=True, shift=False, enabled=True, time=0, boost=None, move=[fbits(0.0), fbits(1.0)],
width=fbits(0.6), height=fbits(1.8))
yield dict(blocks=blocks, player=p, xa=fbits(0.2), za=fbits(0.0))
for _ in range(9000):
blocks = placed(rng, rng.choice([1, 2, 4, 8, 16, 30]))
x = ox + rng.uniform(-2, 3)
z = oz + rng.uniform(-2, 3)
y = float(oy) + rng.choice([0.0, 0.0, 0.0, 0.5, 1.0, 0.0625, 0.9375])
yaw = rng.choice([0.0, 90.0, 180.0, -90.0, 45.0, rng.uniform(-360, 360)])
p = player(rng, x, y, z, yaw)
move = rng.choice([0.0, 0.0, 0.01, 0.1, 0.215, 0.3, rng.uniform(0, 0.4)])
angle = rng.uniform(0, 6.283)
yield dict(blocks=blocks, player=p, xa=fbits(math.cos(angle) * move), za=fbits(math.sin(angle) * move))
def main():
lock, jars, _ = base.verify_artifacts()
libraries = baking.client_libraries()
build = lab.LOCAL / 'rust-auto-jump-parity'
build.mkdir(exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
resources = lab.LOCAL / 'horizon-client' / lock['minecraft']['version']
resources_before = client.resource_fingerprint(resources)
oracle = 'VanillaAutoJumpOracle'
parity = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity'
sources = [Path(__file__).resolve(), lab.ROOT / 'tools/oracle_harness.py', lab.ROOT / 'tools/lab.py',
parity / (oracle + '.java'), parity / 'VanillaSceneCollisionOracle.java',
parity / 'VanillaRegistryCollisionScanOracle.java', lab.ROOT / 'Cargo.lock',
lab.ROOT / 'crates/horizon-client/examples/auto_jump_probe.rs',
lab.ROOT / 'crates/horizon-client/src/auto_jump.rs', lab.ROOT / 'crates/horizon-client/src/motion.rs']
tested = base.source_hashes(sources)
fixtures = list(cases(random.Random(0x4155544f)))
inputs = build / 'cases.jsonl'
inputs.write_text(''.join(json.dumps(f, separators=(',', ':')) + '\n' for f in fixtures))
input_hash = lab.digest(inputs, 'sha256')
classes, dependencies, _ = harness.compile_oracle(build, oracle, jars, libraries, lock['minecraft']['java_major'])
binary = harness.build_probe(build, 'auto_jump_probe')
harness.run_logged([binary, resources, inputs, build / 'rust.txt', build / 'scene.json'], build, 'rust', timeout=3600)
harness.java_run(build, classes, dependencies, oracle, [inputs, build / 'client.txt', resources, build / 'scene.json'], 'client')
matched = harness.compare(build / 'client.txt', build / 'rust.txt', 2 * len(fixtures))
base.ensure_sources_unchanged(sources, tested)
if input_hash != lab.digest(inputs, 'sha256') or resources_before != client.resource_fingerprint(resources):
raise AssertionError('Fixtures or resources changed during validation')
jumps = sum(1 for line in (build / 'rust.txt').read_text().splitlines() if line == 'jump 1')
lab.write_json(report, dict(
schema=1, minecraft=lock['minecraft']['version'], manifest_sha1=lock['minecraft']['manifest_sha1'],
platform=platform.platform(), rustc=subprocess.check_output(['rustc', '--version'], text=True).strip(),
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], fixtures=len(fixtures),
auto_jumps=jumps, sources_sha256=tested, input_sha256=input_hash, resources=resources_before,
client_jar_sha256=lab.digest(jars['client'], 'sha256'), matched_output_lines=matched,
output_sha256=lab.digest(build / 'rust.txt', 'sha256'),
scope='The actual LocalPlayer.updateAutoJump with canAutoJump (option, autoJumpTime, onGround, '
'isStayingOnGroundSurface, passenger, input move vector, getBlockJumpFactor over the real level) on a '
'LocalPlayer shell in an independently constructed copy of the resident test scene with each case\'s '
'real block states placed; collisions through CollisionGetter.getCollisions/BlockCollisions with the '
'player\'s EntityCollisionContext and real collision shapes, jump boost from a real MobEffectInstance. '
'Rust runs MotionWorld::update_auto_jump over the client\'s loaded resources, collision registry and '
'block-collision scan. Entity collisions (none in the scene) and the riding/minecart paths are not exercised.'))
print(f'{len(fixtures):,} auto-jump cases ({jumps:,} jumps) match the pinned client: {report}', flush=True)
if __name__ == '__main__':
main()
+318
View File
@@ -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()
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Compare the camera with the pinned client: Camera.tick (eye height and
field-of-view easing), alignWithEntity (view rotation quaternion and basis,
interpolated eye, third person back and front, camera distance and scale
attributes with mounts, getMaxZoom clipping against visual shapes, sleeping),
calculateFov/calculateHudFov (option, modifier, death and fluid effects,
panoramic mode), AbstractClientPlayer.getFieldOfViewModifier (flight, speed
attribute against walking speed, bow draw, spyglass), the walk and bob
history (ClientAvatarState, updateBob) and GameRenderer.bobHurt/bobView,
driven tick by tick and frame by frame with identical scripted states."""
import json
import random
import struct
try:
from . import lab, oracle_harness as harness
except ImportError:
import lab, oracle_harness as harness
def fbits(v):
return struct.pack('>f', v).hex()
def dbits(v):
return struct.pack('>d', v).hex()
def f32(v):
return struct.unpack('>f', struct.pack('>f', v))[0]
WALK = [0.1, 0.0, 0.05, 0.2, 1e-3, -0.1, 0.15]
SPEED = [f32(0.1), f32(0.1) * 1.3, 0.0, 0.03, 0.5, 1024.0, 0.13, f32(0.1) * 0.85, f32(0.1) * 1.4]
TICKS = [0, 1, 5, 10, 19, 20, 21, 72000, -5, 3]
def fov_fields(rng):
use = rng.choice([None, None, ['bow', rng.choice(TICKS)], ['spyglass', rng.choice(TICKS)], ['other', rng.choice(TICKS)]])
return dict(flying=rng.random() < 0.3, ws=fbits(rng.choice(WALK + [rng.uniform(-0.3, 0.3)])),
speed=dbits(rng.choice(SPEED + [rng.uniform(0, 2)])), use=use)
def fov_cases(rng):
for flying in (False, True):
for ws in WALK:
for speed in SPEED:
for use in [None, ['bow', 0], ['bow', 7], ['bow', 20], ['bow', 40], ['spyglass', 2], ['other', 5]]:
for fp in (False, True):
for scale in (0.0, 0.5, 1.0):
yield dict(t='fov', flying=flying, ws=fbits(ws), speed=dbits(speed), use=use, fp=fp, scale=fbits(scale))
for _ in range(3000):
yield dict(t='fov', **fov_fields(rng), fp=rng.random() < 0.6, scale=fbits(rng.choice([0.0, 1.0, rng.random()])))
FULL = [[0, 0, 0, 1, 1, 1]]
SHAPES = [FULL, FULL, [[0, 0, 0, 1, 0.5, 1]], [[0, 0.5, 0, 1, 1, 1]], [[0, 0, 0, 1, 1, 0.125]], [[0.375, 0, 0.375, 0.625, 1, 0.625]],
[[0, 0, 0, 0.5, 1, 1], [0.5, 0, 0.5, 1, 1, 1]], []]
def world(rng, center):
cells = {}
cx, cy, cz = center
for _ in range(rng.choice([0, 3, 10, 40, 120])):
p = (cx + rng.randint(-6, 6), cy + rng.randint(-3, 5), cz + rng.randint(-6, 6))
cells[f'{p[0]} {p[1]} {p[2]}'] = [[dbits(v) for v in box] for box in rng.choice(SHAPES)]
return cells
ANGLES = [0.0, 90.0, -90.0, 180.0, -180.0, 45.0, 360.0, 720.5, -33.3, 1e-3, 89.9]
def angle(rng):
return rng.choice(ANGLES + [rng.uniform(-90, 90), rng.uniform(-400, 400)])
def entity_state(rng, center):
pos = [center[i] + rng.uniform(0, 1) for i in range(3)]
prev = [p - rng.choice([0, 0, 0.1, 0.3, 1.0, rng.uniform(-1, 1)]) for p in pos]
kind = 'player' if rng.random() < 0.85 else 'marker'
xr, yr = angle(rng), angle(rng)
s = dict(kind=kind, pos=[dbits(v) for v in pos], prev=[dbits(v) for v in prev], xr=fbits(xr), yr=fbits(yr),
xro=fbits(rng.choice([xr, xr - 5, angle(rng)])), yro=fbits(rng.choice([yr, yr + 10, angle(rng)])),
eye=fbits(rng.choice([1.62, 1.27, 0.4, 0.2, 0.81, 3.24, rng.uniform(0, 4)])))
s.update(fov_fields(rng))
s.update(scale_attr=dbits(rng.choice([1.0, 1.0, 0.5, 2.0, 0.0625, 16.0, rng.uniform(0.0625, 3)])),
dist=dbits(rng.choice([4.0, 4.0, 0.0, 1.0, 10.0, 32.0, rng.uniform(0, 32)])),
sleeping=rng.choice([None] * 6 + [[None], [0], [1], [2], [3]]),
dead=rng.choice([None] * 6 + [0, 1, 5, 19, 20, 25]),
mount=rng.choice([None] * 4 + [[dbits(rng.choice([1.0, 2.0, 0.5])), dbits(rng.choice([4.0, 8.0, 1.0]))]]))
return s
def camera_cases(rng):
for n in range(2500):
center = rng.choice([(0, 64, 0), (-10, -60, 7), (1000, 120, -3000), (0, 0, 0)])
ops = []
for _ in range(rng.randrange(1, 40)):
kind = rng.choice(['entity', 'tick', 'tick', 'tick', 'frame', 'frame', 'frame', 'type'])
if not ops:
kind = 'entity'
if kind == 'entity':
ops.append(['entity', entity_state(rng, center)])
elif kind == 'tick':
ops.append(['tick'])
elif kind == 'frame':
pt = rng.choice([0.0, 1.0, 0.5, 0.25, 0.999, rng.random()])
ops.append(['frame', fbits(pt), rng.choice([0, 0, 0, 1, 2, 3]), rng.random() < 0.05])
else:
ops.append(['type', rng.randrange(3)])
yield dict(t='camera', fov=rng.choice([70, 30, 110, 90, rng.randint(30, 110)]),
effect=dbits(rng.choice([1.0, 0.0, 0.5, rng.random()])), type=rng.choice([0, 1, 1, 2]),
cells=world(rng, center), ops=ops)
# A walled-in third-person camera stepping through every distance bound.
for dist in [0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 32.0]:
for wall in range(0, 6):
for yaw in [0.0, 90.0, 180.0, 270.0, 45.0]:
cells = {}
for dx in range(-6, 7):
for dy in range(62, 68):
cells[f'{dx} {dy} {-wall - 1}'] = [[dbits(v) for v in box] for box in FULL]
cells[f'{dx} {dy} {wall + 1}'] = [[dbits(v) for v in box] for box in FULL]
s = dict(kind='player', pos=[dbits(0.5), dbits(64.0), dbits(0.5)], prev=[dbits(0.5), dbits(64.0), dbits(0.5)],
xr=fbits(15.0), yr=fbits(yaw), xro=fbits(15.0), yro=fbits(yaw), eye=fbits(1.62), flying=False,
ws=fbits(0.1), speed=dbits(f32(0.1)), use=None, scale_attr=dbits(1.0), dist=dbits(dist),
sleeping=None, dead=None, mount=None)
ops = [['entity', s]] + [['tick']] * 6 + [['frame', fbits(pt), 0, False] for pt in (0.0, 0.5, 1.0)]
yield dict(t='camera', fov=70, effect=dbits(1.0), type=1 + (wall % 2), cells=cells, ops=ops)
def bob_cases(rng):
for n in range(6000):
living = None
if rng.random() < 0.8:
living = dict(hurt=fbits(rng.choice([-1.0, -0.5, 0.0, 10.0, 9.5, 5.25, 1.0, rng.uniform(-2, 11)])),
duration=rng.choice([10, 10, 0, 5, 20]), dir=fbits(rng.choice([0.0, 90.0, -45.0, 180.0, rng.uniform(-360, 360)])),
death=rng.choice([None, None, None, fbits(rng.choice([0.0, 0.5, 19.5, 20.0, 25.0, rng.uniform(0, 30)]))]))
player = None
if rng.random() < 0.8:
player = [fbits(rng.choice([0.0, -0.3, -1.7, -100.25, rng.uniform(-1000, 0)])),
fbits(rng.choice([0.0, 0.1, 0.05, 0.0999, rng.uniform(0, 0.1)]))]
yield dict(t='bob', living=living, player=player, tilt=dbits(rng.choice([1.0, 0.0, 0.5, rng.random()])),
view=rng.random() < 0.8)
def avatar_cases(rng):
for n in range(1500):
ops = []
for _ in range(rng.randrange(1, 60)):
kind = rng.choice(['tick', 'move', 'move', 'bob', 'bob', 'frame', 'frame', 'reset'])
if kind == 'tick':
ops.append(['tick'])
elif kind == 'move':
ops.append(['move', fbits(rng.choice([0.0, 0.2158, -0.1, rng.uniform(-1, 1)])), fbits(rng.choice([0.0, 0.2806, rng.uniform(-1, 1)]))])
elif kind == 'bob':
v = [rng.choice([0.0, 0.05, -0.2, rng.uniform(-0.5, 0.5)]), rng.uniform(-1, 1), rng.choice([0.0, 0.08, rng.uniform(-0.5, 0.5)])]
ops.append(['bob', rng.random() < 0.7, rng.random() < 0.1, rng.random() < 0.1, [dbits(x) for x in v]])
elif kind == 'frame':
ops.append(['frame', fbits(rng.choice([0.0, 1.0, 0.5, rng.random()]))])
else:
ops.append(['reset'])
yield dict(t='avatar', ops=ops)
def make_cases(build):
rng = random.Random(0x43414d45)
path = build / 'cases.jsonl'
cases = build / 'cases'
cases.mkdir(exist_ok=True)
count = 0
with path.open('w') as out:
for generator in (fov_cases, camera_cases, bob_cases, avatar_cases):
for case in generator(rng):
text = json.dumps(case, separators=(',', ':'))
out.write(text + '\n')
if count < 20000:
(cases / str(count)).write_text(text)
count += 1
return path, count
def main():
scope = ('Real Camera.tick, tickFov, alignWithEntity (setRotation quaternion and forwards/up/left basis, move, '
'interpolated eye with eye-height easing, first person, third person back and mirrored front, scale and '
'camera_distance attributes including a living mount, getMaxZoom over the actual Level.clip with '
'ClipContext.Block.VISUAL against controlled visual shapes, sleeping bed orientation), calculateFov and '
'calculateHudFov (option, eased modifier, death, water/lava with the effect scale, panoramic), '
'AbstractClientPlayer.getFieldOfViewModifier with real Abilities, attribute maps and item stacks, '
'ClientAvatarState walk/bob history with AbstractClientPlayer.updateBob, and GameRenderer.bobHurt/bobView '
'as the full JOML pose matrix with its property flags. The camera entity is a shell with controlled '
'queries; getFluidInCamera is supplied (the fog harnesses cover fluid detection) and the new-minecart '
'interpolation branch is not exercised.')
harness.differential(
'rust-camera-parity', 'VanillaCameraOracle', 'camera_probe', make_cases, scope,
sources=[lab.ROOT / 'crates/horizon-client/src/camera.rs'])
if __name__ == '__main__':
main()
+17 -3
View File
@@ -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 = [], []
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Compare the frame limiter with the pinned client: the real
FramerateLimiter.limitDisplayFPS bytecode, its clock calls rewritten to a
scripted virtual clock, against the Rust limiter with the same script, frame
by frame: sleep requests, spin-waits, overshoot averaging and its reset on a
limit change, interrupted sleeps, oversleep and early wake-ups, the "below
260" condition of Minecraft.runTick, and the limits FramerateLimitTracker
hands it (option values, 60 in menus, 10 when minimised or idle)."""
import json
import random
try:
from . import lab, oracle_harness as harness
except ImportError:
import lab, oracle_harness as harness
LIMITS = [10, 30, 60, 60, 120, 144, 165, 240, 250, 259, 260, 1, 2, 7, 1000, 2**31 - 1]
def cases(rng):
for limit in LIMITS:
for overshoot in ([0], [50_000], [3_000_000], [30_000_000], [-200_000], [100_000, 2_500_000, 0, 24_999_999, 25_000_000]):
for spin in (1_000, 50_000, 7_919):
frames = [[rng.choice([0, 1_000_000, 4_000_000, 16_000_000]), limit] for _ in range(12)]
yield dict(now=10**12, call=rng.choice([0, 25]), spin=spin, overshoot=overshoot, interrupts=[False],
last=10**12, average=0, last_limit=0, frames=frames)
for n in range(5000):
frames = []
limit = rng.choice(LIMITS)
for _ in range(rng.randrange(1, 40)):
if rng.random() < 0.15:
limit = rng.choice(LIMITS)
work = rng.choice([0, 100_000, 2_000_000, 5_000_000, 16_666_666, 30_000_000, 120_000_000, rng.randrange(0, 50_000_000)])
frames.append([work, limit])
start = rng.choice([0, 10**9, 10**15, rng.randrange(0, 10**16)])
yield dict(now=start, call=rng.choice([1, 30, 400, 20_000]), spin=rng.choice([100, 1_000, 70_000, 600_000]),
overshoot=[rng.choice([0, 30_000, 80_000, 1_200_000, 2_100_000, 24_000_000, 26_000_000, -300_000, rng.randrange(-1_000_000, 4_000_000)])
for _ in range(rng.randrange(1, 8))],
interrupts=[False] + [rng.random() < 0.3 for _ in range(rng.randrange(0, 5))],
last=start - rng.choice([0, 1, 5_000_000, 200_000_000]),
average=rng.choice([0, 0, 1, 500_000, 2_000_000]), last_limit=rng.choice([0, 60, limit]), frames=frames)
def make_cases(build):
rng = random.Random(0x46505331)
path = build / 'cases.jsonl'
count = 0
with path.open('w') as out:
for case in cases(rng):
out.write(json.dumps(case, separators=(',', ':')) + '\n')
count += 1
return path, count
def main():
scope = ('The actual FramerateLimiter.limitDisplayFPS bytecode from the client jar, loaded through a ClassFile '
'rewrite that redirects System.nanoTime, LockSupport.parkNanos, Thread.interrupted and Thread.onSpinWait '
'to a scripted virtual clock (query cost, oversleep/early wake per park, spin cost, interrupt pattern), '
'with its static state seeded per case and the Minecraft.runTick "limit < 260" gate. Compares every '
'frame\'s park requests, spin count, clock queries, lastFrameTime, averageOvershootNs and '
'lastFramerateLimit. The limits themselves (FramerateLimitTracker) are compared by the game loop harness; '
'real OS sleep accuracy is outside the comparison.')
harness.differential(
'rust-frame-limiter-parity', 'VanillaFrameLimiterOracle', 'frame_limiter_probe', make_cases, scope,
sources=[lab.ROOT / 'crates/horizon-client/src/window.rs'])
if __name__ == '__main__':
main()
+126
View File
@@ -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()
+53 -18
View File
@@ -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}'
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Compare toggle sprint/sneak (and toggle attack/use) with the pinned client:
the real Options ToggleKeyMappings driven by the real static KeyMapping
functions as keyboard presses, SDL key repeats and releases, mouse buttons,
screens opening and closing over the game (releaseAll and
restoreToggleStatesOnScreenClosed), respawning (resetToggleKeys) and toggle
options changing mid-sequence, event by event."""
import json
import random
try:
from . import lab, oracle_harness as harness
except ImportError:
import lab, oracle_harness as harness
def op(rng, screen):
kind = rng.choice(['key', 'key', 'key', 'key', 'button', 'button', 'open', 'close', 'option', 'option', 'respawn'])
if kind == 'key':
return ['key', rng.choice([0, 1]), rng.choice([1, 1, 0, 0, -1]), screen]
if kind == 'button':
return ['button', rng.choice([2, 3]), rng.random() < 0.5, screen]
if kind == 'option':
return ['option', rng.randrange(4), rng.random() < 0.6]
return [kind]
def cases(rng):
# Every short sequence of the main events for sneak with toggle off and on.
events = [['key', 0, 1, False], ['key', 0, 0, False], ['key', 0, -1, False], ['key', 0, 1, True], ['key', 0, 0, True],
['open'], ['close'], ['respawn'], ['option', 0, True], ['option', 0, False]]
for a in events:
for b in events:
for c in events:
for toggled in (False, True):
yield dict(ops=[['option', 0, toggled], a, b, c, ['key', 0, 1, False], ['open'], ['close']])
for _ in range(6000):
screen = False
ops = [['option', i, rng.random() < 0.5] for i in range(4)]
for _ in range(rng.randrange(1, 60)):
o = op(rng, screen)
if o[0] == 'open':
screen = True
elif o[0] == 'close':
screen = False
elif o[0] in ('key', 'button'):
o[3] = screen
ops.append(o)
yield dict(ops=ops)
def make_cases(build):
path = build / 'cases.jsonl'
count = 0
with path.open('w') as out:
for case in cases(random.Random(0x544f474c)):
out.write(json.dumps(case, separators=(',', ':')) + '\n')
count += 1
return path, count
def main():
scope = ('The real Options keyShift/keySprint/keyAttack/keyUse ToggleKeyMappings with their real toggle options and '
'shouldRestore flags, driven through the real static KeyMapping.set/click (every mapping bound to the key), '
'releaseAll, restoreToggleStatesOnScreenClosed and resetToggleKeys, dispatched as KeyboardHandler.keyPress '
'(release always; press and SDL repeat only without a screen), MouseHandler.onButton (only without a screen), '
'Gui.setScreen and LocalPlayer.respawn call them. Compares down state and pending clicks after every event. '
'The screen-consumed keyPressed/keyReleased branches and KeyMapping.setAll (an InputQuirks platform quirk) are '
'not exercised; KeyboardInput then reads isDown as the sprint and local input harnesses compare.')
harness.differential(
'rust-toggle-keys-parity', 'VanillaToggleKeysOracle', 'toggle_keys_probe', make_cases, scope,
sources=[lab.ROOT / 'crates/horizon-client/src/keymapping.rs'])
if __name__ == '__main__':
main()