Compare commits

...
Author SHA1 Message Date
rdbtCVSandClaude Sonnet 5.5 9fd2e38592 Merge origin/main into split/friends-share-view
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:03:43 +00:00
Kacper 2abe71ca92 Merge pull request #14589 from MSedgeMC/fix/drowned-fluid-push
A swimming drowned is not pushed by the current
2026-11-12 04:29:17 +01:00
Kacper f787306fce Merge pull request #15457 from svetch/fix/26.3-seventh-republish
Prepare: accept Mojang's seventh 26.3 republish (9f67e10e, index e99f9973)
2026-11-12 04:16:20 +01:00
Kacper d8bf54d1e6 Merge pull request #14494 from vnsmoreira/fix/explosion-creaking-heart-xp
A player's blast awards a natural creaking heart's experience
2026-11-12 04:16:11 +01:00
Kacper f165e7fa29 Merge pull request #15419 from yahya-nusseibeh/fix/launcher-sign-in-bounds
Launcher: bound the sign-in services' interval and lifetimes
2026-11-12 04:16:01 +01:00
Kacper 9d73f6a26a Merge pull request #15151 from MSedgeMC/fix/chiseled-shelf-can-take
fix: a chiseled bookshelf only gives a book to a hopper with room
2026-11-12 04:15:48 +01:00
Kacper 4a60f12112 Merge pull request #13630 from MSedgeMC/fix/goat-long-jump
A goat keeps its long-jump cooldown in its chunk save
2026-11-12 04:09:39 +01:00
Kacper 192be7194f Merge pull request #15120 from MSedgeMC/port_map/farmland-is-near-water
port_map: FarmlandBlock.isNearWater is the water fluid search
2026-11-12 04:01:45 +01:00
Kacper 5f3caf9be4 Merge pull request #13881 from Skr1zzzz/split-renderer
Move building and adopting the renderer out of game.rs into game/render_settings.rs
2026-11-12 04:00:55 +01:00
Kacper 571ef142c5 Merge pull request #15477 from Ragbie/fix/stack-dump-windows-unused
horizon-threads: stack_dump's Unix-only code no longer fails clippy on Windows
2026-11-12 03:48:06 +01:00
Kacper 21845f7949 Merge pull request #14376 from Joansitoh/fix/14371-rod-neighbours
Lightning rod: a strike updates the front block's neighbours without redstone experiments
2026-11-12 03:46:31 +01:00
Kacper 27a4dc65c9 Merge pull request #14365 from oloion/perf/range-dispatch-binary-search
assets: avoid heap allocation in item model range dispatch binary search
2026-11-12 03:46:19 +01:00
Kacper deaab60ba9 Merge pull request #15418 from yahya-nusseibeh/fix/protocol-inflate-growth
Protocol: grow the inflate buffer instead of reserving the declared size
2026-11-12 03:38:58 +01:00
Kacper c2c3cea8e3 Merge pull request #15478 from Bl4ckspell7/refactor/gfx-census-module
Renderer: residency and memory figures move out of gfx.rs into gfx/census.rs
2026-11-12 03:33:14 +01:00
Kacpherek be34bdfd3c Merge commit '24b778c3b4' into unstick/14589
# Conflicts:
#	crates/horizon-client/src/mobs/living_tests.rs
2026-11-12 02:57:18 +01:00
Kacpherek e72b69caca Merge commit '24b778c3b4' into unstick/14494
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-12 02:54:54 +01:00
Kacpherek d9d89df9d8 Merge commit 'aab654d4e8' into unstick/14494
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-12 02:12:20 +01:00
Joansitoh f8e59c3e3b Merge upstream main into fix/14371-rod-neighbours 2026-11-12 01:31:16 +01:00
Kacper 1dcb48e1ec Merge branch 'main' into fix/26.3-seventh-republish 2026-11-12 01:12:39 +01:00
Kacper 80570541d0 Merge branch 'main' into fix/goat-long-jump 2026-11-12 01:11:14 +01:00
Kacper 44699b4c51 Merge branch 'main' into fix/protocol-inflate-growth 2026-11-12 01:05:19 +01:00
Kacper 370854a657 Merge branch 'main' into fix/explosion-creaking-heart-xp 2026-11-12 01:00:16 +01:00
Bl4ckspellandClaude Opus 5.5 83703c28b2 refactor(gfx): move residency and memory figures into gfx/census.rs
gfx.rs is over the 3000-line limit and may not grow, and the 4x terrain MSAA work adds to it. The impl block from resident_sections to cpu_census moves unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:57:12 +01:00
Kacper 1b016ba22e Merge branch 'main' into fix/chiseled-shelf-can-take 2026-11-12 00:55:44 +01:00
Kacper 023cef202f Merge branch 'main' into fix/launcher-sign-in-bounds 2026-11-12 00:55:27 +01:00
Kacper fdc97df507 Merge branch 'main' into split-renderer 2026-11-12 00:55:09 +01:00
Kacper fd7c423476 Merge branch 'main' into perf/range-dispatch-binary-search 2026-11-12 00:54:10 +01:00
Kacper 731699144c Merge branch 'main' into port_map/farmland-is-near-water 2026-11-12 00:54:01 +01:00
Evrim CicekandClaude Opus 5.5 1fc67d4d10 stack_dump: the Unix-only code is unused on Windows without failing clippy
after_handler is only called on the signal path (Linux, Android, Apple),
and the tests' busy thread only serves the test that reads another
thread's stack there: on Windows clippy -D warnings stopped in
horizon-threads, and every build warned about after_handler. The function
takes the same cfg_attr(.., allow(dead_code)) as Watched::thread, and the
test helpers and their imports the test's cfg.

Fixes #15476.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 02:45:58 +03:00
Kacper 54ea398de4 Merge branch 'main' into fix/protocol-inflate-growth 2026-11-11 23:57:50 +01:00
Kacpherek 71d150b028 Read the redstone experiments flag from the enabled features in the lightning rod test 2026-11-11 23:54:20 +01:00
Kacper 6c58b77fed Merge branch 'main' into fix/explosion-creaking-heart-xp 2026-11-11 23:52:41 +01:00
Kacpherek 245d97a312 Close the drowned current test in living_tests 2026-11-11 23:51:47 +01:00
Kacpherek 9aae4a6671 Merge commit '3298a6dcd5' into unstick/14376 2026-11-11 23:50:18 +01:00
Kacpherek 2497c21f48 Merge commit '3298a6dcd5' into unstick/14589 2026-11-11 23:50:17 +01:00
Kacper 195dceeb94 Merge branch 'main' into fix/chiseled-shelf-can-take 2026-11-11 23:48:07 +01:00
Kacper 8dc176f4e6 Merge branch 'main' into fix/launcher-sign-in-bounds 2026-11-11 23:47:50 +01:00
Kacper 13fe0905ba Merge branch 'main' into split-renderer 2026-11-11 23:47:32 +01:00
Kacper 7d7fa98a44 Merge branch 'main' into perf/range-dispatch-binary-search 2026-11-11 23:46:17 +01:00
Kacper fd2f881a32 Merge branch 'main' into port_map/farmland-is-near-water 2026-11-11 23:46:08 +01:00
Kacper 4a90452d73 Merge branch 'main' into fix/drowned-fluid-push 2026-11-11 23:19:17 +01:00
Kacper b9abf293aa Merge branch 'main' into fix/14371-rod-neighbours 2026-11-11 23:17:47 +01:00
Román BenjáminandClaude Opus 5.5 f5a3990fbf Prepare: accept Mojang's seventh 26.3 republish (9f67e10e, index e99f9973)
Fixes #15456.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 23:12:02 +01:00
Vinícius Moreira 190aa00a63 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/explosion-creaking-heart-xp
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:10 -03:00
Kacper 47e22facbb Merge branch 'main' into fix/drowned-fluid-push 2026-11-11 21:49:56 +01:00
Kacper 1d2b14b01d Merge branch 'main' into fix/14371-rod-neighbours 2026-11-11 21:49:49 +01:00
rdbtCVSandGPT 6.1 Sol 1bcc5ec3f9 assets: preserve vanilla range-dispatch binary-search hits
Address kacpherek's request on PR #14365: remove the post-hit tie scan while keeping slice-based search and the short-list upper bound. Extend the existing regression test with 16-entry duplicate and signed-zero lists; expected indices were observed on the JVM and the pinned vanilla method was checked with javap.

Validation: cargo check -p horizon-assets --locked; cargo test -p horizon-assets --locked (53 passed); cargo fmt -p horizon-assets -- --check; cargo clippy -p horizon-assets --all-targets --locked -- -D warnings.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:31:36 +00:00
rdbtCVSandGPT 6.1 Sol 476dabcb08 Merge main into split/friends-share-view
Preserve the ShareView export alongside GetTheGame and combine the Friends documentation updates.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:28:47 +00:00
Kacpherek b61cff5625 Merge main into fix/chiseled-shelf-can-take 2026-11-11 20:44:41 +01:00
Kacpherek 37f6d169d7 Merge main into port_map/farmland-is-near-water 2026-11-11 20:44:26 +01:00
Kacpherek ac4204f395 Merge main into split-renderer 2026-11-11 20:42:09 +01:00
Kacpherek 7714b1d37a Merge main into fix/goat-long-jump 2026-11-11 20:41:46 +01:00
yahya-nusseibehandClaude Opus 5.5 5f61926401 Launcher: bound the sign-in services' interval and lifetimes
The device-code interval, its expiry and the Minecraft token lifetime came from the services
unchecked; Instant + a huge interval panicked the sign-in thread and left the window on the
code. The interval is clamped to 1-60 s (slow-down too), the device code's expiry to an hour
and the token lifetime to a week, all added with saturating_add.

