Merge remote-tracking branch 'origin/main' into ci/launcher-check

# Conflicts:
#	tools/ci/rust_changes.py
This commit is contained in:
Ludovic Marie
2026-10-26 17:15:21 +01:00
239 changed files with 2880 additions and 16775 deletions
-1
View File
@@ -2,7 +2,6 @@
target/
.local/
**/node_modules/
website/
benchmarks/
packs/
mods/
+1 -1
View File
@@ -2,5 +2,5 @@
# where Git for Windows' core.autocrlf would check them out with CRLF.
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+2 -1
View File
@@ -54,7 +54,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Build and test fast
+2 -2
View File
@@ -4,8 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
+8 -4
View File
@@ -134,7 +134,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
</details>
@@ -310,7 +310,7 @@ Horizon should run well on small machines: integrated graphics, the Steam Deck,
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
@@ -600,7 +600,9 @@ You can port a mod to Horizon with your own AI agent on your own computer. Horiz
<details><summary>Technical</summary>
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
</details>
@@ -936,7 +938,9 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
<details><summary>Technical</summary>
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
+3
View File
@@ -262,6 +262,9 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Command { line } => format!("command: /{line}"),
Output::QuietCommands { lines, .. } => {
format!("{} quiet command(s)", lines.len())
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
@@ -0,0 +1,48 @@
use horizon_client::{
Result,
item_data::StackData,
items::{ItemStack, Items},
};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
let items = Items::catalog(&root)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let mut stack = ItemStack::new(
row["item"].as_str().unwrap(),
row["count"].as_u64().unwrap() as u32,
);
match row["tool"].as_str().unwrap() {
"default" => {}
mode => {
let mut data = StackData::default();
let tool = if mode == "implicit" {
Tag::compound([] as [(&str, Tag); 0])
} else {
Tag::compound([(
"can_destroy_blocks_in_creative",
Tag::Byte(i8::from(mode == "allow")),
)])
};
data.set_other("minecraft:tool", Some(tool));
stack.extra = Some(Box::new(data));
}
}
writeln!(
out,
"{}",
items.can_destroy_block(Some(&stack), row["creative"].as_bool().unwrap())
)?;
}
Ok(())
}
@@ -27,6 +27,7 @@ mod chat_screen_fixture;
mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
mod creative_mining_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
mod emitter_particles_probe;
@@ -108,6 +109,7 @@ probes! {
block_transform_fixture,
breaking_fixture,
camera_probe,
creative_mining_probe,
cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
+74 -3
View File
@@ -58,7 +58,10 @@ enum Animate {
BubbleColumn {
drag: bool,
},
Fire,
/// `BaseFireBlock` (`SoulFireBlock.canBurn` is always true).
Fire {
soul: bool,
},
/// An active sculk sensor.
SculkSensor,
/// Potent sulfur that is not dry.
@@ -140,7 +143,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"bubble_column" => Animate::BubbleColumn {
drag: prop("drag").as_deref() == Some("true"),
},
"fire" | "soul_fire" => Animate::Fire,
"fire" => Animate::Fire { soul: false },
"soul_fire" => Animate::Fire { soul: true },
"dried_ghast" => Animate::DriedGhast {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
@@ -190,6 +194,8 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec<Vec<(T, f32)>>,
}
@@ -220,6 +226,21 @@ impl AnimateTables {
})
.collect()
};
let behaviour: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join("block-behaviours.json"))?)?;
let ignite_odds = behaviour["fire"]["igniteOdds"].as_object();
let burnable = (0..registry.len() as u32)
.map(|id| {
// FireBlock.getIgniteOdds: a waterlogged state never burns.
registry.state(BlockStateId(id)).is_some_and(|s| {
s.properties.get("waterlogged").map(String::as_str) != Some("true")
&& ignite_odds
.and_then(|m| m.get(s.name.as_str()))
.and_then(serde_json::Value::as_i64)
.is_some_and(|odds| odds > 0)
})
})
.collect();
let mut ambient = vec![Vec::new(); biomes.list.len()];
for (name, &id) in &biomes.index {
let path = root.join(format!(
@@ -248,6 +269,7 @@ impl AnimateTables {
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
burnable,
ambient,
})
}
@@ -1103,7 +1125,7 @@ fn block_animate_tick(
}
}
}
Animate::Fire => {
Animate::Fire { soul } => {
let r = engine.random();
if next_int(r, 24) == 0 {
let (volume, pitch) = (1.0 + r.next_float(), r.next_float() * 0.7 + 0.3);
@@ -1115,6 +1137,55 @@ fn block_animate_tick(
pitch,
);
}
// BaseFireBlock.animateTick: three large smoke above the fire, or
// two at each burnable neighbour's face when nothing burnable or
// sturdy supports it (SoulFireBlock.canBurn is always true).
let can_burn = |p: [i32; 3]| {
soul || tables
.burnable
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let below = [pos[0], pos[1] - 1, pos[2]];
let sturdy = ctx.facts.get(ctx.state(below)).sturdy_full & (1 << 1) != 0;
if !can_burn(below) && !sturdy {
// (neighbour, coordinate axis, face at the axis' high end): the
// west, east, north, south and above faces, in vanilla order.
for (at, axis, high) in [
([pos[0] - 1, pos[1], pos[2]], 0usize, false),
([pos[0] + 1, pos[1], pos[2]], 0, true),
([pos[0], pos[1], pos[2] - 1], 2, false),
([pos[0], pos[1], pos[2] + 1], 2, true),
([pos[0], pos[1] + 1, pos[2]], 1, true),
] {
if !can_burn(at) {
continue;
}
for _ in 0..2 {
let r = engine.random();
let d = [r.next_double(), r.next_double(), r.next_double()];
let mut p = [fx + d[0], fy + d[1], fz + d[2]];
// The face coordinate hugs the edge: `* 0.1F` (float).
p[axis] = if high {
f64::from(pos[axis]) + 1.0 - d[axis] * f64::from(0.1f32)
} else {
f64::from(pos[axis]) + d[axis] * f64::from(0.1f32)
};
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
} else {
for _ in 0..3 {
let r = engine.random();
let p = [
fx + r.next_double(),
fy + r.next_double() * 0.5 + 0.5,
fz + r.next_double(),
];
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
}
}
}
+18 -7
View File
@@ -664,7 +664,9 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
"minecraft:trial_spawner" => {
// `TrialSpawnerStateData.Packed` and `FullConfig`: every key is optional
// and defaulted by vanilla; they are kept as loaded.
// and defaulted by vanilla; they are kept as loaded. No `spawn_data` is
// added: an absent one loads as Optional.empty and is not stored, and
// an empty entity would keep the spawner from ever picking a mob.
if let Tag::Compound(c) = tag {
for (k, v) in c {
let key = k.to_string_lossy();
@@ -676,12 +678,6 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
}
}
if !f.out.contains_key(&"spawn_data".into()) {
f.put(
"spawn_data",
Tag::compound([("entity", Tag::Compound(Compound::new()))]),
);
}
}
"minecraft:vault" => {
// `VaultConfig.DEFAULT` (when the config is absent): the trial key and the
@@ -827,6 +823,21 @@ mod tests {
canonical(&snbt(s)).expect("known type")
}
#[test]
fn a_trial_spawner_without_spawn_data_saves_none() {
let fresh = canon(
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,normal_config:"minecraft:trial_chamber/ranged/skeleton/normal"}"#,
);
assert_eq!(fresh.get("spawn_data"), None);
let picked = canon(
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,spawn_data:{entity:{id:"minecraft:skeleton"}}}"#,
);
assert_eq!(
picked.get("spawn_data"),
Some(&snbt(r#"{entity:{id:"minecraft:skeleton"}}"#))
);
}
#[test]
fn every_type_has_defaults() {
for id in TYPES.iter().chain(NEWEST.iter()) {
+4
View File
@@ -161,6 +161,9 @@ pub struct Chunk {
/// Structure starts reaching into this chunk (`structures.References`):
/// structure id and the start's chunk.
pub structure_refs: Vec<(Arc<str>, (i32, i32))>,
/// The starts generation made in this chunk (`structures.starts`), saved
/// with the state their placement has reached by then.
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
}
impl Chunk {
pub fn empty(x: i32, z: i32) -> Self {
@@ -178,6 +181,7 @@ impl Chunk {
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
structure_starts: Vec::new(),
postprocess: Vec::new(),
saved: None,
entities: Vec::new(),
+102 -2
View File
@@ -94,9 +94,66 @@ pub enum BlockEntity {
Beehive(Box<crate::beehive::Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<crate::command_block::CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
let Some(slot) = self.items.iter().position(Option::is_none) else {
return false;
};
self.total[slot] = cooking_time;
self.progress[slot] = 0;
self.items[slot] = Some(ItemStack {
count: 1,
..held.clone()
});
held.count = held.count.saturating_sub(1);
true
}
/// `cookTick` (lit): each item's progress rises by one; the slots whose
/// progress reached their cooking time give up their item (the caller
/// drops its result).
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
let mut done = Vec::new();
for i in 0..4 {
if self.items[i].is_none() {
continue;
}
self.progress[i] += 1;
if self.progress[i] >= self.total[i]
&& let Some(item) = self.items[i].take()
{
done.push((i, item));
}
}
done
}
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
/// Whether anything changed (`setChanged`).
pub fn cooldown_tick(&mut self) -> bool {
let mut changed = false;
for i in 0..4 {
if self.progress[i] > 0 {
changed = true;
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
}
}
changed
}
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
@@ -315,6 +372,9 @@ impl BlockEntity {
if name == "minecraft:brewing_stand" {
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
}
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
@@ -352,6 +412,7 @@ impl BlockEntity {
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
Self::Campfire(_) => "minecraft:campfire",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -373,6 +434,7 @@ impl BlockEntity {
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -393,7 +455,9 @@ impl BlockEntity {
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &[],
| Self::CommandBlock(_)
// Not a Container (only Clearable): hoppers don't reach in.
| Self::Campfire(_) => &[],
}
}
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
@@ -414,7 +478,8 @@ impl BlockEntity {
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &mut [],
| Self::CommandBlock(_)
| Self::Campfire(_) => &mut [],
}
}
}
@@ -706,6 +771,41 @@ mod tests {
use super::*;
use horizon_core::random::{LegacyRandom, RandomSource};
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
let mut beef = ItemStack::new("minecraft:beef", 2);
// placeFood: the first free slot, one item, its recipe's time.
assert!(c.place_food(&mut beef, 600));
assert_eq!(beef.count, 1);
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
assert_eq!((c.progress[0], c.total[0]), (0, 600));
let mut fish = ItemStack::new("minecraft:cod", 1);
assert!(c.place_food(&mut fish, 3));
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
// cookTick: the cod is done on its third tick, the beef keeps cooking.
assert!(c.cook_tick().is_empty());
assert!(c.cook_tick().is_empty());
let done = c.cook_tick();
assert_eq!(done.len(), 1);
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
assert!(c.items[1].is_none());
assert_eq!(c.progress[0], 3);
// cooldownTick: two a tick, never below zero.
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 1);
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 0);
// Full: four slots, no fifth.
for _ in 0..3 {
let mut more = ItemStack::new("minecraft:potato", 1);
assert!(c.place_food(&mut more, 600));
}
let mut fifth = ItemStack::new("minecraft:potato", 1);
assert!(!c.place_food(&mut fifth, 600));
assert_eq!(fifth.count, 1);
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
+28 -3
View File
@@ -18,6 +18,7 @@ mod boats;
mod books;
mod bossbars;
mod breezes;
mod campfire;
mod cartography;
mod chat;
mod client_ambient;
@@ -3345,6 +3346,13 @@ impl Game<'_> {
self.with_player_actor(|game| game.break_block_as_player(pos))
}
fn break_block_as_player(&mut self, pos: [i32; 3]) -> Result<()> {
if !self
.res
.items()
.can_destroy_block(self.held(), self.creative)
{
return Ok(());
}
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
// ServerPlayerGameMode.destroyBlock.
let state = self.world.block(pos[0], pos[1], pos[2]);
@@ -3970,7 +3978,12 @@ impl Game<'_> {
return Ok(());
};
// GameType.isBlockPlacingRestricted: adventure and spectator.
if !self.may_build() {
if !self.may_build()
|| !self
.res
.items()
.can_destroy_block(self.held(), self.creative)
{
self.mining = None;
return Ok(());
}
@@ -5302,7 +5315,8 @@ impl Game<'_> {
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::Skull(_),
| BlockEntity::Skull(_)
| BlockEntity::Campfire(_),
) => {}
None => {
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
@@ -5572,7 +5586,8 @@ impl Game<'_> {
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::Skull(_),
| BlockEntity::Skull(_)
| BlockEntity::Campfire(_),
) => {}
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
None => {
@@ -7037,6 +7052,9 @@ impl Game<'_> {
// `VaultRenderer`.
self.mark_entity(out.len(), "");
self.vault_geometry(camera, alpha, &mut out);
// `CampfireRenderer`.
self.mark_entity(out.len(), "");
self.campfire_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
// Mobs and their projectiles (a span each).
@@ -8740,6 +8758,13 @@ impl Game<'_> {
{
return Ok(UseResult::Success { swing: true });
}
if allow_block_actions
&& let Some(hit) = self.pick()
&& let Some(placed) = self.use_on_campfire(hit.pos)?
{
// SUCCESS_SERVER swings; a full campfire's CONSUME does not.
return Ok(UseResult::Success { swing: placed });
}
// BucketItem has no useOn: a guest's UseItemOn leaves it to UseItem.
let bucket = horizon_client::items::bucket_contents(&stack.item);
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
@@ -56,6 +56,7 @@ impl Game<'_> {
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?,
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
_ => {}
}
}
@@ -92,7 +93,10 @@ impl Game<'_> {
| "minecraft:vault"
| "minecraft:beehive"
| "minecraft:bee_nest"
| "minecraft:brewing_stand" => Ticking::Yes,
| "minecraft:brewing_stand"
// Lit, cookTick; unlit, cooldownTick.
| "minecraft:campfire"
| "minecraft:soul_campfire" => Ticking::Yes,
"minecraft:jukebox" => {
let record = registry
.state(state)
+1 -1
View File
@@ -55,7 +55,7 @@ pub(super) struct Boat {
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
pub(super) const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
+190
View File
@@ -0,0 +1,190 @@
//! Campfires' cooking: `CampfireBlock.useItemOn` (`CampfireBlockEntity.placeFood`)
//! and the block entity's `cookTick` (lit) and `cooldownTick` (unlit). The
//! items drawn on the fire are `campfire_geometry`'s; a server's clients get
//! them through the block entity's update tag (`lan::chunks::entity_tag`).
use super::Game;
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity,
items::ItemStack, mesher::Vertex, mobs::Who,
};
use horizon_core::position::BlockPos;
/// The `campfire_cooking` recipe kind (`RecipeType.CAMPFIRE_COOKING`).
const KIND: &str = "minecraft:campfire_cooking";
fn is_campfire(name: &str) -> bool {
name == "minecraft:campfire" || name == "minecraft:soul_campfire"
}
impl Game<'_> {
/// `CampfireBlock.useItemOn` with a held item that is a campfire recipe's
/// input (`RecipePropertySet.CAMPFIRE_INPUT`): on a server level the food
/// goes into the first free slot (`placeFood`), and the use is consumed
/// whether or not there was room. A client of a server leaves the placing
/// to the server. Whether the food went in; None when this isn't a
/// campfire use, so the item's own use runs.
pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
let state = self.world.block(pos[0], pos[1], pos[2]);
if !is_campfire(&self.res.facts().get(state).name) {
return Ok(None);
}
let Some(held) = self.held().cloned() else {
return Ok(None);
};
let Some(time) = self.res.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
return Ok(None);
};
// A client of a server predicts the placing; the server decides.
if self.remote.is_some() {
return Ok(Some(true));
}
let key = (pos[0], pos[1], pos[2]);
let placed = match self.world.block_entities.get_mut(&key) {
Some(BlockEntity::Campfire(c)) => {
let mut one = held.clone();
c.place_food(&mut one, time)
}
_ => false,
};
if placed {
// ItemStack.consumeAndReturn: creative keeps the item.
self.consume_held();
self.stats.custom("interact_with_campfire", 1);
self.post_block_game_event(
"minecraft:block_change",
pos,
Some(Who::Player),
Some(state),
);
self.mark_block_entity_updated(key);
}
Ok(Some(placed))
}
/// `CampfireBlockEntity.cookTick` of a lit campfire, `cooldownTick` of an
/// unlit one. A finished item's result (the recipe's, or the item itself
/// when no recipe takes it any more) drops at the block's corner
/// (`Containers.dropItemStack` at its integer position), then the clients
/// see the slot empty and a `block_change` goes out.
pub(super) fn tick_campfire(&mut self, pos: Pos) -> Result<()> {
let state = self.world.block(pos.0, pos.1, pos.2);
let lit = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("lit"))
.is_some_and(|v| v == "true");
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
// BlockEntity.setChanged: a timer that moved is saved with the
// chunk (no update to the clients, who don't see the timers).
let changed = if lit {
c.items.iter().any(Option::is_some)
} else {
c.cooldown_tick()
};
if changed {
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
}
if !lit {
return Ok(());
}
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
let done = c.cook_tick();
for (_, item) in done {
let result = self
.res
.recipes()
.cook(KIND, &item)
.map_or_else(|| item.clone(), |r| r.result.clone());
self.campfire_drop(pos, result);
self.mark_block_entity_updated(pos);
self.post_block_game_event(
"minecraft:block_change",
[pos.0, pos.1, pos.2],
None,
Some(state),
);
}
Ok(())
}
/// `CampfireRenderer`: each cooking item lies on the fire, slot `i`
/// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat
/// (90° about X), at a corner and three eighths of its size, drawn as a
/// FIXED item seeded by the block's position plus the slot.
pub(super) fn campfire_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
for (key, entity) in &self.world.block_entities {
let BlockEntity::Campfire(c) = entity else {
continue;
};
if c.items.iter().all(Option::is_none) {
continue;
}
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
if at.distance_squared(camera) >= 64.0 * 64.0 {
continue;
}
// Direction.get2DDataValue: south 0, west 1, north 2, east 3.
let state = self.world.block(key.0, key.1, key.2);
let facing = match self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing"))
.map(String::as_str)
{
Some("west") => 1,
Some("north") => 2,
Some("east") => 3,
_ => 0,
};
let light = vertex_light(self.world.light(key.0, key.1, key.2));
let packed = BlockPos::new(key.0, key.1, key.2).pack();
for (i, stack) in c.items.iter().enumerate() {
let Some(stack) = stack else {
continue;
};
let seed = (packed as i32).wrapping_add(i as i32);
let item = self.resolve_item(stack, DisplayContext::Fixed, None, seed);
if item.is_empty() {
continue;
}
// Direction.toYRot: 90° per 2D step from south.
let yaw = -(((i + facing) % 4) as f32 * 90.0);
let pose = glam::Mat4::from_translation(
(at - camera).as_vec3() + glam::Vec3::new(0.5, 0.449_218_75, 0.5),
) * glam::Mat4::from_rotation_y(yaw.to_radians())
* glam::Mat4::from_rotation_x(90f32.to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(-0.3125, -0.3125, 0.0))
* glam::Mat4::from_scale(glam::Vec3::splat(0.375));
self.submit_item_world(&item, pose, light, out);
}
}
}
/// `Containers.dropItemStack(level, x, y, z, stack)` at the block's
/// integer position.
fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
let at = BlockPos::new(pos.0, pos.1, pos.2);
for piece in
horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count)
{
self.spawn_item(
ItemStack {
count: piece.count,
..stack.clone()
},
piece.pos,
piece.motion,
0,
);
}
}
}
+5 -1
View File
@@ -373,6 +373,7 @@ impl Game<'_> {
// Drawn seated (Entity.isPassenger); set apart because the
// json! above is at the macro recursion limit.
state["passenger"] = m.extra.passenger.into();
state["guardian_beam"] = serde_json::json!(horizon_client::mobs::guardian::beam(m, 0.0));
state
})
.collect();
@@ -1065,7 +1066,10 @@ impl Game<'_> {
}
MobEvent::BreakBlock { pos, drop } => {
let old = self.world.block(pos[0], pos[1], pos[2]);
self.destroy_particles(pos, old);
// Block.spawnDestroyParticles: fire breaks leave nothing.
if !self.res.facts().get(old).has_class("BaseFireBlock") {
self.destroy_particles(pos, old);
}
if drop {
let drops = self.res.items().block_drops_in(
self.pack.assets.registry,
+52 -6
View File
@@ -1560,6 +1560,7 @@ impl Game<'_> {
y -= 1.0;
}
self.breath_clouds.push(Breath {
id: 0,
at: [x, y.floor() + 1.0, z],
radius: 5.0,
age: 0,
@@ -2022,6 +2023,7 @@ impl Game<'_> {
// assignDirectionalMovement: the direction times the acceleration power 0.1.
let velocity = Vec3::new(v.x * 0.1, v.y * 0.1, v.z * 0.1);
self.dragon_fireballs.push(DragonFireball {
id: 0,
position: at,
previous: at,
velocity,
@@ -2068,6 +2070,7 @@ impl Game<'_> {
});
if hit_block || hit_player || b.age > 600 {
self.breath_clouds.push(Breath {
id: 0,
at: [next.x, next.y, next.z],
radius: 3.0,
age: 0,
@@ -2197,15 +2200,58 @@ impl Game<'_> {
}
}
pub(super) struct DragonFireball {
position: Vec3,
previous: Vec3,
velocity: Vec3,
/// Its entity id once a server's players track it (0 until then).
pub(super) id: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
pub(super) velocity: Vec3,
age: i32,
}
impl DragonFireball {
/// One a server sent (`handleAddEntity`).
pub(super) fn remote(id: u32, at: Vec3, velocity: Vec3) -> Self {
Self {
id,
position: at,
previous: at,
velocity,
age: 0,
}
}
/// `AbstractHurtingProjectile.tick` on a server's client: `applyInertia`
/// first (power 0.1, inertia 0.95 or 0.8 in water), then the move; only
/// the server decides what it hits.
pub(super) fn remote_step(&mut self, water: bool) {
self.previous = self.position;
self.age += 1;
let v = self.velocity;
// Vec3.normalize: a vector shorter than 1e-5 stays zero.
let l = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt();
let n = if l < 1.0e-5 {
Vec3::new(0.0, 0.0, 0.0)
} else {
Vec3::new(v.x / l, v.y / l, v.z / l)
};
let inertia = f64::from(if water { 0.8f32 } else { 0.95f32 });
let v = Vec3::new(
(v.x + n.x * 0.1) * inertia,
(v.y + n.y * 0.1) * inertia,
(v.z + n.z * 0.1) * inertia,
);
self.velocity = v;
self.position = Vec3::new(
self.position.x + v.x,
self.position.y + v.y,
self.position.z + v.z,
);
}
}
pub(super) struct Breath {
at: [f64; 3],
radius: f32,
age: i32,
/// Its entity id once a server's players track it (0 until then).
pub(super) id: u32,
pub(super) at: [f64; 3],
pub(super) radius: f32,
pub(super) age: i32,
duration: i32,
from_dragon: bool,
}
+15 -4
View File
@@ -355,12 +355,23 @@ impl Game<'_> {
* glam::DMat4::from_rotation_y(y_rot.to_radians());
let light = self.world.light(f.pos[0], f.pos[1], f.pos[2]);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// ItemFrameRenderer.getBlockLightLevel: a glow frame's model is at least 5 bright.
let frame_light = if f.glow {
(light & 0xF0) | (light & 15).max(5)
} else {
light
};
let frame_packed =
(u32::from(frame_light >> 4) * 16) | ((u32::from(frame_light & 15) * 16) << 8);
let mut quad =
|m: &glam::DMat4, corners: [[f64; 3]; 4], uv: [[f32; 2]; 4], light: u32| {
for (c, uv) in corners.iter().zip(uv) {
let p = m.transform_point3(glam::DVec3::from_array(*c));
// The frame model is drawn with the entity shader, lit by its face normal.
let at = corners.map(|c| m.transform_point3(glam::DVec3::from_array(c)));
let n = (at[1] - at[0]).cross(at[2] - at[0]).normalize_or_zero();
let normal = [0, 1, 2].map(|k| (n[k].clamp(-1.0, 1.0) * 127.0) as i8);
for (p, uv) in at.into_iter().zip(uv) {
out.push(Vertex {
normal: [0; 4],
normal: [normal[0], normal[1], normal[2], -127],
pos: p.as_vec3().to_array(),
uv,
layer: 0,
@@ -393,7 +404,7 @@ impl Game<'_> {
]
}),
map_uv(r, *uv),
packed,
frame_packed,
);
}
}
+28 -7
View File
@@ -14,7 +14,7 @@ use horizon_client::{
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::{Mob, leash::Holder},
mobs::{Mob, Who, leash::Holder},
text::Text,
};
use horizon_core::geometry::Vec3;
@@ -147,6 +147,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
fn mob_meta(
m: &Mob,
hub: &Hub,
player_entity: Option<i32>,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
@@ -196,6 +197,25 @@ fn mob_meta(
let e = &m.extra;
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
match kind {
"minecraft:guardian" | "minecraft:elder_guardian" => {
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
if e.flag2 {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
let target = if e.flag {
m.ai.target.and_then(|who| match who {
Who::Player => player_entity,
Who::Mob(id) => Some(id as i32),
})
} else {
None
};
if let Some(target) = target {
meta.push((17, serializer::INT, Value::Int(target)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
defaults.push((17, serializer::INT, Value::Int(0)));
}
// Bat.DATA_ID_FLAGS: resting in bit 0, flying by default.
"minecraft:bat" => {
if e.flag {
@@ -831,12 +851,13 @@ impl Game<'_> {
for m in self.mobs.iter().filter(|m| !m.removed) {
// The lead's holder: the player the mob sees (a guest, else the
// host) or another mob. A fence knot isn't an entity here.
let player_entity = mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
.map(|g| g.entity_id)
.or(host_entity);
let leash = m.extra.leash.and_then(|h| match h {
Holder::Player => mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
.map(|g| g.entity_id)
.or(host_entity),
Holder::Player => player_entity,
Holder::Mob(id) => Some(id as i32),
Holder::Knot(_) => None,
});
@@ -846,7 +867,7 @@ impl Game<'_> {
e.on_ground = m.on_ground;
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
e.needs_sync = m.needs_sync;
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
e.leash = leash;
+45 -1
View File
@@ -77,6 +77,10 @@ pub(super) struct GuestPlay {
last_inventory: u64,
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
pub(super) portal: horizon_client::nether_portal::PortalState,
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
/// its client's PERFORM_RESPAWN (`respawn_requested`).
pub(super) won_game: bool,
pub(super) respawn_requested: bool,
/// `Player.takeXpDelay`: ticks until the next orb can be taken.
xp_delay: u32,
/// What last killed the guest outright (`/kill`): the death message's key.
@@ -875,7 +879,11 @@ impl Game<'_> {
.filter(|g| host.hub.here(g))
.filter(|g| {
g.menu.as_ref().is_some_and(|m| {
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|| m.pos.is_some_and(|k| {
k.1 == super::minecarts::CART_KEY_Y
&& !self.chest_vehicle_still_valid(k.2, g)
})
})
})
.map(|g| g.conn)
@@ -889,6 +897,35 @@ impl Game<'_> {
}
}
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
/// boat is still there and within the guest's entity interaction range
/// plus 4 of its box.
fn chest_vehicle_still_valid(&self, id: i32, g: &horizon_client::lan::guest::Guest) -> bool {
let bounds = self
.carts
.iter()
.find(|c| c.id as i32 == id)
.map(|c| c.bounds())
.or_else(|| {
self.boats
.iter()
.find(|b| b.id as i32 == id)
.map(|b| b.bounds())
});
let Some(b) = bounds else {
return false;
};
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let (lo, hi) = (b.min_position(), b.max_position());
let d = [
eye[0] - eye[0].clamp(lo.x, hi.x),
eye[1] - eye[1].clamp(lo.y, hi.y),
eye[2] - eye[2].clamp(lo.z, hi.z),
];
let range = if g.creative() { 5.0 } else { 3.0 } + 4.0;
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
}
/// A packet from a guest that the hub does not handle itself.
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
@@ -1314,6 +1351,13 @@ impl Game<'_> {
self.guest_suggestions(host, conn, &payload);
}
S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p),
// handleClientCommand: PERFORM_RESPAWN after the poem leaves the End
// (the next server tick sends the player on).
S::ClientCommand(c)
if c.action == 0 && host.play.get(&conn).is_some_and(|p| p.won_game) =>
{
host.play.entry(conn).or_default().respawn_requested = true;
}
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
// ServerStatsCounter.sendStats.
S::ClientCommand(c) if c.action == 1 => {
@@ -289,6 +289,10 @@ impl Game<'_> {
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
self.potions.thrown_ids().for_each(&mut give);
self.end.eye_ids().for_each(&mut give);
self.breath_clouds.iter_mut().for_each(|b| give(&mut b.id));
self.dragon_fireballs
.iter_mut()
.for_each(|b| give(&mut b.id));
}
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
@@ -445,6 +449,35 @@ impl Game<'_> {
}
out.push(e);
}
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
// client draws them from these (`tick_remote_clouds`).
let breath = self.network_particle(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::DragonBreath,
),
None,
);
for b in self.dragon_fireballs.iter().filter(|b| b.id != 0) {
let p = b.position;
let mut e = state(b.id, "minecraft:dragon_fireball", at(p), 0.0, false);
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
out.push(e);
}
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
if b.age < 20 {
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
}
if let Some(p) = &breath {
e.meta
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
}
e.defaults
.push((9, serializer::BOOLEAN, Value::Bool(false)));
out.push(e);
}
out.extend(self.lan_dragon_state());
}
}
@@ -95,7 +95,11 @@ impl Game<'_> {
/// The options a server's particle stands for (None for a type or data
/// this client doesn't draw).
fn received_particle(&mut self, p: &Particle, at: [f64; 3]) -> Option<ParticleOptions> {
pub(super) fn received_particle(
&mut self,
p: &Particle,
at: [f64; 3],
) -> Option<ParticleOptions> {
let name = self
.remote
.as_ref()?
@@ -144,15 +144,16 @@ impl Game<'_> {
.offers
.iter()
.filter_map(|o| {
let cost =
|st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| play::NetCost {
item: hub.item_id(&st.item),
count: st.count as i32,
};
let cost = |st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| {
hub.stack(&Some(st.clone())).map(play::NetCost::from)
};
Some(play::NetOffer {
cost_a: cost(&o.base_cost_a.stack, &mut host.hub),
cost_a: cost(&o.base_cost_a.stack, &mut host.hub)?,
result: host.hub.stack(&Some(o.result.clone()))?,
cost_b: o.cost_b.as_ref().map(|b| cost(&b.stack, &mut host.hub)),
cost_b: match &o.cost_b {
Some(b) => Some(cost(&b.stack, &mut host.hub)?),
None => None,
},
out_of_stock: o.out_of_stock(),
uses: o.uses,
max_uses: o.max_uses,
+3 -3
View File
@@ -88,8 +88,8 @@ pub(super) struct Minecart {
/// Hopper minecarts: an unpowered activator rail enables them.
pub enabled: bool,
}
const WIDTH: f64 = 0.98;
const HEIGHT: f64 = 0.7;
pub(super) const WIDTH: f64 = 0.98;
pub(super) const HEIGHT: f64 = 0.7;
/// AbstractMinecart.getDefaultDisplayOffset, in sixteenths of a block.
fn display_offset(kind: CartKind) -> f64 {
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
@@ -97,7 +97,7 @@ fn display_offset(kind: CartKind) -> f64 {
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
pub(super) const SEAT: f64 = 0.1875 - 0.6;
/// The placeholder container key under which an open cart's items live.
pub(super) const CART_KEY_Y: i32 = -100_000;
pub(super) const CART_KEY_Y: i32 = horizon_client::lan::menu::VEHICLE_KEY_Y;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Shape {
+56
View File
@@ -68,6 +68,10 @@ pub(super) struct ModsState {
/// is still being shown, and a mod answering its own lines would recurse.
#[cfg(feature = "mods")]
commands: Vec<String>,
/// `command.run-quiet` batches for this window's own world, by mod id,
/// run on the next tick as `commands` are.
#[cfg(feature = "mods")]
quiet: Vec<(String, Vec<String>)>,
/// The server's command tree with the running mods' commands added.
#[cfg(feature = "mods")]
remote_tree: Option<Dispatcher>,
@@ -505,6 +509,51 @@ impl Game<'_> {
}
None
}
/// A mod's `command.run-quiet` lines (vanilla's commands only, as
/// `command.run`): to a server that runs them quietly as one payload
/// (`horizon_protocol::quiet`), to any other as chat commands, or for this
/// window's own world next tick.
fn mod_quiet_commands(&mut self, mod_id: String, lines: Vec<String>) {
let lines: Vec<String> = lines
.into_iter()
.filter(|l| vanilla_command(l.split(' ').next().unwrap_or_default()))
.collect();
match self.remote.as_ref().map(|r| r.quiet_commands) {
Some(true) => {
for data in horizon_protocol::quiet::encode(&mod_id, &lines) {
self.send(horizon_protocol::packets::play::Serverbound::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: horizon_protocol::quiet::CHANNEL.into(),
data,
},
));
}
}
// The server checks the player's permissions; its feedback shows.
Some(false) => {
for line in lines {
self.send_chat(&format!("/{line}"));
}
}
None => self.mods.quiet.push((mod_id, lines)),
}
}
/// A quiet batch in this window's own world, as a server runs one
/// (game/lan_quiet.rs): each line as the player typing it, no chat, a
/// chat message refused; one line in the log.
fn run_quiet_batch(&mut self, mod_id: &str, lines: &[String]) {
let permission = self.ui.registries.permission;
let mut failed = 0;
for line in lines {
let ok = self.signable_arguments(permission, line).is_empty()
&& self.run_command_quietly(line);
failed += usize::from(!ok);
}
log::info!(
"[mod {mod_id}] ran {} quiet command(s): {failed} failed",
lines.len()
);
}
/// A game tick.
pub(super) fn tick_mods(&mut self) {
// Here, at the player's level: shown inside a console call (a control
@@ -512,6 +561,9 @@ impl Game<'_> {
for line in std::mem::take(&mut self.mods.commands) {
self.run_command(&line);
}
for (mod_id, lines) in std::mem::take(&mut self.mods.quiet) {
self.run_quiet_batch(&mod_id, &lines);
}
if !self.mods.mods.is_running() {
return;
}
@@ -606,6 +658,10 @@ impl Game<'_> {
}
continue;
}
Output::QuietCommands { mod_id, lines } => {
self.mod_quiet_commands(mod_id, lines);
continue;
}
Output::Stopped { name, reason } => {
// Its screen goes with it: the entry's run is gone, so
// `mod_ui_view` finds no widgets and the screen closes.
@@ -454,11 +454,24 @@ impl Game<'_> {
for m in &self.mobs {
self.portals.before.insert(m.id, m.position);
}
for b in &self.boats {
self.portals.before.insert(b.id, b.position);
}
for c in &self.carts {
self.portals.before.insert(c.id, c.position);
}
let ids: Vec<(u32, bool)> = self
.dropped
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.chain(
self.boats
.iter()
.map(|b| b.id)
.chain(self.carts.iter().map(|c| c.id))
.map(|id| (id, !self.vehicle_occupied(id))),
)
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.portals.entities.retain(|id, _| present.contains(id));
@@ -501,8 +514,15 @@ impl Game<'_> {
&& self.riding != Some(m.id)
&& m.species() != Species::Wither
}
/// `NetherPortalBlock.entityInside` for the items and mobs that moved this
/// tick: the portal blocks their box swept through.
/// Whether the player, a server player or a mob rides vehicle `id`.
fn vehicle_occupied(&self, id: u32) -> bool {
self.riding == Some(id)
|| self.ridden_by_guest(id)
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
/// and minecarts that moved this tick: the portal blocks their box swept
/// through.
pub(super) fn find_entity_portals(&mut self) {
if self.remote.is_some() {
return;
@@ -519,8 +539,23 @@ impl Game<'_> {
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.collect();
let vehicles: Vec<(u32, Vec3, f32, f32)> = self
.boats
.iter()
.map(|b| (b.id, b.position, super::boats::WIDTH, super::boats::HEIGHT))
.chain(self.carts.iter().map(|c| {
(
c.id,
c.position,
super::minecarts::WIDTH,
super::minecarts::HEIGHT,
)
}))
.filter(|(id, ..)| !self.vehicle_occupied(*id))
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
.collect();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
for (id, to, width, height) in items.into_iter().chain(mobs) {
for (id, to, width, height) in items.into_iter().chain(mobs).chain(vehicles) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
@@ -678,6 +713,44 @@ impl Game<'_> {
}
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = &self.boats[i];
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
let plan = self.portal_plan(entry, b.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let b = &self.boats[i];
let tag = horizon_client::entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
uuid,
self.persist_raw(id),
b.chest.then_some(&b.items[..]),
);
self.boats.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let c = &self.carts[i];
let (width, height) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
let plan = self.portal_plan(entry, c.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
self.carts.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
}
}
/// Entities waiting for the loaded dimension whose exit area is loaded:
@@ -71,6 +71,8 @@ pub(super) struct BodyPotions {
pub(super) struct PotionState {
pub(super) effects: PlayerEffects,
thrown: Vec<ThrownPotion>,
/// The area effect clouds a server tracks (game/remote_projectiles.rs).
pub(super) clouds: Vec<super::remote_projectiles::RemoteCloud>,
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
/// Armor the protection table was computed for.
protection_for: Option<Vec<Option<ItemStack>>>,
+42 -4
View File
@@ -109,6 +109,8 @@ enum Received {
pub(super) struct Remote {
pub session: Session,
pub brand: String,
/// The server announced it runs mods' quiet commands.
pub quiet_commands: bool,
pub(super) builtin: Arc<Builtin>,
lang: Arc<Language>,
pub(super) registries: Arc<Dynamic>,
@@ -419,6 +421,7 @@ impl Game<'_> {
self.remote = Some(Remote {
session,
brand: "unknown".into(),
quiet_commands: false,
builtin: Arc::new(builtin),
lang,
registries: Arc::new(Dynamic::default()),
@@ -839,6 +842,11 @@ impl Game<'_> {
remote.brand = brand;
}
}
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
Event::Reconfigured(registries) => {
self.boss_overlay.reset();
self.set_registries(registries);
@@ -1398,6 +1406,14 @@ impl Game<'_> {
{
return;
}
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
// the server's position replaces its own at once.
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
if path.is_some() {
b.position = Vec3::new(position[0], position[1], position[2]);
}
return;
}
if let Some(p) = r.pending_items.get_mut(&id) {
p.0 = position;
}
@@ -2979,6 +2995,8 @@ impl Game<'_> {
.find(|p| p.id == id as u32)
{
p.velocity = v;
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
b.velocity = v;
} else if let Some(p) = self
.remote
.as_mut()
@@ -3462,6 +3480,13 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Creeper {
horizon_client::mobs::hostile::creeper_client_tick(m);
}
if matches!(
m.species(),
horizon_client::mobs::Species::Guardian
| horizon_client::mobs::Species::ElderGuardian
) {
horizon_client::mobs::guardian::client_tick(m);
}
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
@@ -3734,6 +3759,15 @@ impl Game<'_> {
/// MultiPlayerGameMode.startDestroyBlock / continueDestroyBlock.
fn remote_mining(&mut self, start: bool) {
if !self
.res
.items()
.can_destroy_block(self.held(), self.creative)
{
self.stop_remote_mining();
self.mining = None;
return;
}
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -4868,16 +4902,20 @@ impl Game<'_> {
if self.open_menu_name(p.container) != "minecraft:merchant" {
return;
}
let cost = |g: &Self, c: &play::NetCost| horizon_client::merchant::ItemCost {
stack: ItemStack::new(&g.item_name(c.item), c.count.max(0) as u32),
let cost = |g: &Self, c: &play::NetCost| {
g.to_stack(&Some(c.clone().into()))
.map(|stack| horizon_client::merchant::ItemCost { stack })
};
let offers: Vec<_> = p
.offers
.iter()
.filter_map(|o| {
let mut offer = horizon_client::merchant::MerchantOffer {
base_cost_a: cost(self, &o.cost_a),
cost_b: o.cost_b.as_ref().map(|b| cost(self, b)),
base_cost_a: cost(self, &o.cost_a)?,
cost_b: match &o.cost_b {
Some(b) => Some(cost(self, b)?),
None => None,
},
result: self.to_stack(&Some(o.result.clone()))?,
uses: o.uses,
max_uses: o.max_uses,
@@ -4,14 +4,14 @@
//! own simulation, which a client of a server doesn't run.
use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{Mob, Species, bat, tame};
use horizon_client::mobs::{Mob, Species, Who, bat, tame};
use horizon_protocol::entity_data::Value;
use horizon_protocol::registry::Builtin;
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
/// `builtin` names the ids of built-in registries.
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_entity: i32) {
let species = m.species();
let baby = super::lan::baby_index(&m.kind.id);
for (index, _, value) in values {
@@ -46,6 +46,19 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
_ => {}
}
match (species, *index, value) {
(Species::Guardian | Species::ElderGuardian, 16, Value::Bool(moving)) => {
m.extra.flag2 = *moving;
}
(Species::Guardian | Species::ElderGuardian, 17, Value::Int(target)) => {
// Guardian.onSyncedDataUpdated resets the visual charge clock.
m.extra.counter2 = 0;
m.extra.flag = *target != 0;
m.ai.target = (*target != 0).then_some(if *target == local_entity {
Who::Player
} else {
Who::Mob(*target as u32)
});
}
// Bat.DATA_ID_FLAGS: resting in bit 0.
(Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0),
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
@@ -116,7 +129,7 @@ impl Game<'_> {
return;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
apply(m, values, &r.builtin);
apply(m, values, &r.builtin, r.entity_id);
}
}
}
@@ -153,7 +166,7 @@ mod tests {
}
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
super::apply(m, values, &builtin());
super::apply(m, values, &builtin(), 99);
}
fn mob(kind: &str) -> Option<Mob> {
@@ -164,6 +177,49 @@ mod tests {
Some(m)
}
#[test]
fn guardian_charge_follows_target_updates_and_caps_at_its_duration() {
use horizon_client::mobs::guardian::{beam, client_tick};
for (kind, duration) in [("guardian", 80), ("elder_guardian", 60)] {
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
return;
};
assert!(beam(&m, 0.0).is_none());
apply(
&mut m,
&[
(16, s::BOOLEAN, Value::Bool(true)),
(17, s::INT, Value::Int(99)),
],
);
assert!(m.extra.flag2);
assert_eq!(m.ai.target, Some(Who::Player));
assert_eq!(beam(&m, 0.0), Some((0.0, 0.0)));
for _ in 0..duration + 10 {
client_tick(&mut m);
}
assert_eq!(beam(&m, 0.0), Some((duration as f32, 1.0)));
// Another player or a mob retains its network entity id.
apply(&mut m, &[(17, s::INT, Value::Int(42))]);
assert_eq!(m.ai.target, Some(Who::Mob(42)));
assert_eq!(m.extra.counter2, 0);
client_tick(&mut m);
assert_eq!(m.extra.counter2, 1);
apply(
&mut m,
&[
(16, s::BOOLEAN, Value::Bool(false)),
(17, s::INT, Value::Int(0)),
],
);
client_tick(&mut m);
assert!(!m.extra.flag2);
assert_eq!(m.ai.target, None);
assert_eq!(m.extra.counter2, 0);
assert!(beam(&m, 0.0).is_none());
}
}
#[test]
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
let Some(mut m) = mob("minecraft:sheep") else {
@@ -3,11 +3,26 @@
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
use horizon_protocol::entity_data::Value;
use horizon_world::registry::BlockStateId;
/// An `AreaEffectCloud` a server tracks: what its client tick reads.
pub(super) struct RemoteCloud {
id: u32,
at: [f64; 3],
radius: f32,
waiting: bool,
particle: ParticleOptions,
}
/// `AreaEffectCloud.DEFAULT_PARTICLE`: a white entity effect.
fn default_cloud_particle() -> ParticleOptions {
ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4])
}
/// The projectile a type id names, as Horizon models it.
fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
Some(match kind {
@@ -118,6 +133,16 @@ impl Game<'_> {
| "minecraft:experience_bottle" => {
self.potions.add_remote_thrown(id, kind, at, velocity);
}
"minecraft:dragon_fireball" => self
.dragon_fireballs
.push(super::dragon::DragonFireball::remote(id, at, velocity)),
"minecraft:area_effect_cloud" => self.potions.clouds.push(RemoteCloud {
id,
at: position,
radius: 3.0,
waiting: false,
particle: default_cloud_particle(),
}),
"minecraft:eye_of_ender" => {
self.end
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
@@ -151,6 +176,31 @@ impl Game<'_> {
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
let id = id as u32;
self.remote_hook_data(id, values);
if let Some(at) = self
.potions
.clouds
.iter()
.find(|c| c.id == id)
.map(|c| c.at)
{
// AreaEffectCloud.DATA_RADIUS, DATA_WAITING and DATA_PARTICLE.
for (index, _, value) in values {
let particle = match (index, value) {
(10, Value::Particle(p)) => self.received_particle(p, at),
_ => None,
};
let Some(c) = self.potions.clouds.iter_mut().find(|c| c.id == id) else {
return;
};
match (index, value) {
(8, Value::Float(r)) => c.radius = *r,
(9, Value::Bool(w)) => c.waiting = *w,
(10, _) => c.particle = particle.unwrap_or_else(default_cloud_particle),
_ => {}
}
}
return;
}
if self.rockets.iter().any(|r| r.id == id) {
for (index, _, value) in values {
match (index, value) {
@@ -336,6 +386,75 @@ impl Game<'_> {
self.step_remote_thrown();
self.step_remote_hooks(&mut step);
self.end.step_remote_eyes();
self.tick_remote_clouds();
// The dragon's fireballs fly on between the tracker's updates, with
// their dragon_breath trail (`AbstractHurtingProjectile.getTrailParticle`).
let trail = ParticleOptions::simple(ParticleType::DragonBreath);
for b in &mut self.dragon_fireballs {
let at = b.position;
let water = self
.res
.facts()
.get(self.world.block(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
))
.fluid
== horizon_client::blocks::Fluid::Water;
b.remote_step(water);
let p = b.position;
self.particles.add(&trail, [p.x, p.y + 0.5, p.z], [0.0; 3]);
}
}
/// `AreaEffectCloud.clientTick`: while waiting, now and then two
/// particles near the middle; then about one per square block of the
/// disc, drifting (an entity effect stays still).
fn tick_remote_clouds(&mut self) {
for c in &self.potions.clouds {
let r = self.particles.random();
if c.waiting && r.next_boolean() {
continue;
}
let (count, spread) = if c.waiting {
(2, 0.2)
} else {
(
(std::f32::consts::PI * c.radius * c.radius).ceil() as i32,
c.radius,
)
};
for _ in 0..count {
let r = self.particles.random();
let angle = r.next_float() * std::f32::consts::TAU;
let distance = r.next_float().sqrt() * spread;
let at = [
c.at[0] + f64::from(angle.cos() * distance),
c.at[1],
c.at[2] + f64::from(angle.sin() * distance),
];
let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect {
let default = c.waiting && r.next_boolean();
let options = if default {
default_cloud_particle()
} else {
c.particle.clone()
};
(options, [0.0; 3])
} else if c.waiting {
(c.particle.clone(), [0.0; 3])
} else {
let v = [
(0.5 - r.next_double()) * 0.15,
f64::from(0.01f32),
(0.5 - r.next_double()) * 0.15,
];
(c.particle.clone(), v)
};
self.particles.add(&options, at, velocity);
}
}
}
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
@@ -346,6 +465,8 @@ impl Game<'_> {
self.rockets.retain(|r| r.id != id);
self.crystals.retain(|c| c.id != id);
self.potions.remove_remote_thrown(id);
self.potions.clouds.retain(|c| c.id != id);
self.dragon_fireballs.retain(|b| b.id != id);
self.end.remove_remote_eye(id);
self.remove_remote_hook(id);
}
@@ -361,6 +482,8 @@ impl Game<'_> {
// The dragon a server sent goes with its crystals.
self.dragon = None;
self.potions.clear_thrown();
self.potions.clouds.clear();
self.dragon_fireballs.clear();
self.end.clear_remote_eyes();
self.clear_remote_hooks();
}
@@ -13,6 +13,7 @@ use super::respawn_anchor::RespawnConfig;
use super::{Dim, Game, lan::LanHost};
use horizon_client::{lan::hub::TickCtx, text::Text};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// A trip under way: the player is in the destination's level, whose
/// chunks are loading around `target`.
@@ -103,8 +104,35 @@ impl Game<'_> {
if let Some(pos) = self.nether_portal_inside(at) {
self.portals.player.set_inside(pos, PLAYER_DELAY);
}
// `ServerPlayer.showEndCredits`: the player stays out of the world while the
// poem rolls, then PERFORM_RESPAWN takes it to its respawn point.
let play = host.play.entry(conn).or_default();
if play.won_game {
if std::mem::take(&mut play.respawn_requested) {
play.won_game = false;
self.start_server_trip(host, conn, Dim::Overworld, None);
self.portals.player.cooldown = 10;
}
return;
}
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
if end_portal {
// EndPortalBlock.entityInside: the first exit from the End shows the credits.
if self.dim == Dim::End && !host.hub.guest(conn).is_some_and(|g| g.seen_credits) {
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn) {
g.seen_credits = true;
}
host.play.entry(conn).or_default().won_game = true;
// ClientboundGameEventPacket.WIN_GAME.
host.hub.send_to(
conn,
&C::GameEvent(play::GameEvent {
event: 4,
param: 0.0,
}),
);
return;
}
if self.portals.player.cooldown == 0 {
let target = if self.dim == Dim::End {
Dim::Overworld
+6 -2
View File
@@ -398,8 +398,12 @@ impl GameLevel<'_> {
}
}
PreRemove::Campfire => {
for _ in 0..4 {
self.drop_item_stack(pos, None);
let items = match &entity {
Some(BlockEntity::Campfire(c)) => c.items.clone(),
_ => Default::default(),
};
for stack in items {
self.drop_item_stack(pos, stack);
}
}
PreRemove::Jukebox => {
+5 -1
View File
@@ -1703,6 +1703,8 @@ impl Game<'_> {
};
let name = if let Some(n) = stack.custom_name() {
n.to_owned()
} else if let Some(n) = stack.item_name() {
n
} else if stack.data().is_some_and(|d| d.ominous_banner) {
self.translate("block.minecraft.ominous_banner", "Ominous Banner")
} else if stack.data().and_then(|d| d.potion.as_ref()).is_some()
@@ -1729,7 +1731,9 @@ impl Game<'_> {
// ItemContainerContents.addToTooltip.
let hover_name = |s: &ItemStack| {
s.custom_name()
.map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned)
.map(str::to_owned)
.or_else(|| s.item_name())
.unwrap_or_else(|| self.pack.names.display_item(&s.item))
};
if display.shows("minecraft:container") {
for text in
+8
View File
@@ -204,6 +204,14 @@ impl ItemStack {
pub fn custom_name(&self) -> Option<&str> {
self.data().and_then(|d| d.custom_name.as_deref())
}
/// `minecraft:item_name` in the current language (`getItemName`'s
/// component). It stays in the patch as loaded, so a translatable name
/// saves and reaches clients unchanged; the ominous banner's is modelled
/// by `ominous_banner` instead.
pub fn item_name(&self) -> Option<String> {
let name = self.data()?.other("minecraft:item_name")?;
Some(crate::entity_nbt::name_text(name).plain())
}
pub fn potion(&self) -> Option<&str> {
self.data().and_then(|d| d.potion.as_deref())
}
+85
View File
@@ -238,6 +238,85 @@ mod tests {
use super::*;
use horizon_protocol::registry::Ids;
#[test]
fn merchant_water_cost_survives_the_offer_packet() {
use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::play::{NetOffer, Offers},
};
let mut components = vec![String::new(); component::POTION_CONTENTS as usize + 1];
components[component::POTION_CONTENTS as usize] = "minecraft:potion_contents".into();
let builtin = Builtin {
registries: [
(
"minecraft:item".into(),
Ids::from_names(vec!["minecraft:potion".into(), "minecraft:emerald".into()]),
),
(
"minecraft:potion".into(),
Ids::from_names(vec!["minecraft:water".into()]),
),
(
"minecraft:data_component_type".into(),
Ids::from_names(components),
),
]
.into(),
};
let dynamic = Dynamic::default();
let water = ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water");
let wire = crate::lan::guest::stack_to_network(
|name| builtin.get("minecraft:item").unwrap().id(name).unwrap(),
|registry, name| builtin.get(registry)?.id(name),
&Some(water.clone()),
)
.unwrap();
let offers = vec![NetOffer {
cost_a: wire.into(),
result: Stack::new(1, 1),
cost_b: Some(Stack::new(1, 2).into()),
out_of_stock: false,
uses: 0,
max_uses: 2,
xp: 0,
special_price: 0,
price_multiplier: 0.05,
demand: 0,
}];
let mut w = Writer::new();
Offers::write(&offers, &mut w);
let mut r = Reader::new(&w.data);
let decoded = Offers::read(&mut r).unwrap();
assert_eq!(r.remaining(), 0);
let cost = crate::merchant::ItemCost {
stack: from_network(&decoded[0].cost_a.clone().into(), &builtin, &dynamic),
};
assert_eq!(cost.stack, water);
assert_eq!(cost.stack.potion(), Some("minecraft:water"));
let authoritative = crate::merchant::ItemCost {
stack: water.clone(),
};
for payment in [
water,
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:swiftness"),
ItemStack::new("minecraft:potion", 1),
] {
assert_eq!(cost.test(&payment), authoritative.test(&payment));
}
assert!(cost.test(&ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water")));
assert!(
!cost.test(&ItemStack::new("minecraft:potion", 1).with_potion("minecraft:swiftness"))
);
let emerald = crate::merchant::ItemCost {
stack: from_network(
&decoded[0].cost_b.clone().unwrap().into(),
&builtin,
&dynamic,
),
};
assert!(emerald.test(&ItemStack::new("minecraft:emerald", 2)));
}
/// A bundle's contents cross the wire, so a server's client can draw
/// its tooltip (BundleContents' stream codec: a list of templates).
#[test]
@@ -286,6 +365,7 @@ mod tests {
for (id, name) in [
(component::DAMAGE, "minecraft:damage"),
(component::CUSTOM_NAME, "minecraft:custom_name"),
(component::ITEM_NAME, "minecraft:item_name"),
(component::ENCHANTMENTS, "minecraft:enchantments"),
(component::POTION_CONTENTS, "minecraft:potion_contents"),
(component::REPAIR_COST, "minecraft:repair_cost"),
@@ -345,6 +425,10 @@ mod tests {
.push((Arc::from("minecraft:sharpness"), 2));
d.repair_cost = 9;
d.custom_name = Some("Keeper".to_owned());
// A translatable item name reaches the client as the same component.
let item_name = Tag::compound([("translate", Tag::String("filled_map.monument".into()))]);
d.other
.push(("minecraft:item_name".into(), item_name.clone()));
d.trim = Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")));
let id = |registry: &str, name: &str| {
builtin
@@ -374,6 +458,7 @@ mod tests {
);
assert_eq!(d.repair_cost, 9);
assert_eq!(d.custom_name.as_deref(), Some("Keeper"));
assert_eq!(d.other("minecraft:item_name"), Some(&item_name));
assert_eq!(
d.trim,
Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")))
+11
View File
@@ -188,6 +188,9 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
if let Some(name) = stack.custom_name() {
return name.to_owned();
}
if let Some(name) = stack.item_name() {
return name;
}
if stack.data().is_some_and(|d| d.ominous_banner) {
return translate("block.minecraft.ominous_banner")
.unwrap_or_else(|| "Ominous Banner".to_owned());
@@ -611,6 +614,14 @@ mod tests {
assert_eq!(rarity(&banner, "common"), "uncommon");
banner.data_mut().custom_name = Some("Captain's Standard".into());
assert_eq!(name_line(&banner, &translate), "Captain's Standard");
let mut custom_item = ItemStack::new("minecraft:diamond_sword", 1);
custom_item.data_mut().other.push((
"minecraft:item_name".into(),
Tag::String("Excalibur".into()),
));
assert_eq!(name_line(&custom_item, &translate), "Excalibur");
custom_item.data_mut().custom_name = Some("Custom Blade".into());
assert_eq!(name_line(&custom_item, &translate), "Custom Blade");
banner.enchant("minecraft:unbreaking", 1);
assert_eq!(rarity(&banner, "common"), "rare");
let plain = ItemStack::new("minecraft:white_banner", 1);
+26
View File
@@ -71,6 +71,8 @@ pub struct ItemInfo {
/// The `tool` component's `damage_per_block`, when the item has the
/// component (a mace or trident has one without rules).
pub tool_damage_per_block: Option<u32>,
/// `tool.can_destroy_blocks_in_creative` (defaults to true).
pub can_destroy_blocks_in_creative: bool,
pub food: Option<(u32, f32)>,
pub can_always_eat: bool,
/// `consumable`: its timing, sound, particles and `on_consume_effects`.
@@ -558,6 +560,10 @@ impl Items {
tool_damage_per_block: c
.get("minecraft:tool")
.map(|t| t["damage_per_block"].as_u64().unwrap_or(1) as u32),
can_destroy_blocks_in_creative:
c["minecraft:tool"]["can_destroy_blocks_in_creative"]
.as_bool()
.unwrap_or(true),
food,
can_always_eat,
// Consumable.CODEC's defaults.
@@ -758,6 +764,26 @@ impl Items {
None => spec == block,
}
}
/// `Item.canDestroyBlock`: a tool may forbid block destruction with instabuild.
pub fn can_destroy_block(&self, held: Option<&ItemStack>, creative: bool) -> bool {
if !creative {
return true;
}
let Some(held) = held.filter(|s| s.count > 0 && &*s.item != "minecraft:air") else {
return true;
};
if let Some(tool) = held.data().and_then(|d| d.other("minecraft:tool")) {
return tool
.get("can_destroy_blocks_in_creative")
.and_then(|v| match v {
horizon_world::nbt::Tag::Byte(v) => Some(*v),
_ => None,
})
.is_none_or(|v| v != 0);
}
self.get(&held.item)
.is_none_or(|info| info.can_destroy_blocks_in_creative)
}
/// `Tool.getMiningSpeed` for the held item (1.0 without a tool component).
pub fn mining_speed(&self, held: Option<&ItemStack>, block: &str) -> f32 {
let Some(info) = held.and_then(|s| self.get(&s.item)) else {
+5
View File
@@ -952,6 +952,11 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
Some(Tag::compound(entries))
}
BlockEntity::Jukebox(_) | BlockEntity::Lectern(_) => None,
// CampfireBlockEntity.getUpdateTag: the items alone.
BlockEntity::Campfire(c) => Some(Tag::compound([(
"Items",
crate::save::encode_items(&c.items),
)])),
_ => None,
}
}
+10
View File
@@ -237,6 +237,8 @@ pub struct Guest {
pub drag: Option<(u8, Vec<usize>)>,
/// The compound this player was loaded from, for fields Horizon does not model.
pub saved: Option<horizon_world::nbt::Tag>,
/// `ServerPlayer.seenCredits`: the end poem has been shown.
pub seen_credits: bool,
/// `ServerPlayer.recipeBook`.
pub recipe_book: crate::recipe_book::Settings,
/// `PlayerAdvancements`: the criteria obtained, whether they came from
@@ -358,6 +360,7 @@ impl Guest {
next_window: 0,
drag: None,
saved: None,
seen_credits: false,
recipe_book: Default::default(),
advancements: Default::default(),
advancements_loaded: false,
@@ -645,6 +648,13 @@ fn network_stack(
horizon_protocol::item::Component::Text(horizon_protocol::text::literal(name)),
));
}
// The component as loaded, so a translatable name stays one.
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
stack.added.push((
horizon_protocol::item::component::ITEM_NAME,
horizon_protocol::item::Component::Text(name.clone()),
));
}
Some(stack)
}
+7 -1
View File
@@ -37,6 +37,10 @@ pub struct Menu {
pub sent_data: Vec<i16>,
}
/// The y of the key a chest minecart's or chest boat's items are kept under
/// while a menu shows them.
pub const VEHICLE_KEY_Y: i32 = -100_000;
/// Where the slots of a container menu live.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Slots {
@@ -72,10 +76,12 @@ impl Slots {
Slots::Ender => at.into_iter().collect(),
_ => self.halves(),
};
// A vehicle's container is kept under a key, not a block: the game
// checks its reach to the vehicle (`isChestVehicleStillValid`).
self.valid(world)
&& blocks
.iter()
.all(|k| guest.within_block_range([k.0, k.1, k.2], 4.0))
.all(|k| k.1 == VEHICLE_KEY_Y || guest.within_block_range([k.0, k.1, k.2], 4.0))
}
/// Container slot `n`.
pub fn get(self, world: &World, guest: &Guest, mut n: usize) -> Option<ItemStack> {
@@ -85,6 +85,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
guest.respawn_elsewhere = level.respawn_elsewhere.clone();
guest.saved = level.player_extra.clone();
guest.seen_credits = level.seen_credits;
let extra = level.player_extra.as_ref();
let short = |key: &str, default: i64| {
extra
@@ -166,6 +167,7 @@ pub fn store(guest: &Guest, level: &mut Level) {
level.spawn = guest.spawn.map(|s| [s[0] - 0.5, s[1], s[2] - 0.5]);
level.respawn_elsewhere = guest.respawn_elsewhere.clone();
level.on_ground = guest.on_ground;
level.seen_credits = guest.seen_credits;
level.player_extra = guest.saved.clone();
// The live body's fire, air and fall distance (Entity.saveWithoutId),
// or what the file had while the player has no body yet.
+8 -3
View File
@@ -241,9 +241,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
} else {
a.v[SPIKES] += (1.0 - a.v[SPIKES]) * 0.06;
}
if m.extra.flag && m.extra.counter2 < attack_duration(m) {
m.extra.counter2 += 1;
}
client_tick(m);
if in_water {
m.air = 300;
} else if m.on_ground {
@@ -332,6 +330,13 @@ pub fn elder_server_ai_step(m: &mut Mob, cx: &mut Cx) {
]);
}
}
/// Guardian.aiStep's visual attack clock, also used by server clients.
/// Does not run attack goals, movement, damage or random draws.
pub fn client_tick(m: &mut Mob) {
if m.extra.flag && m.extra.counter2 < attack_duration(m) {
m.extra.counter2 += 1;
}
}
/// The beam's clock for rendering: (attack time with the partial tick,
/// attack scale), while a target is locked.
pub fn beam(m: &Mob, partial: f32) -> Option<(f32, f32)> {
+4
View File
@@ -40,6 +40,8 @@ pub enum Event {
Status(String),
/// The server's `minecraft:brand` custom payload.
Brand(String),
/// The server runs mods' quiet commands (`horizon_protocol::quiet`).
QuietCommands,
/// Configuration finished: the play state begins with these registries.
Joined {
registries: Arc<Dynamic>,
@@ -668,6 +670,8 @@ fn receive_brand(payload: &cfg::CustomPayload, send: &Sender<Event>) {
&& let Ok(brand) = horizon_protocol::buf::Reader::new(&payload.data).string(32767)
{
let _ = send.send(Event::Brand(brand));
} else if payload.channel == horizon_protocol::quiet::CHANNEL {
let _ = send.send(Event::QuietCommands);
}
}
+215 -27
View File
@@ -526,6 +526,12 @@ pub fn encode_block_entity(
}
}
BlockEntity::Sculk(s) => entries.extend(s.to_nbt()),
// CampfireBlockEntity.saveAdditional.
BlockEntity::Campfire(c) => {
entries.push(("Items", encode_items(&c.items)));
entries.push(("CookingTimes", Tag::IntArray(c.progress.to_vec())));
entries.push(("CookingTotalTimes", Tag::IntArray(c.total.to_vec())));
}
BlockEntity::Crafter(c) => {
entries.push(("Items", encode_items(&c.items)));
entries.push((
@@ -706,6 +712,18 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
}
BlockEntity::Sign(sign) => sign.decode(tag),
BlockEntity::Sculk(s) => s.load(tag),
// CampfireBlockEntity.loadAdditional: missing time arrays stay zeros.
BlockEntity::Campfire(c) => {
for (slot, item) in decode_items(tag.get("Items"), 4).into_iter().enumerate() {
c.items[slot] = item;
}
let ints = |key: &str| match tag.get(key) {
Some(Tag::IntArray(v)) => std::array::from_fn(|i| v.get(i).copied().unwrap_or(0)),
_ => [0; 4],
};
c.progress = ints("CookingTimes");
c.total = ints("CookingTotalTimes");
}
BlockEntity::Crafter(c) => {
for (slot, item) in decode_items(tag.get("Items"), 9).into_iter().enumerate() {
c.items[slot] = item;
@@ -736,18 +754,67 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
Some((pos, entity))
}
/// What a dimension's saved chunks depend on: skylight and the build height
/// (`DimensionType.minY` and `height`). `Chunk` always holds the Overworld's
/// sections; a level saves only the ones inside its own height.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShape {
pub sky_light: bool,
pub min_y: i32,
pub height: i32,
}
impl LevelShape {
pub const OVERWORLD: Self = Self {
sky_light: true,
min_y: MIN_Y,
height: SECTIONS as i32 * 16,
};
pub const NETHER: Self = Self {
sky_light: false,
min_y: 0,
height: 256,
};
pub const END: Self = Self {
sky_light: true,
min_y: 0,
height: 256,
};
/// The shape of a dimension folder (`dimensions/minecraft/<name>`, or the
/// old `DIM-1` and `DIM1`).
pub fn for_dir(root: &Path) -> Self {
if root.ends_with("the_nether") || root.ends_with("DIM-1") {
Self::NETHER
} else if root.ends_with("the_end") || root.ends_with("DIM1") {
Self::END
} else {
Self::OVERWORLD
}
}
fn min_section(self) -> i32 {
self.min_y >> 4
}
fn section_count(self) -> usize {
(self.height >> 4) as usize
}
fn has_section(self, y: i32) -> bool {
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
}
}
/// A column's sections as `Heightmap.primeHeightmaps` reads them.
struct HeightmapView<'a> {
sections: &'a [Arc<Section>],
tables: &'a SaveTables,
min_y: i32,
height: i32,
}
impl HeightmapChunk for HeightmapView<'_> {
type State = crate::assets::StateFacts;
fn min_y(&self) -> i32 {
MIN_Y
self.min_y
}
fn height(&self) -> i32 {
SECTIONS as i32 * 16
self.height
}
fn highest_section_y(&self) -> i32 {
let top = self
@@ -767,7 +834,7 @@ impl HeightmapChunk for HeightmapView<'_> {
}
}
/// Heightmaps kept after world generation (`ChunkStatus.FULL.heightmapsAfter`).
fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
fn heightmaps(chunk: &Chunk, tables: &SaveTables, shape: LevelShape) -> Tag {
let kinds = [
HeightmapType::WorldSurface,
HeightmapType::OceanFloor,
@@ -776,11 +843,13 @@ fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
];
let mut maps: Vec<Heightmap> = kinds
.iter()
.filter_map(|k| Heightmap::new(*k, MIN_Y, SECTIONS as i32 * 16).ok())
.filter_map(|k| Heightmap::new(*k, shape.min_y, shape.height).ok())
.collect();
let view = HeightmapView {
sections: &chunk.sections,
tables,
min_y: shape.min_y,
height: shape.height,
};
let mut refs: Vec<&mut Heightmap> = maps.iter_mut().collect();
if Heightmap::prime(&view, &mut refs).is_err() {
@@ -810,7 +879,12 @@ pub fn client_heightmaps_in(
.iter()
.filter_map(|(id, k)| Heightmap::new(*k, min_y, height).ok().map(|m| (*id, m)))
.collect();
let view = HeightmapView { sections, tables };
let view = HeightmapView {
sections,
tables,
min_y,
height,
};
let mut refs: Vec<&mut Heightmap> = maps.iter_mut().map(|(_, m)| m).collect();
if Heightmap::prime(&view, &mut refs).is_err() {
return Vec::new();
@@ -836,17 +910,18 @@ fn nibbles(light: &Light, sky: bool) -> Vec<u8> {
}
pub fn encode(chunk: &Chunk, registry: &BlockRegistry, biomes: &Biomes) -> NamedTag {
encode_with(chunk, registry, biomes, None, true)
encode_with(chunk, registry, biomes, None, LevelShape::OVERWORLD)
}
/// The chunk NBT vanilla's `SerializableChunkData.write` produces for a full
/// chunk. `sky_light` is false in dimensions without skylight.
/// chunk in a level of `shape`.
pub fn encode_with(
chunk: &Chunk,
registry: &BlockRegistry,
biomes: &Biomes,
tables: Option<&SaveTables>,
sky_light: bool,
shape: LevelShape,
) -> NamedTag {
let sky_light = shape.sky_light;
let mut sections = Vec::new();
let air = tables.map_or(0, |t| t.air);
// Sky layers below the highest section with light other than 15 are
@@ -858,6 +933,9 @@ pub fn encode_with(
});
for (si, section) in chunk.sections.iter().enumerate() {
let y = (MIN_Y >> 4) + si as i32;
if !shape.has_section(y) {
continue;
}
// PalettedContainer.pack: distinct states in index order, indices at
// the saved width.
let packed = section.blocks.pack();
@@ -987,7 +1065,7 @@ pub fn encode_with(
("DataVersion", Tag::Int(DATA_VERSION)),
("xPos", Tag::Int(chunk.x)),
("zPos", Tag::Int(chunk.z)),
("yPos", Tag::Int(MIN_Y >> 4)),
("yPos", Tag::Int(shape.min_section())),
("Status", string("minecraft:full")),
("LastUpdate", Tag::Long(extras.map_or(0, |e| e.last_update))),
("isLightOn", Tag::Byte(i8::from(chunk.lit))),
@@ -1019,11 +1097,30 @@ pub fn encode_with(
.collect();
s.insert("References".into(), Tag::Compound(refs));
}
let starts: Compound = chunk
.structure_starts
.iter()
.filter_map(|start| match start.save() {
Some(tag) => Some((start.name.as_str().into(), tag)),
None => {
log::warn!(
"Horizon: structure start {} not saved: one of its pieces cannot be",
start.name
);
None
}
})
.collect();
if !starts.is_empty()
&& let Some(Tag::Compound(s)) = map.get_mut(&"structures".into())
{
s.insert("starts".into(), Tag::Compound(starts));
}
map.entry("PostProcessing".into())
.or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); SECTIONS]));
.or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); shape.section_count()]));
}
if let Some(t) = tables {
put(&mut root, "Heightmaps", heightmaps(chunk, t));
put(&mut root, "Heightmaps", heightmaps(chunk, t, shape));
let names = |k: u32| t.block_names.get(k as usize).cloned();
let (blocks, fluids) = extras.map_or((&[][..], &[][..]), |e| {
(&e.block_ticks[..], &e.fluid_ticks[..])
@@ -1239,18 +1336,19 @@ pub fn encode_proto(
registry: &BlockRegistry,
biomes: &Biomes,
tables: Option<&SaveTables>,
sky_light: bool,
shape: LevelShape,
) -> NamedTag {
let mut root = encode_with(&proto.column, registry, biomes, tables, sky_light);
let mut root = encode_with(&proto.column, registry, biomes, tables, shape);
let status = if proto.decorated {
ChunkStatus::Features
} else {
ChunkStatus::Terrain
};
// ProtoChunk.postProcessing: one list per section, packed section offsets.
let mut marks = vec![Vec::new(); SECTIONS];
let mut marks = vec![Vec::new(); shape.section_count()];
for &[x, y, z] in &proto.column.postprocess {
if let Some(list) = marks.get_mut(((y - MIN_Y) >> 4) as usize) {
let section = usize::try_from((y - shape.min_y) >> 4).ok();
if let Some(list) = section.and_then(|s| marks.get_mut(s)) {
list.push(Tag::Short(
((x & 15) | (y & 15) << 4 | (z & 15) << 8) as i16,
));
@@ -1564,7 +1662,7 @@ struct Encoding {
registry: &'static BlockRegistry,
biomes: Arc<Biomes>,
tables: Option<Arc<SaveTables>>,
sky_light: bool,
shape: LevelShape,
}
impl Record {
fn tag(&self) -> Option<Arc<NamedTag>> {
@@ -1575,7 +1673,7 @@ impl Record {
e.registry,
&e.biomes,
e.tables.as_deref(),
e.sky_light,
e.shape,
))),
Record::Delete => None,
}
@@ -1612,12 +1710,12 @@ pub struct SaveDir {
outstanding: Arc<Outstanding>,
/// Set by `Persistent` before the first load.
pub tables: OnceLock<Arc<SaveTables>>,
/// False in dimensions without skylight (no SkyLight layers are written).
pub sky_light: bool,
/// The dimension's skylight and height, from its folder name.
pub shape: LevelShape,
}
impl SaveDir {
pub fn new(root: PathBuf) -> Self {
let sky_light = !root.ends_with("the_nether") && !root.ends_with("DIM-1");
let shape = LevelShape::for_dir(&root);
Self {
root,
regions: Default::default(),
@@ -1627,7 +1725,7 @@ impl SaveDir {
writer: Mutex::new(None),
outstanding: Default::default(),
tables: OnceLock::new(),
sky_light,
shape,
}
}
fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf {
@@ -1658,7 +1756,7 @@ impl SaveDir {
let root = self.read_record("region", x, z)?;
let tables = self.tables.get().map(|t| &**t);
let mut chunk = decode_with(&root, registry, biomes, tables)?;
load_light(&root.tag, &mut chunk, self.sky_light);
load_light(&root.tag, &mut chunk, self.shape.sky_light);
Some(chunk)
}
/// A proto chunk saved before it became final (`decode_proto`).
@@ -1683,7 +1781,7 @@ impl SaveDir {
let records = protos
.iter()
.map(|p| {
let tag = encode_proto(p, registry, biomes, tables, self.sky_light);
let tag = encode_proto(p, registry, biomes, tables, self.shape);
((p.column.x, p.column.z), Some(tag))
})
.collect();
@@ -1858,7 +1956,7 @@ impl SaveDir {
.map(|c| {
(
(c.x, c.z),
Some(encode_with(c, registry, biomes, tables, self.sky_light)),
Some(encode_with(c, registry, biomes, tables, self.shape)),
)
})
.collect();
@@ -1877,7 +1975,7 @@ impl SaveDir {
registry,
biomes: biomes.clone(),
tables: self.tables.get().cloned(),
sky_light: self.sky_light,
shape: self.shape,
});
let records = chunks
.iter()
@@ -3347,7 +3445,7 @@ impl SaveDir {
{
return None;
}
decode_lod_surfaces(&root, assets, biomes, self.sky_light)
decode_lod_surfaces(&root, assets, biomes, self.shape.sky_light)
}
}
@@ -3438,6 +3536,71 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
#[cfg(test)]
mod tests {
#[test]
fn nether_and_end_chunks_save_with_their_own_height() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
return;
};
let root = PathBuf::from(root);
let assets = crate::assets::Assets::load(&root).expect("assets");
let biomes = Biomes::load(&root).expect("biomes");
let tables = SaveTables::new(&assets);
let rack = assets
.registry
.default_state("minecraft:netherrack")
.unwrap();
let mut chunk = Chunk::empty(0, 0);
for y in 0..128 {
chunk.set(0, y, 0, rack.0);
}
let saved = encode_with(
&chunk,
assets.registry,
&biomes,
Some(&tables),
LevelShape::NETHER,
);
assert_eq!(number(saved.tag.get("yPos")), Some(0));
let Some(Tag::List(sections)) = saved.tag.get("sections") else {
panic!("no sections");
};
let ys: Vec<i32> = sections
.iter()
.filter_map(|s| number(s.get("Y")).map(|y| y as i32))
.collect();
assert_eq!(ys, (0..16).collect::<Vec<_>>());
let Some(Tag::List(post)) = saved.tag.get("PostProcessing") else {
panic!("no PostProcessing");
};
assert_eq!(post.len(), 16);
// Heightmap values count from the level's minimum: the column's top
// block at y=127 reads 128 (vanilla), not 192 (from the Overworld's -64).
let Some(Tag::LongArray(surface)) = saved
.tag
.get("Heightmaps")
.and_then(|h| h.get("WORLD_SURFACE"))
else {
panic!("no WORLD_SURFACE");
};
assert_eq!(surface[0] & 0x1ff, 128);
let overworld = encode_with(
&chunk,
assets.registry,
&biomes,
Some(&tables),
LevelShape::OVERWORLD,
);
assert_eq!(number(overworld.tag.get("yPos")), Some(-4));
assert_eq!(
LevelShape::for_dir(Path::new("w/dimensions/minecraft/the_end")),
LevelShape::END
);
assert_eq!(
LevelShape::for_dir(Path::new("w/DIM-1")),
LevelShape::NETHER
);
}
#[test]
fn saved_far_surface_reads_builds_and_water_without_gameplay_chunks() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
@@ -3556,7 +3719,7 @@ mod tests {
"unfinished proto saves must not suppress complete decoration"
);
let mut nether = SaveDir::new(dir.clone());
nether.sky_light = false;
nether.shape = LevelShape::NETHER;
let dark = nether.lod_surfaces(-2, 3, &assets, &biomes).unwrap();
assert!(
dark.iter()
@@ -3875,6 +4038,31 @@ mod tests {
assert_eq!(back.redstone_signal(), 2);
}
#[test]
fn campfire_block_entity_round_trips() {
let mut c = crate::containers::Campfire::default();
c.items[1] = Some(crate::items::ItemStack::new("minecraft:beef", 1));
c.progress[1] = 120;
c.total[1] = 600;
let tag = encode_block_entity(
(3, 64, -2),
&crate::containers::BlockEntity::Campfire(Box::new(c.clone())),
"minecraft:soul_campfire",
);
// One block entity type for both campfires.
assert_eq!(text(tag.get("id")).as_deref(), Some("minecraft:campfire"));
let (pos, back) = decode_block_entity(&tag).expect("campfire");
assert_eq!(pos, (3, 64, -2));
let crate::containers::BlockEntity::Campfire(back) = back else {
panic!("not a campfire");
};
assert_eq!(*back, c);
// The update tag a server's clients get: the items alone.
let update =
crate::lan::chunks::entity_tag(&crate::containers::BlockEntity::Campfire(Box::new(c)))
.expect("update tag");
assert!(update.get("Items").is_some() && update.get("CookingTimes").is_none());
}
#[test]
fn spawner_block_entity_round_trips() {
// A dungeon spawner as vanilla saves it (`id` is mob_spawner, not the block).
let saved = horizon_world::nbt::parse_snbt(
+3 -1
View File
@@ -175,7 +175,9 @@ pub fn increased_repair_cost(cost: u32) -> u32 {
pub fn hover_name(names: &dyn Fn(&str) -> String, stack: &ItemStack) -> String {
stack
.custom_name()
.map_or_else(|| names(&stack.item), str::to_owned)
.map(str::to_owned)
.or_else(|| stack.item_name())
.unwrap_or_else(|| names(&stack.item))
}
/// `AnvilMenu.createResult`. `name` is the rename box text (None = untouched).
pub fn anvil_result(
+14 -5
View File
@@ -158,6 +158,12 @@ impl VanillaTerrain {
chunk.terrain.min_y().max(MIN_Y),
(chunk.terrain.min_y() + chunk.terrain.height()).min(MIN_Y + HEIGHT),
);
out.structure_refs = chunk
.structures()
.into_iter()
.map(|(id, start, _, _)| (Arc::from(id), (start.x, start.z)))
.collect();
out.structure_starts = chunk.own_starts();
let mut values = Box::new([0u32; 4096]);
for si in 0..SECTIONS as i32 {
let base = MIN_Y + si * 16;
@@ -436,6 +442,14 @@ impl ChunkGenerator for VanillaTerrain {
.iter()
.map(|(pos, nbt)| {
let mut nbt = nbt.clone();
// Placed data can lack its type (a brushable block's loot table
// alone); vanilla saves every pending block entity with its id,
// and skips one without when the chunk finishes.
if let Some(id) =
crate::lan::chunks::block_entity_type(chunk.block_name(pos[0], pos[1], pos[2]))
{
nbt.insert("id".into(), Tag::String(id.into()));
}
for (key, v) in ["x", "y", "z"].into_iter().zip(pos) {
nbt.insert(key.into(), Tag::Int(*v));
}
@@ -566,11 +580,6 @@ impl ChunkGenerator for VanillaTerrain {
extras.block_entities = unmodelled;
out.saved = Some(Arc::new(extras));
}
out.structure_refs = chunk
.structures()
.into_iter()
.map(|(id, start, _, _)| (Arc::from(id), (start.x, start.z)))
.collect();
let entities = std::mem::take(&mut chunk.entities);
out.entities = entities
.into_iter()
@@ -315,3 +315,70 @@ fn saved_proto_chunks_restore_exactly() {
drop(save);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saved_proto_block_entities_keep_their_id() {
use horizon_client::{
save::{Persistent, SaveDir},
vanillagen::VanillaTerrain,
};
use horizon_world::{
nbt::{Limits, Tag},
region::RegionFile,
};
let Some(root) = resources() else {
return;
};
let assets = Arc::new(Assets::load(&root).unwrap());
let biomes = Arc::new(Biomes::load(&root).unwrap());
let terrain = VanillaTerrain::for_dimension(
&root.join("worldgen"),
assets.registry,
&biomes,
12345,
"minecraft:overworld",
)
.unwrap();
let dir = std::env::temp_dir().join(format!("horizon-proto-ids-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let save = Arc::new(SaveDir::new(dir.clone()));
let generator = Persistent {
inner: Arc::new(terrain),
save: save.clone(),
registry: assets.clone(),
biomes,
};
let mut worker = generator.worker();
// A cold ocean ruin of seed 12345: its suspicious gravel is placed with a
// loot table and no type.
let keys: Vec<(i32, i32)> = (0..9).map(|i| (43 + i % 3 - 1, 21 + i / 3 - 1)).collect();
let mut region: [Proto; 9] =
std::array::from_fn(|i| generator.proto(&mut worker, keys[i].0, keys[i].1));
generator.decorate(&mut worker, &mut region);
generator.save_protos(&region.iter().collect::<Vec<_>>());
save.flush().unwrap();
let mut file = RegionFile::open(dir.join("region/r.1.0.mca")).unwrap();
let mut suspicious = 0;
for &(x, z) in &keys {
let Some(record) = file
.read(horizon_core::position::ChunkPos::new(x, z))
.unwrap()
else {
continue;
};
let root = record.nbt(Limits::default()).unwrap();
let Some(Tag::List(entities)) = root.tag.get("block_entities") else {
continue;
};
for e in entities {
assert!(e.get("id").is_some(), "chunk {x} {z}: {e:?}");
if let Some(Tag::String(table)) = e.get("LootTable") {
suspicious += usize::from(table.to_string_lossy().contains("archaeology/"));
}
}
}
assert!(suspicious > 0, "the ruin placed no suspicious blocks");
drop(generator);
drop(save);
let _ = std::fs::remove_dir_all(&dir);
}
+8
View File
@@ -111,6 +111,14 @@ interface storage {
interface command {
/// `line` without its slash; its feedback goes to the chat as if typed.
run: func(line: string);
/// `lines` (each without its slash, at most 256 a call) as `run` does,
/// but with nothing in the chat: no feedback and no failure. In single
/// player and on Horizon's servers, each runs exactly as the player
/// typing it would (the same rights), in order, 64 a tick, and the
/// server logs one line a batch; one that carries a chat message (`say`,
/// `msg`) is refused. On any other server they go as chat commands, and
/// there their feedback shows: a mod running many should say so.
run-quiet: func(lines: list<string>);
}
/// Capability `ui`: widgets the game draws with its own menus' widgets, for
+3 -2
View File
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
pub const LEVEL: u32 = 2;
pub const LEVEL: u32 = 3;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
pub const FUNCTIONS: [(&str, u32); 33] = [
pub const FUNCTIONS: [(&str, u32); 34] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -47,6 +47,7 @@ pub const FUNCTIONS: [(&str, u32); 33] = [
("players.tell", 1),
("players.broadcast", 1),
("game.raycast", 2),
("command.run-quiet", 3),
];
/// What the game's program carries so the launcher can read its level
+23
View File
@@ -89,6 +89,9 @@ const FUEL_FRAME: u64 = 10_000_000;
const FUEL_EVENT: u64 = 100_000_000;
/// Chat lines a mod may show per call, HUD lines and their length.
const CHAT_PER_CALL: usize = 8;
/// `command.run-quiet` lines one call of a mod may queue (a quiet payload's
/// cap, `horizon_protocol::quiet::MAX_LINES`).
const QUIET_PER_CALL: usize = 256;
/// How far `game.raycast` sees, in blocks.
pub const RAYCAST_MAX: f32 = 1024.0;
const HUD_LINES: usize = 16;
@@ -160,6 +163,9 @@ pub enum Output {
/// `command.run`: a vanilla command line, without its slash, to run as
/// the player.
Command { line: String },
/// `command.run-quiet`: vanilla command lines to run as the player with
/// nothing in the chat (`horizon_protocol::quiet` on a server).
QuietCommands { mod_id: String, lines: Vec<String> },
/// The mod was turned off: it trapped, overran or failed to start.
Stopped { name: String, reason: String },
/// `players.tell`: a line after the mod's name, to the player with this id.
@@ -1408,6 +1414,23 @@ impl command::Host for State {
self.out.push(Output::Command { line: clip(line) });
}
}
fn run_quiet(&mut self, lines: Vec<String>) {
let queued: usize = self
.out
.iter()
.map(|o| match o {
Output::QuietCommands { lines, .. } => lines.len(),
_ => 0,
})
.sum();
let room = QUIET_PER_CALL.saturating_sub(queued);
if room > 0 && !lines.is_empty() {
self.out.push(Output::QuietCommands {
mod_id: self.id.clone(),
lines: lines.into_iter().take(room).map(clip).collect(),
});
}
}
}
impl hud::Host for State {
fn set_panel(&mut self, corner: Corner, lines: Vec<Line>) {
+27 -1
View File
@@ -26,7 +26,7 @@ id = "test_ui_guest"
name = "Test UI Guest"
version = "0.1.0"
api = "0.1"
capabilities = ["game-read", "chat", "ui", "render", "input"]
capabilities = ["game-read", "chat", "command", "ui", "render", "input"]
experimental = ["ui", "render", "input"]
"#;
@@ -763,6 +763,32 @@ fn game_raycast_sees_the_view_hit_within_its_distance() {
assert_eq!(said(&mut mods, "1e9"), [hit]);
}
#[test]
fn quiet_commands_come_out_as_one_batch_capped() {
let mut mods = load("horizon_mods_test_ui_guest.hmod");
let quiet = |mods: &mut Mods, command: &str| -> Vec<(String, Vec<String>)> {
assert!(mods.command(command));
mods.drain()
.into_iter()
.filter_map(|o| match o {
Output::QuietCommands { mod_id, lines } => Some((mod_id, lines)),
_ => None,
})
.collect()
};
assert_eq!(
quiet(&mut mods, "quiet setblock 0 0 0 stone;say hi"),
[(
"test_ui_guest".to_string(),
vec!["setblock 0 0 0 stone".to_string(), "say hi".to_string()]
)]
);
let batches = quiet(&mut mods, "quiet_many 300");
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].1.len(), 256);
assert_eq!(batches[0].1[255], "setblock 255 300 0 stone");
}
#[test]
fn corner_panels_text_width_and_readings() {
let mut mods = load("horizon_mods_test_ui_guest.hmod");
+14 -2
View File
@@ -5,9 +5,10 @@
//! `/input_claim`, `/input_poll`, `/input_held` and `/input_wheel` test
//! `input`; `/zoom FACTOR SMOOTH TURN` tests `render.set-zoom`;
//! `/corner` and `/readings` the calls the official extras mods use;
//! `/raycast DISTANCE` says what `game.raycast` sees.
//! `/raycast DISTANCE` says what `game.raycast` sees; `/quiet A;B` runs
//! lines with `command.run-quiet` (`/quiet_many N`: N of them).
use horizon_mod_sdk::{
Mod, Registration, chat, game,
Mod, Registration, chat, command, game,
input::{self, Event},
render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape},
ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget},
@@ -99,6 +100,8 @@ impl Mod for Guest {
"zoom",
"minimap",
"raycast",
"quiet",
"quiet_many",
"corner",
"readings",
]
@@ -219,6 +222,15 @@ impl Mod for Guest {
});
render::set_minimap(map);
}
"quiet" => command::run_quiet(&args.split(';').map(str::to_owned).collect::<Vec<_>>()),
"quiet_many" => {
let n: usize = args.parse().unwrap_or(0);
command::run_quiet(
&(0..n)
.map(|i| format!("setblock {i} 300 0 stone"))
.collect::<Vec<_>>(),
);
}
"raycast" => {
let distance = args.parse().unwrap_or(f32::NAN);
chat::show(&match game::raycast(distance) {
+40 -3
View File
@@ -91,8 +91,9 @@ pub fn keep(
pub fn parse(spec: &str) -> Option<ureq::Proxy> {
if socks(spec) {
say(&format!(
"{spec} is a SOCKS proxy; downloads go without one. Write the http:// address your \
proxy app listens on as well."
"{} is a SOCKS proxy; downloads go without one. Write the http:// address your \
proxy app listens on as well.",
redacted(spec)
));
return None;
}
@@ -100,13 +101,29 @@ pub fn parse(spec: &str) -> Option<ureq::Proxy> {
Ok(proxy) => Some(proxy),
Err(e) => {
say(&format!(
"The proxy address {spec} can't be used ({e}); downloads go without one."
"The proxy address {} can't be used ({e}); downloads go without one.",
redacted(spec)
));
None
}
}
}
/// `spec` as it may be shown: a user name and password in it become `***`. An address that can't
/// be used is printed to stderr and shown in the launcher, and a proxy's credentials don't belong in
/// either. Everything up to the last `@` is the credentials, so a password with an unescaped `/` or
/// `@` is hidden too.
pub fn redacted(spec: &str) -> String {
let (scheme, rest) = match spec.split_once("://") {
Some((scheme, rest)) => (format!("{scheme}://"), rest),
None => (String::new(), spec),
};
match rest.rfind('@') {
Some(at) => format!("{scheme}***@{}", &rest[at + 1..]),
None => spec.to_owned(),
}
}
/// Whether `spec` names a SOCKS proxy, which these downloads don't go through: `ureq` reads a
/// SOCKS address happily and only fails when it connects through it.
pub fn socks(spec: &str) -> bool {
@@ -227,6 +244,26 @@ mod tests {
lock(&TURN)
}
#[test]
fn credentials_are_not_shown() {
assert_eq!(redacted("http://127.0.0.1:7890"), "http://127.0.0.1:7890");
assert_eq!(redacted("proxy.local:3128"), "proxy.local:3128");
assert_eq!(
redacted("http://user:pass@proxy.local:3128"),
"http://***@proxy.local:3128"
);
assert_eq!(
redacted("socks5://u:p@10.0.0.1:1080"),
"socks5://***@10.0.0.1:1080"
);
assert_eq!(
redacted("user:pass@proxy.local:3128"),
"***@proxy.local:3128"
);
assert_eq!(redacted("http://u:p/a@ss@proxy:1"), "http://***@proxy:1");
assert_eq!(redacted("http://user:pass@"), "http://***@");
}
#[test]
fn a_proxy_the_launcher_sets_is_trimmed_and_kept() {
let _turn = alone();
+2 -2
View File
@@ -164,7 +164,7 @@ pub struct Profile {
pub properties: Vec<(String, String, Option<String>)>,
}
impl Component {
fn read(kind: i32, r: &mut Reader) -> Result<Self> {
pub(crate) fn read(kind: i32, r: &mut Reader) -> Result<Self> {
use component::*;
Ok(match kind {
MAX_STACK_SIZE | MAX_DAMAGE | DAMAGE | RARITY | REPAIR_COST | MAP_ID
@@ -338,7 +338,7 @@ impl Component {
}
})
}
fn write(&self, kind: i32, w: &mut Writer) {
pub(crate) fn write(&self, kind: i32, w: &mut Writer) {
match self {
Self::Raw(bytes) => w.bytes(bytes),
Self::Trim { material, pattern } => {
+72 -8
View File
@@ -8,7 +8,7 @@ use crate::{
},
chunk::{ChunkData, LightData},
entity_data::{EntityData, Particle, Value},
item::{Hashed, HashedSlot, Slot, Stack},
item::{Component, Hashed, HashedSlot, Slot, Stack},
packet, packet_enum,
packets::{
Empty,
@@ -88,11 +88,31 @@ packet!(ContainerSetContent {
carried: Option<Stack> => Slot,
});
/// ItemCost.STREAM_CODEC: item, count, then the exact component predicate
/// (Horizon sends and reads it as a component patch without removals).
/// (a list of typed component values, with no patch removal count).
#[derive(Clone, Debug, PartialEq)]
pub struct NetCost {
pub item: i32,
pub count: i32,
pub components: Vec<(i32, Component)>,
}
impl From<Stack> for NetCost {
fn from(stack: Stack) -> Self {
Self {
item: stack.item,
count: stack.count,
components: stack.added,
}
}
}
impl From<NetCost> for Stack {
fn from(cost: NetCost) -> Self {
Self {
item: cost.item,
count: cost.count,
added: cost.components,
removed: Vec::new(),
}
}
}
/// MerchantOffer.STREAM_CODEC.
#[derive(Clone, Debug, PartialEq)]
@@ -114,10 +134,17 @@ impl Codec<Vec<NetOffer>> for Offers {
fn cost(r: &mut Reader) -> Result<NetCost> {
let item = r.varint()?;
let count = r.varint()?;
if r.varint()? != 0 {
return Err(Error::new("item cost components are not supported"));
let n = r.count(r.remaining())?;
let mut components = Vec::with_capacity(n.min(64));
for _ in 0..n {
let kind = r.varint()?;
components.push((kind, Component::read(kind, r)?));
}
Ok(NetCost { item, count })
Ok(NetCost {
item,
count,
components,
})
}
let n = r.count(r.remaining())?;
let mut out = Vec::with_capacity(n.min(64));
@@ -144,7 +171,11 @@ impl Codec<Vec<NetOffer>> for Offers {
fn cost(c: &NetCost, w: &mut Writer) {
w.varint(c.item);
w.varint(c.count);
w.varint(0);
w.varint(c.components.len() as i32);
for (kind, value) in &c.components {
w.varint(*kind);
value.write(*kind, w);
}
}
w.varint(v.len() as i32);
for o in v {
@@ -2300,6 +2331,39 @@ mod tests {
use super::*;
use crate::{chunk::BlockEntityInfo, packets::round_trip, text::literal};
#[test]
fn merchant_cost_predicate_is_a_typed_list_without_patch_counts() {
// ItemCost.STREAM_CODEC: item=7, count=1, one typed component,
// potion_contents=53, potion present (water=0), no color/effects/name.
// Result: count=1, item=8, empty component patch. Secondary cost:
// item=9, count=2, empty predicate, followed by the offer fields.
let mut bytes = vec![1, 7, 1, 1, 53, 1, 0, 0, 0, 0, 1, 8, 0, 0, 1, 9, 2, 0, 0];
bytes.extend_from_slice(&[0; 4]); // uses
bytes.extend_from_slice(&2i32.to_be_bytes()); // max uses
bytes.extend_from_slice(&[0; 16]); // xp, special price, multiplier, demand
let mut r = Reader::new(&bytes);
let offers = Offers::read(&mut r).unwrap();
assert_eq!(r.remaining(), 0);
assert_eq!(
offers[0].cost_a.components,
vec![(
53,
Component::Potion {
potion: Some(0),
color: None,
rest: Vec::new()
}
)]
);
assert!(offers[0].cost_b.as_ref().unwrap().components.is_empty());
let mut w = Writer::new();
Offers::write(&offers, &mut w);
assert_eq!(w.data, bytes);
for len in 0..bytes.len() {
assert!(Offers::read(&mut Reader::new(&bytes[..len])).is_err());
}
}
#[test]
fn serverbound_abilities_carry_only_flying() {
// ServerboundPlayerAbilitiesPacket.write: one byte, 2 when flying.
@@ -2576,9 +2640,9 @@ mod tests {
Clientbound::MerchantOffers(MerchantOffers {
container: 3,
offers: vec![NetOffer {
cost_a: NetCost { item: 5, count: 24 },
cost_a: Stack::new(5, 24).into(),
result: Stack::new(7, 1),
cost_b: Some(NetCost { item: 9, count: 1 }),
cost_b: Some(Stack::new(9, 1).into()),
out_of_stock: false,
uses: 2,
max_uses: 16,
+1 -1
View File
@@ -1,4 +1,4 @@
/* The Horizon server panel. Colours and spacing are horizon-theme's tokens (website/src/tokens.css). */
/* The Horizon server panel. Colours and spacing are horizon-theme's tokens (crates/horizon-theme/tokens.css). */
:root {
--page: #fafafc;
--plate-soft: #f3f3f8;
+1 -1
View File
@@ -30,7 +30,7 @@ The totals are grouped per version, OS, GPU vendor and render distance. Each gro
## Benchmarks summary
`src/summary.rs` makes it with the same rules as `website/scripts/benchmarks.mjs`, which bakes the page's fallback copy; both are tested against the same fixtures (`website/scripts/fixtures/benchmarks/`).
`src/summary.rs` makes it with the same rules as the website's `scripts/benchmarks.mjs` (ludovic111/horizon-site), which bakes the page's fallback copy; both are tested against the same fixtures (`tests/fixtures/benchmarks/` here, copied in the site's repository).
- Only reports that compare: a version 1 `horizon-benchmark-report` of the standard flight over the four scenes, VSync off, no frame cap, on screen, from Horizon or plain vanilla, naming its GPU and CPU.
- A group (GPU, CPU, render distance, game) is published only with **at least 3 different installs**. Each install counts once: the median of its runs, then the median across installs.
@@ -1,6 +1,6 @@
//! The hardware classes the public output breaks results down by. Coarse on purpose: a class
//! holds many machines, and a published cell still needs `MIN_INSTALLS` different installs.
//! `website/scripts/benchmarks.mjs` has the same functions, tested on the same fixtures.
//! The website's `scripts/benchmarks.mjs` (ludovic111/horizon-site) has the same functions, tested on the same fixtures.
//!
//! The raw values (the exact memory, the driver, the screen) stay in the database for the
//! retention period and are never published.
@@ -1,7 +1,7 @@
//! `GET /v1/benchmarks/summary`: players' benchmark uploads, grouped for the website's Benchmarks page.
//!
//! The same rules as `website/scripts/benchmarks.mjs` (which builds the page's baked copy), checked against the
//! same fixtures (`website/scripts/fixtures/benchmarks/`):
//! The same rules as the website's `scripts/benchmarks.mjs` (ludovic111/horizon-site; it builds the page's baked
//! copy), checked against the same fixtures (`tests/fixtures/benchmarks/`, which the site's test keeps a copy of):
//!
//! - Only reports that compare: `horizon-benchmark-report` version 1, kind `game`, Horizon or plain vanilla, the
//! standard flight over the four scenes, VSync off, no frame cap, drawn on a screen, with a GPU and CPU name.
@@ -198,7 +198,7 @@ struct Group {
versions: BTreeSet<String>,
}
/// Players' groups of at least `min_installs` installs, as `website/scripts/benchmarks.mjs` makes them.
/// Players' groups of at least `min_installs` installs, as the website's `scripts/benchmarks.mjs` makes them.
pub fn groups(uploads: &[Upload], min_installs: usize) -> Vec<Value> {
let mut by: BTreeMap<(String, String, i64, &str), Group> = BTreeMap::new();
for u in uploads {
@@ -1,7 +1,8 @@
# Benchmark summary fixtures
Shared by the website's aggregator test (`website/scripts/benchmarks.test.mjs`) and the telemetry server's
`GET /v1/benchmarks/summary` test: the same `benchmark` items in, the same groups out.
Used by the telemetry server's `GET /v1/benchmarks/summary` test (`tests/summary.rs`) and, as a copy, by the
website's aggregator test (`scripts/benchmarks.test.mjs` in ludovic111/horizon-site, which checks its copy against
this one on main): the same `benchmark` items in, the same groups out. Change both together.
- `items.jsonl`: one stored item per line (`install_id`, `day`, `version`, `data`: a benchmark report). Group
RTX 4080 + i7-13700KF at render distance 12 has three installs (one with two runs); the same install at 32,
@@ -1,5 +1,5 @@
//! `GET /v1/benchmarks/summary` against the website's fixtures (website/scripts/fixtures/benchmarks/): the same
//! uploads must give the groups `website/scripts/benchmarks.mjs` gives.
//! `GET /v1/benchmarks/summary` against the shared fixtures (tests/fixtures/benchmarks/): the same uploads must
//! give the groups the website's `scripts/benchmarks.mjs` (ludovic111/horizon-site) gives on its copy of them.
use std::sync::Arc;
use std::sync::atomic::AtomicU32;
@@ -15,10 +15,7 @@ use http_body_util::BodyExt;
use serde_json::Value;
use tower::ServiceExt;
const FIXTURES: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../website/scripts/fixtures/benchmarks"
);
const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/benchmarks");
fn items() -> Vec<Value> {
std::fs::read_to_string(format!("{FIXTURES}/items.jsonl"))
+8 -8
View File
@@ -1,6 +1,6 @@
//! Horizon's design tokens, the one source of truth for the launcher (`launcher/src/theme.rs`),
//! the game's on-screen keyboard (`horizon-client`'s `ui_theme`) and the website (`website/src/tokens.css`,
//! generated from [`css`] by `cargo run -p xtask -- tokens`). Change a value here and all three
//! the game's on-screen keyboard (`horizon-client`'s `ui_theme`) and the website (`tokens.css` in this crate,
//! generated from [`css`] by `cargo run -p xtask -- tokens`, which the site's build reads from main). Change a value here and all three
//! follow. The system is described in `docs/design-system.md`.
//!
//! Cool neutrals in two appearances and one violet accent; Geist and Geist Mono only, bundled
@@ -528,7 +528,7 @@ pub fn contrast(a: u32, b: u32) -> f32 {
(x.max(y) + 0.05) / (x.min(y) + 0.05)
}
/// `website/src/tokens.css`: every token as a CSS custom property, the light palette by
/// `tokens.css`: every token as a CSS custom property, the light palette by
/// default, the dark one when the system asks for it or the page sets `data-theme="dark"`.
pub fn css() -> String {
let mut s = String::new();
@@ -771,15 +771,15 @@ mod tests {
assert!((bezier([0., 0., 1., 1.], 0.3) - 0.3).abs() < 1e-3);
}
/// `website/src/tokens.css` is what [`css`] writes; regenerate it with
/// `cargo run -p xtask -- tokens`.
/// `tokens.css` (beside Cargo.toml, read by the website's build) is what [`css`] writes;
/// regenerate it with `cargo run -p xtask -- tokens`.
#[test]
fn website_tokens_are_up_to_date() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../website/src/tokens.css");
fn tokens_css_is_up_to_date() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tokens.css");
let written = std::fs::read_to_string(path).unwrap_or_default();
assert!(
written == css(),
"website/src/tokens.css is out of date: run `cargo run -p xtask -- tokens`"
"crates/horizon-theme/tokens.css is out of date: run `cargo run -p xtask -- tokens`"
);
}
}
@@ -169,6 +169,7 @@ mod stored_effect_attributes_fixture;
mod stored_effect_notifications_fixture;
mod structure_density_fixture;
mod structure_pieces;
mod structure_start_nbt;
mod suffocation_fixture;
mod swimming_fixture;
mod synced_bytes_fixture;
@@ -340,6 +341,7 @@ probes! {
stored_effect_notifications_fixture,
structure_density_fixture,
structure_pieces,
structure_start_nbt,
suffocation_fixture,
swimming_fixture,
synced_bytes_fixture,
@@ -21,7 +21,7 @@ use std::{fs, path::Path};
type Error = Box<dyn std::error::Error + Send + Sync>;
fn registry(resources: &Path) -> Result<&'static BlockRegistry, Error> {
pub fn registry(resources: &Path) -> Result<&'static BlockRegistry, Error> {
let facts: Value = serde_json::from_str(&fs::read_to_string(resources.join("facts.json"))?)?;
let states = facts["states"].as_array().ok_or("facts states")?;
let registry = BlockRegistry::from_nbt(&Tag::compound([
@@ -0,0 +1,74 @@
//! The structure starts a chunk owns, as `StructureStart.createTag` saves them.
//!
//! structure_start_nbt <client resource dir> <seed> <dimension> <cx,cz>...
//! prints `nbt CX CZ TAG` for every start whose chunk is a target, the tag
//! with sorted keys and typed numbers (VanillaStructureStartOracle's
//! `canonical`), or `unsaved CX CZ ID` when Horizon cannot save it yet.
use horizon_core::position::ChunkPos;
use horizon_world::{
nbt::Tag,
worldgen::generator::{GeneratorData, OverworldGenerator},
};
type Error = Box<dyn std::error::Error + Send + Sync>;
/// `VanillaStructureStartOracle.canonical`.
fn canonical(tag: &Tag) -> String {
let join = |parts: Vec<String>| parts.join(",");
match tag {
Tag::Compound(c) => {
let mut keys: Vec<_> = c.keys().map(|k| (k.to_string_lossy(), k)).collect();
keys.sort_by(|a, b| a.0.encode_utf16().cmp(b.0.encode_utf16()));
let parts = keys
.into_iter()
.map(|(name, key)| format!("{name}:{}", canonical(&c[key])))
.collect();
format!("{{{}}}", join(parts))
}
Tag::List(l) => format!("[{}]", join(l.iter().map(canonical).collect())),
Tag::Byte(v) => format!("{v}b"),
Tag::Short(v) => format!("{v}s"),
Tag::Int(v) => v.to_string(),
Tag::Long(v) => format!("{v}L"),
Tag::Float(bits) => format!("{}f", *bits as i32),
Tag::Double(bits) => format!("{}d", *bits as i64),
Tag::String(s) => format!(
"\"{}\"",
s.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
),
Tag::IntArray(a) => format!("[I;{}]", join(a.iter().map(i32::to_string).collect())),
Tag::LongArray(a) => format!("[L;{}]", join(a.iter().map(i64::to_string).collect())),
Tag::ByteArray(a) => format!(
"[B;{}]",
join(a.iter().map(|b| (*b as i8).to_string()).collect())
),
Tag::End => "END".into(),
}
}
pub fn main() -> Result<(), Error> {
let mut args = crate::args().skip(1);
let resources = std::path::PathBuf::from(args.next().ok_or("client resource directory")?);
let seed: i64 = args.next().ok_or("seed")?.parse()?;
let dimension = format!("minecraft:{}", args.next().ok_or("dimension")?);
let registry = crate::structure_pieces::registry(&resources)?;
let data = GeneratorData::load(&resources.join("worldgen"), registry)?;
let generator = OverworldGenerator::for_dimension(data, seed, "minecraft:normal", &dimension)?;
let mut worker = generator.worker();
for target in args {
let (x, z) = target.split_once(',').ok_or("target cx,cz")?;
let chunk = ChunkPos::new(x.parse()?, z.parse()?);
for start in worker.structure_starts(chunk) {
if start.chunk != chunk {
continue;
}
match start.save() {
Some(tag) => println!("nbt {} {} {}", chunk.x, chunk.z, canonical(&tag)),
None => println!("unsaved {} {} {}", chunk.x, chunk.z, start.name),
}
}
}
Ok(())
}
+1
View File
@@ -114,6 +114,7 @@ mod place;
mod portal;
pub mod portal_travel;
mod query;
pub(crate) use query::data_2d;
mod rails;
mod redstone;
pub mod removal;
@@ -618,6 +618,15 @@ impl ProtoChunk {
pub fn is_decorated(&self) -> bool {
self.decorated
}
/// The structure starts this chunk holds itself (`structures.starts`).
pub fn own_starts(&self) -> Vec<Arc<super::structure::start::StructureStart>> {
let pos = self.position();
self.structure_starts
.iter()
.filter(|s| s.chunk == pos)
.cloned()
.collect()
}
/// Also restores a saved proto chunk that was decorated before it was saved.
pub fn set_decorated(&mut self) {
self.decorated = true;
@@ -79,6 +79,15 @@ impl Rotation {
pub fn get_rotated(self, other: Self) -> Self {
Self::ALL[(self as usize + other as usize) % 4]
}
/// `Rotation.name()`, as structure pieces save it.
pub fn saved_name(self) -> &'static str {
match self {
Self::None => "NONE",
Self::Clockwise90 => "CLOCKWISE_90",
Self::Clockwise180 => "CLOCKWISE_180",
Self::Counterclockwise90 => "COUNTERCLOCKWISE_90",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Mirror {
@@ -86,6 +95,16 @@ pub enum Mirror {
LeftRight,
FrontBack,
}
impl Mirror {
/// `Mirror.name()`, as structure pieces save it.
pub fn saved_name(self) -> &'static str {
match self {
Self::None => "NONE",
Self::LeftRight => "LEFT_RIGHT",
Self::FrontBack => "FRONT_BACK",
}
}
}
/// Inclusive integer box (vanilla BoundingBox).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -381,19 +381,22 @@ pub fn pieces(
} else {
vec!["minecraft:structure_block".into(), "minecraft:air".into()]
};
EndCityPiece(TemplatePiece::new(
"minecraft:ecp",
&format!("minecraft:end_city/{}", d.name),
d.template,
d.position,
d.rotation,
Mirror::None,
[0; 3],
Arc::new(ProcessorList::default()),
true,
ignore,
TemplateHandler::Plain,
))
EndCityPiece(
TemplatePiece::new(
"minecraft:ecp",
&format!("minecraft:end_city/{}", d.name),
d.template,
d.position,
d.rotation,
Mirror::None,
[0; 3],
Arc::new(ProcessorList::default()),
true,
ignore,
TemplateHandler::Plain,
),
d.depth,
)
})
.collect(),
)
@@ -401,11 +404,21 @@ pub fn pieces(
/// EndCityPieces.EndCityPiece.
#[derive(Debug)]
pub struct EndCityPiece(TemplatePiece);
/// The template piece and its genDepth (the collision tag of its batch).
pub struct EndCityPiece(TemplatePiece, i32);
impl LegacyPiece for EndCityPiece {
fn bounding_box(&self) -> BoundingBox {
self.0.bounding_box
}
/// `EndCityPiece`: the template piece's tag, `OW` for the pieces that
/// overwrite air (only structure blocks ignored).
fn save(&self) -> Option<crate::nbt::Compound> {
let mut tag = self.0.saved();
tag.insert("GD".into(), crate::nbt::Tag::Int(self.1));
let overwrite = self.0.ignore.len() == 1;
tag.insert("OW".into(), crate::nbt::Tag::Byte(i8::from(overwrite)));
Some(tag)
}
fn type_name(&self) -> &'static str {
"minecraft:ecp"
}
@@ -294,6 +294,8 @@ mod tests {
source_x: junction[0],
source_ground_y: junction[1],
source_z: junction[2],
delta_y: 0,
dest_projection: Projection::Rigid,
}],
waterlogging: false,
})
@@ -303,6 +305,7 @@ mod tests {
Arc::new(PoolElement::Single {
template: Arc::new(StructureTemplate::empty()),
processors: Default::default(),
processors_source: serde_json::Value::Null,
legacy: false,
projection,
waterlogging: None,
@@ -871,6 +871,29 @@ impl LegacyPiece for FortressPiece {
fn bounding_box(&self) -> BoundingBox {
self.frame.bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
use crate::nbt::Tag;
let mut tag = super::legacy::piece_tag(
self.type_name(),
self.frame.bounding_box,
self.frame.orientation,
self.depth,
);
let flag = Tag::Byte(i8::from(*self.flag.lock().unwrap()));
match self.kind {
Kind::BridgeEndFiller => {
tag.insert("Seed".into(), Tag::Int(self.seed));
}
Kind::SmallCorridorLeftTurn | Kind::SmallCorridorRightTurn => {
tag.insert("Chest".into(), flag);
}
Kind::MonsterThrone => {
tag.insert("Mob".into(), flag);
}
_ => {}
}
Some(tag)
}
fn type_name(&self) -> &'static str {
match self.kind {
// StartPiece is built through the BridgeCrossing constructor.
@@ -5,6 +5,7 @@ use super::{
};
use crate::{
heightmap::HeightmapType,
nbt::{Compound, Tag},
worldgen::{
DecodeError,
block_feature::FeatureBlockBindings,
@@ -30,12 +31,24 @@ pub enum Projection {
Rigid,
TerrainMatching,
}
impl Projection {
/// `StructureTemplatePool.Projection.getSerializedName`.
pub fn saved_name(self) -> &'static str {
match self {
Self::Rigid => "rigid",
Self::TerrainMatching => "terrain_matching",
}
}
}
#[derive(Clone, Debug)]
pub enum PoolElement {
Empty,
Single {
template: Arc<StructureTemplate>,
processors: ProcessorList,
/// The element's `processors` as written: a list id or an inline list,
/// saved back as it was.
processors_source: Value,
legacy: bool,
projection: Projection,
waterlogging: Option<bool>,
@@ -136,6 +149,7 @@ impl PoolElement {
resources,
bindings,
)?,
processors_source: value["processors"].clone(),
legacy: kind == "minecraft:legacy_single_pool_element",
projection: projection()?,
waterlogging: match value
@@ -178,6 +192,64 @@ impl PoolElement {
_ => return Err(DecodeError::Field("pool element type")),
})
}
/// `StructurePoolElement.CODEC`'s encoding, as a jigsaw piece saves it.
pub fn save(&self) -> Tag {
let text = |s: &str| Tag::String(s.into());
match self {
Self::Empty => Tag::compound([("element_type", text("minecraft:empty_pool_element"))]),
Self::Single {
template,
processors_source,
legacy,
projection,
waterlogging,
..
} => {
let kind = if *legacy {
"minecraft:legacy_single_pool_element"
} else {
"minecraft:single_pool_element"
};
let mut entries = vec![
("element_type", text(kind)),
("location", text(&template.location)),
(
"processors",
super::processor::json_to_nbt(processors_source),
),
("projection", text(projection.saved_name())),
];
if let Some(apply) = waterlogging {
let settings = if *apply {
"apply_waterlogging"
} else {
"ignore_waterlogging"
};
entries.push(("override_liquid_settings", text(settings)));
}
Tag::compound(entries)
}
Self::Feature {
feature,
projection,
} => Tag::compound([
("element_type", text("minecraft:feature_pool_element")),
("feature", text(feature)),
("projection", text(projection.saved_name())),
]),
Self::List {
elements,
projection,
} => Tag::compound([
("element_type", text("minecraft:list_pool_element")),
(
"elements",
Tag::List(elements.iter().map(Self::save).collect()),
),
("projection", text(projection.saved_name())),
]),
}
}
pub fn projection(&self) -> Projection {
match self {
Self::Empty => Projection::TerrainMatching,
@@ -466,6 +538,9 @@ pub struct Junction {
pub source_x: i32,
pub source_ground_y: i32,
pub source_z: i32,
/// Saved only (`JigsawJunction.deltaY`, `destProjection`).
pub delta_y: i32,
pub dest_projection: Projection,
}
#[derive(Debug)]
pub struct PoolPiece {
@@ -517,6 +592,12 @@ impl PoolPiece {
source_x: int(j, "source_x")?,
source_ground_y: int(j, "source_ground_y")?,
source_z: int(j, "source_z")?,
delta_y: int(j, "delta_y")?,
dest_projection: match j["dest_proj"].as_str() {
Some("rigid") => Projection::Rigid,
Some("terrain_matching") => Projection::TerrainMatching,
_ => return Err(DecodeError::Field("dest_proj")),
},
})
})
.collect::<Result<_, DecodeError>>()?;
@@ -535,6 +616,45 @@ impl PoolPiece {
waterlogging: v["liquid_settings"].as_str() != Some("ignore_waterlogging"),
})
}
/// `PoolElementStructurePiece`'s tag (always genDepth 0, no orientation).
pub fn save(&self) -> Compound {
let mut tag = super::legacy::piece_tag("minecraft:jigsaw", self.bounding_box, None, 0);
let junctions = self
.junctions
.iter()
.map(|j| {
Tag::compound([
("source_x", Tag::Int(j.source_x)),
("source_ground_y", Tag::Int(j.source_ground_y)),
("source_z", Tag::Int(j.source_z)),
("delta_y", Tag::Int(j.delta_y)),
(
"dest_proj",
Tag::String(j.dest_projection.saved_name().into()),
),
])
})
.collect();
for (key, value) in [
("PosX", Tag::Int(self.position[0])),
("PosY", Tag::Int(self.position[1])),
("PosZ", Tag::Int(self.position[2])),
("ground_level_delta", Tag::Int(self.ground_level_delta)),
("pool_element", self.element.save()),
("rotation", Tag::String(self.rotation.saved_name().into())),
("junctions", Tag::List(junctions)),
] {
tag.insert(key.into(), value);
}
// LiquidSettings.APPLY_WATERLOGGING, the default, is not written.
if !self.waterlogging {
tag.insert(
"liquid_settings".into(),
Tag::String("ignore_waterlogging".into()),
);
}
tag
}
pub(super) fn moved(&mut self, dy: i32) {
self.position[1] += dy;
self.bounding_box = self.bounding_box.moved([0, dy, 0]);
@@ -1204,6 +1324,8 @@ impl Placer<'_, '_> {
source_x: target_pos[0],
source_ground_y: junction_y - local_y + source_ground,
source_z: target_pos[2],
delta_y,
dest_projection: target.projection(),
});
self.pieces.push(PoolPiece {
element: target.clone(),
@@ -1215,6 +1337,12 @@ impl Placer<'_, '_> {
source_x: jigsaw.pos[0],
source_ground_y: junction_y - target_local_y + target_ground,
source_z: jigsaw.pos[2],
delta_y: -delta_y,
dest_projection: if source_rigid {
Projection::Rigid
} else {
Projection::TerrainMatching
},
}],
waterlogging: self.config.waterlogging,
});
@@ -282,6 +282,11 @@ pub trait LegacyPiece: std::fmt::Debug + Send + Sync {
fn bounding_box(&self) -> BoundingBox;
/// The vanilla StructurePieceType id, for diagnostics and comparisons.
fn type_name(&self) -> &'static str;
/// `StructurePiece.createTag`; None while Horizon cannot save this type,
/// and then its start is not saved.
fn save(&self) -> Option<Compound> {
None
}
fn post_process(&self, context: &mut PieceContext<'_>) -> Result<()>;
/// Structure.afterPlace, run on the first piece once per chunk after all
/// pieces were placed, with every piece's current box.
@@ -290,6 +295,31 @@ pub trait LegacyPiece: std::fmt::Debug + Send + Sync {
}
}
/// `StructurePiece.createTag`'s own fields: the type, `BB`, `O` (the
/// orientation's 2D data value, -1 for none) and `GD` (genDepth).
pub fn piece_tag(
id: &str,
b: BoundingBox,
orientation: Option<Direction>,
gen_depth: i32,
) -> Compound {
let o = orientation.map_or(-1, crate::level::data_2d);
[
("id", Tag::String(id.into())),
(
"BB",
Tag::IntArray(vec![
b.min[0], b.min[1], b.min[2], b.max[0], b.max[1], b.max[2],
]),
),
("O", Tag::Int(o)),
("GD", Tag::Int(gen_depth)),
]
.into_iter()
.map(|(k, v)| (JavaString::from(k), v))
.collect()
}
/// StructurePiece's orientation transform and block helpers.
#[derive(Clone, Copy, Debug)]
pub struct PieceFrame {
@@ -732,6 +732,57 @@ impl LegacyPiece for MineshaftPiece {
fn bounding_box(&self) -> BoundingBox {
self.frame.bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
use crate::nbt::Tag;
let flag = |b: bool| Tag::Byte(i8::from(b));
let mut tag = super::legacy::piece_tag(
self.type_name(),
self.frame.bounding_box,
self.frame.orientation,
self.depth,
);
// MineshaftStructure.Type's ordinal: NORMAL, MESA.
let mesa = self.wood.log == MESA.log;
let mut entries = vec![("MST", Tag::Int(i32::from(mesa)))];
match &self.kind {
Kind::Room(entrances) => entries.push((
"Entrances",
Tag::List(
entrances
.iter()
.map(|b| {
Tag::IntArray(vec![
b.min[0], b.min[1], b.min[2], b.max[0], b.max[1], b.max[2],
])
})
.collect(),
),
)),
Kind::Corridor {
rails,
spider,
sections,
placed_spider,
} => entries.extend([
("hr", flag(*rails)),
("sc", flag(*spider)),
("hps", flag(*placed_spider.lock().unwrap())),
("Num", Tag::Int(*sections)),
]),
Kind::Crossing {
direction,
two_floored,
} => {
let d = crate::level::data_2d(*direction) as i8;
entries.extend([("tf", flag(*two_floored)), ("D", Tag::Byte(d))]);
}
Kind::Stairs => {}
}
for (key, value) in entries {
tag.insert(key.into(), value);
}
Some(tag)
}
fn type_name(&self) -> &'static str {
match self.kind {
Kind::Room(_) => "minecraft:msroom",
@@ -463,6 +463,16 @@ impl LegacyPiece for MonumentBuilding {
fn type_name(&self) -> &'static str {
"minecraft:omb"
}
/// `MonumentBuilding`: the base fields only; vanilla builds its rooms
/// again after loading (`OceanMonumentStructure.regeneratePiecesAfterLoad`).
fn save(&self) -> Option<crate::nbt::Compound> {
Some(super::legacy::piece_tag(
"minecraft:omb",
self.frame.bounding_box,
self.frame.orientation,
0,
))
}
fn post_process(&self, c: &mut PieceContext<'_>) -> Result<()> {
let sea = c.world.sea_level()?;
let water = c.state("water");
@@ -73,6 +73,9 @@ impl LegacyPiece for NetherFossilPiece {
fn bounding_box(&self) -> BoundingBox {
self.0.bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
Some(self.0.saved())
}
fn type_name(&self) -> &'static str {
"minecraft:nefos"
}
@@ -325,6 +325,8 @@ pub fn post_process(piece: &TemplatePiece, c: &mut PieceContext<'_>) -> Result<(
new_y = min_y + 1;
}
position[1] = new_y;
// OceanRuinPiece.postProcess keeps the height in templatePosition.
*piece.state.lock().unwrap() = Some(new_y);
if let Some(markers) = piece.place_at(c, position, &[])? {
for (marker, pos) in markers {
if marker == "chest" {
@@ -910,6 +910,8 @@ impl Assembler<'_> {
source_x: slot.target_pos[0],
source_ground_y: junction_y - local_y + source_ground,
source_z: slot.target_pos[2],
delta_y,
dest_projection: target.projection(),
});
slot.free.borrow_mut().removed.push(target_box);
self.pieces.push(PoolPiece {
@@ -922,6 +924,12 @@ impl Assembler<'_> {
source_x: jigsaw.pos[0],
source_ground_y: junction_y - target_local[1] + target_ground,
source_z: jigsaw.pos[2],
delta_y: -delta_y,
dest_projection: if source_rigid {
Projection::Rigid
} else {
Projection::TerrainMatching
},
}],
waterlogging: self.config.waterlogging,
});
@@ -1022,6 +1030,7 @@ mod tests {
let element = Arc::new(PoolElement::Single {
template,
processors: Default::default(),
processors_source: serde_json::Value::Null,
legacy: false,
projection: Projection::Rigid,
waterlogging: None,
@@ -1038,6 +1047,8 @@ mod tests {
source_x: 1,
source_ground_y: 2,
source_z: 3,
delta_y: 0,
dest_projection: Projection::Rigid,
}],
waterlogging: true,
};
@@ -706,7 +706,7 @@ fn holder_set(
}
Ok(Arc::new(out))
}
fn json_to_nbt(value: &Value) -> Tag {
pub(super) fn json_to_nbt(value: &Value) -> Tag {
match value {
Value::Bool(b) => Tag::Byte(i8::from(*b)),
Value::Number(n) => match n.as_i64() {
@@ -25,6 +25,17 @@ pub enum Vertical {
InNether,
}
impl Vertical {
/// `RuinedPortalPiece.VerticalPlacement.getSerializedName`.
fn saved_name(self) -> &'static str {
match self {
Self::OnLandSurface => "on_land_surface",
Self::PartlyBuried => "partly_buried",
Self::OnOceanFloor => "on_ocean_floor",
Self::InMountain => "in_mountain",
Self::Underground => "underground",
Self::InNether => "in_nether",
}
}
fn heightmap(self) -> HeightmapType {
if self == Self::OnOceanFloor {
HeightmapType::OceanFloorWg
@@ -70,6 +81,9 @@ impl Setup {
#[derive(Clone, Copy, Debug)]
pub struct PortalProps {
placement: Vertical,
mossiness: f32,
air_pocket: bool,
blackstone: bool,
cold: bool,
overgrown: bool,
vines: bool,
@@ -182,6 +196,9 @@ pub fn start(
};
let props = PortalProps {
placement: setup.placement,
mossiness: setup.mossiness,
air_pocket,
blackstone: setup.blackstone,
cold,
overgrown: setup.overgrown,
vines: setup.vines,
@@ -379,3 +396,28 @@ pub fn post_process(piece: &TemplatePiece, c: &mut PieceContext<'_>) -> Result<(
}
Ok(())
}
impl PortalProps {
/// `RuinedPortalPiece`'s `VerticalPlacement` and `Properties` tags.
pub fn save(&self) -> [(&'static str, crate::nbt::Tag); 2] {
use crate::nbt::Tag;
let flag = |b: bool| Tag::Byte(i8::from(b));
[
(
"VerticalPlacement",
Tag::String(self.placement.saved_name().into()),
),
(
"Properties",
Tag::compound([
("cold", flag(self.cold)),
("mossiness", Tag::Float(self.mossiness.to_bits())),
("air_pocket", flag(self.air_pocket)),
("overgrown", flag(self.overgrown)),
("vines", flag(self.vines)),
("replace_with_blackstone", flag(self.blackstone)),
]),
),
]
}
}
@@ -18,11 +18,22 @@ fn empty_processors() -> Arc<ProcessorList> {
#[derive(Debug)]
pub struct BuriedTreasurePiece {
pub position: [i32; 3],
/// Where the chest went: the box vanilla saves once placed.
pub found: Mutex<Option<[i32; 3]>>,
}
impl LegacyPiece for BuriedTreasurePiece {
fn bounding_box(&self) -> BoundingBox {
BoundingBox::new(self.position, self.position)
}
fn save(&self) -> Option<crate::nbt::Compound> {
let at = self.found.lock().unwrap().unwrap_or(self.position);
Some(super::legacy::piece_tag(
"minecraft:btp",
BoundingBox::new(at, at),
None,
0,
))
}
fn type_name(&self) -> &'static str {
"minecraft:btp"
}
@@ -58,6 +69,7 @@ impl LegacyPiece for BuriedTreasurePiece {
}
}
}
*self.found.lock().unwrap() = Some(pos);
create_chest(c, pos, "minecraft:chests/buried_treasure", None)?;
return Ok(());
}
@@ -193,10 +205,87 @@ pub fn shipwreck_too_big(template: &StructureTemplate) -> bool {
template.size[0] > 32 || template.size[1] > 32
}
impl TemplatePiece {
/// `TemplateStructurePiece.addAdditionalSaveData` and its subclasses',
/// with the height a shipwreck settled on once placed.
pub fn saved(&self) -> crate::nbt::Compound {
use crate::nbt::Tag;
let flag = |b: bool| Tag::Byte(i8::from(b));
let adjusted = match self.handler {
TemplateHandler::Shipwreck { .. }
| TemplateHandler::OceanRuin { .. }
| TemplateHandler::Igloo => *self.state.lock().unwrap(),
_ => None,
};
let dy = adjusted.map_or(0, |y| y - self.position[1]);
let template_dy = if matches!(self.handler, TemplateHandler::Igloo) {
0
} else {
dy
};
let mut tag = super::legacy::piece_tag(
self.type_name,
self.bounding_box.moved([0, dy, 0]),
Some(Direction::North),
0,
);
// Mansion and End City pieces keep the name inside their folder.
let name = self
.template_name
.strip_prefix("minecraft:woodland_mansion/")
.or_else(|| self.template_name.strip_prefix("minecraft:end_city/"))
.unwrap_or(&self.template_name);
let rotation = || Tag::String(self.rotation.saved_name().into());
let mut entries = vec![
("TPX", Tag::Int(self.position[0])),
("TPY", Tag::Int(self.position[1] + template_dy)),
("TPZ", Tag::Int(self.position[2])),
("Template", Tag::String(name.into())),
];
match self.handler {
TemplateHandler::Igloo => entries.push(("Rot", rotation())),
TemplateHandler::Shipwreck { beached } => entries.extend([
("isBeached", flag(beached)),
("Rot", rotation()),
("height_adjusted", flag(adjusted.is_some())),
]),
TemplateHandler::OceanRuin {
warm,
large,
integrity,
} => entries.extend([
("Rot", rotation()),
("Integrity", Tag::Float(integrity.to_bits())),
(
"BiomeType",
Tag::String(if warm { "WARM" } else { "COLD" }.into()),
),
("IsLarge", flag(large)),
]),
TemplateHandler::RuinedPortal(props) => {
entries.push(("Rotation", rotation()));
entries.push(("Mirror", Tag::String(self.mirror.saved_name().into())));
entries.extend(props.save());
}
TemplateHandler::Mansion => entries.extend([
("Rot", rotation()),
("Mi", Tag::String(self.mirror.saved_name().into())),
]),
TemplateHandler::Plain => entries.push(("Rot", rotation())),
}
for (key, value) in entries {
tag.insert(key.into(), value);
}
tag
}
}
impl LegacyPiece for TemplatePiece {
fn bounding_box(&self) -> BoundingBox {
self.bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
Some(self.saved())
}
fn after_place(&self, c: &mut PieceContext<'_>, pieces: &[BoundingBox]) -> Result<()> {
match self.handler {
TemplateHandler::Mansion => super::mansion::after_place(c, pieces),
@@ -232,6 +321,8 @@ impl LegacyPiece for TemplatePiece {
self.position[1] + height - 90 - 1,
self.position[2],
];
// The box stays moved; templatePosition goes back after placing.
*self.state.lock().unwrap() = Some(position[1]);
if let Some(markers) = self.place_at(c, position, &[])? {
for (marker, pos) in markers {
if marker == "chest" {
@@ -362,6 +453,21 @@ impl Scattered {
pub fn frame(&self) -> PieceFrame {
*self.frame.lock().unwrap()
}
/// `ScatteredFeaturePiece`'s tag: the box and the height it settled on.
pub fn saved(&self, id: &str) -> crate::nbt::Compound {
use crate::nbt::Tag;
let f = self.frame();
let mut tag = super::legacy::piece_tag(id, f.bounding_box, f.orientation, 0);
for (key, value) in [
("Width", self.width),
("Height", self.height),
("Depth", self.depth),
("HPos", *self.height_position.lock().unwrap()),
] {
tag.insert(key.into(), Tag::Int(value));
}
tag
}
/// updateAverageGroundHeight over the part of the box inside the chunk.
pub fn average_ground(&self, c: &mut PieceContext<'_>, offset: i32) -> Result<bool> {
if *self.height_position.lock().unwrap() >= 0 {
@@ -419,12 +525,15 @@ impl Scattered {
#[derive(Debug)]
pub struct SwampHutPiece {
pub base: Scattered,
/// spawnedWitch and spawnedCat: both spawn in the same chunk.
spawned: Mutex<bool>,
}
impl SwampHutPiece {
pub fn new(random: &mut impl RandomSource, west: i32, north: i32) -> Self {
let direction = random_horizontal(random);
Self {
base: Scattered::new(west, 64, north, 7, 7, 9, direction),
spawned: Mutex::new(false),
}
}
}
@@ -432,6 +541,13 @@ impl LegacyPiece for SwampHutPiece {
fn bounding_box(&self) -> BoundingBox {
self.base.frame().bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
let mut tag = self.base.saved("minecraft:tesh");
let spawned = crate::nbt::Tag::Byte(i8::from(*self.spawned.lock().unwrap()));
tag.insert("Witch".into(), spawned.clone());
tag.insert("Cat".into(), spawned);
Some(tag)
}
fn type_name(&self) -> &'static str {
"minecraft:tesh"
}
@@ -500,6 +616,7 @@ impl LegacyPiece for SwampHutPiece {
c.world
.entity(super::legacy::spawn_nbt(id, pos, true, true))?;
}
*self.spawned.lock().unwrap() = true;
}
Ok(())
}
@@ -694,6 +694,13 @@ impl Piece {
Self::Legacy(p) => p.type_name(),
}
}
/// `StructurePiece.createTag`; None for a type Horizon cannot save yet.
pub fn save(&self) -> Option<crate::nbt::Compound> {
match self {
Self::Pool(p) => Some(p.save()),
Self::Legacy(p) => p.save(),
}
}
}
fn legacy(pieces: Vec<impl LegacyPiece + 'static>) -> Vec<Piece> {
pieces
@@ -735,6 +742,26 @@ pub struct StructureStart {
/// Piece union, inflated by 12 for terrain-adapting structures.
pub bounding_box: BoundingBox,
}
impl StructureStart {
/// `StructureStart.createTag` in its own chunk; None while one of its
/// pieces cannot be saved. `references` counts `addReference` calls,
/// which only locating structures makes: 0 in a freshly made world.
pub fn save(&self) -> Option<crate::nbt::Tag> {
use crate::nbt::Tag;
let children: Option<Vec<Tag>> = self
.pieces
.iter()
.map(|p| p.save().map(Tag::Compound))
.collect();
Some(Tag::compound([
("id", Tag::String(self.name.as_str().into())),
("ChunkX", Tag::Int(self.chunk.x)),
("ChunkZ", Tag::Int(self.chunk.z)),
("references", Tag::Int(0)),
("Children", Tag::List(children?)),
]))
}
}
/// A block of a noise column (ChunkGenerator.getBaseColumn), by what
/// structures ask of it.
@@ -935,6 +962,7 @@ fn try_generate(
let stub = top_of_chunk_center(context, chunk, HeightmapType::OceanFloorWg)?;
let piece = super::simple::BuriedTreasurePiece {
position: [chunk.x * 16 + 9, 90, chunk.z * 16 + 9],
found: std::sync::Mutex::new(None),
};
(stub, legacy(vec![piece]))
}
@@ -1075,6 +1075,48 @@ impl LegacyPiece for StrongholdPiece {
fn bounding_box(&self) -> BoundingBox {
self.frame.bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
use crate::nbt::Tag;
let flag = |b: bool| Tag::Byte(i8::from(b));
let mut tag = super::legacy::piece_tag(
self.type_name(),
self.frame.bounding_box,
self.frame.orientation,
self.depth,
);
// SmallDoorType.LEGACY_CODEC: the enum name.
let door = match self.door {
Door::Opening => "OPENING",
Door::Wood => "WOOD_DOOR",
Door::Grates => "GRATES",
Door::Iron => "IRON_DOOR",
};
let placed = *self.placed.lock().unwrap();
let [a, b, c, d] = self.flags;
let mut entries = vec![("EntryDoor", Tag::String(door.into()))];
match self.kind {
Kind::ChestCorridor => entries.push(("Chest", flag(placed))),
Kind::Filler => entries.push(("Steps", Tag::Int(self.value))),
Kind::FiveCrossing => entries.extend([
("leftLow", flag(a)),
("leftHigh", flag(b)),
("rightLow", flag(c)),
("rightHigh", flag(d)),
]),
Kind::Library => entries.push(("Tall", flag(a))),
Kind::PortalRoom => entries.push(("Mob", flag(placed))),
Kind::RoomCrossing => entries.push(("Type", Tag::Int(self.value))),
// StartPiece is a StairsDown that is the source.
Kind::StairsDown => entries.push(("Source", flag(false))),
Kind::Start => entries.push(("Source", flag(true))),
Kind::Straight => entries.extend([("Left", flag(a)), ("Right", flag(b))]),
_ => {}
}
for (key, value) in entries {
tag.insert(key.into(), value);
}
Some(tag)
}
fn type_name(&self) -> &'static str {
match self.kind {
Kind::Straight => "minecraft:shs",
@@ -82,6 +82,16 @@ impl LegacyPiece for DesertPyramidPiece {
fn bounding_box(&self) -> BoundingBox {
self.base.frame().bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
let mut tag = self.base.saved("minecraft:tedp");
for (i, placed) in self.placed_chest.lock().unwrap().iter().enumerate() {
tag.insert(
format!("hasPlacedChest{i}").as_str().into(),
crate::nbt::Tag::Byte(i8::from(*placed)),
);
}
Some(tag)
}
fn type_name(&self) -> &'static str {
"minecraft:tedp"
}
@@ -448,6 +458,22 @@ impl LegacyPiece for JungleTemplePiece {
fn bounding_box(&self) -> BoundingBox {
self.base.frame().bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
let mut tag = self.base.saved("minecraft:tejp");
let placed = *self.placed.lock().unwrap();
for (key, value) in [
"placedMainChest",
"placedHiddenChest",
"placedTrap1",
"placedTrap2",
]
.into_iter()
.zip(placed)
{
tag.insert(key.into(), crate::nbt::Tag::Byte(i8::from(value)));
}
Some(tag)
}
fn type_name(&self) -> &'static str {
"minecraft:tejp"
}
+1 -1
View File
@@ -3,7 +3,7 @@
A player's own AI agent (Claude Code, Codex, Cursor, any MCP client) or a script can do
everything a player does with the mouse and keyboard, in the launcher and in the game. This page
covers developers: the surfaces, the full inventory, and the rules every new command follows.
Players read [Control Horizon with your AI agent](../website/docs/agents.md)
Players read [Control Horizon with your AI agent](https://playhorizon.dev/docs/agents)
(playhorizon.dev/docs/agents).
The command lists are generated, so they can't drift:
+1 -1
View File
@@ -53,4 +53,4 @@ Every page has the same parts: **Overview** (what you can build), **Guide** (whe
The website shows these pages at [playhorizon.dev/docs/api](https://playhorizon.dev/docs/api), built from this
folder, and a summary for players and authors at [playhorizon.dev/docs/apis](https://playhorizon.dev/docs/apis)
(`website/docs/apis.md`).
(in the website's repository, ludovic111/horizon-site).
+1
View File
@@ -176,6 +176,7 @@ Capability `command`: vanilla commands run as the player, with the player's perm
| Function | Signature | What it does |
| --- | --- | --- |
| `run` | `func(line: string)` | `line` without its slash; its feedback goes to the chat as if typed. |
| `run-quiet` | `func(lines: list<string>)` | `lines` (each without its slash, at most 256 a call) as `run` does, but with nothing in the chat: no feedback and no failure. In single player and on Horizon's servers, each runs exactly as the player typing it would (the same rights), in order, 64 a tick, and the server logs one line a batch; one that carries a chat message (`say`, `msg`) is refused. On any other server they go as chat commands, and there their feedback shows: a mod running many should say so. |
## `ui`
+1 -1
View File
@@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says
A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every
function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`):
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`. A mod needs the level of
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`. A mod needs the level of
the newest function it imports (a component imports only the functions it calls). The level is read from the file,
never declared:
+4 -4
View File
@@ -8,8 +8,8 @@ Horizon's own settings are pages built from vanilla's widgets, the way mods add
The tokens live in one place, `crates/horizon-theme`. The launcher (`launcher/src/theme.rs`)
and the game (`crates/horizon-client/src/ui_theme.rs`) read them as Rust constants, and the
website reads them as CSS custom properties generated from the same crate
(`cargo run -p xtask -- tokens` writes `website/src/tokens.css`; a test fails when the file is
out of date). Change a value in the crate and all three follow.
(`cargo run -p xtask -- tokens` writes `crates/horizon-theme/tokens.css`, which the website's build
reads from main; a test fails when the file is out of date). Change a value in the crate and all three follow.
## Where it comes from
@@ -140,7 +140,7 @@ Geist (400, 500, 600) and Geist Mono (400), bundled under the SIL Open Font Lice
landscape banner with the hero, the version picker and Play; Mods holds the mod, resource pack
and modpack tabs; Versions and Patch notes are lists of panels; Settings is
grouped panels of rows. Dark by default, Light or System in Settings (Appearance).
- **Website** (`website/`): the Blocks look below. The same page structure as before (what it
- **Website** (ludovic111/horizon-site): the Blocks look below. The same page structure as before (what it
is, screenshots, parity, launcher, patch notes, community), headings in Pixelify Sans, lists as
stacks of panels, the stone texture behind the page and Horizon's pixel landscape (a dawn or a
sunset) behind the hero. Light and dark follow the system, with a toggle. A giant wordmark in
@@ -177,7 +177,7 @@ of the main action. The game's menus keep `LIGHT` and `DARK`.
(`TILE_SHADES`), never Minecraft's stone; the landscape is a banded sky, a sun cut in slices like
the mark, hills and square trees (a sunset in dark, a dawn in light). The launcher bakes them in
`launcher/src/art.rs` at the window's scale so each texel covers whole device pixels; the website
draws them in `website/scripts/art.mjs`.
draws them in its `scripts/art.mjs`.
- **Icons**: Pixelarticons (MIT), 24 unit pixel SVGs in `crates/horizon-theme/icons/pixel/`, tinted
with the text colour.
- **Contrast**: every text colour meets WCAG AA (4.5:1) on the panels, the page and the lightest
+4 -4
View File
@@ -1,10 +1,10 @@
<!doctype html>
<!-- README banner (docs/media/banner.webp), 1600x480, in the design of docs/design-system.md.
Rendered with Playwright from website/ (see docs/media/brand/README.md). -->
Rendered with Playwright (see docs/media/brand/README.md). -->
<meta charset="utf-8"><style>
@font-face{font-family:Geist;src:url(../../website/public/fonts/Geist-Regular.woff2);font-weight:400}
@font-face{font-family:Geist;src:url(../../website/public/fonts/Geist-Medium.woff2);font-weight:500}
@font-face{font-family:"Geist Mono";src:url(../../website/public/fonts/GeistMono-Regular.woff2)}
@font-face{font-family:Geist;src:url(../../crates/horizon-theme/fonts/Geist-Regular.ttf);font-weight:400}
@font-face{font-family:Geist;src:url(../../crates/horizon-theme/fonts/Geist-Medium.ttf);font-weight:500}
@font-face{font-family:"Geist Mono";src:url(../../crates/horizon-theme/fonts/GeistMono-Regular.ttf)}
html,body{margin:0;width:1600px;height:480px;overflow:hidden;background:#0d0e13;color:#ecedf3;font-family:Geist}
main{position:relative;height:100%;padding:56px 72px;box-sizing:border-box}
.top{display:flex;align-items:center;gap:12px;font-size:20px;font-weight:500}
+4 -5
View File
@@ -26,16 +26,15 @@ places it in the design system; `candidates/` keeps the two alternatives that we
| `packaging/icons/horizon.ico` | Windows: embedded in `horizon-client.exe`, its tools and `horizon-launcher.exe` as icon resource 1 (`build.rs` of each, Windows builds only) |
| `packaging/icons/horizon.icns` | macOS: `Horizon.app/Contents/Resources/horizon.icns`, packaged with the .dmg by `packaging/macos/package.sh` |
| `crates/horizon-theme/icons/horizon-64.png` | the game window's icon at run time (X11 and Windows title bars, `window::icon`) |
| `website/public/favicon.svg`, `favicon-32.png`, `apple-touch-icon.png` | the website |
| `web/favicon.svg`, `web/favicon-32.png`, `web/apple-touch-icon.png` | the website (its `npm run assets` copies them into its `public/`) |
| `icon-1024.png`, `icon-macos-1024.png` | previews, store listings |
| `social/github-avatar-500.png` | GitHub: the organisation or account avatar |
The page in `social/` is a screenshot, at their exact size, with Playwright from the
website's dev dependencies:
The page in `social/` is a screenshot, at their exact size, with Playwright, from the repository's root:
```sh
cd website && npm install
node -e 'const {chromium}=require("playwright");(async()=>{const b=await chromium.launch();for(const[f,w,h,o]of[["card",1280,640,"github-social-1280x640"]]){const p=await b.newPage({viewport:{width:w,height:h}});await p.goto("file://"+require("path").resolve("../docs/media/brand/social/"+f+".html"));await p.waitForTimeout(300);await p.screenshot({path:"../docs/media/brand/social/"+o+".png"})}await b.close()})()'
d=$(mktemp -d) && npm install --prefix "$d" playwright && npx --prefix "$d" playwright install chromium
NODE_PATH="$d/node_modules" node -e 'const {chromium}=require("playwright");(async()=>{const b=await chromium.launch();for(const[f,w,h,o]of[["card",1280,640,"github-social-1280x640"]]){const p=await b.newPage({viewport:{width:w,height:h}});await p.goto("file://"+require("path").resolve("docs/media/brand/social/"+f+".html"));await p.waitForTimeout(300);await p.screenshot({path:"docs/media/brand/social/"+o+".png"})}await b.close()})()'
```
## Drawn in code
+3 -3
View File
@@ -1,7 +1,7 @@
<!doctype html><meta charset=utf-8><style>
@font-face{font-family:Geist;src:url(/home/ludovic/.t3/worktrees/horizon/t3-1a6cb6c4/website/public/fonts/Geist-Regular.woff2);font-weight:400}
@font-face{font-family:Geist;src:url(/home/ludovic/.t3/worktrees/horizon/t3-1a6cb6c4/website/public/fonts/Geist-Medium.woff2);font-weight:500}
@font-face{font-family:"Geist Mono";src:url(/home/ludovic/.t3/worktrees/horizon/t3-1a6cb6c4/website/public/fonts/GeistMono-Regular.woff2)}
@font-face{font-family:Geist;src:url(../../../../crates/horizon-theme/fonts/Geist-Regular.ttf);font-weight:400}
@font-face{font-family:Geist;src:url(../../../../crates/horizon-theme/fonts/Geist-Medium.ttf);font-weight:500}
@font-face{font-family:"Geist Mono";src:url(../../../../crates/horizon-theme/fonts/GeistMono-Regular.ttf)}
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#0d0e13;color:#ecedf3;font-family:Geist}
.wrap{position:relative;height:100%;padding:var(--pad);box-sizing:border-box}
.top{display:flex;align-items:center;gap:12px;font-size:var(--small);font-weight:500}

Before

Width:  |  Height:  |  Size: 733 B

After

Width:  |  Height:  |  Size: 733 B

Before

Width:  |  Height:  |  Size: 424 B

After

Width:  |  Height:  |  Size: 424 B

Before

Width:  |  Height:  |  Size: 537 B

After

Width:  |  Height:  |  Size: 537 B

+1 -1
View File
@@ -87,7 +87,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
| `chat` | `chat` | `show(text)`: a line only the local player sees, after the mod's name (8 per call) |
| `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters |
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |
| `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run |
| `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. `run-quiet(lines)`: up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) |
| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` |
| `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` |
| `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key.<mod>.<name>:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` |

Some files were not shown because too many files have changed in this diff Show More