Compare commits

..
Author SHA1 Message Date
rdbtCVSandCodex GPT-6.1 Terra fa0e2b01cd Merge updated item and helper work into mob groundwork
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:22:25 +00:00
rdbtCVSandCodex GPT-6.1 Terra f663c83f98 Merge updated parallel tick helpers into item moves
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:20:28 +00:00
rdbtCVSandCodex GPT-6.1 Terra 4740691c04 Merge origin/main into parallel tick helpers
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:18:54 +00:00
b972efd9fa test: prove item work-ahead collisions, RNG and native fallback
Retain the 307-item corpus and add real boat and border cases, independently expected RNG advances, the environment gate and off/on/off comparison. Reject count, age and pickup-delay mutations independently and retain actual merge invalidation. Exercise 300 nonmerging opaque-component stacks in each native contact mode.

Repair the existing escape test's baseline chunk fixture and promote vanilla's 0.98F drag to double in its expected value; no production gameplay change.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:17:06 +00:00
8a4ca5e7de Merge helper verification into dependent item moves
Preserve helper test commit 41a579d428 and the item phase's original history and authors.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:08:41 +00:00
rdbtCVS 5cc88b517b test: cross parallel tick helper thresholds with real resources
Compare deferred and in-place metadata for 2,085 varied mobs, including pack-selected effect particles and switch-off vector reuse. Exercise live climbing, snapshot order and nearest-player invalidation for 293 mobs. Build 132 random-tick section masks ahead and retain exact event, state and random comparisons through edits and reloads.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
(cherry picked from commit 41a579d428)
2026-11-10 22:55:55 +00:00
41a579d428 test: cross parallel tick helper thresholds with real resources
Compare deferred and in-place metadata for 2,085 varied mobs, including pack-selected effect particles and switch-off vector reuse. Exercise live climbing, snapshot order and nearest-player invalidation for 293 mobs. Build 132 random-tick section masks ahead and retain exact event, state and random comparisons through edits and reloads.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 22:32:11 +00:00
rdbtCVS 0b21fcbf60 Random: preserve the ordered lane and stream when reseeding
Treat LegacyRandom::set_seed as an ordered mutation: acquire the shared stream before replacing its LCG state and clearing the Gaussian cache, without discarding the gate. This also publishes a reseed-only item's state through end_item and keeps finish_lane and LaneDone working.

Add timeout-bounded multi-lane regressions for reseeding before/after draws, reseed-only items, cross-lane Gaussian state and panic cleanup, plus serial cache-reset coverage. The regression rejects the original gate loss, omitted synchronization and retained Gaussian cache.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
(cherry picked from commit 9298d7961c)
2026-11-10 22:08:34 +00:00
72fe5b13ba Mobs: the mob loop's body moves into a group of mobs, still one thread
The per-mob turn of tick_creatures (its world view, brain, movement, events,
riders, deaths and pushes) moves into creatures/mob_group.rs as Group::tick, on
a Group that owns the mobs it ticks, their snapshot grid, pushes, events and the
level random. tick_creatures builds one group of every mob and runs it in list
order, so nothing changes: whether a mob is loaded and ticking, and the
per-chunk difficulty, are still decided at the mob's own turn. The parallel tick
(#14457) will run several groups. creatures.rs shrinks by 177 lines.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:24:28 +00:00
aaf5aa6fcf Mobs: what the parallel mob tick's groups read, behind no switch
MobSlot lets the mob loop's helpers (apply_brain_events, riders, a school's
recruits) work on a group's borrowed mobs as on the list's own; MobWorld.foreign
answers a lookup by id of a mob in another group (ForeignSeen); PendingPushes::
append puts one group's pushes after another's, each mob's in order; and a
SeenGrid of part of a list can answer as the whole list's grid would
(answering_as). Nothing sets them yet: every caller passes the list's own mobs
and foreign: None. For #14457.

From kacpherek's #13644; the grid follows main's scan threshold.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:15 +00:00
eaa027179b Random: a lane that panics is marked finished, so the others don't wait on it
LaneDone, held by a lane's thread while it runs, marks the lane finished when
dropped, also while a panic unwinds: the other lanes run to their end instead of
waiting forever for its items, and the panic reaches the caller when it joins
them. Review finding 1 on #13644; the test times out without it.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:15 +00:00
bbaa73ec43 Random: one LegacyRandom stream drawn in list order from several threads
OrderedDraws shares a LegacyRandom among lanes that each run their part of a list
in list order: an item's first draw waits until every other lane has passed it,
so every draw comes out as one thread would make it. A copy of a gated random
draws on its own. For the parallel mob tick (#14457).

Taken from kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:15 +00:00
d3c8eb00ea Parallel tick: the dropped items' own moves worked out ahead on helpers
While the parallel tick is on, each ticking item's move (ItemEntity.tick up to its
merge) is worked out on a copy on the helper threads before the item loop, and
put in place at its turn. The move reads only the item, the blocks, the obstacle
boxes and the border, none of which the loop changes; an item inside a block
escapes at its own random's speed, which the copy carries. A merge before its turn
that changed its count, age or pickup delay sends it back to the move at its turn,
as do native mods and a mod listening to the round. HORIZON_PARALLEL_TICK=verify
makes each move again at its turn on a copy and logs a difference.

The item's tick reads ItemTables (the blocks, collisions, motion tables, border)
so helpers run the same code the turn runs; ObstacleGrid moves to
entities/obstacles.rs and gains a query with the caller's scratch. A test ticks
307 items (escaping blocks, in water, falling, merging) both ways and checks the
items and the level's random come out the same.