Split out of #15247 at review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:31:24 +02:00
yahya-nusseibehandClaude Opus 5.5 98b40e8e5e protocol: grow the inflate buffer instead of reserving the declared size
A compressed frame declares its uncompressed size up front, and the inflater reserved that
much at once: a few bytes declaring 8 MiB made the reader allocate 8 MiB per frame. The
buffer now grows as output arrives (capped by DEFLATE's ~1032:1 ratio), so a lying frame
is refused after a small allocation and a real large body still inflates whole.

Split out of #15249 at review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:16:47 +02:00
rdbtCVSandGPT 6.1 Sol 8c893c55f0 Merge main into split-renderer to pick up deterministic CI fixture
Preserve the contributor's renderer extraction unchanged while including main's fix for the Git maintenance race in test_git_slim. The diff against main remains the same four moved methods and port-map citation.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 11:59:51 +00:00
MSedgeMC 30e4e488b2 port_map: ChiseledBookShelfBlockEntity.canTakeItem asks the target's room
The hopper's path applies the `hasAnyMatching` rule through
`can_take_into`.
2026-11-11 08:49:01 +00:00
MSedgeMC e2ee8ad7d9 test: a full hopper cannot take a book from a chiseled shelf
`a_full_hopper_cannot_take_a_book_from_a_chiseled_shelf` asks the rule
with a hopper full of stone (false) and an empty one (true). Without the
shelf rule both answer true.
2026-11-11 08:40:34 +00:00
MSedgeMC c59a4d6ffd fix: a chiseled bookshelf only gives a book to a hopper with room
`ChiseledBookShelfBlockEntity.canTakeItem` is
`target.hasAnyMatching(other -> other.isEmpty() || (same item and
components && other.count + stack.count <= target.getMaxStackSize(other)))`:
a hopper may take a book only when it has a free slot or a matching stack
with room.

Horizon's `Containers::can_take` had no shelf rule, so a full hopper
still pulled a book out (and put it back). The hopper's path now asks
`can_take_into`, which applies the shelf rule before the existing checks.
2026-11-11 08:40:13 +00:00
MSedgeMC fb05c1acfc port_map: FarmlandBlock.isNearWater is the water fluid search
Vanilla's predicate is `state.getFluidState().is(FluidTags.WATER)`, which
the inline 9x2x9 search already matches; the row's `canBeHydrated` gap
was wrong.
2026-11-11 05:02:45 +00:00
MSedgeMC 53e8715d83 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:14 +00:00
Vinícius Moreira 306dca0a21 Merge remote-tracking branch 'upstream/main' into fix/explosion-creaking-heart-xp 2026-11-11 00:03:46 -03:00
Vinícius MoreiraandClaude Opus 5.5 26d32147ea Wrap the zombie owner's summon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:53 -03:00
Vinícius MoreiraandClaude Opus 5.5 0b2ed50a68 A nested blast drops the TNT owner; the zombie owner stays and leaves no drops
A native explosion inside a TNT blast is Level.explode(null, ...): it
now takes blast_owner, blast_guest and blast_owner_player aside with
the rest of the blast's state. The group keeps its zombie persistent
and clears the zombie's drops.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:46 -03:00
Vinícius MoreiraandClaude Opus 5.5 386d0560a1 A mob-owned TNT awards no creaking heart experience; block_drops off pops none
PrimedTnt.getOwner names the mob a saved owner UUID points at, so its
blast's indirect source is no player and tryAwardExperience awards
nothing. Keep whether the owner is a player beside blast_owner (a chain
reaction hands a mob owner on as a mob). Block.popExperience draws the
amount, then pops nothing with block_drops off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:32:56 -03:00
MSedgeMC 359388eea7 Merge main; keep both sides' tests and module declarations 2026-11-11 01:03:00 +00:00
Vinícius MoreiraandClaude Opus 5.5 4d6923514e A player's blast awards a natural creaking heart's experience
CreakingHeartBlock.onExplosionHit awards the heart's experience
(tryAwardExperience) when the explosion's indirect source is a player, a
primed TNT's owner, unless that player is in creative or spectator
(preventsBlockDrops). Horizon always broke the heart as if no player
caused it.

Part of #11330

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:34 -03:00
Oloion afb34764ec assets: eliminate heap allocations in RangeSelectItemModel binary search 2026-11-10 17:00:17 +03:00
MSedgeMC 7cbe3d49b0 port_map: the drowned's fluid push names its arm
Drowned.isPushedByFluid had no row; it is the drowned arm of
living.rs::Mob::pushed_by_fluid now.
2026-11-10 00:50:48 +00:00
MSedgeMC 83122825e5 Test the drowned that a current leaves alone
Mob::pushed_by_fluid, the predicate both push sites of
LivingEntity.updateFluidInteraction read: a drowned with its swimming flag set
answers false, the same drowned with the flag cleared answers true, the water
mobs that never take a push (a cod, a squid, a turtle, a nautilus) answer
false either way, and a zombie answers true. Without the drowned arm the
swimming one answers true too.
2026-11-10 00:50:42 +00:00
MSedgeMC c3af4fadeb A swimming drowned is not pushed by the current
Drowned.isPushedByFluid answers `!isSwimming()`: a drowned swimming after its
target holds its line in a stream, while one walking the bottom is carried
along like any other mob. Horizon answered the plain LivingEntity rule for
every drowned, so a drowned chasing a player through flowing water was swept
off its path, and the current was applied even while its own move control was
driving it.

pushed_by_fluid now answers the drowned with its swimming flag, which
Drowned.updateSwimming keeps on the mob (`extra.flag`) and the clients read
from the shared flags, and the rest of the table stays as it was.
2026-11-10 00:50:42 +00:00
Horizon agent ffaaa63836 Merge remote-tracking branch 'origin/main' into tmp/share-view-merge
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 23:45:52 +01:00
JoansitohandClaude Sonnet 5.5 f23a03f6c7 Lightning rod: update the front block's neighbours without redstone experiments
Fixes #14371

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:23:34 +01:00
Horizon agentandClaude Opus 5.5 2719b22258 Friends reads its code while the screen is drawn, as before
The invite code and the host's events are polled while the Friends screen
is drawn or horizon-ctl friends status asks, as on main: Hooks::share stays
dispatched for the screen's page (#14167 step 2a), which is its first user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:50:04 +01:00
Horizon agentandClaude Opus 5.5 3da374ef18 Play with Friends hosts through Hooks::share and Hooks::lan_closed (spec #14167 step 1)
The engine shows the window's world to crates that share it past the LAN
through horizon_hooks::share::ShareView, ticks them next to social::tick()
(also while paused) and tells them when the world closes to LAN. Only the
window's own player dispatches: never the serving Game or a splitscreen guest.
game/friends.rs keeps its logic but goes through the view, its state in its
own static instead of a Game field, ready to move to horizon-friends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:40:22 +01:00
Horizon agentandClaude Opus 5.5 10b318cd6b Merge main into split-renderer
main moved more of game.rs out (#13877, #13878, #13880...): the conflict is only the
neighbours of the moved functions; game.rs takes main and drops the four functions
this branch moves to game/render_settings.rs, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:08:14 +00:00
Horizon agentandClaude Opus 5.5 3877ae52d8 Merge origin/main into the branch
The verify job compared big files against this branch's old base, so main's own growth of options_ui.rs since then failed the size check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:35:03 +01:00
MSedgeMC 166073d4bc Re-run checks: the previous run failed in an unrelated fixture / harness 2026-11-09 14:52:23 +00:00
Skr1zzzzandClaude Opus 5.5 71e2401edc Move building and adopting the renderer out of game.rs into game/render_settings.rs
Part of #11857. new_renderer, adopt_renderer, forget_atlas_slots and all_sections_changed join game/render_settings.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:29:00 +03:00
MSedgeMC 35fc116bd3 fix: drop the goat's old call and pass the memory value to number 2026-11-09 06:41:45 +00:00
MSedgeMC 2a95475e8f refactor: keep the goat's save code in goat.rs
`entity_nbt.rs` is over the 3000-line limit and may not grow (docs/layers.md
#file-size), so the goat's save pair moves into `mobs/goat.rs`: the screaming
and horn fields `Goat.addAdditionalSaveData` writes plus the long jump
cooldown memory, and their read side. `entity_nbt` keeps a one-line call.
2026-11-09 05:52:47 +00:00
MSedgeMC 47894e831f port_map: a goat's long-jump cooldown is saved and read
`Goat.addAdditionalSaveData`'s row in `tools/port_map/game-012.tsv` names the
three own fields; it now also names the `GoatAi` `LONG_JUMP_COOLDOWN_TICKS`
brain memory that the save covers.
2026-11-09 05:18:42 +00:00
MSedgeMC 5e735bd030 test: a goat's long-jump cooldown through the chunk save
`mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt` and
`Game::load_mob` go through. A goat with 57 ticks of long-jump cooldown saves
`Brain.memories."minecraft:long_jump_cooling_down"` as `{value: 57}` and the
reloaded goat's `frog::jump_cooldown` is 57 again. A goat with no cooldown
writes no memory, even over a compound that still names one.

Without the write the save has no memory (left `None`, right `Some(Compound({"value": Int(57)}))`), and the
reloaded goat has no cooldown (left `None`, right `Some(57)`).
2026-11-09 05:18:42 +00:00
MSedgeMC da4591bc88 fix: a goat keeps its long-jump cooldown in its chunk save
`GoatAi` sets `LONG_JUMP_COOLDOWN_TICKS` when a goat long jumps, and
`CountDownCooldownTicks` counts it down; `Brain.Packed` saves every brain
memory with `LivingEntity.addAdditionalSaveData` and reads it back.

Horizon kept the cooldown in the goat's `extra.memories` (the shared
`frog::jump_cooldown` the long jump goal asks) and wrote no `Brain`, so a goat
that had just long jumped unloaded with its chunk and came back ready to jump
again. The save now writes `minecraft:long_jump_cooling_down` as the integer
memory it is and the load puts it back in the goat's memories.
2026-11-09 05:18:42 +00:00
36 changed files with 1287 additions and 488 deletions

No files matched your search

+8 -9
View File
@@ -1764,14 +1764,13 @@ fn stack_from_template(template: &Value) -> Option<StackView> {
/// `RangeSelectItemModel.lastIndexLessOrEqual`.
fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Option<usize> {
let haystack: Vec<f32> = entries.iter().map(|e| e.0).collect();
let found = if haystack.len() < 16 {
haystack
let found = if entries.len() < 16 {
entries
.iter()
.position(|h| *h > needle)
.map_or(haystack.len() as i64 - 1, |i| i as i64 - 1)
.position(|e| e.0 > needle)
.map_or(entries.len() as i64 - 1, |i| i as i64 - 1)
} else {
match binary_search(&haystack, needle) {
match binary_search(entries, needle) {
Ok(index) => index as i64,
Err(insertion) => insertion as i64 - 1,
}
@@ -1780,8 +1779,8 @@ fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Op
}
/// `Arrays.binarySearch(float[], float)`, including its handling of equal
/// values (-0.0 below 0.0, NaN above everything).
fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
let (mut low, mut high) = (0i64, a.len() as i64 - 1);
fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Result<usize, usize> {
let (mut low, mut high) = (0i64, entries.len() as i64 - 1);
let bits = |v: f32| -> i32 {
if v.is_nan() {
0x7fc0_0000
@@ -1791,7 +1790,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
};
while low <= high {
let mid = (low + high) >> 1;
let value = a[mid as usize];
let value = entries[mid as usize].0;
if value < key {
low = mid + 1;
} else if value > key {
@@ -158,3 +158,61 @@ fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
}
assert_eq!(view.to_map(), *merged);
}
#[test]
fn last_index_less_or_equal_linear_and_binary() {
use super::last_index_less_or_equal;
let make = |thresholds: &[f32]| -> Vec<(f32, ItemModelNode)> {
thresholds
.iter()
.map(|&t| (t, ItemModelNode::Empty))
.collect()
};
// Empty
assert_eq!(last_index_less_or_equal(&[], 0.0), None);
// Short (< 16): linear search
let short = make(&[-1.0, 0.0, 1.0, 2.5, 5.0]);
assert_eq!(last_index_less_or_equal(&short, -2.0), None);
assert_eq!(last_index_less_or_equal(&short, -1.0), Some(0));
assert_eq!(last_index_less_or_equal(&short, -0.5), Some(0));
assert_eq!(last_index_less_or_equal(&short, 0.0), Some(1));
assert_eq!(last_index_less_or_equal(&short, 2.5), Some(3));
assert_eq!(last_index_less_or_equal(&short, 3.0), Some(3));
assert_eq!(last_index_less_or_equal(&short, 5.0), Some(4));
assert_eq!(last_index_less_or_equal(&short, 10.0), Some(4));
// Long (>= 16): binary search
let thresholds: Vec<f32> = (0..32).map(|i| i as f32 * 10.0).collect();
let long = make(&thresholds);
assert_eq!(last_index_less_or_equal(&long, -5.0), None);
assert_eq!(last_index_less_or_equal(&long, 0.0), Some(0));
assert_eq!(last_index_less_or_equal(&long, 5.0), Some(0));
assert_eq!(last_index_less_or_equal(&long, 10.0), Some(1));
assert_eq!(last_index_less_or_equal(&long, 150.0), Some(15));
assert_eq!(last_index_less_or_equal(&long, 155.0), Some(15));
assert_eq!(last_index_less_or_equal(&long, 310.0), Some(31));
assert_eq!(last_index_less_or_equal(&long, 1000.0), Some(31));
// Short lists use upper-bound tie behavior, including signed zero.
let duplicates = make(&[1.0, 1.0, 2.0]);
assert_eq!(last_index_less_or_equal(&duplicates, 1.0), Some(1));
let zeroes = make(&[-0.0, 0.0]);
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(1));
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(1));
// At 16 entries, vanilla returns Arrays.binarySearch's hit, not the last tie.
// Expected indices were checked on the JVM.
let duplicates = make(&[
0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0,
]);
assert_eq!(last_index_less_or_equal(&duplicates, 7.0), Some(7));
let zeroes = make(&[
-8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -0.0, -0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0,
]);
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(7));
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(9));
}
+1 -154
View File
@@ -192,6 +192,7 @@ fn packed_wanted() -> bool {
pub(crate) mod animate;
mod arena;
pub mod capture;
mod census;
pub mod compositor;
pub(crate) mod core_lines;
pub(crate) mod core_sky;
@@ -2547,160 +2548,6 @@ enum FirstFrame {
fn on_off(on: bool) -> &'static str {
if on { "on" } else { "off" }
}
impl Renderer {
/// Sections with GPU buffers, for soak tests watching residency.
pub fn resident_sections(&self) -> usize {
self.sections.len()
}
/// Quads of the far terrain tiles with GPU buffers.
pub fn lod_quads(&self) -> u64 {
self.lods
.values()
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
.sum()
}
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
let mut levels = Vec::new();
for (key, tile) in &self.lods {
let level = usize::from(key.level);
if levels.len() <= level {
levels.resize(level + 1, [0, 0]);
}
levels[level][0] += 1;
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
}
levels
}
/// Whether solid quads go to the arena in the compact layout
/// (`packed_quads`), and whether far tiles' own records are on
/// (`lod::packed::far_packing`).
pub fn packed_layout(&self) -> [bool; 2] {
[self.packed_quads, crate::lod::packed::far_packing()]
}
/// Bytes of the arena the resident chunk sections hold, and of the one far
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
/// compact layouts can be seen working.
pub fn vertex_bytes_split(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
[
self.sections.values().map(used).sum(),
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
]
}
/// Bytes of the section vertex arenas' buffers, and of the vertices
/// resident sections hold in them.
pub fn section_vertex_bytes(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let vertices: u64 = self
.sections
.values()
.chain(self.lods.values().map(|tile| &*tile.mesh))
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
.sum();
let translucent = self.translucent_arena.as_ref();
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
[arenas, vertices * stride]
}
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
/// the terrain arenas (their pages, and what resident sections and far tiles use of
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
/// draws' buffers and the visibility buffer's.
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
let translucent = self.translucent_arena.as_ref();
vec![
("section_arena_pages", self.arena.bytes()),
(
"translucent_arena_pages",
translucent.map_or(0, arena::Arena::bytes),
),
("sorted_index_pages", self.sorted_indices.bytes()),
(
"near_section_vertices",
self.sections.values().map(used).sum(),
),
(
"held_section_vertices",
self.hold
.sections()
.filter_map(|held| held.gpu.as_ref())
.map(used)
.sum(),
),
(
"far_tile_vertices",
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
),
("quad_indices", self.indices.size()),
("block_sprites", textures(&self.sprite_textures)),
(
"pbr_maps",
self.pbr_maps
.as_ref()
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
),
(
"flat_draws",
self.flat.as_ref().map_or(0, flat::Flat::bytes),
),
(
"visibility_buffer",
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
),
]
}
/// Device memory the backend's allocator holds, by the label of what holds
/// it, largest first, with its allocated and reserved totals (Vulkan and
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
let report = self.device.generate_allocator_report()?;
let mut by_label: HashMap<String, u64> = HashMap::new();
for a in report.allocations {
*by_label.entry(a.name).or_default() += a.size;
}
let mut labels: Vec<_> = by_label.into_iter().collect();
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some((
report.total_allocated_bytes,
report.total_reserved_bytes,
labels,
))
}
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
/// resident sections, far tiles, block entities and light sources by section.
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
use std::mem::size_of;
let per_section = |map_len: usize, lists: usize| {
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
+ lists * size_of::<([i32; 3], u32)>()
};
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
per_section(
map.capacity(),
map.values().map(Vec::capacity).sum::<usize>(),
)
};
let animated: usize = self
.sections
.values()
.map(|s| s.animated_layers.len() * size_of::<u16>())
.sum();
[
("resident_sections", self.sections.heap_bytes() + animated),
(
"far_tiles",
self.lods.capacity()
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
),
("block_entities", lists(&self.block_entities)),
("light_sources", lists(&self.light_blocks)),
]
}
}
impl Renderer {
pub async fn new(
window: Arc<Window>,
@@ -0,0 +1,164 @@
//! What the renderer holds, for status, soak tests and memory reports (`horizon-ctl memory`,
//! `scene_replay --memory`): resident sections and far tiles, and the bytes of its largest
//! GPU resources and of its per-section bookkeeping.
use std::collections::HashMap;
use super::{
Renderer, SectionGpu, SectionKey, TerrainVertex, arena, flat, lod, texture_bytes, visbuffer,
};
impl Renderer {
/// Sections with GPU buffers, for soak tests watching residency.
pub fn resident_sections(&self) -> usize {
self.sections.len()
}
/// Quads of the far terrain tiles with GPU buffers.
pub fn lod_quads(&self) -> u64 {
self.lods
.values()
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
.sum()
}
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
let mut levels = Vec::new();
for (key, tile) in &self.lods {
let level = usize::from(key.level);
if levels.len() <= level {
levels.resize(level + 1, [0, 0]);
}
levels[level][0] += 1;
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
}
levels
}
/// Whether solid quads go to the arena in the compact layout
/// (`packed_quads`), and whether far tiles' own records are on
/// (`lod::packed::far_packing`).
pub fn packed_layout(&self) -> [bool; 2] {
[self.packed_quads, crate::lod::packed::far_packing()]
}
/// Bytes of the arena the resident chunk sections hold, and of the one far
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
/// compact layouts can be seen working.
pub fn vertex_bytes_split(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
[
self.sections.values().map(used).sum(),
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
]
}
/// Bytes of the section vertex arenas' buffers, and of the vertices
/// resident sections hold in them.
pub fn section_vertex_bytes(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let vertices: u64 = self
.sections
.values()
.chain(self.lods.values().map(|tile| &*tile.mesh))
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
.sum();
let translucent = self.translucent_arena.as_ref();
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
[arenas, vertices * stride]
}
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
/// the terrain arenas (their pages, and what resident sections and far tiles use of
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
/// draws' buffers and the visibility buffer's.
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
let translucent = self.translucent_arena.as_ref();
vec![
("section_arena_pages", self.arena.bytes()),
(
"translucent_arena_pages",
translucent.map_or(0, arena::Arena::bytes),
),
("sorted_index_pages", self.sorted_indices.bytes()),
(
"near_section_vertices",
self.sections.values().map(used).sum(),
),
(
"held_section_vertices",
self.hold
.sections()
.filter_map(|held| held.gpu.as_ref())
.map(used)
.sum(),
),
(
"far_tile_vertices",
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
),
("quad_indices", self.indices.size()),
("block_sprites", textures(&self.sprite_textures)),
(
"pbr_maps",
self.pbr_maps
.as_ref()
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
),
(
"flat_draws",
self.flat.as_ref().map_or(0, flat::Flat::bytes),
),
(
"visibility_buffer",
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
),
]
}
/// Device memory the backend's allocator holds, by the label of what holds
/// it, largest first, with its allocated and reserved totals (Vulkan and
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
let report = self.device.generate_allocator_report()?;
let mut by_label: HashMap<String, u64> = HashMap::new();
for a in report.allocations {
*by_label.entry(a.name).or_default() += a.size;
}
let mut labels: Vec<_> = by_label.into_iter().collect();
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some((
report.total_allocated_bytes,
report.total_reserved_bytes,
labels,
))
}
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
/// resident sections, far tiles, block entities and light sources by section.
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
use std::mem::size_of;
let per_section = |map_len: usize, lists: usize| {
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
+ lists * size_of::<([i32; 3], u32)>()
};
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
per_section(
map.capacity(),
map.values().map(Vec::capacity).sum::<usize>(),
)
};
let animated: usize = self
.sections
.values()
.map(|s| s.animated_layers.len() * size_of::<u16>())
.sum();
[
("resident_sections", self.sections.heap_bytes() + animated),
(
"far_tiles",
self.lods.capacity()
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
),
("block_entities", lists(&self.block_entities)),
("light_sources", lists(&self.light_blocks)),
]
}
}
+2 -14
View File
@@ -916,15 +916,7 @@ pub fn mob_to_nbt(
Tag::Int(m.extra.more_carrot_ticks),
);
}
"minecraft:goat" => {
map.insert(
"IsScreamingGoat".into(),
Tag::Byte(i8::from(m.extra.screaming)),
);
let [left, right] = m.extra.lost_horns;
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
}
"minecraft:goat" => crate::mobs::goat::write_nbt(m, map),
"minecraft:creeper" => {
map.insert("Fuse".into(), Tag::Short(m.max_swell as i16));
map.insert(
@@ -2353,11 +2345,7 @@ pub fn mob_from_nbt_with_random(
_ => 0,
};
}
crate::mobs::Species::Goat => {
m.extra.screaming = flag(tag, "IsScreamingGoat");
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
}
crate::mobs::Species::Goat => crate::mobs::goat::read_nbt(&mut m, tag),
_ => {}
}
// `Rabbit.readAdditionalSaveData`.
+3
View File
@@ -26,6 +26,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Play with Friends' host side, until it moves to horizon-friends (spec #14167).
#[cfg(feature = "friends")]
game::register_friends();
// Crates above the engine registered their hooks before this (horizon-official's `main`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
+4 -50
View File
@@ -423,6 +423,7 @@ mod server_links;
mod server_packs;
mod server_travel;
mod shader_packs;
mod share_view;
mod sheep_dye;
mod shelf;
mod shield;
@@ -601,6 +602,8 @@ use resource_loader::{
EntityModels, GameCpuResources, Loaded, LoaderMsg, PendingWorldEntry, WorldRestLoader,
run_loading, write_report,
};
#[cfg(feature = "friends")]
pub(crate) use share_view::register_friends;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -2898,9 +2901,6 @@ struct Game<'a> {
level_shapes: horizon_client::chunk::LevelShapes,
/// This game is that server thread's (game/integrated.rs).
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
#[cfg(feature = "friends")]
friends: Option<friends::Sharing>,
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
social: social::Social,
world_options: Option<lan_screen::WorldOptions>,
@@ -5731,7 +5731,6 @@ impl Game<'_> {
if let Some(log) = self.world.block_log.as_mut() {
log.extend(held);
}
span("tick_friends", || self.tick_friends());
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
span("sleep_status", || self.announce_sleep_status());
@@ -8321,22 +8320,6 @@ impl Game<'_> {
}
self.tracked_special_box(id as i32)
}
/// The renderer for the window, with the current block sprites, GUI
/// atlas and world border texture.
/// A new renderer's atlas holds only the pack's sprites: what was drawn into it at run time
/// (skins, maps, the themed UI's glyphs and corners) has to be drawn again.
pub(super) fn forget_atlas_slots(&mut self) {
self.skin_slots.forget();
self.maps.slots.forget();
self.trim_slots.forget();
self.map_patches.clear();
self.kept_block_entities.get_mut().clear();
horizon_client::ui_theme::reset_atlas();
}
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
self.renderer_for(window, &self.pack, &self.gui)
}
/// Rebuild GPU-owned state after the backend reports device loss.
fn recover_device(&mut self) -> Result<()> {
if !self.renderer.as_ref().is_some_and(Renderer::device_lost) {
@@ -8399,34 +8382,6 @@ impl Game<'_> {
log::info!("Horizon: graphics device recovered; rebuilding world meshes");
Ok(())
}
/// A renderer made again from the resources loaded takes over: what named the old one's
/// atlas slots is forgotten, the world is meshed again, and the shader pack and the
/// settings are applied again.
pub(super) fn adopt_renderer(&mut self, mut renderer: Renderer) {
// A new renderer starts with the vanilla column and without the world border's wall.
renderer.clear_sections(self.world.layout());
self.border_renderer.invalidate();
self.renderer = Some(renderer);
self.forget_atlas_slots();
self.clear_lod();
self.pending_uploads.clear();
let context = self.worker_context();
self.streaming.reload_resources(&mut self.world, context);
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
&mut self.native_shaders.picked,
));
self.apply_ui_settings();
}
/// `LevelExtractor.allChanged`: displayed sections and mesh work are
/// discarded, then every loaded section is rebuilt.
fn all_sections_changed(&mut self) {
self.clear_lod();
self.pending_uploads.clear();
if let Some(renderer) = self.renderer.as_mut() {
renderer.clear_sections(self.world.layout());
}
self.streaming.reload_sections(&mut self.world);
}
/// A translated message for the player the game acts for: `overlay` is
/// `sendOverlayMessage` (the action bar), else `sendSystemMessage` (chat).
/// A guest gets a packet, so its own client translates it.
@@ -11138,6 +11093,7 @@ impl Game<'_> {
// the world's own ticks.
horizon_client::social::set_presence_status(self.presence_status());
horizon_client::social::tick();
self.share_tick();
// Minecraft.tick: ClientTickEvent.Pre before any of it.
self.native_client_tick_pre();
if self.title.is_none() && !self.pause {
@@ -11878,8 +11834,6 @@ impl<'a> Game<'a> {
test_defs_snapshot: false,
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
#[cfg(feature = "friends")]
friends: None,
social: Default::default(),
debug_keys: Default::default(),
debug_charts: Default::default(),
+22 -10
View File
@@ -247,8 +247,16 @@ impl Game<'_> {
}
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
/// death and a natural heart drops experience. `onExplosionHit` does the
/// same for the creaking (`by_player` false), without the experience.
/// same for the creaking, and awards the experience only when its
/// blast's indirect source is a player (`creaking_heart_broken_by`).
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
let award = by_player && !self.creative && !self.spectator();
self.creaking_heart_broken_by(pos, award);
}
/// `creaking_heart_broken` once the breaker is known: `award` is
/// `tryAwardExperience`'s player check (a player who neither builds
/// instantly, `preventsBlockDrops`, nor spectates).
pub(super) fn creaking_heart_broken_by(&mut self, pos: [i32; 3], award: bool) {
let key = (pos[0], pos[1], pos[2]);
if !matches!(
self.world.block_entities.get(&key),
@@ -271,16 +279,20 @@ impl Game<'_> {
.state(state)
.and_then(|s| s.properties.get("natural").cloned())
.is_some_and(|v| v == "true");
if by_player && natural && !self.creative && !self.spectator() {
if award && natural {
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
self.spawn_orbs(
[
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
],
xp,
);
// Block.popExperience: the amount is drawn, then nothing pops
// with `block_drops` off.
if self.ui.rules.bool("block_drops") {
self.spawn_orbs(
[
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
],
xp,
);
}
}
self.world
.block_entities
+11 -3
View File
@@ -1341,10 +1341,18 @@ impl Game<'_> {
}
let name = self.res.facts().get(state).name.clone();
let is_tnt = &*name == "minecraft:tnt";
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
// experience, as Horizon does not track a TNT's owner).
// CreakingHeartBlock.onExplosionHit: its creaking dies, and a blast
// whose indirect source is a player (a primed TNT's owner) awards
// the heart's experience (`tryAwardExperience`).
if &*name == "minecraft:creaking_heart" {
self.creaking_heart_broken(pos, false);
// tryAwardExperience checks the TNT owner's own abilities.
let award = match self.combat.blast_guest {
Some(conn) => self
.command_guest(conn)
.is_some_and(|g| !g.creative() && !g.spectator()),
None => self.combat.blast_owner_player && !self.creative && !self.spectator(),
};
self.creaking_heart_broken_by(pos, award);
}
// Block.onExplosionHit: the drops copy from the block entity, which
// then spills its contents as the block turns to air.
+368 -209
View File
@@ -7,10 +7,21 @@
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address. The joining side is horizon-friends' (`game`), registered by
//! horizon-official.
use super::{Game, lan::LanSettings};
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
//!
//! It sees the game only through `horizon_hooks::share` (the engine's `ShareView`) and
//! `Hooks::lan_closed`, and keeps what it shares in its own static,
//! so it can leave the engine for horizon-friends (spec #14167). The screen and the
//! `horizon-ctl friends` command below still reach it from `Game` until then.
use super::Game;
use horizon_client::{Result, hud::HudBatch};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_hooks::{
Hooks,
share::{LanOptions, ShareView},
};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
mod screen;
@@ -18,7 +29,7 @@ mod screen;
use screen::{self as friends_screen, Action, FriendsView};
pub use screen::{TITLE, title};
pub(super) struct Sharing {
struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
opened_lan: bool,
@@ -31,12 +42,52 @@ pub(super) struct Sharing {
paths: (u32, u32),
}
/// The world shared, if one is. The game has one window player who can share (the engine
/// calls the hooks for it only), so one is all there is.
static SHARING: Mutex<Option<Sharing>> = Mutex::new(None);
/// Whether [`SHARING`] holds one: a tick with nothing shared is this one load.
static ON: AtomicBool = AtomicBool::new(false);
fn sharing() -> MutexGuard<'static, Option<Sharing>> {
SHARING.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Ends the sharing, if there is one. The lock is let go before the caller touches the
/// game: closing LAN calls [`Hooks::lan_closed`], which takes it again.
fn take() -> Option<Sharing> {
let taken = sharing().take();
ON.store(false, Ordering::Relaxed);
taken
}
/// Registered by the engine's entry while this module is part of it.
pub struct Friends;
pub static HOOKS: Friends = Friends;
/// No `Hooks::share` yet: the code and sessions are read while the screen is drawn or
/// `horizon-ctl friends status` asks, as before. The screen's page uses it (#14167 step 2a).
impl Hooks for Friends {
/// The LAN server is closing: the code goes with it.
fn lan_closed(&self) {
if ON.load(Ordering::Relaxed)
&& let Some(mut sharing) = take()
{
sharing.host.stop();
}
}
}
pub const HELP: &[&str] = &[
"friends host share the open world behind an invite code",
"friends stop stop sharing",
"friends status the code, how friends reach this host, the guests",
];
/// A string in the player's language, or the English one.
fn tr(world: &dyn ShareView, key: &str, english: &str) -> String {
world.tr(key).unwrap_or_else(|| english.to_owned())
}
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
fn stun_servers() -> Vec<String> {
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
@@ -53,101 +104,99 @@ fn env_flag(name: &str, default: bool) -> bool {
std::env::var(name).map_or(default, |v| v != "0")
}
impl Game<'_> {
/// Start sharing the open world.
pub(super) fn friends_start(&mut self) -> Result<()> {
if self.friends.is_some() {
return Ok(());
}
// Single player on its server thread shares that server, as Open to
// LAN publishes it; another server can't be shared.
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
return Err("open a singleplayer world first".into());
}
let opened_lan = self.lan_port().is_none();
if opened_lan {
// ShareToLanScreen's defaults, as the world options screen offers them.
self.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: false,
force_game_mode: true,
})?;
}
let port = self.lan_port().ok_or("the world isn't open to LAN")?;
let host = match Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
}) {
Ok(h) => h,
Err(e) => {
if opened_lan {
self.lan_close(true);
}
return Err(e.into());
/// Start sharing the open world.
fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
if ON.load(Ordering::Relaxed) {
return Ok(());
}
if !world.can_share() {
return Err("open a singleplayer world first".into());
}
let (port, opened_lan) = match world.lan_port() {
Some(port) => (port, false),
// ShareToLanScreen's defaults, as the world options screen offers them.
None => (world.open_lan(LanOptions::default())?, true),
};
let host = Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
});
let host = match host {
Ok(h) => h,
Err(e) => {
if opened_lan {
world.close_lan();
}
};
self.friends = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
Ok(())
}
return Err(e.to_string());
}
};
*sharing() = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
ON.store(true, Ordering::Relaxed);
Ok(())
}
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
pub(super) fn friends_stop(&mut self) {
let Some(mut sharing) = self.friends.take() else {
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
fn stop(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(mut sharing) = take() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
world.close_lan();
} else {
let text = tr(
world,
"horizon.friends.stopped",
"Stopped sharing the invite code.",
);
world.chat(&text);
}
}
/// New codes and sessions, while the screen is drawn and when `friends status` asks.
fn tick(world: &mut dyn ShareView) {
if !ON.load(Ordering::Relaxed) {
return;
}
let mut announce = false;
let mut stopped = None;
{
let mut guard = sharing();
let Some(sharing) = guard.as_mut() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
self.lan_close(true);
} else {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
)));
}
}
/// The LAN server is closing: the code goes with it.
pub(super) fn friends_lan_closed(&mut self) {
if let Some(mut sharing) = self.friends.take() {
sharing.host.stop();
}
}
/// New codes and sessions, once a tick and while the pause menu is drawn.
pub(super) fn tick_friends(&mut self) {
let Some(sharing) = self.friends.as_mut() else {
return;
};
let mut announce = false;
let mut stopped = None;
for event in sharing.host.poll() {
match event {
HostEvent::Code(code) => {
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
sharing.notice = Some(lang::get_or(
sharing.notice = Some(tr(
world,
"horizon.friends.short_code_changed",
"Someone kept trying a wrong short code, so it changed.",
));
@@ -158,7 +207,8 @@ impl Game<'_> {
}
HostEvent::WrongCode { count } => {
sharing.wrong = count;
sharing.notice = Some(lang::get_or(
sharing.notice = Some(tr(
world,
"horizon.friends.wrong_code",
"Someone tried a wrong code.",
));
@@ -167,7 +217,8 @@ impl Game<'_> {
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
HostEvent::Disconnected { .. } => {}
HostEvent::RelayStopped => {
let text = lang::get_or(
let text = tr(
world,
"horizon.friends.relay_stopped",
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
);
@@ -176,164 +227,272 @@ impl Game<'_> {
}
}
}
if let Some(text) = stopped {
self.system_message(Text::literal(text));
}
if announce {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
)));
}
}
if let Some(text) = stopped {
world.chat(&text);
}
if announce {
let text = tr(
world,
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
);
world.chat(&text);
}
}
pub(super) fn friends_copy(&mut self) {
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
return;
fn copy(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(code) = sharing().as_ref().and_then(|s| s.code.clone()) else {
return;
};
world.copy(&code);
if let Some(s) = sharing().as_mut() {
s.copied = Some(Instant::now());
}
}
/// What Play with Friends shows.
fn status(world: &dyn ShareView) -> FriendsView {
// Another server, or a splitscreen guest in P1's world: nothing to share.
if !world.can_share() {
return FriendsView::default();
}
let guard = sharing();
let Some(s) = guard.as_ref() else {
return FriendsView {
shown: true,
..FriendsView::default()
};
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&code);
if let Some(s) = self.friends.as_mut() {
s.copied = Some(Instant::now());
}
};
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
let state = s.host.state();
let mut status = if s.code.is_none() {
tr(
world,
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
FriendsView {
shown: true,
sharing: true,
code: s.code.clone(),
status,
reachable,
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
guests,
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
}
/// `horizon-ctl friends host|stop|status`.
fn control(
word: Option<&str>,
world: &mut dyn ShareView,
) -> std::result::Result<serde_json::Value, String> {
use serde_json::json;
match word {
Some("host") => {
start(world)?;
Ok(json!({ "ok": true, "lan_port": world.lan_port() }))
}
Some("stop") => {
stop(world);
Ok(json!({ "ok": true }))
}
Some("status") => {
tick(world);
let view = status(world);
let guard = sharing();
let sharing = guard.as_ref().filter(|_| view.sharing);
let state = sharing.map(|s| s.host.state());
Ok(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": sharing.map_or(0, |s| s.host.sessions()),
"direct": sharing.map_or(0, |s| s.paths.0),
"relayed": sharing.map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": world.lan_port(),
}))
}
_ => Err("friends host|stop|status".into()),
}
}
impl Game<'_> {
/// Play with Friends. The world is paused under it in singleplayer, so
/// new codes and sessions are read while it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
self.tick_friends();
let view = self.friends_view();
let world = &mut self.share_view();
tick(world);
let view = status(world);
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", friends_screen::TITLE)
});
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
}
pub(super) fn friends_screen_click(&mut self) {
let view = self.friends_view();
let view = status(&self.share_view());
let Some(action) =
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
else {
return;
};
self.audio.play_button_click();
if action == Action::Done {
self.screen = super::Screen::Paused;
return;
}
let world = &mut self.share_view();
match action {
Action::Share => {
if let Err(e) = self.friends_start() {
self.system_message(Text::literal(
lang::get_or(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e.to_string(), 1),
));
if let Err(e) = start(world) {
let text = tr(
world,
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e, 1);
world.chat(&text);
}
}
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the folder buttons open their folders.
Action::Copy => copy(world),
Action::Stop => stop(world),
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_uri(url);
world.open_url(url);
}
}
Action::Done => self.screen = super::Screen::Paused,
Action::Done => {}
}
}
/// Whether the pause menu offers Play with Friends (a singleplayer world).
pub(super) fn friends_shown(&self) -> bool {
self.friends_view().shown
}
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = match &self.lan {
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
// On the server thread: the other players the server lists.
None if self.integrated.is_some() => {
let me = self.options.player_name();
let mut names = self.remote_online_names().unwrap_or_default();
names.retain(|n| *n != me);
names
}
None => Vec::new(),
};
let Some(s) = &self.friends else {
return FriendsView {
shown: self.remote.is_none() || self.integrated.is_some(),
..FriendsView::default()
};
};
let state = s.host.state();
let mut status = if s.code.is_none() {
lang::get_or(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
FriendsView {
shown: true,
sharing: true,
code: s.code.clone(),
status,
reachable,
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
guests,
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
self.can_share()
}
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
use serde_json::json;
if words[0] != "friends" {
return Ok(None);
}
match words.get(1).copied() {
Some("host") => {
self.friends_start()?;
Ok(Some(json!({ "ok": true, "lan_port": self.lan_port() })))
Ok(Some(control(
words.get(1).copied(),
&mut self.share_view(),
)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A world as the engine shows it, recording what the crate did to it.
#[derive(Default)]
struct Fake {
can_share: bool,
lan: Option<u16>,
refuse_lan: bool,
did: Vec<String>,
}
impl ShareView for Fake {
fn can_share(&self) -> bool {
self.can_share
}
fn lan_port(&self) -> Option<u16> {
self.lan
}
fn open_lan(&mut self, options: LanOptions) -> std::result::Result<u16, String> {
self.did.push(format!("open_lan {options:?}"));
if self.refuse_lan {
return Err("no free port".into());
}
Some("stop") => {
self.friends_stop();
Ok(Some(json!({ "ok": true })))
}
Some("status") => {
self.tick_friends();
let view = self.friends_view();
let state = self.friends.as_ref().map(|s| s.host.state());
Ok(Some(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": self.friends.as_ref().map_or(0, |s| s.host.sessions()),
"direct": self.friends.as_ref().map_or(0, |s| s.paths.0),
"relayed": self.friends.as_ref().map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": self.lan_port(),
})))
}
_ => Err("friends host|stop|status".into()),
self.lan = Some(25565);
Ok(25565)
}
fn close_lan(&mut self) {
self.did.push("close_lan".into());
self.lan = None;
}
fn players(&self, each: &mut dyn FnMut(&str)) {
each("Alex");
}
fn chat(&mut self, text: &str) {
self.did.push(format!("chat {text}"));
}
fn copy(&mut self, text: &str) {
self.did.push(format!("copy {text}"));
}
fn open_url(&mut self, url: &str) {
self.did.push(format!("open_url {url}"));
}
fn tr(&self, _key: &str) -> Option<String> {
None
}
}
#[test]
fn a_world_that_cannot_be_shared_is_left_alone() {
// Another server, or a splitscreen guest in P1's world.
let mut guest = Fake {
lan: Some(25565),
..Fake::default()
};
assert_eq!(
control(Some("host"), &mut guest).unwrap_err(),
"open a singleplayer world first"
);
stop(&mut guest);
copy(&mut guest);
assert!(guest.did.is_empty(), "{:?}", guest.did);
assert!(!status(&guest).shown);
assert!(!ON.load(Ordering::Relaxed));
}
#[test]
fn sharing_opens_lan_as_open_to_lan_does_and_a_refusal_shares_nothing() {
let mut world = Fake {
can_share: true,
refuse_lan: true,
..Fake::default()
};
assert_eq!(start(&mut world).unwrap_err(), "no free port");
assert_eq!(
world.did,
["open_lan LanOptions { guest_commands: false, force_game_mode: true }"]
);
// Offered, not shared: the guests show once sharing does.
let view = status(&world);
assert!(view.shown && !view.sharing && view.guests.is_empty());
assert!(!ON.load(Ordering::Relaxed));
}
}
@@ -13,10 +13,6 @@ pub fn title() -> String {
}
impl Game<'_> {
pub(super) fn friends_lan_closed(&mut self) {}
pub(super) fn tick_friends(&mut self) {}
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
self.screen = Screen::Paused;
+2 -2
View File
@@ -1490,7 +1490,7 @@ impl Game<'_> {
None => self.unpublish(),
};
if closed {
self.friends_lan_closed();
self.lan_closed_hooks();
}
if closed && announce && self.integrated.is_some() {
self.system_message(Text::translate(
@@ -1500,7 +1500,7 @@ impl Game<'_> {
}
return;
}
self.friends_lan_closed();
self.lan_closed_hooks();
self.native_players_leaving();
let Some(mut host) = self.lan.take() else {
return;
+46 -8
View File
@@ -445,14 +445,14 @@ impl Game<'_> {
// is the same block as before, so its placement does not schedule
// it, and without this it stayed powered for good.
b.schedule_tick(level, ticks, BlockPos::new(p[0], p[1], p[2]), 8);
if level.redstone_experiments {
b.lightning_rod_update_neighbors(
level,
ticks,
BlockPos::new(p[0], p[1], p[2]),
state,
);
}
// `updateNeighbours`: unconditional; the experiments only decide
// whether the update carries an orientation.
b.lightning_rod_update_neighbors(
level,
ticks,
BlockPos::new(p[0], p[1], p[2]),
state,
);
});
}
// Level event 3002 with the rod's axis: sparks along it.
@@ -831,6 +831,44 @@ mod tests {
}
}
/// `LightningRodBlock.onLightningStrike` updates the neighbours of the block
/// in front of the rod whether or not redstone experiments are on (#14371):
/// a wire beside that block is strongly powered by the struck rod.
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn a_struck_rod_updates_the_wire_beside_the_block_behind_it() {
let mut game = super::super::native_machines::tests::server_game();
let registry = game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
let wire = registry.default_state("minecraft:redstone_wire").unwrap();
let mut rod = registry
.state(registry.default_state("minecraft:lightning_rod").unwrap())
.cloned()
.unwrap();
rod.properties.insert("facing".into(), "up".into());
let rod = registry.id(&rod).unwrap();
// The rod on stone at (8, 64, 8); wire on stone beside that block.
game.world.set(8, 64, 8, stone).unwrap();
game.world.set(9, 63, 8, stone).unwrap();
game.world.set(9, 64, 8, wire).unwrap();
game.world.set(8, 65, 8, rod).unwrap();
assert!(
!game
.level
.enabled_features
.iter()
.any(|feature| feature == "minecraft:redstone_experiments")
);
game.strike_rod([8.5, 66.0, 8.5]);
let power = registry
.state(game.world.block(9, 64, 8))
.unwrap()
.properties
.get("power")
.cloned();
assert_eq!(power.as_deref(), Some("15"));
}
#[test]
fn viewer_box_reaches_15_around_and_down_and_21_up() {
let bolt = [0.0, 64.0, 0.0];
+86 -1
View File
@@ -489,6 +489,29 @@ impl Containers<'_> {
}
}
/// `canTakeItemThroughFace` (furnaces and brewing stands restrict it).
/// `Container.canTakeItem` for a hopper's slot of `source`: the vanilla
/// shelf rule needs to know where the item is going.
fn can_take_into(
&self,
view: &View,
slot: usize,
stack: &ItemStack,
face: Direction,
target: &View,
) -> bool {
if view.kind == Kind::Bookshelf {
// `ChiseledBookShelfBlockEntity.canTakeItem`: the target must have
// a free slot or a matching stack with room (`hasAnyMatching`).
return (0..target.len()).any(|i| match self.get(target, i) {
None => true,
Some(other) => {
other.same_item_same_components(stack)
&& other.count + stack.count <= self.max_stack(other)
}
});
}
self.can_take(view, slot, stack, face)
}
fn can_take(&self, view: &View, slot: usize, stack: &ItemStack, face: Direction) -> bool {
if let Kind::Composter(level) = view.kind {
// OutputContainer.canTakeItemThroughFace.
@@ -1443,7 +1466,7 @@ impl Game<'_> {
let Some(stack) = c.insertion_item(source, slot).map(Cow::into_owned) else {
continue;
};
if !c.can_take(source, slot, &stack, Direction::Down) {
if !c.can_take_into(source, slot, &stack, Direction::Down, hopper) {
continue;
}
}
@@ -2482,3 +2505,65 @@ mod tests {
#[cfg(test)]
#[path = "machines_chest_cat_tests.rs"]
mod machines_chest_cat_tests;
#[cfg(test)]
mod can_take_tests {
use super::*;
use crate::game::native_machines::tests::server_game;
use horizon_client::containers::ChiseledBookshelf;
/// `ChiseledBookShelfBlockEntity.canTakeItem`: a hopper takes a book only
/// when it has a free slot or a matching stack with room.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_full_hopper_cannot_take_a_book_from_a_chiseled_shelf() {
let mut game = server_game();
let shelf_pos = (2, 4, 3);
let hopper_pos = (2, 5, 3);
let shelf_state = game
.pack
.assets
.registry
.default_state("minecraft:chiseled_bookshelf")
.unwrap();
let hopper_state = game
.pack
.assets
.registry
.default_state("minecraft:hopper")
.unwrap();
game.world
.set(shelf_pos.0, shelf_pos.1, shelf_pos.2, shelf_state)
.unwrap();
game.world
.set(hopper_pos.0, hopper_pos.1, hopper_pos.2, hopper_state)
.unwrap();
let book = ItemStack::new("minecraft:book", 1);
let mut shelf = ChiseledBookshelf::default();
shelf.items[0] = Some(book.clone());
game.world
.block_entities
.insert(shelf_pos, BlockEntity::ChiseledBookshelf(Box::new(shelf)));
let stone = ItemStack::new("minecraft:stone", 64);
game.world
.block_entities
.insert(hopper_pos, BlockEntity::Chest(vec![Some(stone.clone()); 5]));
let shelf_view = game.containers().view(shelf_pos, false).expect("shelf");
let full_hopper = game.containers().view(hopper_pos, false).expect("hopper");
assert!(
!game
.containers()
.can_take_into(&shelf_view, 0, &book, Direction::Down, &full_hopper),
"a full hopper takes no book"
);
game.world
.block_entities
.insert(hopper_pos, BlockEntity::Chest(vec![None; 5]));
let empty_hopper = game.containers().view(hopper_pos, false).expect("hopper");
assert!(
game.containers()
.can_take_into(&shelf_view, 0, &book, Direction::Down, &empty_hopper),
"an empty hopper takes the book"
);
}
}
+1 -1
View File
@@ -1572,7 +1572,7 @@ impl Game<'_> {
self.left = false;
self.right = false;
// The shared world is closing: its invite code ends with it.
self.friends_lan_closed();
self.lan_closed_hooks();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -221,6 +221,53 @@ impl Game<'_> {
self.streaming.radius
}
}
/// The renderer for the window, with the current block sprites, GUI
/// atlas and world border texture.
/// A new renderer's atlas holds only the pack's sprites: what was drawn into it at run time
/// (skins, maps, the themed UI's glyphs and corners) has to be drawn again.
pub(super) fn forget_atlas_slots(&mut self) {
self.skin_slots.forget();
self.maps.slots.forget();
self.trim_slots.forget();
self.map_patches.clear();
self.kept_block_entities.get_mut().clear();
horizon_client::ui_theme::reset_atlas();
}
pub(super) fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
self.renderer_for(window, &self.pack, &self.gui)
}
/// A renderer made again from the resources loaded takes over: what named the old one's
/// atlas slots is forgotten, the world is meshed again, and the shader pack and the
/// settings are applied again.
pub(super) fn adopt_renderer(&mut self, mut renderer: Renderer) {
// A new renderer starts with the vanilla column and without the world border's wall.
renderer.clear_sections(self.world.layout());
self.border_renderer.invalidate();
self.renderer = Some(renderer);
self.forget_atlas_slots();
self.clear_lod();
self.pending_uploads.clear();
let context = self.worker_context();
self.streaming.reload_resources(&mut self.world, context);
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
&mut self.native_shaders.picked,
));
self.apply_ui_settings();
}
/// `LevelExtractor.allChanged`: displayed sections and mesh work are
/// discarded, then every loaded section is rebuilt.
pub(super) fn all_sections_changed(&mut self) {
self.clear_lod();
self.pending_uploads.clear();
if let Some(renderer) = self.renderer.as_mut() {
renderer.clear_sections(self.world.layout());
}
self.streaming.reload_sections(&mut self.world);
}
}
#[cfg(test)]
@@ -0,0 +1,115 @@
//! The window's world as `horizon_hooks::share` shows it to the crates that share it past the
//! LAN (Play with Friends), and where the engine calls `Hooks::share` and `Hooks::lan_closed`.
use super::{Game, lan::LanSettings};
use horizon_client::text::Text;
use horizon_hooks::share::{LanOptions, ShareView};
/// The window's own player: not the integrated server's thread, not a splitscreen guest (it
/// plays P1's world and can't share it; its leaving doesn't close it).
fn shares(game: &Game<'_>) -> bool {
!game.serving() && !game.options.split_guest
}
/// Play with Friends' host side (game/friends.rs) hears the hooks, until it moves to
/// horizon-friends (spec #14167). Before the hooks are sealed.
#[cfg(feature = "friends")]
pub(crate) fn register_friends() {
if horizon_hooks::register(&super::friends::HOOKS).is_err() {
log::warn!("Play with Friends registered after the game started: sharing is off");
}
}
impl Game<'_> {
/// `Hooks::share`, once a client tick (`Minecraft.runTick`, so also while paused).
pub(super) fn share_tick(&mut self) {
if shares(self) {
horizon_hooks::engine::share(&mut World(self));
}
}
/// `Hooks::lan_closed`: the world stopped being open to LAN, or the player left it.
pub(super) fn lan_closed_hooks(&self) {
if shares(self) {
horizon_hooks::engine::lan_closed();
}
}
/// A singleplayer world runs on this window's server thread, so it can be shared, as Open
/// to LAN publishes it; another server can't be.
pub(super) fn can_share(&self) -> bool {
self.title.is_none() && (self.remote.is_none() || self.integrated.is_some())
}
}
impl<'g> Game<'g> {
/// The view of this window's world, for the engine's own Friends screen and command until
/// they move to the crate.
#[cfg(feature = "friends")]
pub(super) fn share_view(&mut self) -> World<'_, 'g> {
World(self)
}
}
pub(super) struct World<'a, 'g>(&'a mut Game<'g>);
impl ShareView for World<'_, '_> {
fn can_share(&self) -> bool {
self.0.can_share()
}
fn lan_port(&self) -> Option<u16> {
self.0.lan_port()
}
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String> {
self.0
.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: options.guest_commands,
force_game_mode: options.force_game_mode,
})
.map_err(|e| e.to_string())?;
self.0
.lan_port()
.ok_or_else(|| "the world isn't open to LAN".to_owned())
}
fn close_lan(&mut self) {
self.0.lan_close(true);
}
fn players(&self, each: &mut dyn FnMut(&str)) {
let g = &self.0;
if let Some(host) = &g.lan {
for guest in &host.hub.guests {
each(&guest.name);
}
} else if g.integrated.is_some() {
// On the server thread: the other players the server lists.
let me = g.options.player_name();
for name in g.remote_online_names().unwrap_or_default() {
if name != me {
each(&name);
}
}
}
}
fn chat(&mut self, text: &str) {
self.0.system_message(Text::literal(text));
}
fn copy(&mut self, text: &str) {
use horizon_client::clipboard::Clipboard;
self.0.clipboard.set(text);
}
fn open_url(&mut self, url: &str) {
// The system browser, as the folder buttons open their folders.
super::packs::open_uri(url);
}
fn tr(&self, key: &str) -> Option<String> {
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
}
}
+2 -2
View File
@@ -627,14 +627,14 @@ pub(super) fn count_down_cooldown(m: &mut Mob) {
}
}
/// `LONG_JUMP_COOLDOWN_TICKS`.
fn jump_cooldown(m: &Mob) -> Option<i64> {
pub(crate) fn jump_cooldown(m: &Mob) -> Option<i64> {
m.extra
.memories
.iter()
.find(|x| x.0 == LONG_JUMP_COOLDOWN)
.map(|x| x.1)
}
fn set_jump_cooldown(m: &mut Mob, ticks: Option<i64>) {
pub(crate) fn set_jump_cooldown(m: &mut Mob, ticks: Option<i64>) {
let memories = &mut m.extra.memories;
let at = memories.iter().position(|x| x.0 == LONG_JUMP_COOLDOWN);
match (at, ticks) {
+109
View File
@@ -555,6 +555,115 @@ fn finish_ram(m: &mut Mob, events: &mut Vec<MobEvent>) {
m.extra.goat.phase = Phase::Done;
}
/// `Goat.addAdditionalSaveData` and the `GoatAi` brain memory Horizon writes
/// with it: the screaming flag, the missing horns and the long jump cooldown
/// (`LONG_JUMP_COOLDOWN_TICKS`, an integer memory in the brain).
pub fn write_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
use horizon_world::nbt::Tag;
map.insert(
"IsScreamingGoat".into(),
Tag::Byte(i8::from(m.extra.screaming)),
);
let [left, right] = m.extra.lost_horns;
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
let brain = map
.entry("Brain".into())
.or_insert_with(|| Tag::Compound(Default::default()));
if !matches!(brain, Tag::Compound(_)) {
*brain = Tag::Compound(Default::default());
}
let Tag::Compound(brain) = brain else { return };
let memories = brain
.entry("memories".into())
.or_insert_with(|| Tag::Compound(Default::default()));
if !matches!(memories, Tag::Compound(_)) {
*memories = Tag::Compound(Default::default());
}
let Tag::Compound(memories) = memories else {
return;
};
match crate::mobs::frog::jump_cooldown(m) {
Some(ticks) => {
memories.insert(
"minecraft:long_jump_cooling_down".into(),
Tag::compound([("value", Tag::Int(ticks as i32))]),
);
}
None => {
memories.remove(&"minecraft:long_jump_cooling_down".into());
}
}
}
/// `Goat.readAdditionalSaveData` and `Brain.Packed`'s memory: the three own
/// fields and the ticks left of the long jump cooldown.
pub fn read_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
m.extra.screaming = crate::entity_nbt::flag(tag, "IsScreamingGoat");
let horn = |key| crate::save::number(tag.get(key)).is_some_and(|v| v == 0);
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
let cooldown = tag
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get("minecraft:long_jump_cooling_down"))
.and_then(|m| crate::save::number(m.get("value")));
crate::mobs::frog::set_jump_cooldown(m, cooldown);
}
#[cfg(test)]
mod long_jump_save_tests {
use super::*;
use crate::entity_nbt::{mob_from_nbt, mob_to_nbt};
use horizon_world::nbt::Tag;
use std::sync::Arc;
fn goat() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let k = Arc::make_mut(&mut kind);
k.id = "minecraft:goat".into();
k.species = crate::mobs::Species::Goat;
Mob::new(7, kind, Vec3::new(0.5, 0.0, 0.5), 0.0)
}
/// `GoatAi`'s `LONG_JUMP_COOLDOWN_TICKS` is a brain memory, and
/// `Brain.Packed` saves every memory: `mob_to_nbt`/`mob_from_nbt` are the
/// pair `Game::mob_tree_nbt` and `Game::load_mob` go through. Horizon
/// counted the cooldown in the goat's memories and wrote no `Brain`, so a
/// goat that had just long jumped came back ready to jump again.
#[test]
fn a_goat_keeps_its_long_jump_cooldown_through_a_save() {
let mut m = goat();
assert_eq!(crate::mobs::frog::jump_cooldown(&m), None);
crate::mobs::frog::set_jump_cooldown(&mut m, Some(57));
let saved = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(
saved
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get("minecraft:long_jump_cooling_down")),
Some(&Tag::compound([("value", Tag::Int(57))])),
"the save names the ticks left"
);
let types = std::collections::HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
let back = mob_from_nbt(&saved, &types, 2, None).unwrap();
assert_eq!(
crate::mobs::frog::jump_cooldown(&back),
Some(57),
"the reloaded cooldown"
);
// No cooldown writes no memory, even over a compound that names one.
let saved = mob_to_nbt(&goat(), [1, 2, 3, 4], Some(&saved), None, |_| None);
assert_eq!(
saved
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get("minecraft:long_jump_cooling_down")),
None,
"no cooldown, no key"
);
}
}
#[cfg(test)]
mod sound_tests {
use super::*;
+6
View File
@@ -763,6 +763,12 @@ impl Mob {
/// nautiluses keep their own course.
fn pushed_by_fluid(&self) -> bool {
use super::Species as S;
// Drowned.isPushedByFluid: a drowned swimming after its target is not
// pushed by the current, one walking the bottom is (`extra.flag` holds
// the `isSwimming` that `Drowned.updateSwimming` sets).
if self.species() == S::Drowned && self.extra.flag {
return false;
}
!matches!(
self.species(),
S::Tadpole
@@ -355,6 +355,39 @@ fn a_ridden_horse_floats_in_deep_water() {
);
}
/// `Drowned.isPushedByFluid`: a drowned answers `!isSwimming()`, so the current
/// of a stream pushes a drowned walking its bottom and leaves one swimming after
/// its target alone, where every land mob feels it.
#[test]
fn a_swimming_drowned_is_not_pushed_by_the_current() {
use crate::mobs::Species;
let mob = |species| {
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
kind.species = species;
Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0)
};
// Drowned.updateSwimming keeps `isSwimming` on `extra.flag`.
let mut swimming = mob(Species::Drowned);
swimming.extra.flag = true;
assert!(
!swimming.pushed_by_fluid(),
"a swimming drowned holds still"
);
swimming.extra.flag = false;
assert!(swimming.pushed_by_fluid(), "one on the bottom is pushed");
// The water mobs answer false either way.
for quiet in [
Species::Cod,
Species::Squid,
Species::Turtle,
Species::Nautilus,
] {
assert!(!mob(quiet).pushed_by_fluid(), "{quiet:?}");
}
// A land mob is pushed.
assert!(mob(Species::Zombie).pushed_by_fluid());
}
/// `Entity.collide` takes `getEntityCollisions` too: a zombie dropping
/// onto a living shulker or a happy ghast held still (a player on its
/// back) lands on it; a dead shulker, a happy ghast that is not held, or
+23
View File
@@ -23,6 +23,7 @@ pub mod pages;
pub mod players;
mod registry;
pub mod settings;
pub mod share;
pub mod tunnel;
use registry::Registry;
@@ -89,6 +90,14 @@ pub trait Hooks: Sync {
None
}
/// Once a client tick (20 a second, also while the game is paused or on the title screen),
/// with the window's world ([`share`]). Only the window's own player calls it.
fn share(&self, _world: &mut dyn share::ShareView) {}
/// The window's world stopped being open to LAN: Open to LAN's stop, or the player left the
/// world. Called at that moment, by the window's own player only.
fn lan_closed(&self) {}
/// A game window opened: once, before the first frame, for every run that has a window,
/// automated ones included ([`Client::automated`]); never for a dedicated server.
fn client_started(&self, _client: &Client) {}
@@ -301,6 +310,20 @@ pub mod engine {
}
}
/// [`Hooks::share`].
pub fn share(world: &mut dyn share::ShareView) {
for hooks in all() {
hooks.share(world);
}
}
/// [`Hooks::lan_closed`].
pub fn lan_closed() {
for hooks in all() {
hooks.lan_closed();
}
}
/// Whether any hook is registered: without one the engine builds no view to hand them.
pub fn any() -> bool {
!all().is_empty()
+53
View File
@@ -0,0 +1,53 @@
//! Sharing the open world past the LAN (Play with Friends' host side): what the engine lets a
//! crate do with the world a window plays. The engine implements [`ShareView`] and hands it to
//! [`Hooks::share`](crate::Hooks::share) once a client tick; [`Hooks::lan_closed`](crate::Hooks::lan_closed)
//! says the world stopped being open to LAN.
//!
//! Only the window's own player calls them (a splitscreen guest can't share P1's world, and
//! its leaving doesn't close it), never the integrated server's thread or a dedicated server.
/// How [`ShareView::open_lan`] opens the world, as Open to LAN's screen does. The default is
/// what `ShareToLanScreen` offers: no commands for guests, the world's game mode forced on them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct LanOptions {
/// Guests may run commands (`ShareToLanScreen`'s "Allow Cheats").
pub guest_commands: bool,
/// Guests play in the world's game mode.
pub force_game_mode: bool,
}
impl Default for LanOptions {
fn default() -> Self {
Self {
guest_commands: false,
force_game_mode: true,
}
}
}
/// The window's world, as a crate sharing it sees it. Nothing here allocates per call unless a
/// method says so.
pub trait ShareView {
/// A singleplayer world runs on this window's server thread, so it can be shared. False on
/// the title screen and on another server.
fn can_share(&self) -> bool;
/// The port the world is open to LAN on, if it is.
fn lan_port(&self) -> Option<u16>;
/// Opens the world to LAN on a free port, as Open to LAN does; the port, or why not.
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String>;
/// Closes the world to LAN, saying so in chat as Open to LAN's stop does.
fn close_lan(&mut self);
/// The names of the other players in the world (not this one's). For a screen or a command,
/// not every tick.
fn players(&self, each: &mut dyn FnMut(&str));
/// A line in this player's chat, as the game's own system messages; `text` is already in
/// the player's language ([`ShareView::tr`]).
fn chat(&mut self, text: &str);
/// Puts `text` on the system clipboard.
fn copy(&mut self, text: &str);
/// Opens `url` in the system's browser.
fn open_url(&mut self, url: &str);
/// The translation of `key` in the player's language, if the game has one.
fn tr(&self, key: &str) -> Option<String>;
}
+1 -1
View File
@@ -56,7 +56,7 @@ pub mod hooks {
};
pub use horizon_hooks::{
Activity, Client, Dimension, GameMode, GetTheGame, Hooks, Play, Started, TooLate, register,
seat, tunnel,
seat, share, tunnel,
};
}
+48 -6
View File
@@ -123,12 +123,32 @@ impl Inflater {
/// The zlib stream at the front of `input`, which must hold exactly `size` bytes.
fn inflate(&mut self, input: &[u8], size: usize) -> Result<Vec<u8>> {
self.0.reset(true);
// One byte more than declared: a longer stream fills it.
let mut out = Vec::with_capacity(size + 1);
let status = self
.0
.decompress_vec(input, &mut out, FlushDecompress::Finish)
.map_err(|e| Error::new(format!("Badly compressed packet - {e}")))?;
// One byte more than declared: a longer stream fills it. Reserved as the output grows, not
// all at once: a few bytes declaring 8 MiB would otherwise allocate 8 MiB per frame.
// DEFLATE expands at most about 1032:1.
let limit = size + 1;
let mut out =
Vec::with_capacity(limit.min(input.len().saturating_mul(1032).saturating_add(64)));
let status = loop {
let consumed = usize::try_from(self.0.total_in()).unwrap_or(input.len());
let status = self
.0
.decompress_vec(
input.get(consumed..).unwrap_or_default(),
&mut out,
FlushDecompress::Finish,
)
.map_err(|e| Error::new(format!("Badly compressed packet - {e}")))?;
// Done, past the limit, or stopped with room left (the input ran out).
if matches!(status, Status::StreamEnd)
|| out.len() >= limit
|| out.len() < out.capacity()
{
break status;
}
let more = out.len().max(4096).min(limit - out.len());
out.reserve_exact(more);
};
if out.len() != size || !matches!(status, Status::StreamEnd) {
return Err(Error::new(format!(
"Badly compressed packet - actual length {} does not match declared size {size}",
@@ -228,6 +248,28 @@ mod tests {
}
}
#[test]
fn large_bodies_inflate_whole_and_short_streams_are_refused() {
// 2 MiB of zeros deflate to a few KiB: inflated whole as the buffer grows.
let body = vec![0u8; 2 << 20];
let frame = contents(&body, Some(256)).unwrap();
let mut inflater = Inflater::default();
let Body::Inflated(out) = super::body(&frame, Some(256), &mut inflater).unwrap() else {
panic!("compressed");
};
assert!(out == body);
// A few bytes declaring 8 MiB: refused (and no longer 8 MiB reserved for them).
let mut lying = Vec::new();
write_varint(&mut lying, MAX_UNCOMPRESSED as i32);
lying.extend_from_slice(&frame[3..20]);
assert!(super::body(&lying, Some(256), &mut inflater).is_err());
// The same inflater still reads a good frame after.
assert!(matches!(
super::body(&frame, Some(256), &mut inflater).unwrap(),
Body::Inflated(_)
));
}
/// Split one complete frame off the front of `buffer`, if available.
fn split(buffer: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
let Some((start, len)) = header(buffer)? else {
+9
View File
@@ -67,6 +67,10 @@ impl Watched {
/// signal trampoline's, the one frame the system puts between the handler and the interrupted
/// code (named `__restore_rt`, `_sigtramp`, or after whatever symbol precedes it). Unchanged when
/// the handler's frame isn't found.
#[cfg_attr(
not(any(target_vendor = "apple", target_os = "linux", target_os = "android")),
allow(dead_code)
)]
fn after_handler(text: &str) -> String {
fn frame_name(line: &str) -> Option<&str> {
let (n, name) = line.trim().split_once(": ")?;
@@ -157,12 +161,16 @@ mod imp {
#[cfg(test)]
mod tests {
use super::*;
// The busy thread below serves the test that reads another thread's stack, which only the
// systems with a signal path run.
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
};
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
#[inline(never)]
fn spin(mut n: u64) -> u64 {
for _ in 0..1_000_000 {
@@ -173,6 +181,7 @@ mod tests {
/// Its time goes to a call: Apple's arm64 unwinder loses the frame the signal interrupted
/// (it resumes from the return address), while every caller's frame stays.
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
#[inline(never)]
fn busy_in_a_named_frame(stop: &AtomicBool) {
let mut n = 0u64;
+3 -1
View File
@@ -287,7 +287,9 @@ build pays nothing.
The hooks today: `started` (once, when someone plays: not a dedicated server, a benchmark or an automated run),
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
world saved) and `tunnel` (the resolver of `friend:` addresses, Play with Friends'). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
world saved), `tunnel` (the resolver of `friend:` addresses, Play with Friends'), `share` (each client tick, the window's world
through `share::ShareView`: open it to LAN, its players, chat, the clipboard) and `lan_closed` (it stopped being open
to LAN). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
+5 -3
View File
@@ -55,9 +55,11 @@ reads codes and opens the tunnel, which the join (`net.rs`) then reaches on a lo
horizon-friends' `game` side offers it, and the official executable (`horizon-official`) registers it;
horizon-client's own executable has none, so there a `friend:` address is refused and the title screen's
Friends icon, which opens Join a Friend, is inactive (`net::joins_friends`). Hosting needs no hook: the
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. It still lives in
horizon-client (`game/friends.rs`, `game/friends/screen.rs`, the `friends` feature, which comes with
`horizon-extras`) until the pause menu and `horizon-ctl` offer entries to crates above the engine.
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. Hosting sees the
game only through `horizon_hooks::share::ShareView` (open the world to LAN, its players, chat, the clipboard) and
`Hooks::lan_closed`, and keeps what it shares in its own static. It still
lives in horizon-client (`game/friends.rs`, `game/friends/screen.rs`, the `friends` feature, which comes with
`horizon-extras`) until the pause menu and `horizon-ctl` offer entries to crates above the engine (#14167).
**Codes.** Crockford base32 (`0-9 A-Z` without `I L O U`; input ignores case, spaces and dashes and reads
`O` as `0`, `I`/`L` as `1`) in groups of five, with a 16-bit checksum so a typo is caught before
+10 -4
View File
@@ -184,8 +184,14 @@ pub fn device_code(ep: &Endpoints, client_id: &str) -> Result<DeviceCode, String
.get("interval")
.and_then(Value::as_u64)
.unwrap_or(5)
.max(1),
expires_at: now() + v.get("expires_in").and_then(Value::as_u64).unwrap_or(900),
.clamp(1, 60),
// The service's numbers, bounded: Instant + a huge interval panics the sign-in thread.
expires_at: now().saturating_add(
v.get("expires_in")
.and_then(Value::as_u64)
.unwrap_or(900)
.min(3600),
),
device_code,
}),
_ => Err(oauth_error(&v)),
@@ -259,7 +265,7 @@ pub fn wait_for_sign_in(
}
match poll(ep, client_id, code)? {
Poll::Pending => {}
Poll::SlowDown => interval += 5,
Poll::SlowDown => interval = (interval + 5).min(60),
Poll::Done(tokens) => return Ok(tokens),
}
}
@@ -345,7 +351,7 @@ fn minecraft_login(
let (token, expires_in) = minecraft_token(ep, &format!("XBL3.0 x={uhs};{xsts_token}"))?;
Ok(Minecraft {
token,
expires_at: now() + expires_in,
expires_at: now().saturating_add(expires_in.min(7 * 86_400)),
xuid,
})
}
+4 -2
View File
@@ -7,7 +7,8 @@
"702fe59163c6ee6578607daa85811d9bc9c7cc40",
"61fff1316bd50312624a858bbb5eb18fa45208ed",
"5df1795e16eea19d75b923d66ecca9ce183353c5",
"80608ff6753705b6a82e2cb86a5937d4d233abd4"
"80608ff6753705b6a82e2cb86a5937d4d233abd4",
"9f67e10ed013deeaad20ac3c800652765d493408"
],
"accepted_manifest_note": "Other 26.3 version JSONs Mojang republished: only assetIndex differs (non-English translations), jars and libraries are identical. A launcher may still hold any of them.",
"accepted_asset_index_sha1": [
@@ -15,7 +16,8 @@
"1e4e4a68dece1c0f51b8f93b45da8fe8da51bcd3",
"0c8f43b28f837869c1e54a6aeb22c396a1548946",
"917ff6d74b9f635c46bac0d28857206bbcc1ffe4",
"331e5c80937c9cd79647ab34bdc1cbb5ef09f456"
"331e5c80937c9cd79647ab34bdc1cbb5ef09f456",
"e99f9973047785b5a9d52eaf72020b97545053c9"
],
"accepted_asset_index_note": "The asset indexes (id 34) of those republished version JSONs: a launcher keeps one 34.json, the last it downloaded, whichever version JSON it reads.",
"client_sha1": "e877b6a07acd633fb3bb475002175cec036e7b87",
+38
View File
@@ -7866,6 +7866,43 @@ def explosion_player_experience():
f'/forceload remove {x0} {z0}')
def explosion_creaking_heart_experience():
"""#11330: `CreakingHeartBlock.onExplosionHit` awards a natural heart's experience (`tryAwardExperience`)
when the blast's indirect source is a player (a primed TNT's `owner`); an ownerless blast awards none. The
amount is random (20 to 24), so only whether orbs came out is compared. A creative owner gets none
(`preventsBlockDrops`), so the player is in survival for the owned blasts, then back in creative. A TNT
whose `owner` is a zombie's UUID is no player's (none), and with `block_drops` off `popExperience` drops
nothing."""
# Entities tick once the forced chunk's 2-column neighbourhood is loaded (ChunkMap.prepareEntityTickingChunk).
x0, y0, z0 = 4096 - 416, 260, 3584
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks',
'chunks': [[(x0 >> 4) + i, (z0 >> 4) + k] for i in range(-2, 3) for k in range(-2, 3)]},
cmd('/tick freeze', False)]
near = f'x={x0},y={y0 - 4},z={z0},dx=16,dy=16,dz=16'
for owner, mode, drops in ((None, 'survival', True), ('@p', 'creative', True), ('@p', 'survival', True),
('zombie', 'survival', True), ('@p', 'survival', False)):
ops += [cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 + 6} {z0 + 15} air', False),
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} bedrock', False),
cmd(f'/setblock {x0 + 8} {y0} {z0 + 8} creaking_heart[natural=true]', expect_result=1),
cmd(f'/summon tnt {x0 + 8}.5 {y0 + 1} {z0 + 8}.5 {{fuse:1,Tags:["heart_tnt"]}}', expect_result=1)]
if owner == 'zombie':
ops.append(cmd(f'/summon zombie {x0 + 1}.5 {y0} {z0 + 1}.5 '
f'{{NoAI:1b,Silent:1b,PersistenceRequired:1b,Tags:["heart_owner"]}}',
expect_result=1))
owner = f'@e[type=zombie,tag=heart_owner,{near},limit=1]'
if owner:
ops.append(cmd(f'/data modify entity @e[type=tnt,tag=heart_tnt,limit=1] owner set from entity {owner} UUID'))
ops.append(cmd(f'/gamemode {mode} @p', False))
ops.append(cmd(f'/gamerule block_drops {str(drops).lower()}', False))
ops += [cmd('/tick unfreeze', False), {'op': 'tick', 'n': 2}, cmd('/tick freeze', False),
cmd(f'/execute if block {x0 + 8} {y0} {z0 + 8} air', expect_result=1),
cmd(f'/execute if entity @e[type=experience_orb,{near}]', comparison='positive_result'),
cmd(f'/kill @e[type=!player,{near}]', False), cmd(f'/kill @e[type=item,{near}]', False)]
return ops + setup('/gamemode creative @p', '/gamerule block_drops true', '/tick unfreeze',
f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 + 6} {z0 + 15} air',
f'/forceload remove {x0} {z0}')
def groups():
try:
from .spectral_duration_corpus import cases as spectral_saved_duration
@@ -8064,6 +8101,7 @@ def groups():
('dispensed item escape', dispensed_item_escape()),
('command block last output', command_block_last_output()),
('explosion player experience', explosion_player_experience()),
('explosion creaking heart experience', explosion_creaking_heart_experience()),
('comparator setblock output', comparator_setblock_output()),
('glide fall accumulation', glide_fall_accumulation()),
('living short timers', living_short_timers()),
+1 -1
View File
@@ -85,7 +85,7 @@ LevelExtractor.extractBlockDestroyAnimation port crates/horizon-client/src/game/
LevelExtractor.extractGizmos port crates/horizon-client/src/game/debug_keys.rs::Game::debug_gizmos the frame's debug gizmos
LevelExtractor.extractPlayerState inline crates/horizon-client/src/game.rs::Game::render player and hands read from the game state at draw time
LevelExtractor.applyFrustum port crates/horizon-client-render/src/gfx/resident.rs::Resident::visible the frustum decides which sections are visible
LevelExtractor.setLevel inline crates/horizon-client/src/game.rs::Game::all_sections_changed a new level rebuilds every loaded section (allChanged); the null-level branch is not separate
LevelExtractor.setLevel inline crates/horizon-client/src/game/render_settings.rs::Game::all_sections_changed a new level rebuilds every loaded section (allChanged); the null-level branch is not separate
LevelExtractor.blockChanged port crates/horizon-client-render/src/world.rs::World::set a block edit marks its 3x3x3 neighbourhood; gap: minor: the player-changed flag (update bit 8) is not kept
LevelExtractor.setBlockDirty port crates/horizon-client-render/src/world.rs::World::set marks the 3x3x3 section neighbourhood of an edited block
LevelExtractor.setBlocksDirty inline crates/horizon-client-render/src/world.rs::World::set each block of the range goes through the same 3x3x3 marking
1 SectionCopy.getBlockState inline crates/horizon-client-render/src/mesher.rs::Padded::new the copy is Padded's block array; no debug-world barrier or DebugLevelSource blocks
85 LevelExtractor.extractGizmos port crates/horizon-client/src/game/debug_keys.rs::Game::debug_gizmos the frame's debug gizmos
86 LevelExtractor.extractPlayerState inline crates/horizon-client/src/game.rs::Game::render player and hands read from the game state at draw time
87 LevelExtractor.applyFrustum port crates/horizon-client-render/src/gfx/resident.rs::Resident::visible the frustum decides which sections are visible
88 LevelExtractor.setLevel inline crates/horizon-client/src/game.rs::Game::all_sections_changed crates/horizon-client/src/game/render_settings.rs::Game::all_sections_changed a new level rebuilds every loaded section (allChanged); the null-level branch is not separate
89 LevelExtractor.blockChanged port crates/horizon-client-render/src/world.rs::World::set a block edit marks its 3x3x3 neighbourhood; gap: minor: the player-changed flag (update bit 8) is not kept
90 LevelExtractor.setBlockDirty port crates/horizon-client-render/src/world.rs::World::set marks the 3x3x3 section neighbourhood of an edited block
91 LevelExtractor.setBlocksDirty inline crates/horizon-client-render/src/world.rs::World::set each block of the range goes through the same 3x3x3 marking
+1 -1
View File
@@ -82,7 +82,7 @@ Goat.getHurtSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds scream
Goat.getDeathSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming death event selected by IsScreamingGoat
Goat.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::goat_spawn called for a live spawn by spawning.rs::finalize; Ctx::finalize has its own arm with the same draws for a structure mob: the ram and long jump cooldowns, screaming when nextDouble is below 0.02, ageBoundaryReached, and for an adult a lost horn one time in ten (nextFloat below 0.1, the side by nextBoolean)
Goat.getDefaultDimensions port crates/horizon-client/src/mobs/living.rs::Mob::dims baby box (0.45 x 0.65, eye 0.59375) from the species table; no long jump pose scale since there is no long jump
Goat.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt IsScreamingGoat, HasLeftHorn, HasRightHorn
Goat.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt IsScreamingGoat, HasLeftHorn, HasRightHorn; the GoatAi LONG_JUMP_COOLDOWN_TICKS brain memory is saved too
Goat.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt IsScreamingGoat and both horn flags (default true)
Goat.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta gap: minor: the two horn flags are synced; the screaming flag is not
Goat.checkGoatSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules goats_spawnable_on below and bright enough
1 Fox.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta gap: minor: the type and the flags byte are synced; the two trusted-entity slots are not
82 Goat.getDeathSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming death event selected by IsScreamingGoat
83 Goat.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::goat_spawn called for a live spawn by spawning.rs::finalize; Ctx::finalize has its own arm with the same draws for a structure mob: the ram and long jump cooldowns, screaming when nextDouble is below 0.02, ageBoundaryReached, and for an adult a lost horn one time in ten (nextFloat below 0.1, the side by nextBoolean)
84 Goat.getDefaultDimensions port crates/horizon-client/src/mobs/living.rs::Mob::dims baby box (0.45 x 0.65, eye 0.59375) from the species table; no long jump pose scale since there is no long jump
85 Goat.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt IsScreamingGoat, HasLeftHorn, HasRightHorn IsScreamingGoat, HasLeftHorn, HasRightHorn; the GoatAi LONG_JUMP_COOLDOWN_TICKS brain memory is saved too
86 Goat.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt IsScreamingGoat and both horn flags (default true)
87 Goat.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta gap: minor: the two horn flags are synced; the screaming flag is not
88 Goat.checkGoatSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules goats_spawnable_on below and bright enough
+1
View File
@@ -134,6 +134,7 @@ WardenSpawnTracker.hasNearbyWarden inline crates/horizon-client/src/game/sculk.r
WardenSpawnTracker.getNearbyPlayers inline crates/horizon-client/src/game/sculk.rs::Game::try_to_warn gap: minor: only the player who set off the shrieker is counted, not other players within 16 blocks
WardenSpawnTracker.copyData inline crates/horizon-client-render/src/sculk.rs::WardenSpawnTracker::try_warn every tracker takes the highest one's level, cooldown and tick count
Drowned.checkDrownedSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules water below, spawner or deep enough, dark enough, frequency roll
Drowned.isPushedByFluid port crates/horizon-client/src/mobs/living.rs::Mob::pushed_by_fluid `!isSwimming()`: a drowned swimming after its target is not carried by the current
Drowned.isDeepEnoughToSpawn inline crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules below sea level minus 5
Drowned.populateDefaultEquipmentSlots port crates/horizon-client/src/mobs/finalize.rs::Ctx::populate 10 percent chance of a trident (10 in 16) or fishing rod
Drowned.canReplaceCurrentItem port crates/horizon-client/src/game/mob_looting.rs::Game::can_replace the mob pickup compare (armour, toughness, attack damage) with the drowned's nautilus shell guard; called before a mob swaps what it holds
1 Evoker.EvokerWololoSpellGoal.canContinueToUse port crates/horizon-client/src/mobs/illager.rs::UseSpell::can_continue wololo branch: sheep target still set and warm-up left
134 WardenSpawnTracker.getNearbyPlayers inline crates/horizon-client/src/game/sculk.rs::Game::try_to_warn gap: minor: only the player who set off the shrieker is counted, not other players within 16 blocks
135 WardenSpawnTracker.copyData inline crates/horizon-client-render/src/sculk.rs::WardenSpawnTracker::try_warn every tracker takes the highest one's level, cooldown and tick count
136 Drowned.checkDrownedSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules water below, spawner or deep enough, dark enough, frequency roll
137 Drowned.isPushedByFluid port crates/horizon-client/src/mobs/living.rs::Mob::pushed_by_fluid `!isSwimming()`: a drowned swimming after its target is not carried by the current
138 Drowned.isDeepEnoughToSpawn inline crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules below sea level minus 5
139 Drowned.populateDefaultEquipmentSlots port crates/horizon-client/src/mobs/finalize.rs::Ctx::populate 10 percent chance of a trident (10 in 16) or fishing rod
140 Drowned.canReplaceCurrentItem port crates/horizon-client/src/game/mob_looting.rs::Game::can_replace the mob pickup compare (armour, toughness, attack damage) with the drowned's nautilus shell guard; called before a mob swaps what it holds
+1 -1
View File
@@ -108,7 +108,7 @@ FarmlandBlock.getStateForPlacement port crates/horizon-world/src/level/place.rs:
FarmlandBlock.tick port crates/horizon-world/src/level/tick.rs::Sim::tick_block Farmland arm turns the block to its base when it cannot survive
FarmlandBlock.randomTick port crates/horizon-world/src/level/tick.rs::Sim::farmland_random_tick moisture falls or rises with water in reach and rain above, else turns to base
FarmlandBlock.turnToBaseBlock port crates/horizon-world/src/level/tick.rs::Sim::turn_to_base_block the base block is set and Block.pushEntitiesUp goes out as the push_entities_up block event
FarmlandBlock.isNearWater inline crates/horizon-world/src/level/tick.rs::Sim::farmland_random_tick gap: minor: the 9x2x9 water search is inline and tests the water fluid only, not canBeHydrated
FarmlandBlock.isNearWater inline crates/horizon-world/src/level/tick.rs::Sim::farmland_random_tick the 9x2x9 inline search matches vanilla's predicate, `state.getFluidState().is(FluidTags.WATER)` (sources and flowing water alike), not a block tag
FenceBlock.isSameFence inline crates/horizon-world/src/level/shapes.rs::Sim::fence_connects_to the same-fence test (fence tag and wooden match) is inline in fence_connects_to
FenceBlock.getStateForPlacement port crates/horizon-world/src/level/place.rs::Sim::placement_class Fence arm: each horizontal side connects per neighbour and waterlogged from the spot
FenceBlock.updateShape port crates/horizon-world/src/level/shapes.rs::Sim::update_shape_class Fence arm: waterlogged tick and the horizontal side connection update
1 ConcretePowderBlock.onLand port crates/horizon-world/src/level/falling.rs::Behaviours::falling_block_landed landing branch turns the powder to concrete when the spot is water or touches liquid
108 FarmlandBlock.tick port crates/horizon-world/src/level/tick.rs::Sim::tick_block Farmland arm turns the block to its base when it cannot survive
109 FarmlandBlock.randomTick port crates/horizon-world/src/level/tick.rs::Sim::farmland_random_tick moisture falls or rises with water in reach and rain above, else turns to base
110 FarmlandBlock.turnToBaseBlock port crates/horizon-world/src/level/tick.rs::Sim::turn_to_base_block the base block is set and Block.pushEntitiesUp goes out as the push_entities_up block event
111 FarmlandBlock.isNearWater inline crates/horizon-world/src/level/tick.rs::Sim::farmland_random_tick gap: minor: the 9x2x9 water search is inline and tests the water fluid only, not canBeHydrated the 9x2x9 inline search matches vanilla's predicate, `state.getFluidState().is(FluidTags.WATER)` (sources and flowing water alike), not a block tag
112 FenceBlock.isSameFence inline crates/horizon-world/src/level/shapes.rs::Sim::fence_connects_to the same-fence test (fence tag and wooden match) is inline in fence_connects_to
113 FenceBlock.getStateForPlacement port crates/horizon-world/src/level/place.rs::Sim::placement_class Fence arm: each horizontal side connects per neighbour and waterlogged from the spot
114 FenceBlock.updateShape port crates/horizon-world/src/level/shapes.rs::Sim::update_shape_class Fence arm: waterlogged tick and the horizontal side connection update
+1 -1
View File
@@ -32,7 +32,7 @@ ChestBlockEntity.recheckOpen port crates/horizon-world/src/level/tick.rs::Sim::t
ChestLidController.tickLid port crates/horizon-client-render/src/block_entity_anim.rs::ChestLid::tick lid openness step of 0.1 per tick
ChiseledBookShelfBlockEntity.removeItem port crates/horizon-client/src/game/machines.rs::Game::take_one removes one item from the slot
ChiseledBookShelfBlockEntity.setItem port crates/horizon-client/src/game/chiseled_bookshelf.rs::Game::use_chiseled_bookshelf player insert path; slot update via chiseled_bookshelf_update_state
ChiseledBookShelfBlockEntity.canTakeItem port crates/horizon-client/src/game/machines.rs::Containers::can_take gap: minor: the target-has-room check is left to take_one and put_back
ChiseledBookShelfBlockEntity.canTakeItem port crates/horizon-client/src/game/machines.rs::Containers::can_take the target's room: a free slot or a matching stack with room (`hasAnyMatching`); the hopper asks `can_take_into`, which applies it before `can_take`
ChiseledBookShelfBlockEntity.applyImplicitComponents port crates/horizon-client/src/block_entity_item.rs::place Items as container component
ChiseledBookShelfBlockEntity.collectImplicitComponents port crates/horizon-client/src/block_entity_item.rs::collect Items as container component
CommandBlockEntity.onModeSwitch port crates/horizon-client/src/game/command_blocks.rs::Game::apply_command_block mode switch; the AUTO schedule follows the setAutomatic step
1 BellBlockEntity.serverTick port crates/horizon-client/src/game/block_entity_anims.rs::Game::tick_bell_servers world-owned ringing bells tick without a renderer; clients animate their separate copy
32 ChestLidController.tickLid port crates/horizon-client-render/src/block_entity_anim.rs::ChestLid::tick lid openness step of 0.1 per tick
33 ChiseledBookShelfBlockEntity.removeItem port crates/horizon-client/src/game/machines.rs::Game::take_one removes one item from the slot
34 ChiseledBookShelfBlockEntity.setItem port crates/horizon-client/src/game/chiseled_bookshelf.rs::Game::use_chiseled_bookshelf player insert path; slot update via chiseled_bookshelf_update_state
35 ChiseledBookShelfBlockEntity.canTakeItem port crates/horizon-client/src/game/machines.rs::Containers::can_take gap: minor: the target-has-room check is left to take_one and put_back the target's room: a free slot or a matching stack with room (`hasAnyMatching`); the hopper asks `can_take_into`, which applies it before `can_take`
36 ChiseledBookShelfBlockEntity.applyImplicitComponents port crates/horizon-client/src/block_entity_item.rs::place Items as container component
37 ChiseledBookShelfBlockEntity.collectImplicitComponents port crates/horizon-client/src/block_entity_item.rs::collect Items as container component
38 CommandBlockEntity.onModeSwitch port crates/horizon-client/src/game/command_blocks.rs::Game::apply_command_block mode switch; the AUTO schedule follows the setAutomatic step