From kacpherek's #13644, redone on main's item tick.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:11 +00:00
5f61de11d9 Parallel tick: mob entity data and attributes from plain tables, on helpers
mob_meta reads SyncData instead of the hub (SyncData::effect_meta, with main's
shown_color; Hub::effect_meta calls it), and the attribute instances read
AttrSources (the catalog, the items, /attribute's maps) instead of Game. While
the parallel tick is on, the mobs' entity data and attributes are built after
the pass on the pool and put in place (game/lan/mob_states.rs); with it off
they are filled in place as before, with no new Vecs. What is read in order
(idle state, lead, equipment network ids) stays in the pass. A test checks the
states put in place equal those built in place.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:14:03 +00:00
1ef6e26dfa Parallel tick: mob prep and random tick masks on the helper threads
Before the mob loop, each mob's climbing check, snapshot entry and nearest
player read only that mob, the blocks and the players, so they are mapped on the
pool from 256 mobs (creatures/prep.rs, out of creatures.rs). A mob that moved
before its turn is asked its nearest player again where it stands. The masks the
random ticks read are built on the pool before the random ticks; the draws and
the ticks stay serial. The random tick test runs a fourth path with its masks
built ahead and checks it gives vanilla's scan's events and random values.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:14:03 +00:00
2c9aa0ac86 Parallel tick: one pool of helper threads, behind HORIZON_PARALLEL_TICK
horizon_client::parallel: one rayon pool made once (at most 8 threads, the server
thread takes parts too), par_map that gives every part's results in the items'
order, and the HORIZON_PARALLEL_TICK switch (off by default; 1 or verify turn it
on). With the switch off every phase runs its serial code on the server thread.
Goal is Send + Sync, so mobs can be read from helper threads.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:14:03 +00:00
232 changed files with 4489 additions and 5872 deletions

No files matched your search

+12 -12
View File
@@ -1,16 +1,16 @@
# Changes to these files wait for a council member's review (council poll P35, 2026-11-10) once branch protection requires code owners' reviews
# Changes to these files wait for Ludovic's review once branch protection requires code owners' reviews
# (tools/ci/protect_pipeline.sh, docs/security.md). They are what builds, signs and ships releases, the code that
# checks downloads before installing them (backend.rs downloads, installs and updates the launcher itself; links.json
# says where from; the Cargo.toml files pick the crates that do it), and this file. Everything else merges as soon as
# `verify` passes.
/.github/ @horizon-rust/council
/tools/ci/ @horizon-rust/council
/tools/release_train.py @horizon-rust/council
/crates/horizon-release-sign/ @horizon-rust/council
/crates/horizon-server/src/update.rs @horizon-rust/council
/crates/horizon-server/Cargo.toml @horizon-rust/council
/launcher/src/signed.rs @horizon-rust/council
/launcher/src/backend.rs @horizon-rust/council
/launcher/links.json @horizon-rust/council
/launcher/Cargo.toml @horizon-rust/council
/packaging/ @horizon-rust/council
/.github/ @ludovic111
/tools/ci/ @ludovic111
/tools/release_train.py @ludovic111
/crates/horizon-release-sign/ @ludovic111
/crates/horizon-server/src/update.rs @ludovic111
/crates/horizon-server/Cargo.toml @ludovic111
/launcher/src/signed.rs @ludovic111
/launcher/src/backend.rs @ludovic111
/launcher/links.json @ludovic111
/launcher/Cargo.toml @ludovic111
/packaging/ @ludovic111
+7
View File
@@ -32,6 +32,13 @@
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
## Other projects' code
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
the documentation pages and the packs you used. -->
## Self-check
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
+360 -10
View File
@@ -40,7 +40,7 @@ jobs:
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
@@ -119,22 +119,22 @@ jobs:
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable fmt --all -- --check
cargo +stable clippy --workspace --all-targets --locked -- -D warnings
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --locked --no-default-features -- -D warnings
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
# cargo test --locked, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
# these 4-core runners it was no faster (#4978).
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --locked --workspace --lib --bins --tests
cargo +stable test --workspace --lib --bins --tests
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
# recompiling the workspace with a different dependency graph.
cargo +stable test --locked --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --locked --workspace --doc
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
@@ -143,7 +143,7 @@ jobs:
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
@@ -313,8 +313,358 @@ jobs:
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
frame_identity: ${{ contains(github.event.pull_request.labels.*.name, 'frame-identity') }}
# JFR analysis uses JDK-generated fixture recordings on the self-hosted Linux runner.
# Start a manual checks dispatch with: gh workflow run checks --ref <branch>.
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
# (label horizon-game) holding the maintainer's own copy: the official launcher's
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
# HORIZON_SERVER_JAR. Start them by hand: gh workflow run checks --ref <branch>.
vanilla-parity:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '25'
- name: Compare Rust foundations with pinned vanilla client and server bytecode
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_rust_parity.py --prepare
- name: Check Rust world-data interoperability with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_world_parity.py
- name: Compare scheduled ticks and saved tick data with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_tick_parity.py
- name: Compare simulation geometry and cached block collision shapes
env:
RUSTUP_TOOLCHAIN: stable
run: |
python tools/test_geometry_parity.py
python tools/test_collision_parity.py
- name: Compare noise samplers and density kernels with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_noise_parity.py
- name: Compare compiled density samplers with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_density_parity.py
- name: Compare world-generation JSON codecs with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_worldgen_parity.py
- name: Compare heightmap predicates and chunk mutations with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_heightmap_parity.py
- name: Compare pre-light chunk status transitions and block writes
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_chunk_stage_parity.py
- name: Compare feature placement into resident neighboring chunks
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_feature_region_parity.py
- name: Compare nested feature selectors and sequences
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_feature_graph_parity.py
- name: Compare named feature holders and placed-feature tags
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_feature_catalog_parity.py
- name: Compare configured fill-layer block placement
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_fill_layer_parity.py
- name: Compare replacement-rule codecs and evaluation
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_rule_test_parity.py
- name: Compare simple-block placement and feature composition
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_simple_block_parity.py
- name: Compare block-column sampling and obstruction
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_column_parity.py
- name: Compare vertical pillars and cap feature placement
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_pillar_parity.py
- name: Compare exactly-one-neighbor feature spreading
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_neighbor_spread_parity.py
- name: Compare block-pile support and placement
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_pile_parity.py
- name: Compare disk replacement and postprocessing
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_disk_parity.py
- name: Compare block-blob support search and geometry
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_blob_parity.py
- name: Compare spring placement and fluid tick scheduling
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_spring_parity.py
- name: Compare vine attachment priority and placement
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_vines_parity.py
- name: Compare underwater magma floor scans and occlusion
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_underwater_magma_parity.py
- name: Compare blue-ice seeding and growth attempts
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_blue_ice_parity.py
- name: Compare spike geometry and discontinuous roots
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_spike_parity.py
- name: Compare End podium construction and destruction requests
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_end_podium_parity.py
- name: Rust End gateway parity
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_end_gateway_parity.py
- name: Compare bonus chest placement and conditional loot seeding
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_bonus_chest_parity.py
- name: Compare recursive chorus generation and connection refreshes
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_chorus_plant_parity.py
- name: Compare small speleothem patches and waterlogged points
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_speleothem_parity.py
- name: Compare replacement blob search and Manhattan shells
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_replace_blobs_parity.py
- name: Compare bamboo stalks, podzol patches and leafy caps
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_bamboo_parity.py
- name: Compare Nether delta rims and shifted contents
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_delta_parity.py
- name: Compare biome freezing and snow eligibility
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_weather_parity.py
- name: Compare frozen top layers and snowy ground updates
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_snow_freeze_parity.py
- name: Compare generation-stage tick storage and routing
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_generation_tick_parity.py
- name: Compare below-zero upgrade metadata codecs
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_retrogen_data_parity.py
- name: Compare below-zero upgrade lifecycle and block transformations
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_retrogen_chunk_parity.py
- name: Compare chunk-status metadata, ordering and codecs
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_chunk_status_parity.py
- name: Compare proto-chunk block-entity storage and coordinates
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_entity_storage_parity.py
- name: Compare ordered single-block replacement features
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_replace_block_parity.py
- name: Compare scattered ore placement and air-exposure checks
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_scattered_ore_parity.py
- name: Compare resident bulk-section access and raw writes
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_bulk_section_parity.py
- name: Compare biome feature ordering and cycle detection
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_feature_sort_parity.py
- name: Compare world-generation random wrappers and seeding
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_worldgen_random_parity.py
- name: Compare biome filtering and top-level feature context
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_placement_parity.py
- name: Compare biome decoration feature scheduling and seeding
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_decoration_parity.py
- name: Compare decoration against resident biome and terrain sections
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_decoration_region_parity.py
- name: Compare block-state provider choices and rotation
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_state_provider_parity.py
- name: Compare contained fluids and height queries with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_fluid_parity.py
- name: Compare entity obstruction with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_entity_obstruction_parity.py
- name: Compare block solidity and replaceability with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_properties_parity.py
- name: Compare face slicing and block support with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_face_support_parity.py
- name: Compare cached block support with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_support_parity.py
- name: Compare dynamic block support with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_dynamic_support_parity.py
- name: Compare moving-piston collision and interpolation with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_piston_shape_parity.py
- name: Compare supported block survival rules with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_survival_parity.py
- name: Compare noise-stage chunk filling with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_terrain_parity.py
- name: Compare structure terrain-adaptation density with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_structure_density_parity.py
- name: Compare seeded material bands and surface queries with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_material_surface_parity.py
- name: Compare material condition codecs and rule evaluation with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_material_condition_parity.py
- name: Compare biome temperatures and cache behavior with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_temperature_parity.py
- name: Compare badlands and frozen-ocean extensions with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_material_extension_parity.py
- name: Compare bundled material-rule libraries with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_material_rule_library_parity.py
- name: Compare fixed, checkerboard and End biome sources with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_source_parity.py
- name: Compare seeded cave paths and failure state with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_cave_carver_parity.py
- name: Compare seeded canyon paths and width factors with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_canyon_carver_parity.py
- name: Compare initial block-writing feature algorithms with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_basic_feature_parity.py
- name: Compare feature placement chains with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_feature_placement_parity.py
- name: Compare feature placement primitives with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_placement_parity.py
- name: Compare anchored height providers with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_height_provider_parity.py
- name: Compare scalar value providers with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_value_provider_parity.py
- name: Compare cave and canyon geometric primitives with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_carving_geometry_parity.py
- name: Compare carving-mask traversal and callback mutations with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_carving_mask_parity.py
- name: Compare registry-bound biome-source codecs with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_source_codec_parity.py
- name: Compare all four biome-source kinds in chunk generation with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_generation_parity.py
- name: Compare chunk biome filling and failure state with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_chunk_parity.py
- name: Compare biome zoom and seed obfuscation with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_zoom_parity.py
- name: Compare surface-stage chunks with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_material_stage_parity.py
- name: Compare block-state JSON codecs with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_state_codec_parity.py
- name: Compare vertical anchors with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_anchor_parity.py
- name: Compare float logarithms at domain and rounding boundaries
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_float_log_parity.py
jfr-analysis:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
+9 -12
View File
@@ -58,18 +58,6 @@ jobs:
echo "Merges since ${BASE_SHA}:"
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
- name: Preserve the initial failure logs
if: steps.harnesses.outcome == 'failure'
run: |
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
- name: Upload the initial failure logs
if: steps.harnesses.outcome == 'failure'
uses: actions/upload-artifact@v7
with:
name: parity-main-logs
path: .local/parity-main-logs/
if-no-files-found: warn
retention-days: 7
- name: A flake, or the merge that broke them
if: steps.harnesses.outcome == 'failure'
env:
@@ -77,4 +65,13 @@ jobs:
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.base.outputs.sha }}
run: |
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
- name: Logs of the failed harnesses
if: steps.harnesses.outcome == 'failure'
uses: actions/upload-artifact@v7
with:
name: parity-main-logs
path: .local/parity-main-logs/
if-no-files-found: ignore
retention-days: 7
+4 -31
View File
@@ -1,9 +1,7 @@
# Horizon: a map for agents
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
feature; performance work may differ inside as long as it stays compatible with vanilla
([CONTRIBUTING.md](CONTRIBUTING.md#when-it-doesnt-have-to-be-11)). Read [CONTRIBUTING.md](CONTRIBUTING.md) once;
this page tells you where things are.
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
## Rules that bite
@@ -44,6 +42,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
6 hours ([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
issue is taken, and so is one labelled `in progress` (claimed on Discord): pick another. `/unclaim` when you stop;
@@ -55,29 +54,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Render passes keep HDR's light.** A pass that reads, filters or redraws the world's picture (upscaling,
anti-aliasing, sharpening, frame generation, post effects, captures) follows [docs/hdr-passes.md](docs/hdr-passes.md).
## Don't lose time
Where agent time went, measured on sessions and CI in November 2026: reading code, compiling `horizon-client`,
waiting in loops, and work someone else had already done. Tests were a small part.
- **Check nobody else is on it.** One in three pull requests closed unmerged was a duplicate. Before you write
code, look at the issue's linked pull requests and run
`gh pr list --state open --search "<issue number, function or file name>"`; then `/claim`.
- **Find code with the map, not by reading.** Start from the tables below and
`python3 tools/port_ledger.py where Class.method`. Read the function you change and its callers, not whole
files.
- **Compile cheaply while you work.** `cargo check -p <crate>` after an edit. Clippy, fmt and tests run once, at
the end, through `pr_ready.py`. `cargo clippy -p horizon-client` and `cargo build -p horizon-client` are the
slowest commands here: never after every edit, and build the game only when you need to run it.
- **Don't wait in loops.** Start a long command in the background and carry on, or wait for it once. No
`sleep` or `until grep` polling of builds, harnesses or CI.
- **One test per changed behaviour.** None for a refactor or a file move, none that reads source text, and no
harness where vanilla has nothing to compare (Horizon's own features, performance work).
- **A red check isn't always yours.** When `verify` fails in a test your change can't reach, say so in the pull
request and move on; don't hunt it.
- **Unsure about a rule? Finish the work.** Take the reading that lets you finish, and say in the pull request
which one you took. Don't stop, and don't change direction halfway.
## Crates
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
@@ -127,7 +103,6 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
- Use independent expected values or a vanilla oracle; do not derive the answer from the code under test.
- Test outcomes, not only calls to stubs. Use mocks to isolate behavior, not as the behavior itself.
- Skip tests of trivial constants, derives and getters, or behavior already covered by another test.
- One test per changed behaviour is enough. A refactor or a move that changes no behaviour gets no new test.
- Avoid pinning implementation details; a harmless refactor should not break a behavior test.
- Make slow tests faster without removing cases or weakening their coverage.
- Never delete parity harnesses or tests that pin vanilla behaviour: they are the spec.
@@ -184,8 +159,7 @@ python3 tools/lang_coverage.py --summary # the keys each language still misses
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
random draws must happen in vanilla's order. Performance work may break that order where no player can rely
on it; the pull request says so ([CONTRIBUTING.md](CONTRIBUTING.md#when-it-doesnt-have-to-be-11)).
random draws must happen in vanilla's order.
## horizon-client: where things live
@@ -248,8 +222,7 @@ player does (#1514). A window's `Game` never ticks a world itself: the old in-fr
Open `[server-thread]` issues list the gaps left.
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
When you change how the server and its clients talk (packets, tracking, guests, the server thread itself), check
it there. Other gameplay fixes are covered by their own harness and CI's parity job:
Test the server thread when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
+34 -26
View File
@@ -1,8 +1,7 @@
# Contributing to Horizon
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
everything is that **it plays like vanilla**: first exactly like it, then better where players gain from it
([vanilla first, then better](#vanilla-first-then-better)). This page is the short version of how we work; questions are
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/playhorizon).
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
@@ -24,7 +23,7 @@ here.
## Build it
- **Prerequisites:** Rust (stable), Python 3.11+, JDK 25, git 2.38 or newer, and on Linux the ALSA and udev headers
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
(`libasound2-dev libudev-dev`).
- **Resources:** Horizon never downloads the game; it prepares its resources from your own copy of Minecraft 26.3, as
described in [Build from source](README.md#build-from-source).
@@ -37,10 +36,7 @@ here.
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
## Vanilla first, then better
Horizon gets there in two stages. First it plays exactly like vanilla; that is what parity work is for. Then it
improves on vanilla where players gain from it, mostly speed: FPS, TPS, loading, memory.
## Vanilla first
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
@@ -53,10 +49,8 @@ and compares their outputs.
- **One version: 26.3.** Parity work targets 26.3 until its parity is good, redstone first. Don't open pull requests
that move Horizon to a newer Minecraft version for now; to join servers on a newer version, put a Via proxy
(ViaVersion, ViaProxy) in front of them.
- **Ports of vanilla behaviour come with a harness**, new or extended, so the next change can't silently break
them. [The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one. Where vanilla
has nothing to compare (Horizon's own features, performance work, refactors), no harness is needed: one Rust
test of the changed behaviour, or the numbers before and after.
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
off by default wherever they would change the vanilla look.
- **Three layers: engine, platform, Horizon.**
@@ -73,26 +67,40 @@ and compares their outputs.
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
### When it doesn't have to be 1:1
## Only original code, or code whose license allows it
Parity is about what players and the vanilla game see, not about copying vanilla's internals. Performance work (a
multithreaded server tick, rendering, chunk loading, memory) doesn't have to keep vanilla's internal order or its
exact random calls. It has to work together with normal Minecraft:
Horizon is MIT. Its code is written from Minecraft 26.3 itself and from public documentation. Since 30 October 2026
another project's source may also be read and used, **only when that project's license allows it**:
- **Vanilla clients and servers still work with it.** A vanilla 26.3 client plays on a Horizon server, Horizon joins
vanilla servers, and saves and worlds open in both, as before.
- **The game still plays the same.** Farms, redstone, mobs and drops work the way players know from vanilla. A
difference may only show where no player can rely on it, such as which of two mobs ticks first or exactly which
random number a mob gets.
- **Vanilla's slow paths aren't copied.** When vanilla spends a lot of FPS or TPS on something that can reach the
same result faster, Horizon does it faster. Copying a performance problem isn't parity.
- **Say what changes.** The pull request names the vanilla behaviour it no longer reproduces exactly, why players
won't notice it or why it's worth it, and how it was checked: numbers before and after, and the harnesses that
still pass. A change players could notice, such as a different tick order on a busy server, goes behind a setting.
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
public-domain dedication. That code may be read, copied or adapted.
- **Off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative Commons licenses
(CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them), custom or
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
to download a mod or a pack does not give you the right to read its files.
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
your commit counts. In doubt, don't: ask in #contributors first.
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
pull request that used another project's source without saying so is closed.
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
was changed.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
names its sources: the sections of that specification, the documentation pages and the packs it ran.
## Vendor libraries and Java mods
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository.
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository, like any code under an allowed license.
NVIDIA's DLSS, Reflex and any other library under an NVIDIA license may not: they ship only as an optional native mod
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
repository.
Generated
+46 -1
View File
@@ -706,6 +706,25 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
@@ -926,6 +945,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "embedded-io"
version = "0.4.0"
@@ -1554,6 +1579,7 @@ dependencies = [
"open",
"png",
"pollster",
"rayon",
"rustix 1.1.5",
"serde",
"serde_json",
@@ -1947,7 +1973,6 @@ name = "horizon-server-files"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"getrandom 0.3.4",
"horizon-core",
"horizon-protocol",
"horizon-world",
@@ -3665,6 +3690,26 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "rc2"
version = "0.8.1"
+1 -1
View File
@@ -530,7 +530,7 @@ unable to act, and never puts an account that is tied to a person into anyone el
| Account | Today | Can it be shared? | Plan |
| --- | --- | --- | --- |
| **GitHub organisation** (horizon-rust) | Two owners since 2026-11-11: the founder and rdbtCVS (P28); a `council` team holds CODEOWNERS (P35) and is the security managers (P36) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
+1 -1
View File
@@ -109,7 +109,7 @@ horizon-ctl break -1 65 -3 # mines it; replies with
## Build from source
Needs Rust 1.90+, Python 3.11+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
-4
View File
@@ -23,10 +23,6 @@ pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
/// Horizon's website, where the launcher is downloaded: the game started without its prepared
/// files sends the player there.
pub const WEBSITE: &str = "https://playhorizon.dev";
/// The cosmetics service: cosmetics, emotes, "Show me as a Horizon player"
/// (`HORIZON_COSMETICS_API` overrides it).
pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
+12 -2
View File
@@ -21,6 +21,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub mod combat;
pub mod prediction;
pub mod timer;
/// What one check's violation level leads to.
@@ -161,8 +162,8 @@ checks! {
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
/// (horizon-client's `lan::prediction::record`), with a ping a tick to place what the server changes,
/// and played again (`lan::prediction`). A move that lands farther than `tolerance` from the
/// (`prediction::Record`), with a ping a tick to place what the server changes, and played again
/// (horizon-client's `lan::prediction`). A move that lands farther than `tolerance` from the
/// replay flags, by how far it lands (one `tolerance` off weighs 1, at most 5 a tick), and the
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
@@ -278,6 +279,15 @@ pub struct Player {
pub timer: timer::Balance,
/// The server tick and height where the ground last held the player.
pub ground: Option<(u64, f64)>,
/// The client ticks the prediction plays again (only while it runs).
pub record: Option<Box<prediction::Record>>,
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
/// Not the server's player: a hit with extra knockback stops its sprint
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
pub sprinting: bool,
/// The attacks of the client tick being heard.
pub attacks: combat::Attacks,
}
-25
View File
@@ -320,12 +320,7 @@ pub fn java_hash_order(
"FallFlying",
"sleeping_pos",
"Brain",
"last_hurt_by_player",
"last_hurt_by_player_memory_time",
"last_hurt_by_mob",
"ticks_since_last_hurt_by_mob",
"equipment",
"locator_bar_icon",
// ArmorStand.
"Invisible",
"Small",
@@ -342,12 +337,6 @@ pub fn java_hash_order(
"immovable",
"description",
"hide_description",
// Interaction.addAdditionalSaveData.
"width",
"height",
"attack",
"interaction",
"response",
];
// BlockEntity.saveMetadata writes the position after the entity's data.
const AFTER: &[&str] = &["x", "y", "z"];
@@ -447,20 +436,6 @@ mod tests {
);
}
#[test]
fn interaction_compound_orders_width_before_interaction_in_shared_bucket() {
// `width` and `interaction` share a bucket at every table size;
// `Interaction.addAdditionalSaveData` writes `width` first.
let tag = parse_snbt(
r#"{Air:300s,Fire:0s,Invulnerable:0b,Motion:[0.0d,0.0d,0.0d],OnGround:0b,PortalCooldown:0,Pos:[4.5d,100.0d,4.5d],Rotation:[0.0f,0.0f],UUID:[I;66,0,0,1],fall_distance:0.0d,height:1.25f,interaction:{player:[I;1,2,3,4],timestamp:9L},response:0b,width:1.0f}"#,
)
.unwrap()
.0;
assert_eq!(
pretty(&tag).plain(),
"{Motion: [0.0d, 0.0d, 0.0d], Invulnerable: 0b, fall_distance: 0.0d, Air: 300s, OnGround: 0b, PortalCooldown: 0, Rotation: [0.0f, 0.0f], Pos: [4.5d, 100.0d, 4.5d], response: 0b, Fire: 0s, width: 1.0f, interaction: {player: [I; 1, 2, 3, 4], timestamp: 9L}, UUID: [I; 66, 0, 0, 1], height: 1.25f}",
);
}
#[test]
fn compact_form_matches_vanilla() {
assert_eq!(
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
+3
View File
@@ -27,6 +27,9 @@ horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
# once, not for every phase of every tick.
rayon = "1.10"
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
+6 -19
View File
@@ -439,33 +439,20 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
/// The report for a hung server tick, written by the watchdog thread (`ServerWatchdog`): the
/// stack is the server thread's, read from the watchdog (`horizon_threads::stack_dump`), as
/// vanilla's report carries the server thread's stack; without it, the details say why.
pub fn report_watchdog(
home: &Path,
side: &'static str,
message: String,
server_stack: Result<String, horizon_threads::stack_dump::NoStack>,
) -> Option<PathBuf> {
let mut details = system_details(side);
let stack = match server_stack {
Ok(stack) => stack_lines(&stack, false),
Err(why) => {
details.push(("Server thread stack".into(), why.to_string()));
String::new()
}
};
/// The report for a hung server tick, written by the watchdog thread
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
/// the report carries none.
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack,
stack: String::new(),
thread: thread_name(side),
side,
details,
details: system_details(side),
log: crate::logging::recent_lines(),
},
)
+5 -2
View File
@@ -1496,7 +1496,7 @@ pub struct Renderer {
shadow_seen_scratch: crate::fast_hash::FxHashSet<SectionKey>,
/// Shadow translucent section draws, retained between frames.
shadow_translucent_scratch: Vec<(u64, [u32; 5], (u32, Option<u32>))>,
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::game_terrain`).
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
pack_lava_layers: Vec<u16>,
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
@@ -7540,6 +7540,10 @@ impl Renderer {
}
});
horizon_shaders::GameInputs {
terrain_parts: terrain
.as_ref()
.map(|t| pack_atlas::terrain_parts(t, &self.pack_lava_layers)),
terrain,
terrain_geometry,
entity_geometry: Some(pack_entities::geometry_inputs()),
sky: Some(pack_sky::inputs()),
@@ -7549,7 +7553,6 @@ impl Renderer {
weather: Some(pack_weather::inputs()),
lines: Some(pack_lines::inputs()),
hand: Some(pack_hand::inputs()),
..pack_atlas::game_terrain(terrain, &self.pack_lava_layers)
}
}
@@ -666,52 +666,34 @@ const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
}\n \
}\n";
/// GLSL: whether a terrain vertex's quad is solid terrain rather than cutout. Solid: the quads
/// over opaque texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks
/// `ChunkSectionLayer.SOLID` per quad (`FaceBakery.computeMaterialTransparency`,
/// `SectionCompiler.compile`), and lava's faces (sprite layers `lava`, its still and flowing
/// sprites), which Iris 1.11.7 drew with `gbuffers_terrain_solid` in our probes, with and
/// without `separateAo`.
/// `terrain` (`PackAtlas::terrain_inputs`) drawing only solid terrain, then only cutout terrain:
/// each leaves the other part's quads outside the clip volume. Solid: the quads over opaque
/// texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks `ChunkSectionLayer.SOLID`
/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava's
/// faces (sprite layers `lava`, its still and flowing sprites), which Iris 1.11.7 drew with
/// `gbuffers_terrain_solid` in our probes, with and without `separateAo`.
///
/// The test reads only the vertex's sprite layer, which all four vertices of a quad share: a
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
/// holds each vertex's own ambient occlusion.
fn solid(lava: &[u16]) -> String {
pub fn terrain_parts(
terrain: &horizon_shaders::GbuffersInputs,
lava: &[u16],
) -> [horizon_shaders::GbuffersInputs; 2] {
let opaque = crate::mesher::OPAQUE_LAYER;
let lava: String = lava
.iter()
.map(|l| format!(" || hz_v_layer == {l}u"))
.collect();
format!("(hz_v_layer & {opaque}u) != 0u{lava}")
}
/// The game's `terrain` inputs (`PackAtlas::terrain_inputs`) for a pack: whole; drawing only
/// solid terrain, then only cutout terrain (`solid`), each leaving the other part's quads outside
/// the clip volume; and drawing both in one pass, each quad with its part's alpha test.
pub fn game_terrain(
terrain: Option<horizon_shaders::GbuffersInputs>,
lava: &[u16],
) -> horizon_shaders::GameInputs {
let solid = solid(lava);
let part = |terrain: &horizon_shaders::GbuffersInputs, part: bool| {
let part = |solid: bool| {
let mut inputs = terrain.clone();
inputs.after.push_str(&format!(
" if (({solid}) != {part}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
" bool hz_solid = (hz_v_layer & {opaque}u) != 0u{lava};\n \
if (hz_solid != {solid}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
));
inputs
};
horizon_shaders::GameInputs {
terrain_parts: terrain.as_ref().map(|t| [part(t, true), part(t, false)]),
terrain_one_pass: terrain.as_ref().map(|t| horizon_shaders::GbuffersInputs {
parts: Some(horizon_shaders::Parts {
solid: solid.clone(),
cutout_alpha_test: None,
}),
..t.clone()
}),
terrain,
..Default::default()
}
[part(true), part(false)]
}
impl PackAtlas {
@@ -870,7 +852,6 @@ impl PackAtlas {
.collect(),
after: String::new(),
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -125,7 +125,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
parts: None,
item_id_declared: false,
far_depth: false,
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
@@ -193,7 +192,6 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -50,7 +50,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after,
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -2964,13 +2964,17 @@ impl PackPost {
p.name
));
}
match (&terrain, &terrain_cutout) {
match (&terrain, &mut terrain_cutout) {
(Some(t), None) => notes.push(format!("terrain draws with {}", t.name)),
(t, Some(c)) => {
if let Some(t) = t {
notes.push(format!("solid terrain draws with {}", t.name));
}
notes.push(format!("cutout terrain draws with {}", c.name));
// Timed (and labelled) apart from the solid part when it runs the same program.
if t.as_ref().is_some_and(|t| t.name == c.name) {
c.name.push_str(" (cutout)");
}
}
(None, None) => {}
}
@@ -3907,8 +3911,8 @@ impl PackPost {
}
/// Terrain (or, `translucent`, translucent terrain) with the pack's program; `draw` issues
/// the game's draws. Solid terrain in two parts (a pack's `gbuffers_terrain_solid` and
/// `_cutout`) has its cutout part drawn second, from the same draws.
/// the game's draws. Solid terrain in two parts has its cutout part drawn second, from the
/// same draws.
fn draw_terrain(
&mut self,
device: &wgpu::Device,
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,parts:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
@@ -717,7 +717,6 @@ fn shadow_depth_map_reads(
attributes: vec![],
after: String::new(),
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -48,7 +48,6 @@ fn pack(outputs: u32) -> Post {
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -44,7 +44,6 @@ fn pack() -> Post {
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -89,7 +89,14 @@ fn strips_of(
.into(),
..Default::default()
};
let inputs = super::super::pack_atlas::game_terrain(Some(terrain), &[LAVA, LAVA + 1]);
let inputs = horizon_shaders::GameInputs {
terrain_parts: Some(super::super::pack_atlas::terrain_parts(
&terrain,
&[LAVA, LAVA + 1],
)),
terrain: Some(terrain),
..Default::default()
};
const ATTRS: [wgpu::VertexAttribute; 3] =
wgpu::vertex_attr_array![0=>Float32x3,2=>Uint32,3=>Unorm8x4];
let layouts = [Some(wgpu::VertexBufferLayout {
@@ -339,64 +346,3 @@ fn alpha_test_ref_is_0_for_solid_terrain_and_0_5_for_cutout() {
assert_eq!(got, [Some(RED), Some(YELLOW), Some(RED)], "{names:?}");
}
}
/// `alphaTest.gbuffers_terrain` replaces both parts' tests when one program draws them, as it
/// did when they drew in two passes: over `GREATER 0.25`, alpha 0.3 keeps cutout terrain too
/// and 0.2 loses solid terrain too, and `alphaTestRef` reads 0.25 in both.
#[test]
fn a_packs_terrain_alpha_test_applies_to_both_parts() {
let properties = || {
(
"shaders.properties".to_owned(),
"alphaTest.gbuffers_terrain=GREATER 0.25\n".to_owned(),
)
};
for (alpha, kept) in [(0.3, true), (0.2, false)] {
let mut files = program("gbuffers_terrain", RED, alpha).to_vec();
files.push(properties());
let Some((got, _)) = strips_of(files, [255; 4]) else {
return;
};
assert_eq!(got, [kept.then_some(RED); 3], "alpha {alpha}");
}
let files = vec![
(
"gbuffers_terrain.vsh".to_owned(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".to_owned(),
),
(
"gbuffers_terrain.fsh".to_owned(),
"#version 120\nuniform float alphaTestRef;\n/* DRAWBUFFERS:0 */\n\
void main(){gl_FragData[0]=vec4(1.0,alphaTestRef*4.0,0.0,1.0);}"
.to_owned(),
),
properties(),
];
let Some((got, _)) = strips_of(files, [255; 4]) else {
return;
};
assert_eq!(got, [Some([255, 255, 0]); 3]);
}
/// The vertex stage reads its own quad's `alphaTestRef` too: 0 for solid, 0.5 for cutout.
#[test]
fn the_vertex_stage_reads_its_parts_alpha_test_ref() {
let files = vec![
(
"gbuffers_terrain.vsh".to_owned(),
"#version 120\nuniform float alphaTestRef;\nvarying float ref;\n\
void main(){ref=alphaTestRef;gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}"
.to_owned(),
),
(
"gbuffers_terrain.fsh".to_owned(),
"#version 120\nvarying float ref;\n/* DRAWBUFFERS:0 */\n\
void main(){gl_FragData[0]=vec4(1.0,ref*2.0,0.0,1.0);}"
.to_owned(),
),
];
let Some((got, _)) = strips_of(files, [255; 4]) else {
return;
};
assert_eq!(got, [Some(RED), Some([255, 255, 0]), Some(RED)]);
}
@@ -102,7 +102,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
// depth. Its fragments read their own depth, as in Iris.
after: String::new(),
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: true,
paged_maps: false,
@@ -133,7 +132,6 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
],
after: String::new(),
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: true,
paged_maps: false,
@@ -44,7 +44,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
+3 -5
View File
@@ -1897,12 +1897,10 @@ mod tests {
let g = Graphics::default();
assert_eq!(g.workers, 0);
let cores = horizon_threads::worker_count();
// Vanilla's bound: one core is always left for the render and server threads.
assert!((1..=cores.saturating_sub(1).max(1)).contains(&g.worker_threads()));
// The count itself is `horizon_threads::default_workers`, which is one less than
// the *smaller* of the machine's cores and its performance cores (a Linux hybrid
// CPU reports fewer of the latter), so it is not always `cores - 1`.
assert_eq!(g.worker_threads(), horizon_threads::default_workers());
if !cfg!(target_vendor = "apple") {
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
}
// A manual count is the player's own choice, kept as asked.
let manual = Graphics { workers: 3, ..g };
assert_eq!(manual.worker_threads(), 3);
+19 -6
View File
@@ -20,10 +20,6 @@ use crate::{
mod shell_pages;
#[path = "options_ui_wheel.rs"]
mod wheel;
pub(crate) use wheel::slider_unsnapped;
fn tr(key: &str) -> Text {
Text::translate(key, &[])
}
@@ -369,7 +365,7 @@ impl Opt {
),
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
Opt::Sensitivity => Kind::Slider(s.sensitivity),
Opt::WheelSensitivity => wheel::kind(s.mouse_wheel_sensitivity),
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
Opt::Volume(i) => Kind::Slider(if i == 0 {
s.master_volume
} else {
@@ -920,7 +916,7 @@ impl Opt {
}
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
Opt::Sensitivity => s.sensitivity = v,
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = wheel::from_slider(v),
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
Opt::Volume(0) => s.master_volume = v,
Opt::Volume(i) => s.volumes[i] = v,
Opt::Gamma => s.gamma = v,
@@ -1608,6 +1604,23 @@ pub struct MusicGroup {
/// The rows, in `sounds.json` order.
pub tracks: Vec<MusicTrack>,
}
/// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after
/// the last change (`OptionInstanceSliderButton.delayedApplyAt`).
fn delays_apply(opt: Opt) -> bool {
matches!(
opt,
Opt::RenderDistance | Opt::SimulationDistance | Opt::BiomeBlend | Opt::WheelSensitivity
)
}
/// Whether a slider still shows its own position: while held, or for a
/// delayed one until its value applies (both then `resetValue`).
fn slider_unsnapped(opt: Opt, held: bool, moved: Instant) -> bool {
if delays_apply(opt) {
moved.elapsed().as_millis() < 600
} else {
held
}
}
pub struct OptionsUi {
pub stack: Vec<Page>,
scroll: f32,
@@ -1,71 +0,0 @@
//! `Options.mouseWheelSensitivity`'s logarithmic slider (`logMouse`/`unlogMouse`)
//! and the delayed-apply test the slider buttons share.
use super::*;
use std::time::Instant;
/// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after
/// the last change (`OptionInstanceSliderButton.delayedApplyAt`).
fn delays_apply(opt: Opt) -> bool {
matches!(
opt,
Opt::RenderDistance | Opt::SimulationDistance | Opt::BiomeBlend | Opt::WheelSensitivity
)
}
/// Whether a slider still shows its own position: while held, or for a
/// delayed one until its value applies (both then `resetValue`).
pub(crate) fn slider_unsnapped(opt: Opt, held: bool, moved: Instant) -> bool {
if delays_apply(opt) {
moved.elapsed().as_millis() < 600
} else {
held
}
}
/// `Options.unlogMouse`: the stored sensitivity as its hundredth-log step,
/// -200..=100, which the `IntRange(-200, 100)` slider runs over (0.01 to 10.0).
pub(crate) fn step(value: f32) -> f32 {
(value.max(1.0e-6).log10() * 100.0)
.floor()
.clamp(-200.0, 100.0)
}
/// `IntRange(-200, 100).toSliderValue`: the step over the slider's span.
pub(crate) fn kind(value: f32) -> Kind {
Kind::Slider(((step(value) + 200.0) / 300.0).clamp(0.0, 1.0))
}
/// `IntRange(-200, 100).fromSliderValue` then `Options.logMouse`:
/// floor(map(v, 0, 1, -200, 101)), the last step clamped, as 10^(i/100).
pub(crate) fn from_slider(v: f32) -> f32 {
let i = (v.clamp(0.0, 1.0) * 301.0).floor() - 200.0;
10.0_f32.powf(i.clamp(-200.0, 100.0) / 100.0)
}
#[cfg(test)]
mod tests {
use super::*;
/// The slider position `Opt::WheelSensitivity`'s kind() gives a value.
fn wheel_pos(value: f32) -> f32 {
match kind(value) {
Kind::Slider(p) => p,
_ => panic!("the wheel sensitivity is a slider"),
}
}
/// `Options.mouseWheelSensitivity` runs over `IntRange(-200, 100)` through
/// `logMouse`/`unlogMouse`: 10^(i/100), 0.01 to 10.0, not a straight line.
#[test]
fn the_mouse_wheel_sensitivity_slider_is_logarithmic() {
// 1.0 is step 0: 200 of the 300 slider steps in, not at 0.099.
assert!((wheel_pos(1.0) - 2.0 / 3.0).abs() < 1e-6);
assert!((wheel_pos(0.01) - 0.0).abs() < 1e-6);
assert!((wheel_pos(10.0) - 1.0).abs() < 1e-6);
assert!((from_slider(2.0 / 3.0) - 1.0).abs() < 1e-5);
assert!((from_slider(0.0) - 0.01).abs() < 1e-6);
assert!((from_slider(1.0) - 10.0).abs() < 1e-4);
assert!((from_slider(1.0 / 3.0) - 0.1).abs() < 1e-5);
// The value setter goes through the same mapping.
let mut s = Settings::default();
set_slider(Opt::WheelSensitivity, &mut s, 2.0 / 3.0);
assert!((s.mouse_wheel_sensitivity - 1.0).abs() < 1e-5);
set_slider(Opt::WheelSensitivity, &mut s, 0.0);
assert!((s.mouse_wheel_sensitivity - 0.01).abs() < 1e-6);
}
}
@@ -277,14 +277,6 @@ pub fn take_text_input_area() -> Option<[f32; 4]> {
mod tests {
use super::*;
/// The tests that change [`STATE`] take turns: it is one for the whole process, and
/// cargo runs tests in parallel, so one test's `reset` or `should_suppress_key` could
/// land in the middle of another (#14577).
static STATE_TESTS: Mutex<()> = Mutex::new(());
fn one_at_a_time() -> std::sync::MutexGuard<'static, ()> {
STATE_TESTS.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn empty_text_is_no_composition() {
assert_eq!(PreeditEvent::from_sdl_text_editing(None, 0, 0), None);
@@ -360,7 +352,6 @@ mod tests {
#[test]
fn set_and_clear_preedit() {
let _turn = one_at_a_time();
reset();
set_preedit(PreeditEvent::from_winit("nihongo", Some((0, 7))));
assert!(composing());
@@ -371,7 +362,6 @@ mod tests {
#[test]
fn empty_preedit_clears_composing_and_suppresses_trailing_enter_once() {
let _turn = one_at_a_time();
reset();
set_preedit(PreeditEvent::from_winit("こんにちは", Some((0, 15))));
assert!(composing());
@@ -395,7 +385,6 @@ mod tests {
#[test]
fn mark_committed_suppresses_single_trailing_key() {
let _turn = one_at_a_time();
reset();
set_preedit(PreeditEvent::from_winit("漢字", Some((0, 6))));
mark_committed();
+157 -7
View File
@@ -12,13 +12,6 @@
//! or an empty answer keeps the cached lists, `Retry-After` sets the poll interval, and
//! `getFriendData` waits out a ten-second cooldown between reads. The presence statuses
//! are `PresenceHandler.getPresenceStatus`'s, supplied by the client each tick.
mod model;
use model::presence_of;
pub use model::{
Friend, FriendData, Presence, PresenceStatus, ResultCode, parse_friends, parse_presence,
};
use crate::auth::{self, AuthSession};
use serde_json::{Value, json};
use std::{
@@ -30,6 +23,69 @@ use std::{
time::{Duration, Instant},
};
/// authlib's `FriendsService.ResultCode`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResultCode {
Success,
Error,
ServiceNotAvailable,
TooManyRequests,
Forbidden,
UpgradeNeeded,
ConnectionIssue,
TemporaryUnavailable,
UnknownProfile,
Unauthorized,
GenericError,
}
/// `FriendDto`: one friend or one pending request.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Friend {
pub id: u128,
pub name: String,
}
/// authlib's `FriendData`: the three lists the service answers.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FriendData {
pub friends: Vec<Friend>,
pub incoming: Vec<Friend>,
pub outgoing: Vec<Friend>,
}
/// authlib's `PresenceStatus`, as `updatePresence` stores it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PresenceStatus {
Online,
PlayingOffline,
PlayingRealms,
PlayingServer,
PlayingHostedServer,
Offline,
}
impl PresenceStatus {
/// `PresenceStatus.name()`: the string `updatePresence` sends and
/// `PresenceStatusDto.status` carries.
fn wire(self) -> &'static str {
match self {
Self::Online => "ONLINE",
Self::PlayingOffline => "PLAYING_OFFLINE",
Self::PlayingRealms => "PLAYING_REALMS",
Self::PlayingServer => "PLAYING_SERVER",
Self::PlayingHostedServer => "PLAYING_HOSTED_SERVER",
Self::Offline => "OFFLINE",
}
}
}
/// `PresenceStatusDto`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Presence {
pub id: u128,
pub status: PresenceStatus,
}
/// `RemoteFriendListUpdateHandler.State`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
@@ -670,10 +726,104 @@ fn http_error(error: ureq::Error) -> Failure {
Failure { code }
}
/// authlib's `FriendsListResponse` (`getFriends`/`updateFriends` body).
pub fn parse_friends(body: &str) -> Option<FriendData> {
let v: Value = serde_json::from_str(body).ok()?;
Some(FriendData {
friends: parse_friend_list(v.get("friends")),
incoming: parse_friend_list(v.get("incomingRequests")),
outgoing: parse_friend_list(v.get("outgoingRequests")),
})
}
fn parse_friend_list(value: Option<&Value>) -> Vec<Friend> {
value
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|friend| {
Some(Friend {
id: auth::parse_uuid(friend.get("profileId")?.as_str()?)?,
name: friend.get("name")?.as_str()?.to_owned(),
})
})
.collect()
}
/// authlib's `PresenceResponse`.
pub fn parse_presence(body: &str) -> Option<Vec<Presence>> {
let v: Value = serde_json::from_str(body).ok()?;
Some(presence_of(&v))
}
/// `PresenceResponse.presence`: a missing list reads as empty, as Gson's null does.
fn presence_of(v: &Value) -> Vec<Presence> {
v.get("presence")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|entry| {
let status = entry.get("status")?.as_str()?;
let status = match status {
"ONLINE" => PresenceStatus::Online,
"PLAYING_OFFLINE" => PresenceStatus::PlayingOffline,
"PLAYING_REALMS" => PresenceStatus::PlayingRealms,
"PLAYING_SERVER" => PresenceStatus::PlayingServer,
"PLAYING_HOSTED_SERVER" => PresenceStatus::PlayingHostedServer,
_ => PresenceStatus::Offline,
};
Some(Presence {
id: auth::parse_uuid(entry.get("profileId")?.as_str()?)?,
status,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_friends_answer_reads_its_three_lists() {
let data = parse_friends(
r#"{"friends":[{"profileId":"0123456789abcdef0123456789abcdef","name":"Alex"}],
"incomingRequests":[],"outgoingRequests":[{"profileId":"fedcba9876543210fedcba9876543210","name":"Steve"}]}"#,
)
.unwrap();
assert_eq!(
data.friends,
vec![Friend {
id: 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef,
name: "Alex".into()
}]
);
assert!(data.incoming.is_empty());
assert_eq!(data.outgoing[0].name, "Steve");
// The service's answer to an account with no friends, as the live check
// captured it: the three lists plus an `empty` flag the parser ignores.
let empty = parse_friends(
r#"{"empty":true,"friends":[],"incomingRequests":[],"outgoingRequests":[]}"#,
)
.unwrap();
assert_eq!(empty, FriendData::default());
}
#[test]
fn a_presence_answer_reads_each_status() {
let presence = parse_presence(
r#"{"presence":[
{"profileId":"0123456789abcdef0123456789abcdef","pmid":"00000000000000000000000000000000","status":"PLAYING_REALMS","lastUpdated":"2026-11-08T12:00:00Z"},
{"profileId":"fedcba9876543210fedcba9876543210","pmid":"00000000000000000000000000000000","status":"OFFLINE","lastUpdated":"2026-11-08T12:00:00Z"}]}"#,
)
.unwrap();
assert_eq!(presence[0].status, PresenceStatus::PlayingRealms);
assert_eq!(presence[1].status, PresenceStatus::Offline);
// `PresenceResponse` without its list is empty, not missing.
assert_eq!(parse_presence("{}"), Some(Vec::new()));
assert_eq!(parse_presence("not json"), None);
}
#[test]
fn result_codes_map_to_the_handlers_states() {
assert_eq!(State::of(ResultCode::Success), State::Success);
@@ -1,164 +0,0 @@
use crate::auth;
use serde_json::Value;
/// authlib's `FriendsService.ResultCode`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResultCode {
Success,
Error,
ServiceNotAvailable,
TooManyRequests,
Forbidden,
UpgradeNeeded,
ConnectionIssue,
TemporaryUnavailable,
UnknownProfile,
Unauthorized,
GenericError,
}
/// `FriendDto`: one friend or one pending request.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Friend {
pub id: u128,
pub name: String,
}
/// authlib's `FriendData`: the three lists the service answers.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FriendData {
pub friends: Vec<Friend>,
pub incoming: Vec<Friend>,
pub outgoing: Vec<Friend>,
}
/// authlib's `PresenceStatus`, as `updatePresence` stores it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PresenceStatus {
Online,
PlayingOffline,
PlayingRealms,
PlayingServer,
PlayingHostedServer,
Offline,
}
impl PresenceStatus {
/// `PresenceStatus.name()`: the string `updatePresence` sends and
/// `PresenceStatusDto.status` carries.
pub(super) fn wire(self) -> &'static str {
match self {
Self::Online => "ONLINE",
Self::PlayingOffline => "PLAYING_OFFLINE",
Self::PlayingRealms => "PLAYING_REALMS",
Self::PlayingServer => "PLAYING_SERVER",
Self::PlayingHostedServer => "PLAYING_HOSTED_SERVER",
Self::Offline => "OFFLINE",
}
}
}
/// `PresenceStatusDto`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Presence {
pub id: u128,
pub status: PresenceStatus,
}
/// authlib's `FriendsListResponse` (`getFriends`/`updateFriends` body).
pub fn parse_friends(body: &str) -> Option<FriendData> {
let v: Value = serde_json::from_str(body).ok()?;
Some(FriendData {
friends: parse_friend_list(v.get("friends")),
incoming: parse_friend_list(v.get("incomingRequests")),
outgoing: parse_friend_list(v.get("outgoingRequests")),
})
}
fn parse_friend_list(value: Option<&Value>) -> Vec<Friend> {
value
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|friend| {
Some(Friend {
id: auth::parse_uuid(friend.get("profileId")?.as_str()?)?,
name: friend.get("name")?.as_str()?.to_owned(),
})
})
.collect()
}
/// authlib's `PresenceResponse`.
pub fn parse_presence(body: &str) -> Option<Vec<Presence>> {
let v: Value = serde_json::from_str(body).ok()?;
Some(presence_of(&v))
}
/// `PresenceResponse.presence`: a missing list reads as empty, as Gson's null does.
pub(super) fn presence_of(v: &Value) -> Vec<Presence> {
v.get("presence")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|entry| {
let status = entry.get("status")?.as_str()?;
let status = match status {
"ONLINE" => PresenceStatus::Online,
"PLAYING_OFFLINE" => PresenceStatus::PlayingOffline,
"PLAYING_REALMS" => PresenceStatus::PlayingRealms,
"PLAYING_SERVER" => PresenceStatus::PlayingServer,
"PLAYING_HOSTED_SERVER" => PresenceStatus::PlayingHostedServer,
_ => PresenceStatus::Offline,
};
Some(Presence {
id: auth::parse_uuid(entry.get("profileId")?.as_str()?)?,
status,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_friends_answer_reads_its_three_lists() {
let data = parse_friends(
r#"{"friends":[{"profileId":"0123456789abcdef0123456789abcdef","name":"Alex"}],
"incomingRequests":[],"outgoingRequests":[{"profileId":"fedcba9876543210fedcba9876543210","name":"Steve"}]}"#,
)
.unwrap();
assert_eq!(
data.friends,
vec![Friend {
id: 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef,
name: "Alex".into()
}]
);
assert!(data.incoming.is_empty());
assert_eq!(data.outgoing[0].name, "Steve");
// The service's answer to an account with no friends, as the live check
// captured it: the three lists plus an `empty` flag the parser ignores.
let empty = parse_friends(
r#"{"empty":true,"friends":[],"incomingRequests":[],"outgoingRequests":[]}"#,
)
.unwrap();
assert_eq!(empty, FriendData::default());
}
#[test]
fn a_presence_answer_reads_each_status() {
let presence = parse_presence(
r#"{"presence":[
{"profileId":"0123456789abcdef0123456789abcdef","pmid":"00000000000000000000000000000000","status":"PLAYING_REALMS","lastUpdated":"2026-11-08T12:00:00Z"},
{"profileId":"fedcba9876543210fedcba9876543210","pmid":"00000000000000000000000000000000","status":"OFFLINE","lastUpdated":"2026-11-08T12:00:00Z"}]}"#,
)
.unwrap();
assert_eq!(presence[0].status, PresenceStatus::PlayingRealms);
assert_eq!(presence[1].status, PresenceStatus::Offline);
// `PresenceResponse` without its list is empty, not missing.
assert_eq!(parse_presence("{}"), Some(Vec::new()));
assert_eq!(parse_presence("not json"), None);
}
}
@@ -1,13 +1,4 @@
{
"horizon.no_resources.title": "No prepared game files",
"horizon.no_resources.prepare": "Prepare them with horizon-prepare --prepare, or pass --resources <folder>.",
"horizon.no_resources.looked_in": "No prepared game files in %s",
"horizon.no_resources.quit": "Quit",
"horizon.get_the_game.title": "Start Horizon from the launcher",
"horizon.get_the_game.about": "This is the game the Horizon launcher installs and starts. On its own it has none of Minecraft's files, so it can't start.",
"horizon.get_the_game.minecraft": "Horizon needs your own copy of Minecraft: Java Edition. The launcher signs you in and prepares the game files from it.",
"horizon.get_the_game.download": "Download the launcher at %s, then press Play there.",
"horizon.get_the_game.button": "Download the launcher",
"horizon.extras.header.world": "World",
"horizon.extras.pregen": "Chunk pregeneration",
"horizon.extras.pregen.tooltip": "Enables /pregen in your single-player and LAN worlds. With cheats enabled, use /pregen start to generate an area before exploring it. Does not enable the command on servers you join.",
@@ -1208,6 +1208,7 @@ pub fn main() -> horizon_client::Result<()> {
dragon: None,
leash_boats: &[],
who_lookup: None,
foreign: None,
others_grid: $grid,
others_index: None,
others_villagers: None,
+1
View File
@@ -8,6 +8,7 @@ pub mod animate_tick;
pub mod arrows;
pub mod bed_geometry;
pub mod block_contact;
pub mod parallel;
pub mod powder_snow;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, autotune, ban_notice, bidi, biome, block_edit,
+2 -104
View File
@@ -84,7 +84,9 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
mod fluids;
mod item_fall;
mod item_no_physics;
mod obstacles;
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
pub use obstacles::ObstacleGrid;
#[cfg(test)]
mod drop_motion_tests {
@@ -1111,110 +1113,6 @@ fn lava_cauldron_contacts(
}
contacts
}
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
/// many vehicles checks a handful of boxes per item, not every box in the level.
#[derive(Default)]
pub struct ObstacleGrid {
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
boxes: Vec<Aabb>,
/// Each box's entity's `getRootVehicle` when it rides something; empty
/// when none was given.
roots: Vec<Option<u32>>,
found: Vec<u32>,
}
impl ObstacleGrid {
const CELL: f64 = 8.0;
pub fn new(boxes: Vec<Aabb>) -> Self {
Self::with_roots(boxes, Vec::new())
}
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
/// mover's own riders out.
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
for (i, b) in boxes.iter().enumerate() {
let (lo, hi) = Self::span(*b);
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Self {
cells,
boxes,
roots,
found: Vec::new(),
}
}
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
let cell = |v: Vec3| {
(
(v.x / Self::CELL).floor() as i32,
(v.y / Self::CELL).floor() as i32,
(v.z / Self::CELL).floor() as i32,
)
};
(cell(b.min_position()), cell(b.max_position()))
}
pub fn is_empty(&self) -> bool {
self.boxes.is_empty()
}
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
self.near_except(area, None, out);
}
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
out.clear();
if self.boxes.is_empty() {
return;
}
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
let (lo, hi) = Self::span(area);
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
let cells = side(lo.0, hi.0)
.saturating_mul(side(lo.1, hi.1))
.saturating_mul(side(lo.2, hi.2));
// An area over more cells than there are boxes (a stand loaded with a
// huge Motion asks for one millions of blocks long): test each box
// once rather than walk the cells.
if cells > self.boxes.len() as i64 {
out.extend(
self.boxes
.iter()
.enumerate()
.filter(|&(i, b)| kept(i) && b.intersects(area))
.map(|(_, b)| *b),
);
return;
}
self.found.clear();
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(list) = self.cells.get(&(x, y, z)) {
self.found.extend(list);
}
}
}
}
self.found.sort_unstable();
self.found.dedup();
for &i in &self.found {
let b = self.boxes[i as usize];
if kept(i as usize) && b.intersects(area) {
out.push(b);
}
}
}
}
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
/// search around an item. Holds indexes into the tick's item list.
#[derive(Default)]
@@ -0,0 +1,122 @@
//! The boxes that stop a dropped item, by cell (`ObstacleGrid`).
use crate::fast_hash::FxHashMap;
use horizon_core::geometry::{Aabb, Vec3};
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
/// many vehicles checks a handful of boxes per item, not every box in the level.
#[derive(Default)]
pub struct ObstacleGrid {
cells: FxHashMap<(i32, i32, i32), Vec<u32>>,
boxes: Vec<Aabb>,
/// Each box's entity's `getRootVehicle` when it rides something; empty
/// when none was given.
roots: Vec<Option<u32>>,
found: Vec<u32>,
}
impl ObstacleGrid {
const CELL: f64 = 8.0;
pub fn new(boxes: Vec<Aabb>) -> Self {
Self::with_roots(boxes, Vec::new())
}
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
/// mover's own riders out.
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
let mut cells: FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
for (i, b) in boxes.iter().enumerate() {
let (lo, hi) = Self::span(*b);
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Self {
cells,
boxes,
roots,
found: Vec::new(),
}
}
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
let cell = |v: Vec3| {
(
(v.x / Self::CELL).floor() as i32,
(v.y / Self::CELL).floor() as i32,
(v.z / Self::CELL).floor() as i32,
)
};
(cell(b.min_position()), cell(b.max_position()))
}
pub fn is_empty(&self) -> bool {
self.boxes.is_empty()
}
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
self.near_except(area, None, out);
}
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
let mut found = std::mem::take(&mut self.found);
self.near_with(area, vehicle, &mut found, out);
self.found = found;
}
/// [`Self::near`] with the caller's scratch, so several threads can ask one grid.
pub fn near_shared(&self, area: Aabb, found: &mut Vec<u32>, out: &mut Vec<Aabb>) {
self.near_with(area, None, found, out);
}
fn near_with(
&self,
area: Aabb,
vehicle: Option<u32>,
found: &mut Vec<u32>,
out: &mut Vec<Aabb>,
) {
out.clear();
if self.boxes.is_empty() {
return;
}
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
let (lo, hi) = Self::span(area);
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
let cells = side(lo.0, hi.0)
.saturating_mul(side(lo.1, hi.1))
.saturating_mul(side(lo.2, hi.2));
// An area over more cells than there are boxes (a stand loaded with a
// huge Motion asks for one millions of blocks long): test each box
// once rather than walk the cells.
if cells > self.boxes.len() as i64 {
out.extend(
self.boxes
.iter()
.enumerate()
.filter(|&(i, b)| kept(i) && b.intersects(area))
.map(|(_, b)| *b),
);
return;
}
found.clear();
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(list) = self.cells.get(&(x, y, z)) {
found.extend(list);
}
}
}
}
found.sort_unstable();
found.dedup();
for &i in found.iter() {
let b = self.boxes[i as usize];
if kept(i as usize) && b.intersects(area) {
out.push(b);
}
}
}
}
+27 -8
View File
@@ -39,8 +39,6 @@ pub fn conversion_tags(raw: Option<&Tag>) -> Option<Tag> {
fn string(s: &str) -> Tag {
Tag::String(s.into())
}
mod refs;
pub use refs::who_by_uuid;
/// Entity type ID (`id`).
pub fn kind(tag: &Tag) -> Option<String> {
horizon_client_core::identifier::normalize_id(&text(tag.get("id"))?)
@@ -286,6 +284,28 @@ pub fn migrate_player_uuid(tag: &mut Tag, old: [i32; 4], new: [i32; 4]) {
_ => {}
}
}
/// The entity a stored UUID names, while it is in the level: `player` (the
/// player's own UUID, when one is in the level), or the mob `uuids` gives that
/// UUID to. `uuids` is the game layer's id -> UUID map, so this is the read half
/// of the pair [`who_uuid`] and `mob_to_nbt`'s `uuid_of` form. A UUID nothing
/// loaded has, or one naming a kind `Who` cannot carry (an item, a projectile, a
/// guest), is `None` - never some other entity as a stand-in.
pub fn who_by_uuid(
uuids: &HashMap<u32, [i32; 4]>,
mobs: &[Mob],
player: Option<[i32; 4]>,
uuid: [i32; 4],
) -> Option<Who> {
if player == Some(uuid) {
return Some(Who::Player);
}
// Only a loaded mob: the map also holds projectiles, vehicles and items,
// whose ids mean nothing to `Who`.
uuids
.iter()
.find(|(id, u)| **u == uuid && mobs.iter().any(|m| m.id == **id))
.map(|(id, _)| Who::Mob(*id))
}
/// `AngerManagement.SUSPECT_ANGER_PAIR`: one suspect's UUID and how angry the
/// warden is at it.
fn suspect_anger(uuid: [i32; 4], anger: i32) -> Tag {
@@ -1237,11 +1257,11 @@ pub fn mob_to_nbt(
));
}
// ITEM_PICKUP_COOLDOWN_TICKS (`Codec.INT`), which a throw sets to 60,
// while the memory is there (its value 0 included).
if let Some(ticks) = crate::mobs::allay::pickup_cooldown(m) {
// while it runs.
if m.extra.ints[3] > 0 {
memories.push((
"minecraft:item_pickup_cooldown_ticks",
Tag::compound([("value", Tag::Int(ticks))]),
Tag::compound([("value", Tag::Int(m.extra.ints[3]))]),
));
}
// Always written, so a forgotten note block does not come back
@@ -2043,7 +2063,6 @@ pub fn mob_from_nbt_with_random(
m.extra.death_loot_table = text(tag.get("DeathLootTable"))
.and_then(|t| horizon_client_core::identifier::normalize_id(&t));
m.extra.death_loot_seed = number(tag.get("DeathLootTableSeed")).unwrap_or(0);
refs::read_last_hurt_by(&mut m, tag);
// `NeutralMob.readPersistentAngerSaveData`: the anger end time and the
// target come back with the mob.
if persistent_anger(m.species()) {
@@ -2469,8 +2488,8 @@ pub fn mob_from_nbt_with_random(
};
m.extra.ints[2] =
number(memory("minecraft:liked_noteblock_cooldown_ticks")).unwrap_or(0) as i32;
let pickup = number(memory("minecraft:item_pickup_cooldown_ticks")).map(|t| t as i32);
crate::mobs::allay::set_pickup_cooldown(&mut m, pickup);
m.extra.ints[3] =
number(memory("minecraft:item_pickup_cooldown_ticks")).unwrap_or(0) as i32;
if let Some(st) = m.extra.allay.as_mut()
&& let Some(noteblock) = memory("minecraft:liked_noteblock")
&& let Some(Tag::IntArray(p)) = noteblock.get("pos")
@@ -1,85 +0,0 @@
//! `EntityReference`s a mob's compound holds: a UUID, resolved against what is in
//! the level.
use super::{Who, number, uuid_from_tag};
use crate::mobs::Mob;
use horizon_world::nbt::Tag;
use std::collections::HashMap;
/// `LivingEntity.readAdditionalSaveData`: `last_hurt_by_mob` is a lazy
/// `EntityReference` (the UUID, which the game resolves once the entity is in
/// the level) and its timestamp is `tickCount - ticks_since_last_hurt_by_mob`.
pub(super) fn read_last_hurt_by(m: &mut Mob, tag: &Tag) {
m.ai.last_hurt_by_ref = uuid_from_tag(tag.get("last_hurt_by_mob"));
m.ai.last_hurt_by_time = (m.age as i32)
.wrapping_sub(number(tag.get("ticks_since_last_hurt_by_mob")).unwrap_or(0) as i32);
}
/// The entity a stored UUID names, while it is in the level: `player` (the
/// player's own UUID, when one is in the level), or the mob `uuids` gives that
/// UUID to. `uuids` is the game layer's id -> UUID map, so this is the read half
/// of the pair [`who_uuid`] and `mob_to_nbt`'s `uuid_of` form. A UUID nothing
/// loaded has, or one naming a kind `Who` cannot carry (an item, a projectile, a
/// guest), is `None` - never some other entity as a stand-in.
pub fn who_by_uuid(
uuids: &HashMap<u32, [i32; 4]>,
mobs: &[Mob],
player: Option<[i32; 4]>,
uuid: [i32; 4],
) -> Option<Who> {
if player == Some(uuid) {
return Some(Who::Player);
}
// Only a loaded mob: the map also holds projectiles, vehicles and items,
// whose ids mean nothing to `Who`.
uuids
.iter()
.find(|(id, u)| **u == uuid && mobs.iter().any(|m| m.id == **id))
.map(|(id, _)| Who::Mob(*id))
}
#[cfg(test)]
mod tests {
use super::super::*;
use crate::mobs::{Species, sensor_scan_tests::kind};
fn types() -> HashMap<String, std::sync::Arc<crate::mobs::MobType>> {
let mut pig = (*kind()).clone();
pig.id = "minecraft:pig".into();
pig.species = Species::Pig;
HashMap::from([("minecraft:pig".to_owned(), std::sync::Arc::new(pig))])
}
fn load(snbt: &str) -> Mob {
let tag = horizon_world::nbt::parse_snbt(snbt).unwrap().0;
mob_from_nbt(&tag, &types(), 7, None).unwrap()
}
/// `LivingEntity.readAdditionalSaveData`: `lastHurtByMob` is the UUID,
/// `lastHurtByMobTimestamp = tickCount - ticks_since_last_hurt_by_mob`, and a
/// freshly loaded entity has `tickCount` 0, so 30 saved ticks make -30.
#[test]
fn a_saved_hurt_by_mob_is_read_back_as_a_reference_and_a_timestamp() {
let m = load(
r#"{id:"minecraft:pig",UUID:[I;5,0,0,1],Pos:[2.5d,200d,4.5d],Health:10f,last_hurt_by_mob:[I;5,0,0,2],ticks_since_last_hurt_by_mob:30}"#,
);
assert_eq!(m.age, 0);
assert_eq!(m.ai.last_hurt_by_ref, Some([5, 0, 0, 2]));
assert_eq!(m.ai.last_hurt_by_time, -30);
// Not resolved yet: `getLastHurtByMob` answers nothing.
assert_eq!(m.ai.last_hurt_by, None);
}
/// Missing keys: no reference, and `getIntOr("ticks_since...", 0)` stamps the
/// timestamp at `tickCount`.
#[test]
fn a_save_without_a_hurt_by_mob_has_none() {
let m = load(r#"{id:"minecraft:pig",UUID:[I;5,0,0,1],Pos:[2.5d,200d,4.5d],Health:10f}"#);
assert_eq!(m.ai.last_hurt_by_ref, None);
assert_eq!(m.ai.last_hurt_by_time, 0);
// A key that is not a UUID int array is no reference (the codec fails).
let m = load(
r#"{id:"minecraft:pig",UUID:[I;5,0,0,1],Pos:[2.5d,200d,4.5d],Health:10f,last_hurt_by_mob:"x"}"#,
);
assert_eq!(m.ai.last_hurt_by_ref, None);
}
}
+2 -2
View File
@@ -758,6 +758,7 @@ enum GuestUse {
/// an entity again from the view.
Entity(Hand, InteractTarget),
}
/// The entity an interact packet names and where its client clicked it (the
/// packet's location, measured from the entity's position): what
/// `ServerGamePacketListenerImpl.handleInteract` takes from
@@ -8481,7 +8482,6 @@ impl Game<'_> {
None,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.vsync,
&self.settings.horizon,
shader_packs::picked_pack_shape(&self.settings),
display,
@@ -12462,7 +12462,7 @@ pub fn run_with() -> Result<()> {
horizon_world::datafix::set_resources(
&std::path::absolute(&vanilla_root).unwrap_or_else(|_| vanilla_root.clone()),
);
if let Some(error) = missing_resources(&vanilla_root, horizon_hooks::engine::get_the_game()) {
if let Some(error) = missing_resources(&vanilla_root) {
// The game build started by hand (the release's zip), not by the
// launcher: a window says where to get it. Scripts and servers get
// the error.
@@ -4,7 +4,6 @@
//! same on vanilla 26.3).
use super::*;
use horizon_client::chunk::Chunk;
use horizon_client::mobs::allay;
use horizon_core::position::ChunkPos;
use horizon_world::nbt::Tag;
use std::sync::Arc;
@@ -85,7 +84,7 @@ fn an_allay_keeps_its_item_pickup_cooldown_memory() {
let mut game = game();
let i = index(&mut game);
// The supplied memory is loaded into the pickup cooldown and saved back.
assert_eq!(allay::pickup_cooldown(&game.mobs[i]), Some(30), "loaded");
assert_eq!(game.mobs[i].extra.ints[3], 30, "loaded");
assert_eq!(
saved(&mut game, i),
Some(Tag::compound([("value", Tag::Int(30))])),
@@ -101,53 +100,6 @@ fn an_allay_keeps_its_item_pickup_cooldown_memory() {
Some(Tag::compound([("value", Tag::Int(20))])),
"after 10 ticks"
);
// CountDownCooldownTicks: the memory reaches 0 and is erased a tick later
// (`stop`).
step(&mut game, 20);
assert_eq!(
saved(&mut game, i),
Some(Tag::compound([("value", Tag::Int(0))])),
"at 0"
);
step(&mut game, 1);
assert_eq!(saved(&mut game, i), None, "erased the tick after");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn pickup_cooldown_saves_the_full_integer_value() {
let mut game = game();
let i = index(&mut game);
assert!(game.run_command(&format!(
"item replace entity {SUPPLIED} weapon.mainhand with minecraft:diamond 1"
)));
for ticks in [i32::MIN, -1, 0, 1, i32::MAX] {
allay::set_pickup_cooldown(&mut game.mobs[i], Some(ticks));
assert!(
!game.mobs[i].can_pick_up_loot(),
"memory present at {ticks}"
);
assert_eq!(
saved(&mut game, i),
Some(Tag::compound([("value", Tag::Int(ticks))])),
"saved {ticks}"
);
let tag = game.mob_tree_nbt(i);
let loaded = horizon_client::entity_nbt::mob_from_nbt(
&tag,
game.res.mob_types(),
game.mobs[i].id,
None,
)
.unwrap();
assert_eq!(
allay::pickup_cooldown(&loaded),
Some(ticks),
"loaded {ticks}"
);
}
allay::set_pickup_cooldown(&mut game.mobs[i], None);
assert!(game.mobs[i].can_pick_up_loot(), "memory erased");
}
// Added controls (after the frozen baseline above).
@@ -183,10 +135,10 @@ fn the_saved_pickup_cooldown_follows_the_model() {
assert_eq!(saved(&mut game, plain), None, "no raw memory");
// A loaded memory the model has ended: no key (the raw copy is not echoed).
let supplied = index(&mut game);
allay::set_pickup_cooldown(&mut game.mobs[supplied], None);
game.mobs[supplied].extra.ints[3] = 0;
assert_eq!(saved(&mut game, supplied), None, "stale raw memory");
// A model mutation, as a throw does (`allays.rs`): saved with its value.
allay::set_pickup_cooldown(&mut game.mobs[plain], Some(60));
game.mobs[plain].extra.ints[3] = 60;
let tag = saved(&mut game, plain);
assert_eq!(
tag,
@@ -197,11 +149,7 @@ fn the_saved_pickup_cooldown_follows_the_model() {
let whole = game.mob_tree_nbt(plain);
let reloaded =
horizon_client::entity_nbt::mob_from_nbt(&whole, game.res.mob_types(), 99, None).unwrap();
assert_eq!(
allay::pickup_cooldown(&reloaded),
Some(60),
"load after save"
);
assert_eq!(reloaded.extra.ints[3], 60, "load after save");
// NoAI: no Brain tick, the value stays.
let frozen = at(&mut game, 2);
step(&mut game, 5);
+1 -1
View File
@@ -288,7 +288,7 @@ impl Game<'_> {
});
let m = &mut self.mobs[i];
m.extra.allay.as_mut().unwrap().take(1);
allay::set_pickup_cooldown(m, Some(60));
m.extra.ints[3] = 60;
// ItemEntity.setDefaultPickUpDelay.
self.spawn_item(stack, from, v, 10);
// BehaviorUtils.throwItem: ItemEntity.setThrower(the allay).
@@ -239,6 +239,19 @@ impl Game<'_> {
self.tick_scheduled()?;
}
self.with_level(|b, level, ticks| {
// The masks the random ticks will read, built on helper threads first while the
// parallel tick is on (`HORIZON_PARALLEL_TICK`): the draws and ticks don't depend on them.
if !debug && horizon_client::parallel::lanes() > 1 {
horizon_client::spans::time("random_tick_masks", || {
let chunks = &level.world.chunks;
level.random_tick_sections.prepare_masks(
&scratch.ticking,
|key| chunks.get(&key).cloned(),
level.facts,
&|id| b.random_tick_candidate(BlockStateId(id)),
);
});
}
horizon_client::spans::time("random_ticks", || {
for (x, z) in scratch.ticking.drain(..).filter(|_| !debug) {
// The last answer, asked from Y `from` at a world revision:
+28 -10
View File
@@ -682,19 +682,14 @@ impl Game<'_> {
/// of it (`ClipContext.Block.FALLDAMAGE_RESETTING`, `ClipContext.Fluid.WATER`)
/// and a hit resets the fall, before the move's own drop counts.
pub(super) fn vehicle_fall_reset(&self, fall: f64, from: Vec3, moved: Vec3) -> f64 {
let length = moved.length_squared();
if fall == 0.0 || length < 1.0 {
return fall;
}
let reset = &self.res.motion().fall_reset;
let hit = horizon_client::entities::fall_reset_hit(
fall_reset(
&self.world,
&self.res.motion().fluids,
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
self.res.motion(),
self.res.facts(),
fall,
from,
moved,
);
if hit { 0.0 } else { fall }
)
}
/// `AbstractBoat.checkFallDamage` after a move that went `moved_y` (with
/// `b` already where it landed): on the ground the fall resets; a move
@@ -2082,3 +2077,26 @@ mod tests {
}
}
horizon_client::impl_joined!(Boat);
/// [`Game::vehicle_fall_reset`] from the tables it reads.
pub(super) fn fall_reset(
world: &horizon_client::world::World,
motion: &horizon_client::motion::MotionResources,
facts: &horizon_client::blocks::BlockFacts,
fall: f64,
from: Vec3,
moved: Vec3,
) -> f64 {
if fall == 0.0 || moved.length_squared() < 1.0 {
return fall;
}
let reset = &motion.fall_reset;
let hit = horizon_client::entities::fall_reset_hit(
world,
&motion.fluids,
|p| reset.contains(&*facts.get(world.block(p.x, p.y, p.z)).name),
from,
moved,
);
if hit { 0.0 } else { fall }
}
-48
View File
@@ -737,54 +737,6 @@ fn damage_taken_award(amount: f32) -> Option<i32> {
(amount < 3.402_823_5e37).then(|| horizon_core::jmath::round_f32(amount * 10.0))
}
/// Hits one mob's tick deals another, handled after the mob loop.
impl Game<'_> {
/// A melee hit on another mob, then `postAttack`; whether it landed.
pub(super) fn mob_attacks_mob(
&mut self,
target: u32,
amount: f32,
source: horizon_client::mobs::Damage,
) -> bool {
let hurt = self.mob_hits_mob(target, amount, source);
if hurt {
self.mob_post_attack(
target,
source.attacker,
None,
Some(super::creatures::damage_type(source.kind, false)),
);
}
hurt
}
/// Whether a hit from a mob's tick on `target`, handled after the mob
/// loop, comes after a tick of the target that vanilla runs after the hit:
/// `ServerLevel.tick` ticks entities in list order and the attacker deals
/// the hit in its own tick, so a target later in the list counts its
/// `HurtTime` and invulnerability down once more that tick. PARTIAL: the
/// rest of that tick (its movement, its own goals) still ran before the hit.
/// The caller compensates only newly reset timers: a stronger hit during
/// cooldown leaves timers that already counted down this tick unchanged.
pub(super) fn catch_up_countdowns(
&self,
attacker: Option<horizon_client::mobs::Who>,
target: u32,
) -> bool {
let Some(horizon_client::mobs::Who::Mob(attacker)) = attacker else {
return false;
};
let ticked = &self.creatures.ticked;
if !self.creatures.deferred_hits || !ticked.contains(&attacker) || !ticked.contains(&target)
{
return false;
}
// The mobs added during the tick come first in `self.mobs`; the
// ticked ones keep their tick order after them.
let at = |id: u32| self.mobs.iter().position(|m| m.id == id);
matches!((at(attacker), at(target)), (Some(a), Some(t)) if a < t)
}
}
#[cfg(test)]
mod damage_taken_tests {
use super::damage_taken_award;
@@ -274,8 +274,6 @@ impl Game<'_> {
let command = op["cmd"].as_str().unwrap_or("");
self.chat_capture = Some(Vec::new());
self.cmds.results = Some(Vec::new());
let earlier = self.adv.announced.len();
self.cmds.broadcast_announced = earlier;
self.harness_owner(|g| {
let previous = g.command_source;
if op["source"].as_str() == Some("console") {
@@ -292,24 +290,7 @@ impl Game<'_> {
g.command_source = previous;
Ok(())
})?;
// What the command's own `broadcastChanges` earned is announced
// before its feedback, as a guest's command sends it
// (`announce_guest_advancements`); the rest is left for the tick.
let earned: Vec<_> = {
let len = self.adv.announced.len();
let end = self.cmds.broadcast_announced.clamp(earlier.min(len), len);
self.adv.announced.drain(earlier.min(len)..end).collect()
};
if let Some(host) = self.lan.as_mut() {
for text in &earned {
host.hub
.system_message(horizon_client::lan::data::component_nbt(
&text.component(),
));
}
}
let mut messages = earned;
messages.extend(self.chat_capture.take().unwrap_or_default());
let messages = self.chat_capture.take().unwrap_or_default();
let results = self.cmds.results.take().unwrap_or_default();
if op["record"].as_bool() != Some(false) || op["trace"].as_bool() == Some(true)
{
@@ -399,7 +380,7 @@ impl Game<'_> {
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::lan::mob_meta(
m,
hub,
hub.sync_data(),
None,
g.world.game_time,
&mut meta,
@@ -147,11 +147,6 @@ pub(in crate::game) struct CommandExtras {
/// The `CommandResultCallback` results of the outermost source, when a
/// harness records them.
pub results: Option<Vec<(bool, i32)>>,
/// How many of `adv.announced` a command's own `broadcastChanges` had
/// made: the harness records those with the command, and leaves what
/// `run_command`'s check after it earns for the next tick, as vanilla
/// does for a command that calls no `broadcastChanges` (`/loot give`).
pub broadcast_announced: usize,
/// The running `/pregen` task.
pub pregen: Option<super::pregen::PregenTask>,
}
@@ -1424,13 +1424,6 @@ impl Game<'_> {
}
let h = self.owner_holder(o);
tracker.track(&h, changed);
// EntityItemAccessor: a player whose slots changed gets
// `containerMenu.broadcastChanges` at once, so InventoryChangeTrigger
// (and its announcement) comes before the command's message.
if changed > 0 && matches!(o, Owner::Entity(Target::Player)) && self.player.is_some() {
self.check_inventory_trigger();
self.cmds.broadcast_announced = self.adv.announced.len();
}
}
if selected == 0 {
return Err(
+106 -288
View File
@@ -4,6 +4,8 @@
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod bred_draws;
mod mob_geometry;
mod mob_group;
mod prep;
mod projectile_world;
mod pufferfish_player;
mod spawn_eggs;
@@ -123,8 +125,6 @@ pub(super) struct Creatures {
/// what the passes after the loop do for a mob's `aiStep`, goals or brain
/// (pickups, item searches) is for these alone.
pub ticked: horizon_client::fast_hash::FxHashSet<u32>,
/// While the mob loop's own events are handled (`catch_up_countdowns`).
pub deferred_hits: bool,
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
/// When each player last put a parrot on a shoulder (`timeEntitySatOnShoulder`).
@@ -1051,18 +1051,11 @@ impl Game<'_> {
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.items()),
lid_openers: &self.lid_openers,
mod_effects: if self.remote.is_none() {
Some(self)
} else {
None
},
mod_effects: self.remote.is_none().then_some(self as _),
// A warden's away suspects are UUIDs: on the server side, where the
// entity UUIDs live, they resolve back to the mobs that are here.
who_lookup: if self.remote.is_none() {
Some(self)
} else {
None
},
who_lookup: self.remote.is_none().then_some(self as _),
foreign: None,
border: Some(self.border.geometry()),
// The first player in the level: a server has no player of its own.
difficulty: self.local_difficulty(
@@ -1148,8 +1141,8 @@ impl Game<'_> {
/// `DistanceManager.inEntityTickingRange` in an entity-ticking chunk
/// (block coordinates), from every player's tickets (`update_chunk_tickets`).
pub(super) fn entity_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
let levels = self.tickets.levels();
move |x, z| levels.entity_ticking_at(x, z)
self.tickets
.entity_ticking_area(&self.world.chunks, self.remote.is_none())
}
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
/// projectiles and effects that follow from them.
@@ -1168,9 +1161,6 @@ impl Game<'_> {
self.died.insert(m.id);
self.mob_died(&m);
}
// A `last_hurt_by_mob` read from a save names its attacker once that
// entity is here, before the mobs' `baseTick`s.
self.resolve_last_hurt_by_refs();
// Their victims have been looted and mourned: no charged creeper is
// waiting any more.
self.combat.charged_exploders.clear();
@@ -1193,50 +1183,32 @@ impl Game<'_> {
self.init_sulfur_attribute_maps();
let uuid = self.level.player_uuid;
let level_id = self.dim.id();
// LivingEntity.isPushable reads onClimbable live; a mob's cached one
// is only refreshed by its own push step, so one that has not ticked
// yet (the first tick after a load, a fresh spawn) or has moved since
// would be pushed out of a ladder or scaffolding. Refreshed here, from
// where each mob stands now, before the snapshot others push it by.
let climbing: Vec<bool> = {
let w = self.mob_world_base(&[]);
self.mobs.iter().map(|m| m.on_climbable(&w)).collect()
};
for (m, climbing) in self.mobs.iter_mut().zip(climbing) {
m.on_climbable = climbing;
}
// Each mob's climbing, refreshed, and its snapshot entry (creatures/prep.rs).
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
self.prepare_mobs(uuid, &mut snapshot);
let mut events = std::mem::take(&mut self.creatures.event_buf);
events.clear();
// SulfurCube.primeWhenOnPoweredPosition runs before super.tick adopts
// the body: the cubes whose adopted body explodes, as this tick starts.
let primable = self.sulfur_cubes_adopted_explosive();
let mut mobs = std::mem::take(&mut self.mobs);
let mobs = std::mem::take(&mut self.mobs);
// Each mob's target as the mods last heard it, by its place in `mobs`
// (`native_living_change_target`).
let mut targets = std::mem::take(&mut self.creatures.target_buf);
targets.clear();
targets.extend(mobs.iter().map(|m| m.ai.target));
let mut dead = Vec::new();
let mut corpses = Vec::new();
let mut died = std::mem::take(&mut self.died);
let died = std::mem::take(&mut self.died);
let mut ticked = std::mem::take(&mut self.creatures.ticked);
ticked.clear();
let ticking = self.entity_ticking_area();
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
let mut guests = self.lan_guest_views();
// What stands in a guest's slot while a mob holds that guest's view for
// its tick (see `lent` below).
let mut spare = guests.first().map(|g| g.1.clone());
let players = self.level_players();
let nearest = super::natural_spawning::NearestPlayers::new(&players);
let mut bindings = match self.lan.as_mut() {
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
let guests = self.lan_guest_views();
let bindings = match self.lan.as_mut() {
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
_ => Default::default(),
};
// The guests' entity ids by connection, for the shulker bullets fired
// at the guest a mob sees.
let guest_ids: Vec<(u32, u32)> = match self.lan.as_ref() {
Some(host) if !guests.is_empty() => host
.hub
@@ -1246,23 +1218,9 @@ impl Game<'_> {
.collect(),
_ => Vec::new(),
};
let mut guest_events = Vec::new();
// The block writes since the last tick: mobs re-path around them.
let path_writes = std::mem::take(self.world.path_writes.get_or_insert_with(Vec::new));
// The mobs whose bodies go this tick: (id, feet, width, height, split).
// 🤖 Agent: the last field is the grown slime, magma cube or sulfur
// cube the body leaves behind (`AbstractCubeMob.remove`), captured as
// the body goes because it has left the list by the time it splits.
let mut gone = Vec::new();
let level_random;
{
// The level random, lent to the mobs (brain behaviours draw from
// it) and taken back after their ticks.
let mut scratch = self.world.random.clone();
let mut pushes = horizon_client::mobs::PendingPushes::default();
let mut keep = Vec::with_capacity(mobs.len());
let mut grid =
horizon_client::mobs::seen_grid::SeenGrid::new(snapshot.iter().map(|s| s.bounds));
let group = {
// All but the snapshot is the same for every mob of the tick, so
// build it once: the player view and the teams scan every UUID.
self.leash_boats = self
@@ -1272,87 +1230,52 @@ impl Game<'_> {
.collect();
let mut base = self.mob_world_base(&[]);
let path_changes = horizon_client::mobs::collision_changes(&base, &path_writes);
// Each mob keeps its snapshot slot all tick; the first slot of an
// id answers, as a scan would.
let mut index = horizon_client::fast_hash::FxHashMap::with_capacity_and_hasher(
snapshot.len(),
Default::default(),
);
let mut villager_slots = Vec::new();
let mut solid_slots = Vec::new();
for (i, s) in snapshot.iter().enumerate() {
index.entry(s.id).or_insert(i as u32);
if s.species == horizon_client::mobs::Species::Villager {
villager_slots.push(i as u32);
}
if s.may_block_mobs() {
solid_slots.push(i as u32);
}
}
laps.lap("creature_prepare");
let single_player = horizon_client::mobs::single_player_pos(
players.iter().map(|p| (p.position, p.spectator)),
);
let nearest = prep::MobsNearest::new(&nearest, &mobs, single_player.is_none());
let mut despawn_w = base.despawn_view();
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
for i in 0..mobs.len() {
let m = &mut mobs[i];
let (bx, bz) = (m.position.x.floor() as i32, m.position.z.floor() as i32);
let seen_guest = if guests.is_empty() {
None
} else {
let bound = bindings.get(&m.id).copied();
let conn = super::lan_mobs::player_for(m, base.player.as_ref(), &guests, bound);
match conn {
Some(conn) => {
bindings.insert(m.id, conn);
}
None => {
bindings.remove(&m.id);
}
}
conn
};
let shared = mob_group::Shared {
uuid,
level_id,
path_changes: &path_changes,
guest_ids: &guest_ids,
};
let start = mob_group::Start {
events,
// The level random, lent to the mobs and taken back after their ticks.
random: self.world.random.clone(),
guests,
player: base.player.take(),
bindings,
died,
ticked,
targets,
};
let mut group = mob_group::Group::new(mobs.into(), snapshot, start);
for i in 0..group.mobs.len() {
let position = group.mobs[i].position;
let (bx, bz) = (position.x.floor() as i32, position.z.floor() as i32);
let seen_guest = group.bind_guest(i);
if !base.world.loaded(bx, bz) {
keep.push(!m.hostile() || m.persistent);
group.unloaded(i);
continue;
}
let nearest_player = single_player.unwrap_or_else(|| {
nearest
.nearest(m.position.x, m.position.y, m.position.z)
.map(|p| p.position)
});
let nearest_player = single_player.unwrap_or_else(|| nearest.at(i, position));
// ServerLevel.tick: every loaded entity checks despawning, but
// only those in entity-ticking chunks (within the simulation
// distance) tick. The despawn check reads the nearest player
// and the difficulty, not the player the mob sees.
// distance) tick.
if !ticking(bx, bz) {
despawn_w.nearest_player = nearest_player;
let kept = horizon_client::mobs::ai::despawn_check(m, &despawn_w);
if !kept {
died.remove(&m.id);
snapshot[i].alive = false;
}
keep.push(kept);
group.rest(i, &despawn_w);
continue;
}
let controlling_player_movement = horizon_client::mobs::equine::controlled(m)
.then(|| {
base.player
.as_ref()
.filter(|p| p.riding_vehicle == Some(m.id))
.map(|p| p.velocity)
.or_else(|| {
guests
.iter()
.find(|(_, p)| p.riding_vehicle == Some(m.id))
.map(|(_, p)| p.velocity)
})
})
.flatten();
let controlling = group.controlling(i);
// A mod canceled its tick: it stays as it is.
if round.canceled(m.id) {
keep.push(true);
if round.canceled(group.mobs[i].id) {
group.keep.push(true);
continue;
}
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
@@ -1362,175 +1285,34 @@ impl Game<'_> {
Some((key, difficulty)) if key == chunk_key => difficulty,
_ => {
let difficulty =
self.local_difficulty([m.position.x, m.position.y, m.position.z]);
self.local_difficulty([position.x, position.y, position.z]);
cached_difficulty = Some((chunk_key, difficulty));
difficulty
}
};
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
horizon_client::mobs::blocks_changed(m, &path_changes);
let from = events.len();
// The guest's view is lent to the mob for its tick, not copied:
// the tick only reads it (`&MobWorld`), and it goes back to the
// guest's slot straight after.
let lent = seen_guest.and_then(|conn| guests.iter().position(|g| g.0 == conn));
let active_player = match (seen_guest, lent) {
(None, _) => base.player.take(),
(Some(_), Some(k)) => spare
.take()
.map(|stand_in| std::mem::replace(&mut guests[k].1, stand_in)),
(Some(_), None) => None,
};
let mut w = MobWorld {
others: &snapshot,
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
others_solid: Some(&solid_slots),
difficulty,
nearest_player,
player: active_player,
teams: std::sync::Arc::clone(&base.teams),
..base
};
w.controlling_player_movement = controlling_player_movement;
ticked.insert(mobs[i].id);
// `horizon-report`'s cost by mob type (tick_profile.rs).
let sampled = super::tick_profile::start().map(|t| (t, mobs[i].kind.clone()));
let kept =
horizon_client::mobs::riders::tick(&mut mobs, i, &w, &mut scratch, &mut events);
if let Some((started, kind)) = sampled {
super::tick_profile::add(level_id, &kind, started);
}
let m = &mut mobs[i];
if seen_guest.is_none() {
base.player = w.player;
} else if let Some(k) = lent {
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
}
// Its tick changed its target: the mods may keep the one it had
// (`native_living_change_target`), and then what the tick did to
// the new one is not dealt.
if let Some(&before) = targets.get(i)
&& m.ai.target != before
{
let named = target_named(
m.ai.target,
seen_guest,
&guests,
&guest_ids,
base.player.as_ref(),
);
if let Some((player, mob)) = named {
let kind = super::native_hooks::mob_kind(m);
let mut cancel = false;
Self::native_living_change_target(
level_id,
m.id,
&kind,
player,
mob,
&mut cancel,
);
if cancel {
if let Some(new) = m.ai.target {
let mut k = from;
while k < events.len() {
if hits_target(&events[k], new) {
events.remove(k);
} else {
k += 1;
}
}
}
m.ai.target = before;
}
}
targets[i] = m.ai.target;
}
// A killed mob's body is removed: its effects that act on
// death run (`LivingEntity.remove` with KILLED).
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
corpses.push(m.clone());
}
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
// LivingEntity.tickDeath: after twenty ticks the body is removed.
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
// 🤖 Agent: `AbstractCubeMob.remove` splits a grown cube into
// its smaller halves when it is removed, twenty ticks after
// the death. The parent leaves the list before the split
// runs, so its size, position, kind and persistence are
// captured here, as they are at the removal.
let split = if matches!(
m.species(),
Species::Slime | Species::MagmaCube | Species::SulfurCube
) && m.extra.size > 1
{
Some(m.clone())
} else {
None
};
gone.push((m.id, m.position, width, height, split));
}
if let Some(conn) = seen_guest {
// ShulkerBullet.finalTarget is the guest's entity: the
// bullet homes on it while the projectiles tick, where
// `Who::Player` is the host's own player.
if let Some(&(_, id)) = guest_ids.iter().find(|g| g.0 == conn) {
for e in &mut events[from..] {
if let MobEvent::Projectile(p) = e
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
&& *target == Some(Who::Player)
{
*target = Some(Who::Mob(id));
}
}
}
let mut k = from;
while k < events.len() {
if super::lan_mobs::acts_on_player(&events[k]) {
guest_events.push((conn, m.id, events.remove(k)));
} else {
k += 1;
}
}
}
// Killed during its own tick (drowning, burning, falling...).
if !m.alive() && !m.removed && died.insert(m.id) {
dead.push(m.clone());
}
if !kept {
died.remove(&m.id);
}
keep.push(kept);
// ServerLevel ticks entities one after another: the ones
// after this mob see it as it is now.
snapshot[i] = seen(m, uuid);
snapshot[i].alive &= kept;
grid.update(i, snapshot[i].bounds);
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
let player = (seen_guest, nearest_player, controlling);
group.tick(i, player, difficulty, &base, &shared);
}
// Targets changed after the mob's own tick (another mob's alert):
// heard now, and a cancel puts the old one back before its next tick.
for (i, m) in mobs.iter_mut().enumerate() {
let Some(&before) = targets.get(i) else {
for (i, m) in group.mobs.iter_mut().enumerate() {
let Some(&before) = group.targets.get(i) else {
break;
};
if m.ai.target == before {
continue;
}
let seen_guest = if guests.is_empty() {
let seen_guest = if group.guests.is_empty() {
None
} else {
bindings.get(&m.id).copied()
group.bindings.get(&m.id).copied()
};
let named = target_named(
m.ai.target,
seen_guest,
&guests,
&group.guests,
&guest_ids,
base.player.as_ref(),
group.player.as_ref(),
);
if let Some((player, mob)) = named {
let kind = super::native_hooks::mob_kind(m);
@@ -1549,13 +1331,29 @@ impl Game<'_> {
}
}
laps.lap("creature_loop");
let mut flags = keep.into_iter();
mobs.retain(|_| flags.next().unwrap_or(true));
for m in &mut mobs {
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
let mut flags = std::mem::take(&mut group.keep).into_iter();
group.mobs.retain(|_| flags.next().unwrap_or(true));
for m in &mut group.mobs {
horizon_client::mobs::apply_mob_pushes(m, &mut group.pushes);
}
level_random = scratch;
}
group
};
let mob_group::Group {
mut mobs,
mut snapshot,
mut events,
random: level_random,
guests,
mut bindings,
died,
ticked,
mut targets,
dead,
corpses,
gone,
guest_events,
..
} = group;
self.world.random = level_random;
if let Some(host) = self.lan.as_mut()
&& !guests.is_empty()
@@ -1571,7 +1369,7 @@ impl Game<'_> {
// first, as `append` put them; with none, the ticked list goes back
// as it is instead of being copied into a new one, every mob a tick.
if self.mobs.is_empty() {
self.mobs = mobs;
self.mobs = mobs.into();
} else {
self.mobs.append(&mut mobs);
}
@@ -1615,9 +1413,7 @@ impl Game<'_> {
let bounds = m.bounds();
self.effects_on_death(&names, bounds, false, Some(m.id), Some(&mut m.random));
}
self.creatures.deferred_hits = true;
let event_result = self.handle_mob_events_reusable(&mut events);
self.creatures.deferred_hits = false;
self.creatures.event_buf = events;
event_result?;
self.lan_guest_mob_events(guest_events);
@@ -2131,6 +1927,18 @@ impl Game<'_> {
}
}
/// `Mob.doHurtTarget` and the other melee callers of post-attack effects.
fn mob_attacks_mob(&mut self, target: u32, amount: f32, source: Damage) -> bool {
let hurt = self.mob_hits_mob(target, amount, source);
if hurt {
self.mob_post_attack(
target,
source.attacker,
None,
Some(damage_type(source.kind, false)),
);
}
hurt
}
/// Melee or ranged damage to another mob; whether the hit landed.
pub(super) fn mob_hits_mob(&mut self, target: u32, amount: f32, mut source: Damage) -> bool {
if source.attacker_species.is_none()
@@ -2141,19 +1949,12 @@ impl Game<'_> {
let mut killed = false;
let mut hurt = false;
let mut infested = Vec::new();
let catch_up = self.catch_up_countdowns(source.attacker, target);
let aim = self.attacker_aim(source.attacker);
// So `living_knockback_v2` sees who hit and what they hold.
let _attacker = self.knockback_scope_for(source.attacker);
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let previous_invulnerable = t.invulnerable;
let hit = t.damage_aimed(amount, source, aim);
hurt = hit;
if hit && catch_up && t.invulnerable > previous_invulnerable {
// `LivingEntity.baseTick`'s countdowns, run after the hit in vanilla.
t.hurt_time = (t.hurt_time - 1).max(0);
t.invulnerable = (t.invulnerable - 1).max(0);
}
if hit {
killed = !t.alive();
// The hurt or death sound the mob raised.
@@ -2225,6 +2026,22 @@ impl Game<'_> {
None => self.last_hurt_by_player.remove(&victim),
};
}
/// The player in the world with `uuid` (`EntityReference.getPlayer`), as
/// `last_hurt_by_player` keeps them: None for the player in place, else the
/// guest's connection.
fn player_with_uuid(&self, uuid: [i32; 4]) -> Option<Option<u32>> {
if self.player.is_some()
&& horizon_client::mobs::tame::owned_by(Some(uuid), self.acting_uuid())
{
return Some(self.lan_acting);
}
let uuid = horizon_client::lan::playerdata::ints_uuid(uuid);
self.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.find(|g| g.placed && g.uuid == uuid)
.map(|g| Some(g.conn))
}
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
/// sequence with the mob as `this` (a chicken's egg depends on its
/// variant); the loot data of the game's resources decides, and without it
@@ -8354,6 +8171,7 @@ mod pearl_tests {
dragon: None,
leash_boats: &[],
who_lookup: None,
foreign: None,
others_grid: None,
others_index: None,
others_villagers: None,
@@ -0,0 +1,338 @@
//! The mob loop of `tick_creature_entities` over a group of mobs, in their list order, with
//! everything the loop keeps for them: their snapshot and its indexes, the pushes and events
//! their ticks give, the level random they draw from, the players they see and what dies. The
//! serial loop is one group holding every mob; a group of the parallel mob tick (#14457) holds
//! the mobs no mob outside it can reach this tick.
use super::*;
use horizon_client::{
fast_hash::{FxHashMap, FxHashSet},
mobs::{MobSlot, PendingPushes, seen_grid::SeenGrid, spawning::LocalDifficulty},
};
use horizon_core::random::LegacyRandom;
/// What every mob of the loop reads and none writes.
pub(super) struct Shared<'a> {
pub uuid: Option<[i32; 4]>,
pub level_id: &'static str,
/// The blocks whose collision changed since the last tick: mobs re-path around them.
pub path_changes: &'a [[i32; 3]],
/// The guests' entity ids by connection, for the shulker bullets fired at the guest a mob
/// sees.
pub guest_ids: &'a [(u32, u32)],
}
/// A mob whose body goes this tick: (id, feet, width, height, the grown cube it splits into).
/// The last field is the grown slime, magma cube or sulfur cube the body leaves behind
/// (`AbstractCubeMob.remove`), captured as the body goes because it has left the list by the
/// time it splits.
pub(super) type Gone = (u32, Vec3, f64, f64, Option<Mob>);
/// A group's mobs and everything the loop keeps for them, in their list order.
pub(super) struct Group<M: MobSlot> {
pub mobs: Vec<M>,
/// Each mob's snapshot entry: as it was before the tick until its turn, as its turn left it
/// after.
pub snapshot: Vec<Seen>,
grid: SeenGrid,
/// The first slot of each id, as a scan would find it.
index: FxHashMap<u32, u32>,
villager_slots: Vec<u32>,
solid_slots: Vec<u32>,
pub pushes: PendingPushes,
pub events: Vec<MobEvent>,
/// The level random, lent to the mobs (brain behaviours draw from it).
pub random: LegacyRandom,
pub guests: super::super::lan_mobs::GuestViews,
/// What stands in a guest's slot while a mob holds that guest's view for its tick.
spare: Option<PlayerView>,
/// The host's own player, lent to each mob that sees it for its tick.
pub player: Option<PlayerView>,
/// The guest each mob sees, by mob id.
pub bindings: FxHashMap<u32, u32>,
pub died: FxHashSet<u32>,
pub ticked: FxHashSet<u32>,
/// Each mob's target as the mods last heard it (`native_living_change_target`).
pub targets: Vec<Option<Who>>,
/// Whether each mob stays in the list, pushed at its turn.
pub keep: Vec<bool>,
pub dead: Vec<Mob>,
pub corpses: Vec<Mob>,
pub gone: Vec<Gone>,
pub guest_events: Vec<(u32, u32, MobEvent)>,
}
/// What a group starts from besides its mobs and their snapshot.
pub(super) struct Start {
pub events: Vec<MobEvent>,
pub random: LegacyRandom,
pub guests: super::super::lan_mobs::GuestViews,
pub player: Option<PlayerView>,
pub bindings: FxHashMap<u32, u32>,
pub died: FxHashSet<u32>,
pub ticked: FxHashSet<u32>,
pub targets: Vec<Option<Who>>,
}
impl<M: MobSlot> Group<M> {
/// A group of `mobs`, whose snapshot entries are `snapshot`.
pub(super) fn new(mobs: Vec<M>, snapshot: Vec<Seen>, start: Start) -> Self {
let grid = SeenGrid::new(snapshot.iter().map(|s| s.bounds));
let mut index = FxHashMap::with_capacity_and_hasher(snapshot.len(), Default::default());
let (mut villager_slots, mut solid_slots) = (Vec::new(), Vec::new());
for (i, s) in snapshot.iter().enumerate() {
index.entry(s.id).or_insert(i as u32);
if s.species == Species::Villager {
villager_slots.push(i as u32);
}
if s.may_block_mobs() {
solid_slots.push(i as u32);
}
}
let n = mobs.len();
Self {
spare: start.guests.first().map(|g| g.1.clone()),
mobs,
snapshot,
grid,
index,
villager_slots,
solid_slots,
pushes: PendingPushes::default(),
events: start.events,
random: start.random,
guests: start.guests,
player: start.player,
bindings: start.bindings,
died: start.died,
ticked: start.ticked,
targets: start.targets,
keep: Vec::with_capacity(n),
dead: Vec::new(),
corpses: Vec::new(),
gone: Vec::new(),
guest_events: Vec::new(),
}
}
/// The guest mob `i` sees now, kept in the bindings: none while no guest is on.
pub(super) fn bind_guest(&mut self, i: usize) -> Option<u32> {
if self.guests.is_empty() {
return None;
}
let m = self.mobs[i].mob();
let bound = self.bindings.get(&m.id).copied();
let conn = super::super::lan_mobs::player_for(m, self.player.as_ref(), &self.guests, bound);
match conn {
Some(conn) => self.bindings.insert(m.id, conn),
None => self.bindings.remove(&m.id),
};
conn
}
/// Mob `i`'s turn when its chunk isn't loaded: it is kept as it is, unless it would despawn.
pub(super) fn unloaded(&mut self, i: usize) {
let m = self.mobs[i].mob();
self.keep.push(!m.hostile() || m.persistent);
}
/// Mob `i`'s turn outside the entity-ticking area: only its despawn check, which reads the
/// nearest player and the difficulty, not the player the mob sees.
pub(super) fn rest(&mut self, i: usize, despawn_w: &MobWorld) {
let m = self.mobs[i].mob_mut();
let kept = horizon_client::mobs::ai::despawn_check(m, despawn_w);
if !kept {
self.died.remove(&m.id);
self.snapshot[i].alive = false;
}
self.keep.push(kept);
}
/// Who controls mob `i`'s moves this tick: the velocity of the player riding it, when it is
/// a controlled mount.
pub(super) fn controlling(&self, i: usize) -> Option<Vec3> {
let m = self.mobs[i].mob();
horizon_client::mobs::equine::controlled(m)
.then(|| {
self.player
.as_ref()
.filter(|p| p.riding_vehicle == Some(m.id))
.map(|p| p.velocity)
.or_else(|| {
self.guests
.iter()
.find(|(_, p)| p.riding_vehicle == Some(m.id))
.map(|(_, p)| p.velocity)
})
})
.flatten()
}
/// Mob `i`'s own tick, then what the loop does with what it did. `players`: the guest it
/// sees (none: the host's player), its nearest player, and the velocity of the player
/// controlling it as a mount ([`Self::controlling`]).
pub(super) fn tick(
&mut self,
i: usize,
(seen_guest, nearest_player, controlling_player_movement): (
Option<u32>,
Option<Vec3>,
Option<Vec3>,
),
difficulty: LocalDifficulty,
base: &MobWorld,
shared: &Shared,
) {
let m = self.mobs[i].mob_mut();
horizon_client::mobs::apply_mob_pushes(m, &mut self.pushes);
horizon_client::mobs::blocks_changed(m, shared.path_changes);
let from = self.events.len();
// The guest's view is lent to the mob for its tick, not copied:
// the tick only reads it (`&MobWorld`), and it goes back to the
// guest's slot straight after.
let lent = seen_guest.and_then(|conn| self.guests.iter().position(|g| g.0 == conn));
let active_player = match (seen_guest, lent) {
(None, _) => self.player.take(),
(Some(_), Some(k)) => self
.spare
.take()
.map(|stand_in| std::mem::replace(&mut self.guests[k].1, stand_in)),
(Some(_), None) => None,
};
let mut w = MobWorld {
others: &self.snapshot,
others_grid: Some(&self.grid),
others_index: Some(&self.index),
others_villagers: Some(&self.villager_slots),
others_solid: Some(&self.solid_slots),
difficulty,
nearest_player,
player: active_player,
teams: std::sync::Arc::clone(&base.teams),
..*base
};
w.controlling_player_movement = controlling_player_movement;
self.ticked.insert(self.mobs[i].mob().id);
// `horizon-report`'s cost by mob type (tick_profile.rs).
let sampled =
super::super::tick_profile::start().map(|t| (t, self.mobs[i].mob().kind.clone()));
let kept = horizon_client::mobs::riders::tick(
&mut self.mobs,
i,
&w,
&mut self.random,
&mut self.events,
);
if let Some((started, kind)) = sampled {
super::super::tick_profile::add(shared.level_id, &kind, started);
}
let m = self.mobs[i].mob_mut();
if seen_guest.is_none() {
self.player = w.player;
} else if let Some(k) = lent {
self.spare = w
.player
.map(|v| std::mem::replace(&mut self.guests[k].1, v));
}
// Its tick changed its target: the mods may keep the one it had
// (`native_living_change_target`), and then what the tick did to
// the new one is not dealt.
if let Some(&before) = self.targets.get(i)
&& m.ai.target != before
{
let named = target_named(
m.ai.target,
seen_guest,
&self.guests,
shared.guest_ids,
self.player.as_ref(),
);
if let Some((player, mob)) = named {
let kind = super::super::native_hooks::mob_kind(m);
let mut cancel = false;
Game::native_living_change_target(
shared.level_id,
m.id,
&kind,
player,
mob,
&mut cancel,
);
if cancel {
if let Some(new) = m.ai.target {
let mut k = from;
while k < self.events.len() {
if hits_target(&self.events[k], new) {
self.events.remove(k);
} else {
k += 1;
}
}
}
m.ai.target = before;
}
}
self.targets[i] = m.ai.target;
}
// A killed mob's body is removed: its effects that act on
// death run (`LivingEntity.remove` with KILLED).
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
self.corpses.push(m.clone());
}
horizon_client::mobs::take_mob_pushes(&mut self.events, from, &mut self.pushes);
// LivingEntity.tickDeath: after twenty ticks the body is removed.
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
// `AbstractCubeMob.remove` splits a grown cube into its smaller halves when it is
// removed, twenty ticks after the death. The parent leaves the list before the split
// runs, so its size, position, kind and persistence are captured here, as they are
// at the removal.
let split = if matches!(
m.species(),
Species::Slime | Species::MagmaCube | Species::SulfurCube
) && m.extra.size > 1
{
Some(m.clone())
} else {
None
};
self.gone.push((m.id, m.position, width, height, split));
}
if let Some(conn) = seen_guest {
// ShulkerBullet.finalTarget is the guest's entity: the
// bullet homes on it while the projectiles tick, where
// `Who::Player` is the host's own player.
if let Some(&(_, id)) = shared.guest_ids.iter().find(|g| g.0 == conn) {
for e in &mut self.events[from..] {
if let MobEvent::Projectile(p) = e
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
&& *target == Some(Who::Player)
{
*target = Some(Who::Mob(id));
}
}
}
let mut k = from;
while k < self.events.len() {
if super::super::lan_mobs::acts_on_player(&self.events[k]) {
self.guest_events.push((conn, m.id, self.events.remove(k)));
} else {
k += 1;
}
}
}
// Killed during its own tick (drowning, burning, falling...).
if !m.alive() && !m.removed && self.died.insert(m.id) {
self.dead.push(m.clone());
}
if !kept {
self.died.remove(&m.id);
}
self.keep.push(kept);
// ServerLevel ticks entities one after another: the ones
// after this mob see it as it is now.
self.snapshot[i] = seen(m, shared.uuid);
self.snapshot[i].alive &= kept;
self.grid.update(i, self.snapshot[i].bounds);
horizon_client::mobs::apply_brain_events(&mut self.mobs, &mut self.events, from);
}
}
@@ -0,0 +1,247 @@
//! What the mob loop (`tick_creature_entities`) prepares for every mob before it runs, on the tick's
//! helper threads while the parallel tick is on (`horizon_client::parallel`): each answer reads only
//! that mob, the blocks and the players, so the helpers give what one thread gives.
use super::super::{Game, natural_spawning::NearestPlayers};
use horizon_client::mobs::{Mob, MobWorld, Seen};
use horizon_client::parallel::par_map;
use horizon_core::geometry::Vec3;
/// From this many mobs a preparation goes to the helper threads.
const MIN_MOBS: usize = 256;
impl Game<'_> {
/// `LivingEntity.isPushable` reads onClimbable live; a mob's cached one is only refreshed by its
/// own push step, so one that has not ticked yet (the first tick after a load, a fresh spawn) or
/// has moved since would be pushed out of a ladder or scaffolding. Refreshed here, from where
/// each mob stands now; then each mob's entry of the snapshot others push it by goes in
/// `snapshot`.
pub(super) fn prepare_mobs(&mut self, player: Option<[i32; 4]>, snapshot: &mut Vec<Seen>) {
let climbing: Vec<bool> = {
let w = self.mob_world_base(&[]);
let rebuild = hookless(&w);
par_map(&self.mobs, MIN_MOBS, |part, out| {
let w = rebuild();
out.extend(part.iter().map(|m| m.on_climbable(&w)));
})
};
for (m, climbing) in self.mobs.iter_mut().zip(climbing) {
m.on_climbable = climbing;
}
snapshot.extend(par_map(&self.mobs, MIN_MOBS, |part, out| {
out.extend(part.iter().map(|m| super::seen(m, player)));
}));
}
}
/// The nearest player each mob's despawn check reads (`Mob.checkDespawn`), found for every mob before
/// the loop. A mob that moved before its turn is asked again where it stands.
pub(super) struct MobsNearest<'p> {
players: &'p NearestPlayers<'p>,
/// Each mob's position as the loop started and its nearest player there; empty while the
/// parallel tick is off (every mob asks at its turn).
found: Vec<(Vec3, Option<Vec3>)>,
}
impl<'p> MobsNearest<'p> {
/// `wanted`: whether the loop asks (not with one player, whose position answers).
pub(super) fn new(players: &'p NearestPlayers<'p>, mobs: &[Mob], wanted: bool) -> Self {
let found = if wanted && horizon_client::parallel::lanes() > 1 {
par_map(mobs, MIN_MOBS, |part, out| {
out.extend(
part.iter()
.map(|m| (m.position, Self::ask(players, m.position))),
);
})
} else {
Vec::new()
};
Self { players, found }
}
fn ask(players: &NearestPlayers, at: Vec3) -> Option<Vec3> {
players.nearest(at.x, at.y, at.z).map(|p| p.position)
}
/// The nearest player to mob `i` of the loop, standing at `at`.
pub(super) fn at(&self, i: usize, at: Vec3) -> Option<Vec3> {
match self.found.get(i) {
Some(&(was, nearest)) if was == at => nearest,
_ => Self::ask(self.players, at),
}
}
}
/// `base` without its hooks into the game, which can't leave the server thread, as a recipe a
/// helper thread builds its own world from: native mod effects (the phases on helpers don't run
/// them) and the UUID lookup.
fn hookless<'w>(base: &MobWorld<'w>) -> impl Fn() -> MobWorld<'w> + Sync + use<'w> {
// Every field but the two hooks, named, so that a field added to `MobWorld` is decided here.
let MobWorld {
world,
registry,
facts,
fluid_tables,
movement,
collision,
suffocation,
data,
player,
controlling_player_movement,
nearest_player,
others,
dragon,
leash_boats,
others_grid,
others_index,
others_villagers,
others_solid,
daylight,
monsters_burn,
game_time,
day_time,
raining,
thundering,
level,
block_tags,
landing,
biomes,
mob_griefing,
universal_anger,
mob_drops,
max_entity_cramming,
difficulty,
dimension_type,
world_seed,
sea_level,
teams,
items,
lid_openers,
mod_effects: _,
who_lookup: _,
foreign: _,
border,
} = base.clone();
move || MobWorld {
world,
registry,
facts,
fluid_tables,
movement,
collision,
suffocation,
data,
player: player.clone(),
controlling_player_movement,
nearest_player,
others,
dragon,
leash_boats,
others_grid,
others_index,
others_villagers,
others_solid,
daylight,
monsters_burn,
game_time,
day_time,
raining,
thundering,
level,
block_tags,
landing,
biomes,
mob_griefing,
universal_anger,
mob_drops,
max_entity_cramming,
difficulty,
dimension_type,
world_seed,
sea_level,
teams: std::sync::Arc::clone(&teams),
items,
lid_openers,
mod_effects: None,
who_lookup: None,
foreign: None,
border,
}
}
#[cfg(test)]
mod tests {
use super::super::super::natural_spawning::LevelPlayer;
use super::*;
use horizon_core::geometry::Aabb;
#[test]
#[ignore = "needs prepared resources and scratch homes/cwd"]
fn preparation_refreshes_climbing_and_nearest_after_a_move() {
let mut game = super::super::super::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
for command in [
"setblock 8 80 9 stone",
"setblock 8 80 8 ladder[facing=north]",
"summon pig 8.5 80 8.5 {NoAI:1b}",
"summon pig 9.5 80 8.5 {NoAI:1b}",
] {
assert!(game.run_command(command), "{command}");
}
assert_eq!(game.mobs.len(), 2);
let examples = game.mobs.clone();
game.mobs = (0..MIN_MOBS + 37)
.map(|i| {
let mut mob = examples[i % 2].clone();
mob.id = 10_000 + i as u32;
mob.on_climbable = i % 2 != 0; // deliberately stale in both cases
mob
})
.collect::<Vec<_>>()
.into();
let mut snapshot = Vec::new();
game.prepare_mobs(None, &mut snapshot);
assert_eq!(snapshot.len(), game.mobs.len());
for (i, (m, seen)) in game.mobs.iter().zip(&snapshot).enumerate() {
assert_eq!(m.on_climbable, i % 2 == 0, "mob {i}");
assert_eq!(seen.id, m.id);
assert_eq!(seen.position, m.position);
assert_eq!(seen.pushable, i % 2 != 0, "snapshot {i}");
}
let player = |x, spectator| {
let position = Vec3::new(x, 80.0, 8.5);
LevelPlayer {
uuid: 0,
position,
bounds: Aabb::new(position, position),
alive: false, // dead players still count for checkDespawn
creative: false,
spectator,
}
};
let players = [player(8.5, true), player(7.5, false), player(9.5, false)];
let index = NearestPlayers::new(&players);
let nearest = MobsNearest::new(&index, &game.mobs, true);
for (i, mob) in game.mobs.iter().enumerate() {
let expected = if i % 2 == 0 {
players[1].position
} else {
players[2].position
};
assert_eq!(nearest.at(i, mob.position), Some(expected));
// A previous mob's push moved this mob across the nearest-player tie.
assert_eq!(
nearest.at(i, Vec3::new(10.0, 80.0, 8.5)),
Some(players[2].position)
);
}
let no_cache = MobsNearest::new(&index, &game.mobs, false);
assert_eq!(
no_cache.at(0, game.mobs[0].position),
Some(players[1].position)
);
let spectators = [player(8.5, true)];
let index = NearestPlayers::new(&spectators);
let nearest = MobsNearest::new(&index, &game.mobs, true);
assert_eq!(nearest.at(0, game.mobs[0].position), None);
}
}
@@ -44,8 +44,6 @@ mod transfer;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
struct Watchdog {
/// The thread running the ticks, whose stack the report carries.
server: horizon_threads::stack_dump::Watched,
base: Instant,
next_nanos: AtomicU64,
tick_nanos: AtomicU64,
@@ -55,11 +53,9 @@ struct Watchdog {
impl Watchdog {
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
/// Started from the thread that runs the ticks.
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
let watchdog = Arc::new(Self {
server: horizon_threads::stack_dump::Watched::current(),
base: Instant::now(),
next_nanos: AtomicU64::new(0),
tick_nanos: AtomicU64::new(50_000_000),
@@ -88,8 +84,6 @@ impl Watchdog {
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
let behind = now.saturating_sub(next);
if behind > max {
// ServerWatchdog: the server thread's stack first, while it is still stuck.
let stack = self.server.stack(Duration::from_secs(5));
let took = format!("{:.2}", behind as f32 / 1e9);
let should = format!(
"{:.2}",
@@ -112,7 +106,6 @@ impl Watchdog {
std::path::Path::new("."),
"server",
message,
stack,
) {
Some(path) => log_from(
"Server Watchdog",
+3 -22
View File
@@ -3672,9 +3672,9 @@ impl Dragon {
let mut d = Self::new(position, yaw, seed);
d.velocity = velocity;
// Vanilla getFloatOr/getIntOr accept any numeric tag type.
d.health = nbt_value::float(tag.get("Health")).map_or(d.health, |h| h as f32);
d.sitting_damage =
nbt_value::float(tag.get("sitting_damage_received")).unwrap_or(0.0) as f32;
if let Some(h) = nbt_value::float(tag.get("Health")) {
d.health = h as f32;
}
if let Some(p) = nbt_value::number(tag.get("DragonPhase")) {
d.phase = Phase::from_id(p as i32);
}
@@ -5082,25 +5082,6 @@ mod tests {
d.phase = phase;
d
}
/// `EnderDragon.readAdditionalSaveData` reads `sitting_damage_received`
/// (`getFloatOr`, 0 when absent); the writer already saved it. Horizon
/// only wrote it, so a perched dragon that took a quarter of its health
/// but was saved before taking off came back with the damage forgotten.
#[test]
fn a_perched_dragons_damage_survives_the_chunk_save() {
let mut d = dragon(Phase::Flaming { ticks: 0 });
d.sitting_damage = 12.5;
let tag = d.to_nbt([1, 2, 3, 4], None);
let back = Dragon::from_nbt(&tag, 0).unwrap();
assert_eq!(back.sitting_damage, 12.5);
// No key at all reads as no damage accumulated.
let mut bare = tag.clone();
if let horizon_world::nbt::Tag::Compound(fields) = &mut bare {
fields.remove(&"sitting_damage_received".into());
}
assert_eq!(Dragon::from_nbt(&bare, 0).unwrap().sitting_damage, 0.0);
}
fn flying() -> Phase {
Phase::Holding {
path: None,
+6 -35
View File
@@ -614,24 +614,9 @@ impl Game<'_> {
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
) -> (bool, bool) {
let no_physics = escape_speed.is_some();
let heights = item.push_by_fluid(&self.world, &self.res.motion().fluids);
// Entity.baseTick splashes before moving, except on the first tick.
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
item.lava_fall(heights.1 > 0.0);
let step = item.tick_move_before_block_effects(
&self.world,
self.res.facts(),
self.collision,
no_physics,
escape_speed,
heights,
entity_boxes,
Some(self.border.geometry()),
Some(&self.res.motion().blocks),
);
item.check_fall(&step, |fall, from, moved| {
self.vehicle_fall_reset(fall, from, moved)
});
let step = self
.item_tables()
.move_item(item, escape_speed, entity_boxes, splash);
if let Some(pos) = step.vertical_contact {
let state = self.world.block(pos.x, pos.y, pos.z);
let position = item.position;
@@ -650,23 +635,9 @@ impl Game<'_> {
return (false, true);
}
}
item.finish_move_block_effects(
&self.world,
self.res.facts(),
no_physics,
Some(&self.res.motion().blocks),
step,
);
item.push_by_bubble_column(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
);
let alive = item.tick_settle(&self.world, self.res.facts(), no_physics, step);
// ItemEntity.tick refreshes immersion and splashes on water entry.
let (water, _) = item.push_by_fluid(&self.world, &self.res.motion().fluids);
item.water_entry(water > 0.0, false, splash);
let alive = self
.item_tables()
.settle_item(item, no_physics, step, splash);
(alive, false)
}
+13 -47
View File
@@ -1448,8 +1448,7 @@ impl Game<'_> {
/// running to that player's hand.
pub(super) struct RemoteHook {
id: u32,
pub(super) owner: u32,
pub(super) owner_fishing: bool,
owner: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
velocity: Vec3,
@@ -1459,34 +1458,6 @@ pub(super) struct RemoteHook {
pub(super) random: LegacyRandom,
}
fn push_remote_hook(hooks: &mut Vec<RemoteHook>, id: u32, owner: u32, at: Vec3, velocity: Vec3) {
for h in hooks.iter_mut().filter(|h| h.owner == owner) {
h.owner_fishing = false;
}
hooks.push(RemoteHook {
id,
owner,
owner_fishing: true,
position: at,
previous: at,
velocity,
state: State::Flying,
on_ground: false,
lerp: HookLerp::default(),
random: random_source::entity(),
});
}
fn remove_remote_hook_from(hooks: &mut Vec<RemoteHook>, id: u32) {
let owner = hooks.iter().find(|h| h.id == id).map(|h| h.owner);
hooks.retain(|h| h.id != id);
if let Some(owner) = owner {
for h in hooks.iter_mut().filter(|h| h.owner == owner) {
h.owner_fishing = false;
}
}
}
/// The `onGround` of a server's update (`handleMoveEntity`), except that a
/// hook resting on the ground stays so while the update does not lift it: the
/// server's tracker can send the flag a tick behind the landing.
@@ -1633,7 +1604,17 @@ impl Game<'_> {
hook.velocity = velocity;
self.hook = Some(hook);
} else {
push_remote_hook(&mut self.remote_hooks, id, owner as u32, at, velocity);
self.remote_hooks.push(RemoteHook {
id,
owner: owner as u32,
position: at,
previous: at,
velocity,
state: State::Flying,
on_ground: false,
lerp: HookLerp::default(),
random: random_source::entity(),
});
}
}
@@ -1939,7 +1920,7 @@ impl Game<'_> {
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
self.hook = None;
}
remove_remote_hook_from(&mut self.remote_hooks, id);
self.remote_hooks.retain(|h| h.id != id);
}
pub(super) fn clear_remote_hooks(&mut self) {
@@ -2329,19 +2310,4 @@ mod tests {
);
assert_eq!(pulled, Vec3::new(1.5, 0.2, 0.0));
}
#[test]
fn removing_one_of_two_remote_hooks_clears_owner_fishing() {
let mut hooks = Vec::new();
super::push_remote_hook(&mut hooks, 10, 7, Vec3::ZERO, Vec3::ZERO);
super::push_remote_hook(&mut hooks, 11, 7, Vec3::ZERO, Vec3::ZERO);
super::push_remote_hook(&mut hooks, 20, 12, Vec3::ZERO, Vec3::ZERO);
assert!(!hooks[0].owner_fishing);
assert!(hooks[1].owner_fishing);
assert!(hooks[2].owner_fishing);
super::remove_remote_hook_from(&mut hooks, 10);
assert_eq!(hooks.len(), 2);
assert!(!hooks.iter().any(|h| h.owner == 7 && h.owner_fishing));
assert!(hooks.iter().any(|h| h.owner == 12 && h.owner_fishing));
}
}
@@ -133,58 +133,6 @@ fn a_hoglin_keeps_attacking_its_attacker_beside_another_hit_hoglin() {
);
}
fn hurt_time(game: &mut Game<'_>, uuid: &str) -> i32 {
let id = mob_id(game, uuid);
game.mobs.iter().find(|m| m.id == id).unwrap().hurt_time
}
/// Vanilla 26.3's hit pen: the hoglin enters the fight activity at the end of
/// the tick after the hit (`HoglinAi.updateActivity` follows the brain), hits
/// in the next one, and again 41 ticks later. The pig ticks after the hoglin,
/// so it reads `HurtTime` 9 on the tick of each hit.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_hoglin_hits_from_its_second_fighting_tick_every_41_ticks() {
let mut game = game();
step(&mut game, 40);
assert!(game.run_command(&format!("damage {HOGLIN} 1 minecraft:mob_attack by {PIG}")));
let mut hits = Vec::new();
for tick in 1..=90 {
step(&mut game, 1);
if hurt_time(&mut game, PIG) == 9 {
hits.push(tick);
}
}
assert_eq!(hits, [2, 43, 84]);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn deferred_stronger_hits_do_not_count_hurt_timers_down_twice() {
use horizon_client::mobs::Damage;
let mut game = game();
let first = mob_id(&mut game, NEAR_HOGLIN);
let second = mob_id(&mut game, HOGLIN);
let target = mob_id(&mut game, PIG);
game.creatures.ticked.extend([first, second, target]);
game.creatures.deferred_hits = true;
assert!(game.mob_hits_mob(target, 4.0, Damage::mob(first, [0.0; 3])));
assert!(game.mob_hits_mob(target, 6.0, Damage::mob(second, [0.0; 3])));
let victim = game.mobs.iter().find(|m| m.id == target).unwrap();
assert_eq!((victim.hurt_time, victim.invulnerable), (9, 19));
assert_eq!(victim.health, 94.0);
// A stronger hit on the following tick also leaves the already-counted
// timers alone rather than decrementing them again.
let victim = game.mobs.iter_mut().find(|m| m.id == target).unwrap();
victim.hurt_time = 8;
victim.invulnerable = 18;
assert!(game.mob_hits_mob(target, 8.0, Damage::mob(first, [0.0; 3])));
let victim = game.mobs.iter().find(|m| m.id == target).unwrap();
assert_eq!((victim.hurt_time, victim.invulnerable), (8, 18));
assert_eq!(victim.health, 92.0);
}
/// Added controls (after the frozen baseline above).
fn mob_id(game: &mut Game<'_>, uuid: &str) -> u32 {
match game.target_with_uuid(horizon_client::save::uuid_from_text(uuid).unwrap()) {
@@ -82,8 +82,7 @@ fn game() -> Game<'static> {
.unwrap();
game.world_root = dir;
game.world_tables = game.build_world_tables(false, &[]).unwrap().0;
// The packs' tables for this test's thread only (#14266).
let _ = horizon_client::structure_blocks::load_thread_loot_data(
let _ = horizon_client::structure_blocks::world_loot_data(
&game.options.resources,
&game.pack_files(),
);
@@ -133,4 +133,44 @@ fn the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entit
game.tick_items();
assert!(game.dropped.is_empty(), "consumed stack of {count}");
}
// More than the helper threshold, with a native contact hook loaded:
// every item must still reach its turn's hook, keeping opaque components.
// Unique components prevent merges despite all items sharing a landing.
for count in [13, 2, 1] {
game.dropped.clear();
probe.contacts.lock().unwrap().clear();
for n in 0..300 {
let mut stack = input.clone();
stack.count = count;
stack
.data_mut()
.set_other("fixture:opaque", Some(horizon_world::nbt::Tag::Long(n)));
game.spawn_item(stack, [landing[0], 64.1, landing[2]], [0.0, -0.3, 0.0], 20);
game.dropped.last_mut().unwrap().ticks = 39;
}
game.ticked_entities_through = u32::MAX;
let before = game.dropped.clone();
game.tick_items();
let expected_contacts: Vec<_> = before
.iter()
.map(|item| (false, item.id, [4, 63, 4], landing, count))
.collect();
assert_eq!(*probe.contacts.lock().unwrap(), expected_contacts);
if count <= 2 {
assert!(game.dropped.is_empty(), "consumed 300 stacks of {count}");
} else {
assert_eq!(game.dropped.len(), 300);
for (item, old) in game.dropped.iter().zip(&before) {
let mut expected = old.stack.clone();
expected.count = 11;
assert_eq!(item.stack, expected);
assert_eq!(item.id, old.id);
assert_eq!(item.join_seq(), old.join_seq());
assert_eq!(item.position, vanilla.position);
assert_eq!(item.velocity, vanilla.velocity);
assert_eq!(item.age, 1);
}
}
}
}
+49 -29
View File
@@ -2,6 +2,8 @@
//! `tick_experience`), the stacks a destroyed container lets fall, and spawning and merging orbs.
use super::*;
mod ahead;
impl Game<'_> {
pub(super) fn tick_items(&mut self) {
// EntityTickEvent.Pre for the items about to tick, and the Post after them.
@@ -43,6 +45,13 @@ impl Game<'_> {
horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
let mut nearby = Vec::new();
let mut bubble_tops = Vec::new();
let mut moves = ahead::Moves::work_out(self, &items, &obstacles, round, |item| {
!self.joined_during_entity_pass(item.id)
&& ticking(
item.position.x.floor() as i32,
item.position.z.floor() as i32,
)
});
// `ItemUtils.onContainerDestroyed`: what a destroyed item held, and
// where it lay, for adding to the level once its own tick is done.
let mut destroyed: Vec<(ItemStack, Vec3)> = Vec::new();
@@ -65,33 +74,35 @@ impl Game<'_> {
{
continue;
}
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
// where the item can reach this tick (a block of slack for the next velocity).
let reach = Vec3::new(
item.velocity.x.abs() + 1.0,
item.velocity.y.abs() + 1.0,
item.velocity.z.abs() + 1.0,
);
obstacles.near(item.bounds().inflate(reach), &mut nearby);
// ItemEntity.tick's noPhysics (servers only): out of a block, a solid entity
// or the border, placed or closed on it, towards the closest open side (#1252).
let no_physics = merging
&& item.inside_block(
&self.world,
self.collision,
let mut at_turn = |item: &mut ItemEntity| {
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
// where the item can reach this tick.
obstacles.near(ahead::reach(item), &mut nearby);
// ItemEntity.tick's noPhysics (servers only): out of a block, a solid entity
// or the border, placed or closed on it, towards the closest open side (#1252).
let no_physics = merging
&& item.inside_block(
&self.world,
self.collision,
&nearby,
Some(self.border.geometry()),
);
// `moveTowardsClosestSpace` draws the speed from the item's own random.
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
let mut entered = Vec::new();
let (alive, discarded) = self.tick_item_with_block_contacts(
item,
escape_speed,
&nearby,
Some(self.border.geometry()),
false,
&mut |splash| entered.push(splash),
);
// `moveTowardsClosestSpace` draws the speed from the item's own random.
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
let mut entered = Vec::new();
let (alive, discarded) = self.tick_item_with_block_contacts(
item,
escape_speed,
&nearby,
false,
&mut |splash| entered.push(splash),
);
(no_physics, alive, discarded, entered)
};
let (no_physics, alive, discarded, entered) = match moves.take(i, item, &mut at_turn) {
Some(moved) => moved,
None => at_turn(item),
};
// Entity.doWaterSplashEffect: the server plays the sound (broadcast
// to clients). Its addParticle is empty.
for splash in entered {
@@ -985,9 +996,14 @@ mod escape_tests {
let mut game = server_game();
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
// Entity-ticking chunks also need the surrounding loaded chunks.
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.update_chunk_tickets();
assert!(game.entity_ticking_area()(8, 8));
let stone = game
@@ -1001,13 +1017,17 @@ mod escape_tests {
game.spawn_item(stack, [8.5, 64.4, 8.5], [0.0, 0.0, 0.0], 10);
let item = game.dropped.last().unwrap();
let expected = f64::from(item.random.clone().next_float() * 0.2 + 0.1);
let mut own = item.random.clone();
own.next_float();
let mut level = game.world.random.clone();
game.ticked_entities_through = u32::MAX;
game.tick_items();
let item = game.dropped.last().unwrap();
// Up is the nearest open side from the middle of the block.
// The push replaces the fall's velocity; then the 0.98 drag.
assert_eq!(item.velocity.y, expected * 0.98);
// Entity.getAirDrag returns 0.98F, promoted to double for the drag.
assert_eq!(item.velocity.y, expected * f64::from(0.98_f32));
assert_eq!(item.random.clone().next_int(), own.next_int());
assert_eq!(game.world.random.next_float(), level.next_float());
}
}
@@ -0,0 +1,452 @@
//! The dropped items' own moves, worked out ahead of the item loop on the tick's helper threads
//! while the parallel tick is on (`horizon_client::parallel`).
//!
//! An item's move (`ItemEntity.tick` up to its merge) reads only itself, the level's blocks, the
//! obstacle boxes and the border, and the item loop changes none of them: so an item's move worked
//! out on a copy before the loop is the move its turn would make. Its escape from inside a block
//! draws the item's own random, which the copy carries. A merge before its turn can change its
//! count, age and pickup delay: such an item moves at its turn instead. With a native mod loaded
//! (its hooks see every item) or a mod listening to the round, every item moves at its turn.
use super::super::{boats::fall_reset, entity::Entity as _, native_ticks};
use super::*;
use horizon_client::{
blocks::BlockFacts,
entities::{ItemEntity, ItemSplash, ItemStep, ObstacleGrid},
motion::MotionResources,
world::World,
};
use horizon_core::geometry::{Aabb, Vec3, border::BorderGeometry};
use horizon_world::{block_collision::BlockCollisionRegistry, registry::BlockRegistry};
/// From this many items their moves go to the helper threads.
const MIN_ITEMS: usize = 256;
/// What an item's own move reads.
pub(in crate::game) struct ItemTables<'a> {
world: &'a World,
facts: &'a BlockFacts,
collision: &'a BlockCollisionRegistry<'a>,
motion: &'a MotionResources,
registry: &'a BlockRegistry,
border: &'a BorderGeometry,
}
impl<'w> Game<'w> {
pub(in crate::game) fn item_tables(&self) -> ItemTables<'_> {
ItemTables {
world: &self.world,
facts: self.res.facts(),
collision: self.collision,
motion: self.res.motion(),
registry: self.pack.assets.registry,
border: self.border.geometry(),
}
}
}
impl ItemTables<'_> {
/// `ItemEntity.tick` up to its move's vertical contact: the fluids, the water splash, the move
/// (pushed out at `escape_speed` when inside a block) and its fall.
pub(in crate::game) fn move_item(
&self,
item: &mut ItemEntity,
escape_speed: Option<f32>,
entity_boxes: &[Aabb],
splash: &mut impl FnMut(ItemSplash),
) -> ItemStep {
let heights = item.push_by_fluid(self.world, &self.motion.fluids);
// Entity.baseTick splashes before moving, except on the first tick.
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
item.lava_fall(heights.1 > 0.0);
let step = item.tick_move_before_block_effects(
self.world,
self.facts,
self.collision,
escape_speed.is_some(),
escape_speed,
heights,
entity_boxes,
Some(self.border),
Some(&self.motion.blocks),
);
item.check_fall(&step, |fall, from, moved| {
fall_reset(self.world, self.motion, self.facts, fall, from, moved)
});
step
}
/// The rest of the move after its vertical contact: block effects, bubble columns, settling
/// and the water entry. Whether the item lives on.
pub(in crate::game) fn settle_item(
&self,
item: &mut ItemEntity,
no_physics: bool,
step: ItemStep,
splash: &mut impl FnMut(ItemSplash),
) -> bool {
item.finish_move_block_effects(
self.world,
self.facts,
no_physics,
Some(&self.motion.blocks),
step,
);
item.push_by_bubble_column(self.world, self.registry, self.facts, self.collision);
let alive = item.tick_settle(self.world, self.facts, no_physics, step);
// ItemEntity.tick refreshes immersion and splashes on water entry.
let (water, _) = item.push_by_fluid(self.world, &self.motion.fluids);
item.water_entry(water > 0.0, false, splash);
alive
}
}
/// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the box around an item that its
/// move reads others' boxes in (a block of slack for the next velocity).
pub(super) fn reach(item: &ItemEntity) -> Aabb {
let v = item.velocity;
item.bounds()
.inflate(Vec3::new(v.x.abs() + 1.0, v.y.abs() + 1.0, v.z.abs() + 1.0))
}
/// An item's move as its turn makes it: without physics (inside a block), alive, discarded, and
/// its splashes.
pub(super) type Moved = (bool, bool, bool, Vec<ItemSplash>);
/// One item's move, worked out on a copy.
pub(super) struct Ahead {
moved: ItemEntity,
result: Moved,
/// What a merge before its turn can change: its count, age and pickup delay.
started: (u32, i32, i64),
}
fn started(item: &ItemEntity) -> (u32, i32, i64) {
(item.stack.count, item.age, item.pickup_delay)
}
/// `item`'s whole move on a server with no native mod, from the tables alone.
fn move_alone(
tables: &ItemTables,
obstacles: &ObstacleGrid,
item: &mut ItemEntity,
found: &mut Vec<u32>,
near: &mut Vec<Aabb>,
) -> Moved {
obstacles.near_shared(reach(item), found, near);
let no_physics = item.inside_block(tables.world, tables.collision, near, Some(tables.border));
// `moveTowardsClosestSpace` draws the speed from the item's own random.
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
let mut splashes = Vec::new();
let step = tables.move_item(item, escape_speed, near, &mut |s| splashes.push(s));
if step.vertical_contact.is_some() && item.stack.count == 0 {
return (no_physics, false, true, splashes);
}
let alive = tables.settle_item(item, no_physics, step, &mut |s| splashes.push(s));
(no_physics, alive, false, splashes)
}
#[cfg(test)]
thread_local! {
/// A test's choice of the path, over the parallel tick's switch.
static AHEAD: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
/// The moves a test's tick put in place, and those a merge made it refuse.
static TAKEN: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
}
/// Whether the moves are worked out ahead: while the parallel tick is on.
fn ahead() -> bool {
#[cfg(test)]
if let Some(ahead) = AHEAD.with(std::cell::Cell::get) {
return ahead;
}
horizon_client::parallel::lanes() > 1
}
/// The moves worked out ahead, by the item's place in the loop.
pub(super) struct Moves(Vec<Option<Ahead>>);
impl Moves {
/// The moves of the items that will tick (`ticks`), on the helper threads; none while the
/// parallel tick is off, for few items, or with a mod that could see an item's move.
pub(super) fn work_out(
game: &Game,
items: &[ItemEntity],
obstacles: &ObstacleGrid,
round: &native_ticks::TickRound,
ticks: impl Fn(&ItemEntity) -> bool,
) -> Self {
if !ahead()
|| items.len() < MIN_ITEMS
|| game.remote.is_some()
|| round.listening()
|| !horizon_client::mod_api::mods().is_empty()
{
return Self(Vec::new());
}
let todo: Vec<usize> = (0..items.len()).filter(|&i| ticks(&items[i])).collect();
let tables = game.item_tables();
let done = horizon_client::parallel::par_map(&todo, MIN_ITEMS / 4, |part, out| {
let (mut found, mut near) = (Vec::new(), Vec::new());
for &i in part {
let mut moved = items[i].clone();
let result = move_alone(&tables, obstacles, &mut moved, &mut found, &mut near);
let started = started(&items[i]);
out.push((
i,
Ahead {
moved,
result,
started,
},
));
}
});
let mut moves: Vec<Option<Ahead>> = (0..items.len()).map(|_| None).collect();
for (i, a) in done {
moves[i] = Some(a);
}
Self(moves)
}
/// Item `i`'s move, put in place, when it was worked out and no merge changed the item since.
/// Under `HORIZON_PARALLEL_TICK=verify` the move is made again at its turn, on a copy, and a
/// difference is logged.
pub(super) fn take(
&mut self,
i: usize,
item: &mut ItemEntity,
again: impl FnOnce(&mut ItemEntity) -> Moved,
) -> Option<Moved> {
let a = self.0.get_mut(i)?.take()?;
let fits = a.started == started(item);
#[cfg(test)]
TAKEN.with(|t| {
let (taken, refused) = t.get();
t.set((taken + usize::from(fits), refused + usize::from(!fits)));
});
if !fits {
return None;
}
if horizon_client::parallel::parallel_tick() == 2 {
let mut serial = item.clone();
let expected = again(&mut serial);
check(item.id, &serial, &expected, &a.moved, &a.result);
}
*item = a.moved;
Some(a.result)
}
}
/// `HORIZON_PARALLEL_TICK=verify`: an item's move worked out ahead against the one at its turn.
fn check(id: u32, serial: &ItemEntity, expected: &Moved, ahead: &ItemEntity, got: &Moved) {
use std::sync::atomic::{AtomicU64, Ordering};
static CHECKED: AtomicU64 = AtomicU64::new(0);
static DIFFERED: AtomicU64 = AtomicU64::new(0);
if format!("{serial:?} {expected:?}") != format!("{ahead:?} {got:?}") {
DIFFERED.fetch_add(1, Ordering::Relaxed);
log::error!("parallel tick: item {id}'s move worked out ahead differs from its turn's");
}
let n = CHECKED.fetch_add(1, Ordering::Relaxed) + 1;
if n == 1 || n.is_multiple_of(10_000) {
log::info!(
"parallel tick: {n} item moves worked out ahead checked, {} differed",
DIFFERED.load(Ordering::Relaxed)
);
}
}
#[cfg(test)]
mod tests {
use super::super::super::native_machines::tests::server_game;
use super::{AHEAD, TAKEN};
use horizon_client::{chunk::Chunk, items::ItemStack};
use horizon_core::{geometry::Vec3, random::RandomSource};
use std::sync::Arc;
/// One item tick with the moves worked out ahead leaves every item, and the level's random,
/// as the tick that moves each item at its turn: items escaping a block (their own random),
/// floating in water, falling, and merging into a later item before its turn (whose move
/// is then made at its turn).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn moves_worked_out_ahead_match_those_made_at_each_turn() {
let mut game = server_game();
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
// `ChunkMap.prepareEntityTickingChunk`: the chunks within 2 are loaded too.
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.update_chunk_tickets();
assert!(game.entity_ticking_area()(8, 8));
game.border.set_center([8.0, 8.0]);
game.border.set_size(16.0);
let registry = game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
let water = registry.default_state("minecraft:water").unwrap();
let mut escaping = Vec::new();
for x in 0..16 {
for z in 0..16 {
let (fx, fz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
let stack = |count| ItemStack::new("minecraft:stone", count);
match (x + z) % 5 {
0 => {
game.world.set(x, 65, z, stone).unwrap();
game.spawn_item(stack(1), [fx, 65.3, fz], [0.0; 3], 10);
escaping.push(game.dropped.last().unwrap().id);
}
1 => {
game.world.set(x, 65, z, water).unwrap();
game.spawn_item(stack(1), [fx, 65.5, fz], [0.0; 3], 10);
}
2 => {
game.spawn_item(stack(1), [fx, 66.0, fz], [0.0; 3], 0);
game.spawn_item(stack(5), [fx, 66.0, fz], [0.0; 3], 0);
}
_ => game.spawn_item(stack(1), [fx, 67.0, fz], [0.1, 0.2, -0.1], 10),
}
}
}
// Real solid-entity boxes, not a stubbed obstacle query. One item is
// caught inside the boat; another falls onto its top this tick.
game.next_entity += 1;
let boat = super::super::super::boats::Boat::new(
game.next_entity,
Arc::from("oak"),
Vec3::new(8.5, 80.0, 8.5),
0.0,
);
let boat_top = boat.bounds().max_position().y;
game.boats.push(boat);
game.spawn_item(
ItemStack::new("minecraft:iron_ingot", 1),
[8.5, 80.2, 8.5],
[0.0; 3],
10,
);
let caught = game.dropped.last().unwrap().id;
escaping.push(caught);
game.spawn_item(
ItemStack::new("minecraft:gold_ingot", 1),
[8.5, 81.0, 8.5],
[0.0, -1.0, 0.0],
10,
);
let landing = game.dropped.last().unwrap().id;
// The border cuts the box even though the centre is still inside it.
game.spawn_item(
ItemStack::new("minecraft:diamond", 1),
[15.99, 80.0, 8.5],
[0.0; 3],
10,
);
let border = game.dropped.last().unwrap().id;
escaping.push(border);
let boxes = game.item_entity_collisions();
for id in [caught, border] {
let item = game.dropped.iter().find(|i| i.id == id).unwrap();
assert!(item.inside_block(
&game.world,
game.collision,
&boxes,
Some(game.border.geometry())
));
assert!(!item.inside_block(&game.world, game.collision, &[], None));
}
for item in game.dropped.iter_mut() {
item.ticks = 39;
}
game.ticked_entities_through = u32::MAX;
let (items, random) = (game.dropped.clone(), game.world.random.clone());
// Each merge-written field independently invalidates a prepared move.
// Rejecting it must not overwrite the merge or advance the item's RNG.
AHEAD.with(|a| a.set(Some(true)));
let obstacles = super::ObstacleGrid::new(boxes);
for changed in 0..3 {
let mut moves = super::Moves::work_out(
&game,
&items,
&obstacles,
&super::native_ticks::TickRound::default(),
|_| true,
);
let mut item = items[0].clone();
match changed {
0 => item.stack.count += 1,
1 => item.age += 1,
_ => item.pickup_delay += 1,
}
let before = format!("{item:?}");
assert!(
moves
.take(0, &mut item, |_| panic!("invalidated move was verified"))
.is_none()
);
assert_eq!(format!("{item:?}"), before);
}
let mut runs = Vec::new();
// First use the real environment switch, then serial/ahead/serial
// overrides. This also checks switch-off behavior above the threshold.
for ahead in [None, Some(false), Some(true), Some(false)] {
game.dropped = items.clone();
game.world.random = random.clone();
AHEAD.with(|a| a.set(ahead));
TAKEN.with(|t| t.set((0, 0)));
game.tick_items();
// Independently advance just the escaping items' starting randoms.
// The helper copy must consume exactly one float and return that
// state at the turn; ordinary moves must consume none of it.
for item in game.dropped.iter() {
let before = items.iter().find(|i| i.id == item.id).unwrap();
let mut expected = before.random.clone();
if escaping.contains(&item.id) {
expected.next_float();
}
assert_eq!(
item.random.clone().next_int(),
expected.next_int(),
"item {}",
item.id
);
}
let landed = game.dropped.iter().find(|i| i.id == landing).unwrap();
assert_eq!(landed.position.y, boat_top);
assert!(landed.on_ground);
assert_eq!(format!("{:?}", game.world.random), format!("{random:?}"));
let left: Vec<String> = game.dropped.iter().map(|i| format!("{i:?}")).collect();
runs.push((
left,
format!("{:?}", game.world.random),
TAKEN.with(|t| t.get()),
));
}
AHEAD.with(|a| a.set(None));
assert!(runs[0].0.len() < items.len(), "some items merged");
assert_eq!(runs[0].0, runs[1].0);
assert_eq!(runs[0].1, runs[1].1);
assert_eq!(runs[0].0, runs[2].0);
assert_eq!(runs[0].1, runs[2].1);
assert_eq!(runs[0].0, runs[3].0);
assert_eq!(runs[0].1, runs[3].1);
assert_eq!(runs[1].2, (0, 0));
assert_eq!(runs[3].2, (0, 0));
if horizon_client::parallel::lanes() > 1 {
assert_eq!(runs[0].2, runs[2].2);
} else {
assert_eq!(runs[0].2, (0, 0));
}
// The path under test ran: most moves were put in place, and a merge refused some.
let (taken, refused) = runs[2].2;
assert!(
taken >= 200 && refused > 0,
"{taken} put in place, {refused} refused"
);
eprintln!(
"item work-ahead proof: {} inputs, {} escaping; {taken} taken, {refused} invalidated",
items.len(),
escaping.len()
);
}
}
+16 -23
View File
@@ -4,6 +4,7 @@
//! costs one `Option` check per tick.
mod event_timings;
mod level_event_targets;
mod mob_states;
use super::{Game, shield::OFFHAND_SLOT};
use event_timings::EventTimings;
@@ -363,12 +364,12 @@ pub(super) fn baby_index(species: Species) -> Option<u8> {
/// own: `mob_meta`'s arms hold these mobs' other entries.
fn mob_variant_meta(
m: &Mob,
hub: &Hub,
sync: &SyncData,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
let (kind, e) = (m.kind.id.as_str(), &m.extra);
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
let id = |registry: &str, name: &str| sync.id(registry, &format!("minecraft:{name}"));
match kind {
// Axolotl.DATA_VARIANT (after AgeableMob's two).
"minecraft:axolotl" => {
@@ -501,7 +502,7 @@ fn mob_variant_meta(
/// `defaults` gets the defaults of the entries that can go back to theirs.
pub(super) fn mob_meta(
m: &Mob,
hub: &Hub,
sync: &SyncData,
player_entity: Option<i32>,
game_time: i64,
meta: &mut Vec<(u8, i32, Value)>,
@@ -558,7 +559,7 @@ pub(super) fn mob_meta(
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the
// swirls of its effects (the ones a mob has are visible, not ambient).
meta.extend(
hub.effect_meta(
sync.effect_meta(
(m.extra.effects.iter())
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
.map(|e| (e.0, m.extra.ambient_effects.contains(&e.0))),
@@ -590,7 +591,7 @@ pub(super) fn mob_meta(
}
}
let e = &m.extra;
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
let id = |registry: &str, name: &str| sync.id(registry, &format!("minecraft:{name}"));
// Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (18, the first entry
// after AgeableMob's two). The total `boost()` stored in `counter2`.
// The default 0 is not registered: vanilla never writes it back, and the
@@ -598,7 +599,7 @@ pub(super) fn mob_meta(
if matches!(kind, "minecraft:pig" | "minecraft:strider") && e.counter2 > 0 {
meta.push((18, serializer::INT, Value::Int(e.counter2)));
}
mob_variant_meta(m, hub, meta, defaults);
mob_variant_meta(m, sync, meta, defaults);
mob_raider_meta(m, meta, defaults);
match kind {
"minecraft:guardian" | "minecraft:elder_guardian" => {
@@ -1948,14 +1949,15 @@ impl Game<'_> {
mut kept: Option<&mut super::lan_idle::IdlePass<'_>>,
) {
out.clear();
let mut speeds = super::lan_attributes::SpeedByType::default();
let mut states = mob_states::Later::new(self.world.game_time);
// ChunkMap.tick sends the changes of an entity outside the
// entity-ticking range only when it changed section or needs sync.
let levels = self.tickets.levels();
let idle_at = |p: horizon_core::geometry::Vec3| {
!levels.in_entity_ticking_range((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
};
for m in self.mobs.iter().filter(|m| !m.removed && m.id > after) {
for (index, m) in (self.mobs.iter().enumerate()).filter(|(_, m)| !m.removed && m.id > after)
{
// The lead's holder: the player the mob sees, another mob or a
// fence knot (game/lan_entities.rs).
let player_entity = || {
@@ -1986,6 +1988,7 @@ impl Game<'_> {
let bare = idle
&& (kept.as_deref_mut()).is_some_and(|k| k.bare(hub, m.id as i32, kind, &place))
&& !m.needs_sync;
let slot = out.as_slice().len();
let e = out.add(m.id as i32, entity_uuid(m.id), kind);
e.position = place.position;
(e.yaw, e.pitch, e.head_yaw) = place.rotation;
@@ -1999,21 +2002,11 @@ impl Game<'_> {
if bare {
continue;
}
mob_meta(
m,
hub,
player_entity(),
self.world.game_time,
&mut e.meta,
&mut e.defaults,
);
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
e.precise = m.species() == horizon_client::mobs::Species::HappyGhast
&& horizon_client::mobs::happy_ghast::on_still_timeout(m);
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
let (sync, sources) = (hub.sync_data(), self.attr_sources());
states.state((slot, index), m, player_entity(), sync, sources, e);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut speeds, &mut e.attributes);
}
states.fill(&self.mobs, hub.sync_data(), self.attr_sources(), out);
for d in self.dropped.iter().filter(|d| d.id > after) {
let idle = idle_at(d.position);
let place = super::lan_idle::Place {
@@ -4116,7 +4109,7 @@ mod tests {
let hub = meta_hub("RaiderMetaTest");
let meta_of = |m: &Mob| {
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
super::mob_meta(m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
@@ -4145,7 +4138,7 @@ mod tests {
// The same health: the ghoul's own maximum is 40, the zombie's 20.
m.health = 10.0;
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(&m, &hub, None, 0, &mut meta, &mut defaults);
super::mob_meta(&m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
for (template, id) in [
@@ -0,0 +1,286 @@
//! The part of each mob's state for its clients (`Game::lan_entities`) that reads only the mob and
//! the synced tables: its entity data and attribute instances. While the parallel tick is on
//! (`horizon_client::parallel`) it is built after the pass, on the tick's helper threads, and put in
//! place; otherwise in place during the pass, as one thread always did.
use super::super::lan_attributes::{AttrSources, SpeedByType};
use super::{mob_freeze_meta, mob_meta};
use horizon_client::lan::{
data::SyncData,
entity::{EntityState, States},
};
use horizon_client::mobs::Mob;
use horizon_protocol::{entity_data::Value, packets::play::AttributeSnapshot};
/// From this many mobs the states go to the helper threads.
const MIN_MOBS: usize = 1024;
/// One mob's entity data (`meta`, and the `defaults` its entries can go back to) and attribute
/// instances; whether its position is sent precisely.
#[allow(clippy::too_many_arguments)]
pub(super) fn mob_state(
m: &Mob,
sync: &SyncData,
sources: AttrSources,
player_entity: Option<i32>,
game_time: i64,
speeds: &mut SpeedByType,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
attributes: &mut Vec<AttributeSnapshot>,
) -> bool {
mob_meta(m, sync, player_entity, game_time, meta, defaults);
mob_freeze_meta(m, meta, defaults);
sources.mob_attributes(m, speeds, attributes);
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
m.species() == horizon_client::mobs::Species::HappyGhast
&& horizon_client::mobs::happy_ghast::on_still_timeout(m)
}
#[cfg(test)]
thread_local! {
/// A test's choice of the path, over the parallel tick's switch.
static DEFER: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
}
/// Whether the states wait for the helper threads: while the parallel tick is on.
fn deferring() -> bool {
#[cfg(test)]
if let Some(defer) = DEFER.with(std::cell::Cell::get) {
return defer;
}
horizon_client::parallel::lanes() > 1
}
/// The mobs' states of one pass: built in place, or (while the parallel tick is on) the mobs whose
/// states wait for the helper threads: (their slot in the states, their index in the mobs, the
/// player entity their lead may name).
pub(super) struct Later {
waiting: Option<Vec<(usize, usize, Option<i32>)>>,
game_time: i64,
speeds: SpeedByType,
}
impl Later {
pub(super) fn new(game_time: i64) -> Self {
Self {
waiting: deferring().then(Vec::new),
game_time,
speeds: SpeedByType::default(),
}
}
/// Mob `index`'s state `e`, in `slot` of the states: built now, or left for [`Self::fill`].
pub(super) fn state(
&mut self,
(slot, index): (usize, usize),
m: &Mob,
player_entity: Option<i32>,
sync: &SyncData,
sources: AttrSources,
e: &mut EntityState,
) {
if let Some(waiting) = &mut self.waiting {
waiting.push((slot, index, player_entity));
return;
}
let (meta, defaults, attributes) = (&mut e.meta, &mut e.defaults, &mut e.attributes);
e.precise = mob_state(
m,
sync,
sources,
player_entity,
self.game_time,
&mut self.speeds,
meta,
defaults,
attributes,
);
}
/// The waiting states, built on the helper threads and put in place.
pub(super) fn fill(
self,
mobs: &[Mob],
sync: &SyncData,
sources: AttrSources,
out: &mut States,
) {
let Some(waiting) = self.waiting else {
return;
};
let game_time = self.game_time;
let built = horizon_client::parallel::par_map(&waiting, MIN_MOBS, |part, done| {
let mut speeds = SpeedByType::default();
for &(slot, index, player) in part {
let (mut meta, mut defaults, mut attributes) = (Vec::new(), Vec::new(), Vec::new());
let precise = mob_state(
&mobs[index],
sync,
sources,
player,
game_time,
&mut speeds,
&mut meta,
&mut defaults,
&mut attributes,
);
done.push((slot, meta, defaults, attributes, precise));
}
});
let states = out.as_mut_slice();
for (slot, meta, defaults, attributes, precise) in built {
let e = &mut states[slot];
(e.meta, e.defaults, e.attributes, e.precise) = (meta, defaults, attributes, precise);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::lan::{NetConfig, hub::HubConfig};
/// The states put in place after the pass are those built in place during it, for mobs whose
/// entity data and attributes vary: effects, armor, a horse's rolled jump, variants, a baby,
/// a happy ghast, and what `/attribute` changed.
#[test]
#[ignore = "needs prepared resources and scratch homes/cwd"]
fn deferred_states_match_those_built_in_place() {
crate::mod_api::in_own_process(
"game::lan::mob_states::tests::deferred_states_match_those_built_in_place",
deferred_states_match_those_built_in_place_in_own_process,
);
}
fn deferred_states_match_those_built_in_place_in_own_process() {
let mut game = super::super::super::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
for command in [
"summon piglin 8.5 80 8.5 {NoAI:1b,PersistenceRequired:1b}",
"effect give @e[type=piglin] speed 30 1",
"item replace entity @e[type=piglin,limit=1] armor.head with iron_helmet",
"summon horse 10.5 80 8.5 {NoAI:1b}",
"summon sheep 12.5 80 8.5 {NoAI:1b,Color:3b,Age:-24000}",
"summon axolotl 14.5 80 8.5 {NoAI:1b,Variant:2}",
"summon happy_ghast 16.5 84 8.5 {NoAI:1b}",
"summon pig 18.5 80 8.5 {NoAI:1b}",
"summon frog 20.5 80 8.5 {NoAI:1b}",
"attribute @e[type=pig,limit=1] minecraft:scale base set 1.5",
] {
assert!(game.run_command(command), "{command}");
}
let examples = game.mobs.clone();
game.mobs = (0..MIN_MOBS * 2 + 37)
.map(|i| {
let mut mob = examples[i % examples.len()].clone();
mob.id = 10_000 + i as u32;
mob.removed = i % 11 == 0;
mob
})
.collect::<Vec<_>>()
.into();
game.lan_host(
super::super::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig::loopback(0, String::new(), 2),
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 20,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let mut lan = game.lan.take().unwrap();
// The shared tables must retain pack-selected effect colors, not just
// vanilla's registry color. This process owns the global pack palette.
horizon_client::brewing::set_pack_colors(vec![("speed".into(), 0xAA12_3456)]);
let players = Default::default();
let mut runs = Vec::new();
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
DEFER.with(|d| d.set(None));
}
}
let _reset = Reset;
for after in [0, 10_012] {
runs.clear();
for defer in [false, true, false] {
DEFER.with(|d| d.set(Some(defer)));
let mut states = States::default();
game.lan_entities(&players, &mut lan.hub, &mut states, false, after, None);
runs.push(states.as_slice().to_vec());
}
let expected_ids: Vec<_> = game
.mobs
.iter()
.filter(|m| !m.removed && m.id > after)
.map(|m| m.id as i32)
.collect();
assert_eq!(
runs[0].iter().map(|e| e.id).collect::<Vec<_>>(),
expected_ids
);
assert!(runs[0].iter().filter(|e| !e.meta.is_empty()).count() >= MIN_MOBS);
assert!(runs[0].iter().any(|e| !e.attributes.is_empty()));
let colored: Vec<_> = runs[0]
.iter()
.filter(|e| e.kind == "minecraft:piglin")
.collect();
assert!(!colored.is_empty());
for e in colored {
let particles = e.meta.iter().find(|e| e.0 == 10).unwrap();
let Value::Particles(particles) = &particles.2 else {
panic!("effect particle metadata");
};
assert_eq!(particles.len(), 1);
assert_eq!(
particles[0].data,
Some(horizon_protocol::entity_data::ParticleData::Color(
0xFF12_3456_u32 as i32
))
);
}
assert_eq!(runs[0], runs[1], "deferred, after {after}");
assert_eq!(runs[0], runs[2], "switch off, after {after}");
// With deferral off the caller can read the metadata immediately,
// before fill, and fill must not replace the vectors it already owns.
let m = game
.mobs
.iter()
.find(|m| !m.removed && m.id > after)
.unwrap();
let mut states = States::default();
let e = states.add(m.id as i32, 0, &m.kind.id);
let mut later = Later::new(game.world.game_time);
later.state((0, 0), m, None, lan.hub.sync_data(), game.attr_sources(), e);
assert_eq!(e.meta, runs[0][0].meta);
assert_eq!(e.defaults, runs[0][0].defaults);
assert_eq!(e.attributes, runs[0][0].attributes);
let pointers = (e.meta.as_ptr(), e.defaults.as_ptr(), e.attributes.as_ptr());
later.fill(
&game.mobs,
lan.hub.sync_data(),
game.attr_sources(),
&mut states,
);
let e = &states.as_slice()[0];
assert_eq!(
pointers,
(e.meta.as_ptr(), e.defaults.as_ptr(), e.attributes.as_ptr())
);
}
}
}
@@ -22,7 +22,7 @@ fn a_sulfur_cube_sends_its_fuse_at_19_and_from_bucket_at_20() {
let hub = meta_hub("SulfurCubeMetaTest");
let meta_of = |m: &Mob| {
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
super::mob_meta(m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
@@ -103,10 +103,32 @@ impl Game<'_> {
}
}
/// What a mob's synced attribute instances are read from (`mob_attributes`).
pub(super) fn attr_sources(&self) -> AttrSources<'_> {
AttrSources {
catalog: &self.motion.attributes,
items: self.items(),
commands: &self.cmds.attributes,
}
}
}
/// What the synced attribute instances are read from: the attribute catalog, the item table and
/// what `/attribute` changed. Plain tables, so the tick's helper threads can read them
/// (`Game::lan_entities`).
#[derive(Clone, Copy)]
pub(super) struct AttrSources<'a> {
catalog: &'a horizon_world::attributes::entity::Catalog,
items: &'a horizon_client::items::Items,
commands:
&'a std::collections::HashMap<Target, horizon_world::attributes::entity::EntityAttributes>,
}
impl AttrSources<'_> {
/// The registry id of a client-syncable attribute the entity type has
/// (`AttributeMap.getInstance` is null for the others), by name.
fn synced_attribute(&self, entity: &str, name: &str) -> Option<i32> {
let catalog = &self.motion.attributes;
let catalog = self.catalog;
let id = catalog.id(name)?;
(catalog.attribute(id).syncable && catalog.default_base(entity, id).is_some())
.then(|| id.registry_id())
@@ -114,7 +136,7 @@ impl Game<'_> {
/// The type's base value of an attribute, as the instance starts.
fn attribute_default(&self, entity: &str, name: &str) -> f64 {
let catalog = &self.motion.attributes;
let catalog = self.catalog;
catalog
.id(name)
.and_then(|id| catalog.default_base(entity, id))
@@ -132,7 +154,7 @@ impl Game<'_> {
out: &mut Vec<AttributeSnapshot>,
) {
for (slot, item) in slots {
let Some(info) = item.and_then(|i| self.items().get(i)) else {
let Some(info) = item.and_then(|i| self.items.get(i)) else {
continue;
};
for (attribute, id, amount, op, group) in &info.modifiers {
@@ -306,6 +328,16 @@ impl Game<'_> {
}
}
/// The instances `/attribute` created in an entity's generic map
/// replace the ones derived from its state.
fn generic_attributes_of(&self, t: Target, out: &mut Vec<AttributeSnapshot>) {
if let Some(map) = self.commands.get(&t) {
generic_attributes_into(map, out);
}
}
}
impl Game<'_> {
/// An armor stand's synced instances: movement speed (its first
/// `aiStep`), its equipment's modifiers and what `/attribute` changed.
pub(super) fn stand_attributes(
@@ -315,18 +347,19 @@ impl Game<'_> {
) -> Vec<AttributeSnapshot> {
const KIND: &str = "minecraft:armor_stand";
let mut out = Vec::new();
if let Some(a) = self.synced_attribute(KIND, "minecraft:movement_speed") {
let sources = self.attr_sources();
if let Some(a) = sources.synced_attribute(KIND, "minecraft:movement_speed") {
instance(
&mut out,
a,
self.attribute_default(KIND, "minecraft:movement_speed"),
sources.attribute_default(KIND, "minecraft:movement_speed"),
);
}
let slots = horizon_client::entities::ArmorStand::SLOTS
.into_iter()
.zip(equipment.iter().map(|s| s.as_ref().map(|s| &s.item)));
self.equipment_attributes(KIND, |_| None, slots, &mut out);
self.generic_attributes_of(Target::Stand(id), &mut out);
sources.equipment_attributes(KIND, |_| None, slots, &mut out);
sources.generic_attributes_of(Target::Stand(id), &mut out);
out
}
@@ -372,14 +405,6 @@ impl Game<'_> {
}
}
}
/// The instances `/attribute` created in an entity's generic map
/// replace the ones derived from its state.
fn generic_attributes_of(&self, t: Target, out: &mut Vec<AttributeSnapshot>) {
if let Some(map) = self.cmds.attributes.get(&t) {
generic_attributes_into(map, out);
}
}
}
#[cfg(test)]
+3 -34
View File
@@ -146,12 +146,6 @@ impl WorldOptions {
self.initial.difficulty = self.difficulty;
self.initial.locked = self.locked;
}
/// `WorldOptionsScreen.updatePortControlsState`: the port box takes an
/// edit only with LAN on; it stays focusable either way, so its value can
/// be read, selected and copied while LAN is off.
fn update_port_controls(&mut self) {
self.port.editable = self.lan;
}
fn parsed_port(&self) -> Option<u16> {
if self.port.value.is_empty() {
return Some(self.hint);
@@ -948,7 +942,7 @@ impl Game<'_> {
if !o.remote {
o.port.x = x + 158.0;
o.port.y = top + PORT_TOP;
o.update_port_controls();
o.port.focused = o.lan && o.port.focused;
o.port.text_color = if !o.lan {
rgba(0x70, 0x70, 0x70, 255)
} else if o.parsed_port().is_some() {
@@ -1172,6 +1166,7 @@ impl Game<'_> {
}
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
o.port.focused = !o.remote
&& o.lan
&& inside
&& ui::hovered(
self.cursor,
@@ -1406,7 +1401,7 @@ impl Game<'_> {
let reverse = shift;
let next = match o.focused_button {
Some(cur) => {
if !o.remote {
if !o.remote && o.lan {
if !reverse && cur == Button::Lan {
o.port.focused = true;
o.focused_button = None;
@@ -1927,30 +1922,4 @@ mod tests {
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
}
/// `WorldOptionsScreen.updatePortControlsState`: with LAN off the port box
/// is read-only but keeps the focus, so its value can be selected and
/// copied; the caret moves, Backspace is consumed without a change, and
/// with LAN on it edits again.
#[test]
fn the_port_box_is_read_only_but_focusable_with_lan_off() {
use winit::keyboard::KeyCode;
let mut o = options(false, false);
assert!(!o.lan);
o.port.focused = true;
o.update_port_controls();
assert!(!o.port.editable);
assert!(o.port.focused);
o.port.set_value("25565");
assert!(o.port.key(KeyCode::Backspace, false, false));
assert_eq!(o.port.value, "25565");
assert!(o.port.key(KeyCode::ArrowLeft, false, false));
assert_eq!(o.port.cursor, 4);
// LAN on: the box takes the edit.
o.lan = true;
o.update_port_controls();
assert!(o.port.editable);
assert!(o.port.key(KeyCode::Backspace, false, false));
assert_eq!(o.port.value, "2555");
}
}
@@ -47,18 +47,12 @@ fn game() -> Game<'static> {
.unwrap();
game.build_registries();
game.set_allow_commands(true);
// Entities tick once the 2 columns around their chunk are loaded
// (`ChunkMap.prepareEntityTickingChunk`), so the chunk of the mobs has all of them.
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(x, z))
.unwrap();
}
}
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
for command in [
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
@@ -159,10 +153,8 @@ fn the_saved_hurt_by_mob_follows_the_live_record_only() {
let a = game.mobs[a_index].id;
// No record, no raw copy: nothing.
assert_eq!(fields(&mut game, victim), (None, None), "no record");
// A raw copy from a stale compound, no live record and no read-back
// reference: both keys go.
// A raw copy from the summon, no live record: both keys go.
let echo = index(&mut game, ECHO);
game.mobs[echo].ai.last_hurt_by_ref = None;
assert_eq!(fields(&mut game, echo), (None, None), "stale raw copy");
// A live record of a resident mob: both keys, at 100 ticks still.
let age = game.mobs[victim].age as i32;
@@ -197,121 +189,3 @@ fn the_saved_hurt_by_mob_follows_the_live_record_only() {
assert!(!game.mobs[ai].alive());
assert_eq!(fields(&mut game, victim), (None, None), "dead attacker");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_loaded_hurt_by_mob_is_read_back_and_ends_after_100_ticks() {
use horizon_client::mobs::Who;
let mut game = game();
let echo = index(&mut game, ECHO);
let a_index = index(&mut game, A);
let a = game.mobs[a_index].id;
// Read back at once (vanilla: `last_hurt_by_mob` A, ticks 0 then 1).
assert_eq!(
fields(&mut game, echo),
(Some(Tag::IntArray(vec![24724, 0, 0, 2])), Some(Tag::Int(0))),
"read back"
);
step(&mut game, 1);
let echo = index(&mut game, ECHO);
assert_eq!(game.mobs[echo].ai.last_hurt_by, Some(Who::Mob(a)));
assert_eq!(fields(&mut game, echo).1, Some(Tag::Int(1)));
// `tickCount - timestamp > 100` ends it: still there after 100 ticks.
step(&mut game, 99);
let echo = index(&mut game, ECHO);
assert_eq!(game.mobs[echo].ai.last_hurt_by, Some(Who::Mob(a)));
assert_eq!(fields(&mut game, echo).1, Some(Tag::Int(100)));
step(&mut game, 1);
let echo = index(&mut game, ECHO);
assert_eq!(game.mobs[echo].ai.last_hurt_by, None, "101 ticks");
assert_eq!(fields(&mut game, echo), (None, None), "101 ticks");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_loaded_hurt_by_mob_of_a_dead_attacker_ends_at_the_next_tick() {
let mut game = game();
step(&mut game, 1);
let echo = index(&mut game, ECHO);
assert!(game.mobs[echo].ai.last_hurt_by.is_some(), "resolved");
let a = index(&mut game, A);
game.mobs[a].health = 0.0;
step(&mut game, 1);
let echo = index(&mut game, ECHO);
assert_eq!(game.mobs[echo].ai.last_hurt_by, None, "dead attacker");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_saved_hurt_timestamp_uses_java_integer_wrapping() {
let mut game = game();
let echo = index(&mut game, ECHO);
game.mobs[echo].ai.last_hurt_by_time = (game.mobs[echo].age as i32).wrapping_sub(i32::MIN);
step(&mut game, 1);
let echo = index(&mut game, ECHO);
assert!(game.mobs[echo].ai.last_hurt_by.is_some());
assert_eq!(fields(&mut game, echo).1, Some(Tag::Int(i32::MIN + 1)));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_loaded_hurt_by_mob_naming_no_entity_stays_until_it_arrives() {
let mut game = game();
assert!(game.run_command(
"summon pig 8.5 100 12.5 {NoAI:1b,PersistenceRequired:1b,UUID:[I;24724,0,0,5],last_hurt_by_mob:[I;24724,0,0,99],ticks_since_last_hurt_by_mob:7}"
));
let ghost = index(&mut game, "00006094-0000-0000-0000-000000000005");
let named = Some(Tag::IntArray(vec![24724, 0, 0, 99]));
assert_eq!(fields(&mut game, ghost), (named.clone(), Some(Tag::Int(7))));
// Nothing resolves it: no baseTick end, however long, and it keeps aging.
step(&mut game, 200);
let ghost = index(&mut game, "00006094-0000-0000-0000-000000000005");
assert_eq!(game.mobs[ghost].ai.last_hurt_by, None, "not the AI's yet");
assert_eq!(fields(&mut game, ghost), (named, Some(Tag::Int(207))));
// The entity it names arrives: it resolves, is past 100 ticks, and ends.
assert!(game.run_command(
"summon pig 12.5 100 12.5 {NoAI:1b,PersistenceRequired:1b,UUID:[I;24724,0,0,99]}"
));
step(&mut game, 1);
let ghost = index(&mut game, "00006094-0000-0000-0000-000000000005");
assert_eq!(
fields(&mut game, ghost),
(None, None),
"resolved, then ended"
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_loaded_hurt_by_mob_naming_the_mob_itself_resolves_and_ends_after_100_ticks() {
use horizon_client::mobs::Who;
// `EntityReference.getEntity` looks the UUID up in the level and does not
// exclude the holder, so `baseTick` ends the record like any other.
const SELF: &str = "00006094-0000-0000-0000-000000000006";
let mut game = game();
assert!(game.run_command(
"summon pig 8.5 100 12.5 {NoAI:1b,PersistenceRequired:1b,UUID:[I;24724,0,0,6],last_hurt_by_mob:[I;24724,0,0,6],ticks_since_last_hurt_by_mob:3}"
));
let named = Some(Tag::IntArray(vec![24724, 0, 0, 6]));
let me = index(&mut game, SELF);
let id = game.mobs[me].id;
assert_eq!(fields(&mut game, me), (named.clone(), Some(Tag::Int(3))));
step(&mut game, 1);
let me = index(&mut game, SELF);
assert_eq!(
game.mobs[me].ai.last_hurt_by,
Some(Who::Mob(id)),
"resolved"
);
assert_eq!(game.mobs[me].ai.last_hurt_by_ref, None, "no bare reference");
assert_eq!(fields(&mut game, me), (named.clone(), Some(Tag::Int(4))));
// Aged 100 ticks: still there; one more and `baseTick` ends it.
step(&mut game, 96);
let me = index(&mut game, SELF);
assert_eq!(game.mobs[me].ai.last_hurt_by, Some(Who::Mob(id)));
assert_eq!(fields(&mut game, me), (named, Some(Tag::Int(100))));
step(&mut game, 1);
let me = index(&mut game, SELF);
assert_eq!(game.mobs[me].ai.last_hurt_by, None, "101 ticks");
assert_eq!(fields(&mut game, me), (None, None), "101 ticks");
}
@@ -316,7 +316,6 @@ impl Loading {
loaded.renderer_cpu,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.vsync,
&self.settings.horizon,
shader_packs::picked_pack_shape(&self.settings),
self.renderer
@@ -467,26 +466,17 @@ impl ApplicationHandler for App {
/// Every launch reads the prepared game files (`horizon-prepare`'s output, whose
/// manifest is `horizon-resources.json`): without them, say where they were
/// looked for and how to get them, before a window or a loader starts. `get` is
/// the page a hook sends the player to (`horizon_hooks::engine::get_the_game`).
pub(super) fn missing_resources(
root: &std::path::Path,
get: Option<horizon_hooks::GetTheGame>,
) -> Option<String> {
/// looked for and how to get them, before a window or a loader starts.
pub(super) fn missing_resources(root: &std::path::Path) -> Option<String> {
let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
if !root.join("horizon-resources.json").is_file() {
let launcher = get.map_or(String::new(), |get| {
format!(
"Horizon is started from its launcher: download it at {} (Download). It needs \
your own Minecraft: Java Edition; the launcher signs you in and prepares the \
game files.\nDevelopers: ",
get.address
)
});
return Some(format!(
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
{launcher}horizon-prepare --prepare, or pass --resources <folder>",
Horizon is started from its launcher: download it at {} (Download). It needs your \
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
Developers: horizon-prepare --prepare, or pass --resources <folder>",
shown.display(),
horizon_client::no_resources::DOWNLOAD_URL
));
}
damaged_prepared(root).map(|problem| {
@@ -32,7 +32,6 @@ pub(super) fn build_renderer(
prepared: Option<horizon_client::gfx::PreparedTextures>,
api: horizon_client::graphics::GraphicsApi,
translucent_batching: bool,
vsync: bool,
// HDR and Smooth Gradients pick the frame, with what the window's display reported to the
// renderer before (`display`, `Renderer::new`).
settings: &horizon_client::graphics::Graphics,
@@ -80,10 +79,6 @@ pub(super) fn build_renderer(
shader_pack.frame(),
display,
))?;
// The loading renderer may have been built before the saved graphics settings were
// adopted. Configure the world swapchain before its first gameplay frame instead of
// waiting for `Game::apply_settings` after the handoff.
renderer.set_present_mode(vsync, settings.present_mode);
renderer.hud.sprite_animations = atlas
.sprite_animations
.iter()
+7 -32
View File
@@ -8,16 +8,6 @@ use horizon_client::{
mobs::{Mob, Species, render},
};
// `FishingRodCast`: the main hand takes precedence when both hold rods.
fn fishing_cast_item(
player_id: u32,
hook_owners: impl Iterator<Item = u32>,
main_hand_rod: bool,
off_hand: bool,
) -> bool {
hook_owners.into_iter().any(|owner| owner == player_id) && main_hand_rod != off_hand
}
/// `CarriedBlockLayer.submit`: block models use coordinates in [0, 1].
fn carried_block_matrix(base: glam::Mat4) -> glam::Mat4 {
base * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.6875, -0.75))
@@ -471,17 +461,13 @@ impl Game<'_> {
mob.body_yaw,
&mob.kind.id,
);
owner.fishing_cast_with_this_item = if local_player {
self.cast_rod().is_some() && self.main_hand_rod() != off_hand
} else {
let active = self.remote_hooks.iter().filter(|h| h.owner_fishing);
fishing_cast_item(
mob.id,
active.map(|hook| hook.owner),
mob.extra.main_hand.as_deref() == Some("minecraft:fishing_rod"),
off_hand,
)
};
// The local player's rod while its hook is out.
owner.fishing_cast_with_this_item = self.cast_rod().is_some()
&& self.main_hand_rod() != off_hand
&& self
.player_model
.as_ref()
.is_some_and(|p| std::ptr::eq(p, mob));
owner.main_arm_left = mob.main_arm_left();
if off_hand == mob.extra.using_off_hand && mob.extra.using_item > 0 {
owner.using_this_item = true;
@@ -717,17 +703,6 @@ mod tests {
))
}
#[test]
fn remote_fishing_rod_cast_follows_owner_and_holding_hand() {
let owners = [7, 12];
assert!(fishing_cast_item(7, owners.into_iter(), true, false));
assert!(!fishing_cast_item(7, owners.into_iter(), true, true));
assert!(fishing_cast_item(12, owners.into_iter(), false, true));
assert!(!fishing_cast_item(12, owners.into_iter(), false, false));
assert!(!fishing_cast_item(8, owners.into_iter(), true, false));
assert!(!fishing_cast_item(7, [].into_iter(), true, false));
}
#[test]
fn a_worn_head_follows_the_living_vehicle() {
let (Some(mut wearer), Some(mut horse)) =
@@ -1,9 +1,9 @@
//! A game started without its prepared files (the game build run by hand, not
//! through a launcher): a window that says where to get them
//! through the launcher): a window that says where to get the launcher
//! (`horizon_client::no_resources`) and quits when it is closed. No loader, no
//! crash report: nothing went wrong in the game.
use super::*;
use horizon_client::no_resources::{Action, NoResources};
use horizon_client::no_resources::{Action, DOWNLOAD_URL, NoResources};
use winit::event::MouseButton;
struct Missing {
@@ -19,11 +19,6 @@ struct Missing {
/// Shows the screen until the player closes it.
pub(super) fn show(looked_in: &std::path::Path, settings: &Settings) -> Result<()> {
// The player's language, from the strings compiled in: there are no prepared ones.
horizon_client::lang::set(horizon_client::lang::Language::load(
&[],
&settings.language,
));
let mut app = Missing {
window: None,
renderer: None,
@@ -31,7 +26,7 @@ pub(super) fn show(looked_in: &std::path::Path, settings: &Settings) -> Result<(
atlas: HudAtlas::build(looked_in)?,
panorama: Arc::new(horizon_client::gfx::panorama::Faces::colour([0; 4])),
},
screen: NoResources::new(looked_in, horizon_hooks::engine::get_the_game()),
screen: NoResources::new(looked_in),
pointer: (0.0, 0.0),
api: settings.horizon.api,
error: None,
@@ -86,7 +81,7 @@ impl Missing {
fn click(&mut self, event_loop: &ActiveEventLoop) {
let mut batch = HudBatch::new(self.size());
match self.with_ui(&mut batch, |ui| self.screen.click(ui)) {
Some(Action::Open(address)) => packs::open_uri(address),
Some(Action::Download) => packs::open_uri(DOWNLOAD_URL),
Some(Action::Quit) => event_loop.exit(),
None => {}
}
-1
View File
@@ -388,7 +388,6 @@ impl Game<'_> {
Some(textures),
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.vsync,
&self.settings.horizon,
super::shader_packs::picked_pack_shape(&self.settings),
self.renderer
+36 -1
View File
@@ -4,7 +4,6 @@
#[cfg(test)]
mod anger_tests;
mod display;
mod hurt_by_refs;
use super::{FallingBlock, Game};
use horizon_client::{
@@ -1615,6 +1614,42 @@ impl Game<'_> {
self.put_mob_passengers(&mut tag, id);
tag
}
/// `LivingEntity.addAdditionalSaveData`'s `last_hurt_by_mob` and
/// `ticks_since_last_hurt_by_mob`, a PARTIAL projection at save time: both
/// from `ai.last_hurt_by` while the attacker is a living mob in the level
/// with a registered UUID and at most 100 ticks have passed (`baseTick`
/// drops the record after that, or once the attacker is dead). Otherwise
/// both keys go, a copy read from loaded data included. A player attacker
/// is not written. This is not `baseTick`'s cleanup itself, the AI's
/// record, `#no_anger`, or reading the keys back.
pub(super) fn put_last_hurt_by_mob(&self, m: &horizon_client::mobs::Mob, tag: &mut Tag) {
let Tag::Compound(c) = tag else { return };
let since = i64::from(m.age) - i64::from(m.ai.last_hurt_by_time);
let reference = match m.ai.last_hurt_by {
Some(horizon_client::mobs::Who::Mob(attacker))
if (0..=100).contains(&since)
&& self.mobs.iter().any(|a| a.id == attacker && a.alive()) =>
{
self.persist.uuid_of(attacker)
}
_ => None,
};
match reference {
Some(uuid) => {
c.insert("last_hurt_by_mob".into(), Tag::IntArray(uuid.to_vec()));
c.insert(
"ticks_since_last_hurt_by_mob".into(),
Tag::Int(since as i32),
);
}
None => {
c.remove(&horizon_world::nbt::JavaString::from("last_hurt_by_mob"));
c.remove(&horizon_world::nbt::JavaString::from(
"ticks_since_last_hurt_by_mob",
));
}
}
}
/// Forget an entity that left the world (its UUID and loaded compound).
pub(super) fn forget_entity(&mut self, id: u32) {
if let Some(uuid) = self.persist.uuids.remove(&id) {
@@ -1,86 +0,0 @@
//! `LivingEntity.lastHurtByMob` is an `EntityReference`: a UUID that stands for
//! the entity once it is in the level. What a mob saves of it, how a value read
//! back resolves, and the same for the player in `lastHurtByPlayer`.
use super::Game;
use horizon_client::mobs::{Mob, Who};
use horizon_world::nbt::{JavaString, Tag};
impl Game<'_> {
/// `LivingEntity.addAdditionalSaveData`'s `last_hurt_by_mob` and
/// `ticks_since_last_hurt_by_mob`, a PARTIAL projection at save time: both
/// from `ai.last_hurt_by` while the attacker is a living mob in the level
/// with a registered UUID and at most 100 ticks have passed (`baseTick`
/// drops the record after that, or once the attacker is dead). Otherwise
/// both keys go, a copy read from loaded data included. A player attacker
/// is not written. A reference read back that names nothing in the level
/// yet (`ai.last_hurt_by_ref`) is written as it is. This is not
/// `baseTick`'s cleanup itself, the AI's record, or `#no_anger`.
pub(in crate::game) fn put_last_hurt_by_mob(&self, m: &Mob, tag: &mut Tag) {
let Tag::Compound(c) = tag else { return };
// A reference read back and not resolved yet (its attacker is not in
// the level) is kept as it is, for ever: `baseTick` cannot resolve it,
// so it does not end it.
if let Some(uuid) = m.ai.last_hurt_by_ref {
c.insert("last_hurt_by_mob".into(), Tag::IntArray(uuid.to_vec()));
c.insert(
"ticks_since_last_hurt_by_mob".into(),
Tag::Int((m.age as i32).wrapping_sub(m.ai.last_hurt_by_time)),
);
return;
}
let since = (m.age as i32).wrapping_sub(m.ai.last_hurt_by_time);
let reference = match m.ai.last_hurt_by {
Some(Who::Mob(attacker))
if since <= 100 && self.mobs.iter().any(|a| a.id == attacker && a.alive()) =>
{
self.persist.uuid_of(attacker)
}
_ => None,
};
match reference {
Some(uuid) => {
c.insert("last_hurt_by_mob".into(), Tag::IntArray(uuid.to_vec()));
c.insert("ticks_since_last_hurt_by_mob".into(), Tag::Int(since));
}
None => {
c.remove(&JavaString::from("last_hurt_by_mob"));
c.remove(&JavaString::from("ticks_since_last_hurt_by_mob"));
}
}
}
/// `EntityReference.getEntity` for `last_hurt_by_mob` read back from a save:
/// a mob whose reference names an entity now in the level (a mob, or the
/// player) takes it as `last_hurt_by`, and `baseTick` ends it as it does
/// any other. One naming nothing here stays a bare UUID, still saved. A
/// reference to the mob itself resolves like any other (`getEntity` looks
/// up the UUID and does not exclude the holder).
pub(in crate::game) fn resolve_last_hurt_by_refs(&mut self) {
let resolved: Vec<(usize, Who)> = self
.mobs
.iter()
.enumerate()
.filter_map(|(i, m)| Some((i, self.who_by_uuid(m.ai.last_hurt_by_ref?)?)))
.collect();
for (i, who) in resolved {
let ai = &mut self.mobs[i].ai;
ai.last_hurt_by = Some(who);
ai.last_hurt_by_ref = None;
}
}
/// The player in the world with `uuid` (`EntityReference.getPlayer`), as
/// `last_hurt_by_player` keeps them: None for the player in place, else the
/// guest's connection.
pub(in crate::game) fn player_with_uuid(&self, uuid: [i32; 4]) -> Option<Option<u32>> {
if self.player.is_some()
&& horizon_client::mobs::tame::owned_by(Some(uuid), self.acting_uuid())
{
return Some(self.lan_acting);
}
let uuid = horizon_client::lan::playerdata::ints_uuid(uuid);
self.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.find(|g| g.placed && g.uuid == uuid)
.map(|g| Some(g.conn))
}
}
@@ -266,6 +266,7 @@ fn server_ping_client_pong_keeps_the_existing_prediction_route() {
.hub
.guest_mut(1)
.unwrap()
.anticheat
.record
.as_deref_mut()
.expect("existing client Pong updates prediction");
+2 -1
View File
@@ -66,7 +66,8 @@ impl Game<'_> {
motion.item_use_speed = item_use.map(|(speed, _)| speed);
motion.item_use_slows_sprint = item_use.is_some_and(|(_, slows)| slows);
}
let (Some(record), Some(server)) = (g.record.as_deref_mut(), g.motion.as_deref()) else {
let (Some(record), Some(server)) = (g.anticheat.record.as_deref_mut(), g.motion.as_deref())
else {
return;
};
let mut run = |body: &mut PlayerMotion, keys, sprinting| {
@@ -38,7 +38,6 @@ impl Game<'_> {
None,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.vsync,
&self.settings.horizon,
shader_packs::picked_pack_shape(&self.settings),
self.renderer
+1 -15
View File
@@ -177,22 +177,8 @@ impl Game<'_> {
);
}
pub(super) fn social_click(&mut self) {
let size = self.gui_size();
let cursor = self.cursor;
let view = self.social_view();
let Some(kind) = social_screen::click_kind(size, cursor, &view) else {
return;
};
// The widget first: the search box's `onClick` puts the caret under the
// pointer, before any list button is considered.
if let social_screen::Click::Search = kind {
let shift = self.has_shift_down();
self.social
.search
.click(&self.gui.atlas, cursor.0, shift, false);
return;
}
let social_screen::Click::Action(action) = kind else {
let Some(action) = social_screen::click(self.gui_size(), self.cursor, &view) else {
return;
};
self.audio.play_button_click();
-2
View File
@@ -380,7 +380,6 @@ impl<'a> Split<'a> {
None,
api,
batching,
self.host.settings.vsync,
&self.host.settings.horizon,
shader_pack,
old.display_reading(),
@@ -465,7 +464,6 @@ impl<'a> Split<'a> {
None,
api,
batching,
self.host.settings.vsync,
&self.host.settings.horizon,
shader_pack,
old.display_reading(),
@@ -518,7 +518,6 @@ impl Split<'_> {
None,
host.settings.horizon.api,
host.settings.horizon.translucent_batching,
host.settings.vsync,
&host.settings.horizon,
// The pack its own game loads, from its profile.
crate::game::shader_packs::picked_pack_shape(&guest.settings),
@@ -77,9 +77,8 @@ fn with_stick_table(game: &mut Game<'_>, name: &str) {
game.world_root = dir;
game.world_tables = game.build_world_tables(false, &[]).unwrap().0;
// World open warms the loot data with the world's packs (game.rs); the
// break's roll reads that copy, here this test's own, so the stick reaches
// no other test's stand (#14266).
let _ = horizon_client::structure_blocks::load_thread_loot_data(
// break's roll reads that copy.
let _ = horizon_client::structure_blocks::world_loot_data(
&game.options.resources,
&game.pack_files(),
);
@@ -4,40 +4,26 @@ use super::*;
fn a_folder_without_prepared_files_is_named_before_loading() {
let root = std::env::temp_dir().join(format!("horizon-unprepared-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let unprepared = root.join(".local/horizon-client/26.3");
let missing = missing_resources(&unprepared, None).expect("no manifest is reported");
let missing = missing_resources(&root.join(".local/horizon-client/26.3"))
.expect("no manifest is reported");
assert!(missing.contains("horizon-resources.json"), "{missing}");
assert!(missing.contains("horizon-unprepared-"), "{missing}");
assert!(missing.contains("horizon-prepare --prepare"), "{missing}");
// No hook: no site, no launcher.
assert!(!missing.contains("playhorizon"), "{missing}");
assert!(!missing.contains("launcher"), "{missing}");
let get = horizon_hooks::GetTheGame {
address: "https://example.org/get",
title: "",
paragraphs: &[],
button: "",
};
let missing = missing_resources(&unprepared, Some(get)).expect("no manifest is reported");
assert!(
missing.contains("launcher: download it at https://example.org/get"),
"{missing}"
);
assert!(missing.contains("launcher"), "{missing}");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("horizon-resources.json"), "{}").unwrap();
assert_eq!(missing_resources(&root, None), None);
assert_eq!(missing_resources(&root), None);
// A killed export leaves an empty file behind; the error names it
// instead of serde's "expected value at line 1 column 1".
std::fs::write(root.join("gameplay-facts.json"), "").unwrap();
let damaged = missing_resources(&root, None).expect("an empty prepared file is reported");
let damaged = missing_resources(&root).expect("an empty prepared file is reported");
assert!(damaged.contains("gameplay-facts.json"), "{damaged}");
assert!(damaged.contains("damaged"), "{damaged}");
std::fs::write(root.join("gameplay-facts.json"), "{}").unwrap();
assert_eq!(missing_resources(&root, None), None);
assert_eq!(missing_resources(&root), None);
std::fs::write(root.join("horizon-resources.json"), " \n").unwrap();
let damaged = missing_resources(&root, None).expect("an empty manifest is reported");
let damaged = missing_resources(&root).expect("an empty manifest is reported");
assert!(damaged.contains("horizon-resources.json"), "{damaged}");
std::fs::remove_dir_all(&root).unwrap();
}
+34 -4
View File
@@ -1351,10 +1351,16 @@ mod tests {
)
.unwrap();
game.options.dedicated = Some(Vec::new());
game.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
// Entity ticking waits for every chunk within 2 columns
// (`ChunkMap.prepareEntityTickingChunk`).
for z in -2..=2 {
for x in -2..=2 {
game.world.chunks.insert(
(x, z),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(x, z)),
);
}
}
game.streaming.forced.insert((0, 0));
Some(game)
}
@@ -1841,6 +1847,30 @@ mod tests {
game.particles.requested.as_ref().unwrap().len()
}
/// #13852: a chunk enters entity ticking only once every chunk within 2
/// columns is loaded (`ChunkMap.prepareEntityTickingChunk`), so TNT beside
/// a missing chunk keeps its fuse until the neighbourhood is whole.
#[test]
fn tnt_waits_while_a_chunk_two_columns_away_is_missing() {
let Some(mut game) = server_game() else {
return;
};
game.world.chunks.remove(&(2, 0));
game.add_primed_tnt(BlockPos::new(8, 64, 8), 0.0, 80, None, None);
game.tnts[0].no_gravity = true;
game.tnts[0].velocity = Vec3::ZERO;
for _ in 0..3 {
game.tick_world(true).unwrap();
}
assert_eq!(game.tnts[0].fuse, 80, "no entity ticking yet");
game.world.chunks.insert(
(2, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(2, 0)),
);
game.tick_world(true).unwrap();
assert!(game.tnts[0].fuse < 80, "ticks once the chunk is back");
}
/// #10063: TNT lit between level ticks (flint and steel, a command) has its
/// first tick in the next level tick, so its fuse reads 79 after it, and it
/// explodes on the 80th (`PrimedTnt.tick`). Horizon held every TNT that
+8 -5
View File
@@ -157,8 +157,11 @@ impl Hub {
};
if ac.config.runs(Check::Prediction) {
let g = &mut self.guests[i];
let (input, sprinting) = (g.input, g.client_sprinting);
g.record.get_or_insert_default().tick_end(input, sprinting);
let (input, sprinting) = (g.input, g.anticheat.sprinting);
g.anticheat
.record
.get_or_insert_default()
.tick_end(input, sprinting);
}
if !ac.config.runs(Check::Timer) {
return;
@@ -246,7 +249,7 @@ impl Hub {
flags: u8,
) {
if self.recording() {
let record = self.guests[i].record.get_or_insert_default();
let record = self.guests[i].anticheat.record.get_or_insert_default();
record.moved(pos, rot, flags);
}
}
@@ -256,7 +259,7 @@ impl Hub {
pub(super) fn anticheat_pong(&mut self, i: usize, id: i32) {
let ticks = self.ticks;
if self.recording() {
let record = self.guests[i].record.get_or_insert_default();
let record = self.guests[i].anticheat.record.get_or_insert_default();
// Ids are server ticks, cut to 32 bits.
record.pong(u64::from(id as u32), ticks);
}
@@ -277,7 +280,7 @@ impl Hub {
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner),
);
if let Some(record) = g.record.as_deref_mut() {
if let Some(record) = g.anticheat.record.as_deref_mut() {
for change in changes {
record.changed(ticks, change);
}
+53
View File
@@ -4,6 +4,7 @@
//! (`data/minecraft/...`) and the registries report.
use crate::chunk::LevelShapes;
use horizon_protocol::{
entity_data,
packets::configuration::{RegistryData, RegistryEntry, RegistryTags, TagEntry, UpdateTags},
registry::{Builtin, Dynamic},
};
@@ -506,6 +507,58 @@ impl SyncData {
.any(|(_, e)| e.from_pack.contains(&true))
}
/// `LivingEntity.updateSynchronizedMobEffectParticles`: DATA_EFFECT_PARTICLES
/// (10) and DATA_EFFECT_AMBIENCE_ID (11) for the visible `effects`, as
/// (effect, ambient). The entries are left out when there is nothing to
/// show, which is their default.
pub fn effect_meta<'a>(
&self,
effects: impl IntoIterator<Item = (&'a str, bool)>,
) -> Vec<(u8, i32, entity_data::Value)> {
let mut particles = Vec::new();
let mut all_ambient = true;
for (effect, ambient) in effects {
let effect = effect.trim_start_matches("minecraft:");
// MobEffect.particleFactory: the omens have their own particles,
// the others an entity effect in their colour (faint if ambient).
let (name, data) = match effect {
"trial_omen" | "raid_omen" => (effect, None),
_ => {
let Some(def) = crate::brewing::effect(effect) else {
continue;
};
let alpha = if ambient { 38 } else { 255 };
let argb = (alpha << 24) | def.shown_color() as i32;
(
"entity_effect",
Some(entity_data::ParticleData::Color(argb)),
)
}
};
let Some(id) = self.id("minecraft:particle_type", &format!("minecraft:{name}")) else {
continue;
};
particles.push(entity_data::Particle { id, data });
all_ambient &= ambient;
}
if particles.is_empty() {
return Vec::new();
}
let mut meta = vec![(
10,
entity_data::serializer::PARTICLES,
entity_data::Value::Particles(particles),
)];
if all_ambient {
meta.push((
11,
entity_data::serializer::BOOLEAN,
entity_data::Value::Bool(true),
));
}
meta
}
/// The id of a name in a built-in or synchronized registry.
pub fn id(&self, registry: &str, name: &str) -> Option<i32> {
if registry == "minecraft:data_component_type"
+3 -14
View File
@@ -1,7 +1,6 @@
//! A remote player: connection, position, chunk and entity views, and the
//! player state (inventory, health, game mode) that the host's gameplay code
//! operates on when it acts for them.
use super::prediction::record::{Change, Record};
use super::{Joined, PlayerSender, entity::Tracker};
use crate::{
items::ItemStack,
@@ -222,19 +221,10 @@ pub struct Guest {
pub position_this_tick: bool,
/// Violation levels and the checks' state (the anticheat, when on).
pub anticheat: horizon_anticheat::Player,
/// The client ticks the movement prediction plays again (only while it records).
pub record: Option<Box<Record>>,
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
/// Not the server's player: a hit with extra knockback stops its sprint
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
pub client_sprinting: bool,
/// The movement prediction's replay of the client's own moves (only while it runs).
pub replay: Option<Box<super::prediction::Replay>>,
/// What the server sent this tick that changes how the client moves (only while it records).
pub changes: std::sync::Mutex<Vec<Change>>,
pub changes: std::sync::Mutex<Vec<horizon_anticheat::prediction::Change>>,
/// It accepted a move since the last entity range checks
/// (ServerChunkCache.move), which then check all of its pairs again.
pub moved: bool,
@@ -525,8 +515,6 @@ impl Guest {
support_blocks_sent: 0,
position_this_tick: false,
anticheat: Default::default(),
record: None,
client_sprinting: false,
replay: None,
changes: Default::default(),
moved: false,
@@ -638,7 +626,7 @@ impl Guest {
if self.fake && !super::conn::fake_wants_id(packet.id()) {
return;
}
if self.record.is_some() {
if self.anticheat.record.is_some() {
self.note_change(packet);
}
self.sender.send(packet.encode());
@@ -665,6 +653,7 @@ impl Guest {
/// What `packet` changes about how the client moves, for the prediction's replay: kept until
/// the tick's ping places it (`Hub::anticheat_ping`).
fn note_change(&self, packet: &play::Clientbound) {
use horizon_anticheat::prediction::Change;
use play::Clientbound as C;
let own = |id: i32| id == self.entity_id;
let change = match packet {
+16 -40
View File
@@ -24,7 +24,7 @@ use horizon_core::{
};
use horizon_protocol::ids::play::clientbound as ids;
use horizon_protocol::{
entity_data::{Particle, ParticleData, Value, serializer},
entity_data::{Value, serializer},
packets::play::{self, Clientbound as C, Serverbound as S},
text::{literal, translatable},
};
@@ -3113,7 +3113,7 @@ impl Hub {
// changed, so the one nearest the player stands for them.
for &i in &viewers {
let g = &mut self.guests[i];
let Some(record) = g.record.as_deref_mut() else {
let Some(record) = g.anticheat.record.as_deref_mut() else {
continue;
};
let slack = super::prediction::slack(std::array::from_fn(|i| {
@@ -3197,7 +3197,7 @@ impl Hub {
if recording {
for &i in &viewers {
let g = &mut self.guests[i];
let Some(record) = g.record.as_deref_mut() else {
let Some(record) = g.anticheat.record.as_deref_mut() else {
continue;
};
let slack = super::prediction::slack(std::array::from_fn(|i| {
@@ -3250,39 +3250,12 @@ impl Hub {
&self,
effects: impl IntoIterator<Item = (&'a str, bool)>,
) -> Vec<(u8, i32, Value)> {
let mut particles = Vec::new();
let mut all_ambient = true;
for (effect, ambient) in effects {
let effect = effect.trim_start_matches("minecraft:");
// MobEffect.particleFactory: the omens have their own particles,
// the others an entity effect in their colour (faint if ambient).
let (name, data) = match effect {
"trial_omen" | "raid_omen" => (effect, None),
_ => {
let Some(def) = crate::brewing::effect(effect) else {
continue;
};
let alpha = if ambient { 38 } else { 255 };
let argb = (alpha << 24) | def.shown_color() as i32;
("entity_effect", Some(ParticleData::Color(argb)))
}
};
let Some(id) =
self.registry_id("minecraft:particle_type", &format!("minecraft:{name}"))
else {
continue;
};
particles.push(Particle { id, data });
all_ambient &= ambient;
}
if particles.is_empty() {
return Vec::new();
}
let mut meta = vec![(10, serializer::PARTICLES, Value::Particles(particles))];
if all_ambient {
meta.push((11, serializer::BOOLEAN, Value::Bool(true)));
}
meta
self.sync.effect_meta(effects)
}
/// The registries and tables the clients are synced with (read by helper threads).
pub fn sync_data(&self) -> &SyncData {
&self.sync
}
fn player_state(
@@ -3588,7 +3561,7 @@ impl Hub {
}));
for at in gone.drain(..) {
let block = at.map(|v| v.floor() as i32);
if let Some(record) = g.record.as_deref_mut()
if let Some(record) = g.anticheat.record.as_deref_mut()
&& super::prediction::near(g.position, block, slack)
{
record.entity_gone(ticks);
@@ -3990,7 +3963,7 @@ impl Hub {
fn set_sprinting(&mut self, i: usize, sprinting: bool) {
let g = &mut self.guests[i];
g.sprinting = sprinting;
g.client_sprinting = sprinting;
g.anticheat.sprinting = sprinting;
if let Some(m) = g.motion.as_mut() {
let _ = m.set_sprint_modifier(sprinting);
}
@@ -4078,7 +4051,10 @@ impl Hub {
// The prediction's replay starts again where the client now is.
if recording {
let kept = g.awaiting_keeps_motion;
g.record.get_or_insert_default().teleported(at, kept);
g.anticheat
.record
.get_or_insert_default()
.teleported(at, kept);
}
let rot = (p.yaw, p.pitch);
self.position_change(i, [p.x, p.y, p.z], rot, 0, ctx, events);
@@ -5064,7 +5040,7 @@ impl Hub {
// ClientPacketListener.handleRespawn without KEEP_ENTITY_DATA: the client's new player
// starts with wasSprinting false. (A level change keeps it: the new player sprints on, or
// says STOP.)
g.client_sprinting = false;
g.anticheat.sprinting = false;
// The respawned player is a new one, without effects; so is the
// client's (ClientPacketListener.handleRespawn).
g.effects = Default::default();
+3 -5
View File
@@ -1,5 +1,5 @@
//! The movement prediction's replay (docs/anticheat.md): each client tick a player's record holds
//! ([`Record`]) is played again with the client's own movement code,
//! ([`horizon_anticheat::prediction::Record`]) is played again with the client's own movement code,
//! from where the last one ended, and compared with where the client said it went.
//!
//! The inputs are known exactly: a vanilla client sends ServerboundPlayerInputPacket after its tick's
@@ -21,10 +21,8 @@
use std::collections::VecDeque;
pub mod record;
use horizon_anticheat::prediction::{Change, ClientTick, KEPT, Record, Restart};
use horizon_core::geometry::Vec3;
use record::{Change, ClientTick, KEPT, Record, Restart};
use crate::motion::PlayerMotion;
use crate::sprint::Keys;
@@ -652,13 +650,13 @@ fn sync(body: &mut PlayerMotion, server: &PlayerMotion) {
#[cfg(test)]
mod tests {
use super::record::Record;
use super::*;
use crate::{
assets::Assets,
motion::{MotionResources, MotionWorld},
world::World,
};
use horizon_anticheat::prediction::Record;
use std::path::Path;
/// Keys for each tick: 20 walking, 12 sprint-jumping, 12 strafing while turning, 6 still.
+33 -54
View File
@@ -5,6 +5,10 @@
//! fly and amphibious node evaluators (`path`, `nav`), move/look/jump/body
//! controls (`control`), `LivingEntity` physics (`living`) and per-species
//! behaviour, models, animations and sounds.
mod mob_slot;
mod pending_pushes;
pub use mob_slot::MobSlot;
pub use pending_pushes::{PendingPushes, apply_mob_pushes};
pub mod ai;
pub mod allay;
pub mod ambient;
@@ -48,7 +52,6 @@ pub mod golem;
pub mod guardian;
pub mod happy_ghast;
pub mod hostile;
mod hurt_by;
pub mod illager;
pub mod illusioner;
pub mod item_break;
@@ -2924,28 +2927,6 @@ pub fn take_mob_pushes(events: &mut Vec<MobEvent>, from: usize, pending: &mut Pe
}
events.truncate(kept);
}
/// Pushes waiting for their mob's tick, in push order. A list while short;
/// past [`PendingPushes::LIST_MAX`] (a crowd pushing each other) one list
/// per mob, as scanning the whole list for each mob grows with its square.
#[derive(Default)]
pub struct PendingPushes {
list: Vec<(u32, Vec3)>,
by_mob: crate::fast_hash::FxHashMap<u32, Vec<Vec3>>,
}
impl PendingPushes {
const LIST_MAX: usize = 64;
pub fn push(&mut self, id: u32, v: Vec3) {
if self.by_mob.is_empty() && self.list.len() < Self::LIST_MAX {
self.list.push((id, v));
return;
}
// Older pushes first, so each mob's stay in order.
for (id, v) in self.list.drain(..) {
self.by_mob.entry(id).or_default().push(v);
}
self.by_mob.entry(id).or_default().push(v);
}
}
/// `ServerLevel.sendBlockUpdated`: for each write that changed a block's collision
/// shape, a mob whose path runs near it re-paths (`PathNavigation.shouldRecomputePath`).
/// The writes whose shape changed are found once per tick ([`collision_changes`]).
@@ -2973,50 +2954,28 @@ pub fn collision_changes(w: &MobWorld, writes: &[((i32, i32, i32), u32, u32)]) -
})
.collect()
}
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
// Entity.push(x, y, z) sets needsSync.
let mut add = |v: Vec3| {
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
m.needs_sync = true;
};
if !pending.list.is_empty() {
pending.list.retain(|(id, v)| {
if *id != m.id {
return true;
}
add(*v);
false
});
}
if !pending.by_mob.is_empty()
&& let Some(pushes) = pending.by_mob.remove(&m.id)
{
pushes.into_iter().for_each(add);
}
}
/// Applies the brain memories a mob's tick set on other mobs (from event
/// index `from` on), at once: vanilla sets them on the other entity during
/// the tick, before the mobs after it tick.
pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: usize) {
pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>, from: usize) {
let mut i = from;
while i < events.len() {
match events[i] {
MobEvent::BreedTarget { mob, partner } => {
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
animal_brain::set_breed_partner(m, partner);
}
events.remove(i);
}
MobEvent::LockGaze { mob, partner } => {
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
animal_brain::lock_gaze(m, partner);
villager_brain::lock_gaze_half(m, Who::Mob(partner));
}
events.remove(i);
}
MobEvent::Retreat { mob, from, at } => {
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
piglin::receive_retreat(m, from, at);
}
events.remove(i);
@@ -3030,7 +2989,11 @@ pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: us
events.remove(i);
}
MobEvent::SchoolSize { leader, by } => {
if let Some(m) = mobs.iter_mut().find(|m| m.id == leader) {
if let Some(m) = mobs
.iter_mut()
.map(MobSlot::mob_mut)
.find(|m| m.id == leader)
{
m.extra.followers += by;
}
events.remove(i);
@@ -3040,7 +3003,11 @@ pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: us
MobEvent::Breed {
parent, partner, ..
} => {
if let Some(m) = mobs.iter_mut().find(|m| m.id == partner) {
if let Some(m) = mobs
.iter_mut()
.map(MobSlot::mob_mut)
.find(|m| m.id == partner)
{
m.extra.age = 6000;
m.extra.in_love = 0;
animal_brain::forget_breed_target(m, parent);
@@ -3062,6 +3029,7 @@ pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: us
let mut raised = Vec::new();
let (aim, fish_pos, fish_silent) = mobs
.iter()
.map(MobSlot::mob)
.find(|m| m.id == mob)
.map(|m| {
(
@@ -3076,7 +3044,10 @@ pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: us
})
.map(|(aim, pos, silent)| (Some(aim), Some(pos), silent))
.unwrap_or((None, None, true));
if let Some(t) = mobs.iter_mut().find(|m| m.id == target)
if let Some(t) = mobs
.iter_mut()
.map(MobSlot::mob_mut)
.find(|m| m.id == target)
&& t.damage_aimed(amount, source, aim)
{
t.add_effect("minecraft:poison", poison_ticks, 0);
@@ -3291,6 +3262,9 @@ pub struct MobWorld<'a> {
/// not in the level (a warden's anger, `AngerManagement.angerByUuid`).
/// None: this world keeps no entity UUIDs, so nothing is resolved.
pub who_lookup: Option<&'a dyn WhoLookup>,
/// Another thread's mobs, for a lookup by id that misses `others`: a group of the server's
/// parallel mob loop (`game/mob_islands.rs`) answers as the serial loop would.
pub foreign: Option<&'a dyn ForeignSeen>,
/// The level's world border, whose walls stop a mob's moves
/// (`Entity.collectColliders`); None: nothing stops them there.
pub border: Option<&'a horizon_core::geometry::border::BorderGeometry>,
@@ -3305,6 +3279,10 @@ pub trait ModEffectTicks {
/// names while it is in the level, never some other entity as a stand-in. Only
/// the game layer keeps entity UUIDs, so it answers this (`Game::who_by_uuid`)
/// and the mob layer reaches it through `MobWorld`.
/// A mob outside this thread's part of the mob loop, as the serial loop would see it now.
pub trait ForeignSeen {
fn seen(&self, id: u32) -> Option<&Seen>;
}
pub trait WhoLookup {
fn who_by_uuid(&self, uuid: [i32; 4]) -> Option<Who>;
}
@@ -3511,7 +3489,7 @@ impl Mob {
/// something off its pickup cooldown, a sulfur cube with no block.
pub fn can_pick_up_loot(&self) -> bool {
match self.species() {
Species::Allay => allay::can_pick_up_loot(self),
Species::Allay => self.extra.main_hand.is_some() && self.extra.ints[3] <= 0,
Species::SulfurCube => !sulfur_cube::has_body(self),
_ => self.extra.pick_up_loot,
}
@@ -3938,7 +3916,8 @@ impl Mob {
self.last_damage = Some((source, i64::from(self.age)));
self.last_direct = None;
if let Some(who) = source.attacker {
self.ai.hurt_by(who, self.age as i32);
self.ai.last_hurt_by = Some(who);
self.ai.last_hurt_by_time = self.age as i32;
if who == Who::Player {
self.ai.last_hurt_by_player = 100;
}
+9 -5
View File
@@ -90,8 +90,6 @@ pub struct Ai {
pub target: Option<Who>,
pub last_hurt_by: Option<Who>,
pub last_hurt_by_time: i32,
/// The bare UUID of an unresolved `lastHurtByMob` (see `hurt_by`).
pub last_hurt_by_ref: Option<[i32; 4]>,
/// Ticks left during which kills count as player kills.
pub last_hurt_by_player: i32,
pub no_action_time: i32,
@@ -626,7 +624,7 @@ fn drop_invalid_player_target(m: &mut Mob, w: &MobWorld) {
m.ai.target = None;
if matches!(m.species(), Species::ZombifiedPiglin | Species::IronGolem) {
super::neutral_anger::stop(m);
m.ai.forget_hurt_by(m.ai.last_hurt_by_time);
m.ai.last_hurt_by = None;
}
}
}
@@ -1359,7 +1357,12 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
if matches!(m.species(), Species::Evoker | Species::Illusioner) {
super::illager::cast_particles(m, events);
}
super::hurt_by::base_tick(m, w);
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
if m.invulnerable > 0 {
m.invulnerable -= 1;
}
// LivingEntity.baseTick runs tickEffects after the timers count down.
tick_effects(m, w.mod_effects, &mut |m| hurt_teleports(m, w, events));
// Entity.applyEffectsFromBlocks runs in LivingEntity.aiStep, after
@@ -2954,7 +2957,8 @@ pub fn forgive_dead_player(m: &mut Mob) {
// is the player who died, the last hurt by with it.
Species::Enderman | Species::ZombifiedPiglin => {
if m.ai.target == Some(Who::Player) {
m.ai.forget_hurt_by(m.age as i32);
m.ai.last_hurt_by = None;
m.ai.last_hurt_by_time = m.age as i32;
m.ai.target = None;
}
}
+6 -25
View File
@@ -22,25 +22,9 @@ pub mod interaction;
// Allay state: `main_hand` the held filter; `State::inventory` the fetched stack,
// `tame` a liked player, `point` the wanted item (set by the game),
// `State::noteblock` the liked note block with `ints[2]` its cooldown,
// `pickup_cooldown` the pickup cooldown, `flag` dancing with `ints[4]` the dance clock,
// `ints[3]` the pickup cooldown, `flag` dancing with `ints[4]` the dance clock,
// `egg_time` the duplication cooldown. AnimState slots: holding (0),
// spinning (1).
/// `ITEM_PICKUP_COOLDOWN_TICKS`. `CountDownCooldownTicks` counts it down while it is above 0 and
/// erases it the tick after it reached 0 (`stop`), so a memory at 0 is still
/// there for a tick, and `Allay.isOnPickupCooldown` is the memory's presence.
pub fn pickup_cooldown(m: &Mob) -> Option<i32> {
m.extra.allay.as_ref()?.pickup_cooldown
}
/// `Allay.canPickUpLoot`: a held item and no pickup-cooldown memory.
pub fn can_pick_up_loot(m: &Mob) -> bool {
m.extra.main_hand.is_some() && pickup_cooldown(m).is_none()
}
/// Set or erase `ITEM_PICKUP_COOLDOWN_TICKS` (see [`pickup_cooldown`]).
pub fn set_pickup_cooldown(m: &mut Mob, ticks: Option<i32>) {
if let Some(state) = m.extra.allay.as_mut() {
state.pickup_cooldown = ticks;
}
}
pub const HOLD: usize = 0;
pub const SPIN: usize = 1;
/// `Allay.isLikedPlayer`: compare the supplied verified player UUID with
@@ -173,8 +157,6 @@ pub fn pickup_reach(bounds: Aabb) -> Aabb {
pub struct State {
/// Allay's separate one-slot SimpleContainer.
pub inventory: Option<ItemStack>,
/// The full Codec.INT value, including zero while the memory is present.
pub pickup_cooldown: Option<i32>,
pub vibration: sculk::Data,
pub noteblock: Option<GlobalPos>,
/// The `LIKED_PLAYER` memory's time to live (`MemorySlot.timeToLive`);
@@ -344,7 +326,7 @@ impl Goal for Fetch {
MOVE
}
fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool {
m.extra.point.is_some() && pickup_cooldown(m).is_none()
m.extra.point.is_some() && m.extra.ints[3] <= 0
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
if let Some(p) = m.extra.point
@@ -509,12 +491,11 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld) {
tick_liked_player(m);
passive_heal(m);
stop_dancing_away(m, w);
if m.extra.ints[2] > 0 {
m.extra.ints[2] -= 1;
for i in [2, 3] {
if m.extra.ints[i] > 0 {
m.extra.ints[i] -= 1;
}
}
// CountDownCooldownTicks stops and erases a nonpositive memory; a positive
// value reaches zero for one tick before that stop.
set_pickup_cooldown(m, pickup_cooldown(m).filter(|t| *t > 0).map(|t| t - 1));
update_duplication_cooldown(m);
animate(m);
}
+22 -5
View File
@@ -794,18 +794,35 @@ fn can_be_followed(s: Species, followers: i32) -> bool {
}
/// `startFollowing` on a recruit, applied after the recruiter's tick. A fish
/// another leader took this tick (its snapshot was older) stays with it.
pub fn start_following(mobs: &mut [Mob], fish: u32, leader: u32) {
let alive = |mobs: &[Mob], id: u32| mobs.iter().any(|m| m.id == id && m.alive() && !m.removed);
let Some(current) = mobs.iter().find(|m| m.id == fish).map(|m| m.extra.leader) else {
pub fn start_following<M: super::MobSlot>(mobs: &mut [M], fish: u32, leader: u32) {
let alive = |mobs: &[M], id: u32| {
mobs.iter()
.map(super::MobSlot::mob)
.any(|m| m.id == id && m.alive() && !m.removed)
};
let Some(current) = mobs
.iter()
.map(super::MobSlot::mob)
.find(|m| m.id == fish)
.map(|m| m.extra.leader)
else {
return;
};
if current.is_some_and(|l| alive(mobs, l)) {
return;
}
if let Some(m) = mobs.iter_mut().find(|m| m.id == fish) {
if let Some(m) = mobs
.iter_mut()
.map(super::MobSlot::mob_mut)
.find(|m| m.id == fish)
{
m.extra.leader = Some(leader);
}
if let Some(m) = mobs.iter_mut().find(|m| m.id == leader) {
if let Some(m) = mobs
.iter_mut()
.map(super::MobSlot::mob_mut)
.find(|m| m.id == leader)
{
m.extra.followers += 1;
}
}
-1
View File
@@ -203,7 +203,6 @@ pub fn angry(m: &Mob, w: &MobWorld) -> bool {
/// `NeutralMob.stopBeingAngry`.
pub fn stop_being_angry(m: &mut Mob) {
m.ai.last_hurt_by = None;
m.ai.last_hurt_by_ref = None;
m.ai.last_hurt_by_time = m.age as i32;
m.ai.target = None;
let b = st(m);
+1 -1
View File
@@ -21,7 +21,7 @@ pub struct Cx<'a, 'w> {
pub lr: &'a mut LegacyRandom,
pub events: &'a mut Vec<MobEvent>,
}
pub trait Goal: std::fmt::Debug {
pub trait Goal: std::fmt::Debug + Send + Sync {
fn flags(&self) -> u8;
/// A goal that draws its first start delay in its constructor (a schooling fish's
/// `FollowFlockLeaderGoal`): the delay again, when the mob's random is seeded afterwards.
+2 -1
View File
@@ -48,7 +48,8 @@ impl Goal for ResetUniversalAnger {
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
self.timestamp = m.ai.last_hurt_by_time;
// setLastHurtByMob(null) also stamps the time.
m.ai.forget_hurt_by(m.age as i32);
m.ai.last_hurt_by = None;
m.ai.last_hurt_by_time = m.age as i32;
m.ai.target = None;
m.extra.angry_at = None;
// `TimeUtil.rangeOfSeconds(20, 39)`.
-42
View File
@@ -1,42 +0,0 @@
//! The mob that last hurt a mob (`LivingEntity.lastHurtByMob`): its record in
//! `Ai`, its end in `baseTick`, and what a hit or a stop being angry does to it.
use super::ai::Ai;
use super::{Mob, MobWorld, Who};
// `LivingEntity.lastHurtByMob` is a lazy `EntityReference`: a record read back
// from `last_hurt_by_mob` stays the bare UUID (`Ai::last_hurt_by_ref`) until the
// game resolves it into `last_hurt_by`. `getLastHurtByMob` answers nothing for
// it and `baseTick` does not end it; its timestamp keeps aging.
impl Ai {
/// `LivingEntity.setLastHurtByMob(who)`: the new record replaces a
/// reference read back that was not resolved.
pub fn hurt_by(&mut self, who: Who, tick: i32) {
self.last_hurt_by = Some(who);
self.last_hurt_by_ref = None;
self.last_hurt_by_time = tick;
}
/// `LivingEntity.setLastHurtByMob(null)`, which also stamps the time.
pub fn forget_hurt_by(&mut self, tick: i32) {
self.last_hurt_by = None;
self.last_hurt_by_ref = None;
self.last_hurt_by_time = tick;
}
}
/// `LivingEntity.baseTick`'s hurt timers: `hurtTime` and `invulnerableTime`
/// count down, and the mob that hurt it is forgotten once it is not alive or
/// after more than 100 ticks. One that is not in the level
/// (`getLastHurtByMob` is null) goes on.
pub(super) fn base_tick(m: &mut Mob, w: &MobWorld) {
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
if m.invulnerable > 0 {
m.invulnerable -= 1;
}
if let Some(who) = m.ai.last_hurt_by
&& let Some(by) = w.target_info(who)
&& (!by.alive || (m.age as i32).wrapping_sub(m.ai.last_hurt_by_time) > 100)
{
m.ai.forget_hurt_by(m.age as i32);
}
}
@@ -174,6 +174,7 @@ fn a_mob_bounces_off_a_bouncy_block_and_not_off_stone() {
dragon: None,
leash_boats: &[],
who_lookup: None,
foreign: None,
others_grid: None,
others_index: None,
others_villagers: None,
@@ -291,6 +292,7 @@ fn a_ridden_horse_floats_in_deep_water() {
dragon: None,
leash_boats: &[],
who_lookup: None,
foreign: None,
others_grid: None,
others_index: None,
others_villagers: None,
@@ -411,6 +413,7 @@ fn a_falling_mob_lands_on_a_shulker_or_a_held_happy_ghast() {
nearest_player: None,
others: &others,
who_lookup: None,
foreign: None,
dragon: None,
leash_boats: &[],
others_grid: None,
@@ -0,0 +1,29 @@
//! [`MobSlot`]: a mob a list hands out.
use super::Mob;
/// A mob a list hands out: the list's own, or a borrow of one in a larger list (a group of the
/// server's parallel mob loop, `game/mob_islands.rs`, works on its mobs in place).
pub trait MobSlot {
fn mob(&self) -> &Mob;
fn mob_mut(&mut self) -> &mut Mob;
}
impl MobSlot for Mob {
#[inline]
fn mob(&self) -> &Mob {
self
}
#[inline]
fn mob_mut(&mut self) -> &mut Mob {
self
}
}
impl MobSlot for &mut Mob {
#[inline]
fn mob(&self) -> &Mob {
self
}
#[inline]
fn mob_mut(&mut self) -> &mut Mob {
self
}
}
@@ -0,0 +1,103 @@
//! The pushes a mob's tick gives others, waiting for each one's own tick.
use super::Mob;
use horizon_core::geometry::Vec3;
/// Pushes waiting for their mob's tick, in push order. A list while short;
/// past [`PendingPushes::LIST_MAX`] (a crowd pushing each other) one list
/// per mob, as scanning the whole list for each mob grows with its square.
#[derive(Default)]
pub struct PendingPushes {
list: Vec<(u32, Vec3)>,
by_mob: crate::fast_hash::FxHashMap<u32, Vec<Vec3>>,
}
impl PendingPushes {
const LIST_MAX: usize = 64;
/// `other`'s pushes after these, each mob's in their order.
pub fn append(&mut self, other: PendingPushes) {
for (id, v) in other.list {
self.push(id, v);
}
for (id, list) in other.by_mob {
for v in list {
self.push(id, v);
}
}
}
pub fn push(&mut self, id: u32, v: Vec3) {
if self.by_mob.is_empty() && self.list.len() < Self::LIST_MAX {
self.list.push((id, v));
return;
}
// Older pushes first, so each mob's stay in order.
for (id, v) in self.list.drain(..) {
self.by_mob.entry(id).or_default().push(v);
}
self.by_mob.entry(id).or_default().push(v);
}
}
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
// Entity.push(x, y, z) sets needsSync.
let mut add = |v: Vec3| {
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
m.needs_sync = true;
};
if !pending.list.is_empty() {
pending.list.retain(|(id, v)| {
if *id != m.id {
return true;
}
add(*v);
false
});
}
if !pending.by_mob.is_empty()
&& let Some(pushes) = pending.by_mob.remove(&m.id)
{
pushes.into_iter().for_each(add);
}
}
#[cfg(test)]
mod tests {
use super::PendingPushes;
use horizon_core::geometry::Vec3;
/// Each mob's pushes, in the order they apply.
fn per_mob(p: &PendingPushes) -> std::collections::BTreeMap<u32, Vec<[f64; 3]>> {
let mut out = std::collections::BTreeMap::<u32, Vec<[f64; 3]>>::new();
for (id, v) in &p.list {
out.entry(*id).or_default().push([v.x, v.y, v.z]);
}
for (id, list) in &p.by_mob {
out.entry(*id)
.or_default()
.extend(list.iter().map(|v| [v.x, v.y, v.z]));
}
out
}
/// Two lists of pushes, the second appended to the first, apply as one list of them all in
/// that order, on either side of the switch from one list to a list per mob.
#[test]
fn appended_pushes_apply_in_order() {
let pushes: Vec<(u32, Vec3)> = (0..200u32)
.map(|k| (k * 7 % 11, Vec3::new(f64::from(k), 0.0, -f64::from(k))))
.collect();
let mut all = PendingPushes::default();
for &(id, v) in &pushes {
all.push(id, v);
}
for split in [0, 30, 64, 70, 150, 200] {
let (mut first, mut second) = (PendingPushes::default(), PendingPushes::default());
for &(id, v) in &pushes[..split] {
first.push(id, v);
}
for &(id, v) in &pushes[split..] {
second.push(id, v);
}
first.append(second);
assert_eq!(per_mob(&first), per_mob(&all), "split at {split}");
}
}
}
+2 -44
View File
@@ -1159,46 +1159,6 @@ impl<G: Goal + Clone + 'static> Goal for NotRetreating<G> {
}
}
/// A goal of the fight activity: `HoglinAi.updateActivity` runs after the
/// brain, so a hoglin given a target fights from the next tick on (here
/// `brain_step` runs after the goals). The goal may start a tick early, as
/// the goal selector picks new goals every other tick only and the brain
/// every tick, but it acts only once the activity is fight.
#[derive(Clone, Debug)]
struct InFight<G>(G);
impl<G: Goal + Clone + 'static> Goal for InFight<G> {
goal_clone!();
fn flags(&self) -> u8 {
self.0.flags()
}
fn name(&self) -> &'static str {
self.0.name()
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
self.0.can_use(m, cx)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
self.0.can_continue(m, cx)
}
fn interruptable(&self) -> bool {
self.0.interruptable()
}
fn every_tick(&self) -> bool {
self.0.every_tick()
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
self.0.start(m, cx)
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
self.0.stop(m, cx)
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
if m.extra.piglin.activity == Activity::Fight {
self.0.tick(m, cx)
}
}
}
/// A goal of the idle and avoid activities' movement: the ride activity has
/// none of it (`PiglinAi.initRideHoglinActivity`), so it waits while the
/// brain is in the ride activity.
@@ -1497,10 +1457,8 @@ pub fn register_hoglin(m: &mut Mob) {
g.add(0, Float);
// `HoglinAi.initRetreatActivity`: `SetWalkTargetAwayFrom.entity(AVOID_TARGET, 1.3, 15, false)`.
g.add(1, WalkAway::retreat(1.3, 15.0, false));
// HoglinAi: MeleeAttack.create(40) for adults, 15 for babies, in the
// fight activity. Its `ATTACK_COOLING_DOWN` lives 40 brain ticks and
// `MemorySlot.tick` erases it on the 41st, so hits come 41 (16) apart.
g.add(2, InFight(MeleeAttack::new(1.0, true).intervals(41, 16)));
// HoglinAi: MeleeAttack.create(40) for adults, 15 for babies.
g.add(2, MeleeAttack::new(1.0, true).intervals(40, 15));
g.add(3, NotRetreating(super::goals::Breed::new(1.0)));
g.add(3, HoglinAvoidRepellent);
// `HoglinAi.initIdleActivity`: an adult keeps away from the nearest adult
@@ -200,7 +200,6 @@ pub fn forgive_dead_player(m: &mut Mob) {
/// `NeutralMob.stopBeingAngry`.
fn stop_being_angry(m: &mut Mob) {
m.ai.last_hurt_by = None;
m.ai.last_hurt_by_ref = None;
m.ai.target = None;
super::neutral_anger::stop(m);
m.extra.flag2 = false;
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff. Show more