Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 18ceb494e9 Server watchdog: put the server thread's stack in the crash report
ServerWatchdog's report only had the last 50 log lines, so a tick stuck
for 60 s on the official server (#15349) never said where. The watchdog now
interrupts the server thread with a signal (Linux, Android, Apple) whose
handler captures that thread's backtrace, and the report carries it as its
stack, as vanilla's report carries the server thread's stack. A thread that
does not answer in 5 s, or a system without the signal, is named in the
report's details instead.

Refs #15349

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:35:19 +01:00
Kacper 69ba5a7b63 Merge pull request #15239 from yahya-nusseibeh/fix/port-pack-panics
Port-pack: report odd worldgen JSON instead of panicking, and end a panicking modpack step
2026-11-11 19:20:37 +01:00
Kacper 2e2218eced Merge pull request #15176 from angel2rider/tools/tools-port-ledger-py-reads-the-pinned-version-fr
tools: port_ledger.py reads the pinned version from the lock file
2026-11-11 19:20:18 +01:00
Kacper f4e5eb5bcd Merge pull request #15109 from angel2rider/tools/port-modules-say-how-they-are-used
tools: the port modules say how they are used, and the port report finds every version site
2026-11-11 19:19:58 +01:00
Kacper 49667c9f3b Merge pull request #15097 from angel2rider/tools/git-version-check
tools: say git 2.38 or newer is required, instead of printing git's usage
2026-11-11 19:19:38 +01:00
Kacper 37050a4985 Merge pull request #14704 from Hiuoy/layers/get-the-game-hook
Layer split: the "no prepared files" screen gets playhorizon.dev through a hook
2026-11-11 19:19:17 +01:00
Kacper 7d76c6bb80 Merge pull request #14582 from voduff/fix/14577-preedit-test-lock
The preedit tests take turns with the composition state
2026-11-11 19:18:58 +01:00
Kacper a1cd2db8eb Merge pull request #14562 from MSedgeMC/fix/social-search-caret
A click in the social screen's search box places its caret
2026-11-11 19:18:38 +01:00
Kacper c2157fb2db Merge pull request #14420 from voduff/fix/14266-thread-loot
A test's world-pack loot tables stay on its own thread
2026-11-11 19:18:18 +01:00
Kacper fb03ea2f84 Merge pull request #14141 from MSedgeMC/fix/dragon-sitting-damage
A perched dragon keeps its saved damage through a reload
2026-11-11 19:17:59 +01:00
Kacper 92705c55fe Merge pull request #13754 from v0ntr3n/fix/remote-fishing-rod-cast
Show other players' fishing rods as cast while their bobber is out
2026-11-11 19:17:39 +01:00
rdbt a85af177a8 Merge pull request #15347 from kacpherek/docs/vanilla-then-better
docs: vanilla first, then better; drop the license rules; time rules for agents
2026-11-11 17:56:38 +00:00
rdbt 663ed839ff Merge pull request #14384 from Cx188/fix/14209-remove-manual-vanilla-parity
ci: remove the obsolete manual vanilla-parity job
2026-11-11 17:40:18 +00:00
rdbt 41e2db604b Merge pull request #15324 from NikitaMGrimm/ci/quarantine-never-green
Parity: six harnesses that never passed run by name only
2026-11-11 17:40:08 +00:00
rdbt 7d5427bb4f Merge pull request #15085 from angel2rider/ci/run-cargo-with-locked
ci: run cargo with --locked so a stale Cargo.lock fails instead of being rewritten
2026-11-11 17:39:36 +00:00
rdbt cbc97b2529 Merge pull request #15325 from NikitaMGrimm/ci/audit-first-failure
Nightly audit: a harness that fails in its first audit gets an issue too
2026-11-11 17:39:07 +00:00
rdbt 86a8472693 Merge pull request #15327 from NikitaMGrimm/ci/parity-main-bisect-budget
parity-main: the bisect stops on a time budget and the next run goes on from there
2026-11-11 17:38:54 +00:00
rdbt b621a878d3 Merge pull request #14087 from horizon-rust/fix/hoglin-repeat-attack
A hoglin hits from its fight activity, 41 ticks apart
2026-11-11 17:38:17 +00:00
rdbt 0d8f2ef270 Merge pull request #14088 from horizon-rust/fix/zombie-villager-random-profession
command_exec reads the zombie villagers' profession only where it is not random
2026-11-11 17:38:09 +00:00
rdbt e68a3c6974 Merge pull request #14219 from NikitaMGrimm/fix/command-exec-group-isolation
command_exec removes what each group leaves alive before the next group
2026-11-11 17:38:00 +00:00
rdbt 5bf9ac1931 Merge pull request #14086 from horizon-rust/fix/allay-pickup-cooldown
An allay stays on its pickup cooldown at 0; /item replace triggers inventory_changed
2026-11-11 17:37:52 +00:00
rdbt 8b3db9eec1 Merge pull request #14579 from Mayonesu0/up-12830-last-hurt-by-mob-load
A saved last_hurt_by_mob is read back and ends as in vanilla
2026-11-11 17:37:44 +00:00
rdbt 9c1245513c Merge pull request #15346 from horizon-rust/fix/parity-main-preserve-failures
Preserve parity failure logs before diagnosis
2026-11-11 17:37:12 +00:00
rdbt 4a3d5daf95 Merge pull request #15337 from horizon-rust/fix/ci-ubuntu-dns-7b5e12e1
ci: evaluate Ubuntu 24.04 for hosted checks
2026-11-11 17:36:13 +00:00
rdbtCVSandCodex GPT-6.1 Terra 199c56fd1a Preserve parity failure logs before bisect
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 17:29:52 +00:00
Kacpherek b0211887f2 docs: tell agents where time goes and what they can skip
AGENTS.md gets a short "Don't lose time" section from measured agent sessions and CI: check for duplicates first, find code with the map, cargo check while working, no polling loops, one test per changed behaviour, don't hunt unrelated red checks, finish when a rule is unclear. CONTRIBUTING asks for a harness only where vanilla has something to compare.
2026-11-11 18:14:48 +01:00
Kacpherek 99f82f18a9 docs: drop the license rules the removed CONTRIBUTING section left in three docs
docs/cleanroom, docs/native-hooks-neoforge and docs/how-horizon-is-built linked to the removed section and repeated its rules. The history of code removed for its license stays in how-horizon-is-built.
2026-11-11 18:14:07 +01:00
Kacpherek e5177563e9 docs: drop the per-pull-request license rules from CONTRIBUTING and the template
The license section and the template's "Other projects' code" block are removed by a Horizon Team decision: agents lost time on them and misread them.
2026-11-11 17:58:09 +01:00
Kacpherek 531a071845 docs: parity first, then improve on vanilla where players gain from it
CONTRIBUTING now says Horizon works in two stages: first it plays exactly like vanilla, then it improves on it. Performance work keeps compatibility with vanilla clients, servers and saves and the gameplay players rely on, but need not copy vanilla's internal order, random calls or slow paths. Pull requests say what they no longer reproduce exactly and how it was checked.
2026-11-11 17:54:24 +01:00
rdbtCVSandCodex GPT-6.1 Sol 49cdc775d8 Keep hosted verification and launcher routing aligned
Allow the supported pinned Ubuntu 24.04 fallback in the runner-policy check while preserving the shared pool-routing and privilege guards.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:54:00 +00:00
rdbt b66b2f8372 Merge pull request #15331 from NikitaMGrimm/ci/quarantine-regressed
Parity: command_exec and four regressed harnesses run by name only until their fix
2026-11-11 16:53:13 +00:00
rdbt e14b35cecb Merge pull request #14390 from voduff/map-moved-fns-2
port map: rows follow remove_entity and the minecart save fns
2026-11-11 16:47:27 +00:00
rdbtCVSandCodex GPT-6.1 Sol 4e71c65c5c ci: test Ubuntu 24.04 for hosted verification DNS failures
Refs #15336. Pin the GitHub-hosted verify fallback for a controlled CI run; preserve the existing self-hosted routing and checks.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:46:17 +00:00
rdbtandCodex GPT-6.1 Sol 9a8c769601 Merge pull request #15291 from KBroneZ/claude/up-feature-parse
Report missing inline-feature fields against the complete flat feature map, matching the vanilla decoder.

Validation: 84/84 strict vanilla records, the feature-codec unit test, and all 5 Pong compatibility tests passed locally; required verify passed on f55a82aa12.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:39:20 +00:00
rdbtCVSandCodex GPT-6.1 Sol f8a833cc41 Merge main to pick up the corrected Pong test field
Keep the gate change unchanged and include main #15250, which carries the validated Guest.record test correction.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:37:21 +00:00
rdbt 5a58f445a6 Merge pull request #15250 from KBroneZ/claude/up-interaction-data
Print an Interaction's width before its interaction record
2026-11-11 16:34:36 +00:00
rdbtCVSandCodex GPT-6.1 Sol ddd66cd75f Follow the movement-record relocation in the existing Pong test
Match the already validated fix in #14219, #14088, #15250 and #15291: Guest.record holds the prediction record directly after main #14005.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:26:41 +00:00
rdbtCVSandCodex GPT-6.1 Sol 12315601fd Keep the two repaired harnesses in the automatic parity gate
The requested bulk merge includes #15250 and #15291. Keep interaction_data and inline_feature_parse selected so those fixes retain automatic coverage.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:21:59 +00:00
rdbt fe0dc37392 Merge pull request #14859 from NikitaMGrimm/fix/wrapper-work-dirs
frame_map_writes and wolf_anger_writes run in work folders of their own
2026-11-11 16:18:51 +00:00
rdbt 8f3adbae40 Merge pull request #15264 from Akirabane/fix/rcon-utf16-units
RCON: cut a command's output at 4096 UTF-16 units, as vanilla does
2026-11-11 16:11:05 +00:00
rdbtCVSandCodex GPT-6.1 Sol 678ed9eb24 Merge main into map-moved-fns-2 and retain the PlayerEntry note
Resolve the port-ledger conflict by retaining the explicit social_screen.rs::Entry description. The other path corrections are already on main.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:11:04 +00:00
rdbt 61444edbef Merge pull request #15257 from yahya-nusseibeh/fix/telemetry-bounds
Telemetry: refuse an impossible official player count
2026-11-11 16:10:57 +00:00
rdbt d38111c32a Merge pull request #15255 from yahya-nusseibeh/fix/server-connection-limits
Server: per-address RCON places, write timeouts for event streams, digest token compare
2026-11-11 16:10:49 +00:00
rdbt 0e87353bd8 Merge pull request #15246 from yahya-nusseibeh/fix/friends-relay-hardening
Friends and relay: bounded handshakes, endpoints, STUN and UPnP input
2026-11-11 16:10:42 +00:00
rdbt 74039da8e1 Merge pull request #15228 from yahya-nusseibeh/fix/relay-budgets
Relay: keep real codes joinable and registrations bounded per site
2026-11-11 16:10:34 +00:00
rdbt 5d71cad052 Merge pull request #15227 from yahya-nusseibeh/fix/command-intern-cap
Commands: a server's registry names can't leak without bound
2026-11-11 16:10:27 +00:00
rdbt 0bdf9ff9d8 Merge pull request #15224 from yahya-nusseibeh/fix/config-key-whitespace
horizon-server: a space in a config key can't get past the API's deny-list
2026-11-11 16:10:20 +00:00
rdbt e94ffb5b10 Merge pull request #15222 from yahya-nusseibeh/fix/backup-save-on-always
horizon-server: a backup always turns saving back on
2026-11-11 16:10:14 +00:00
rdbt 0065a8925a Merge pull request #14632 from MSedgeMC/port_map/game2-009-only2
port map: the game2-009 rows whose gaps are gone
2026-11-11 16:10:06 +00:00
rdbt 91f26d5aa2 Merge pull request #14606 from MSedgeMC/port_map/game-005-spawn-egg-paths
port map: the spawn egg block-entity rows point at spawn_eggs.rs
2026-11-11 16:09:58 +00:00
rdbt d0706e0059 Merge pull request #15272 from Akirabane/fix/identifier-namespace-dot-dot
Identifier: the namespace .. is refused, as vanilla's isValidNamespace does
2026-11-11 16:09:35 +00:00
rdbt 5c0a3b5ecf Merge pull request #15256 from yahya-nusseibeh/fix/server-list-writes-checked
Server: list edits report a failed save, server.properties is written atomically, plain params
2026-11-11 16:09:25 +00:00
rdbt fb0127a07f Merge pull request #15234 from yahya-nusseibeh/fix/service-system-sudo-user
Server: a --system unit runs as the account that ran sudo, never root
2026-11-11 16:09:15 +00:00
rdbt 9aa200334a Merge pull request #15225 from yahya-nusseibeh/fix/management-secret-os-random
Server files: the management secret comes from OS randomness, not the seed's stream
2026-11-11 16:09:04 +00:00
rdbt 7c0b174bbf Merge pull request #15083 from senjinthedragon/terrain-one-draw
Shader packs: one program's solid and cutout terrain draw in one pass
2026-11-11 16:08:54 +00:00
rdbt b52cee2760 Merge pull request #14575 from MSedgeMC/ledger/moved-citations-2
port map: finish the moved-module citations from the week's splits
2026-11-11 16:08:43 +00:00
rdbtCVSandCodex GPT-6.1 Sol 82d4e2faa9 Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:06:33 +00:00
rdbtCVSandCodex GPT-6.1 Sol fa6140c555 Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:06:33 +00:00
rdbtCVSandCodex GPT-6.1 Sol 9945e0cb21 Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:01:02 +00:00
rdbtCVSandCodex GPT-6.1 Sol f55a82aa12 Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:01:02 +00:00
rdbtCVSandCodex GPT-6.1 Sol f72c7f644d Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:01:02 +00:00
rdbtCVSandCodex GPT-6.1 Sol 15ed0df804 Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:01:01 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 1b2c3b4ae2 parity: command_exec runs by name only while a few of its commands differ in every run
Part of #15316.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:59:56 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 9d87f01363 parity: four harnesses that regressed on main run by name only until their fix
use_item_on (#15320), recipe_book_view (#15321), block_entity_render
(#15322) and fishing_hook_entities (#15323) passed at main's last green
parity-main (fdc87aecd, 2026-11-07) and fail now whatever a pull request
changes. Each has an issue naming the merge, or the range, that broke it.

Part of #15316.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:59:55 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 e10f3459c8 nightly audit: the new issue's wording covers a harness an earlier audit left out
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:59:11 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 2b43458b4a parity-main: the search's budget starts after HEAD and the base ran; a comment that failed is tried again
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:59:10 +01:00
rdbtCVSandCodex GPT-6.1 Sol 9d97041ffb Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:53:37 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 05e964cd8f docs: wrap the audit line
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:51:19 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 5a56eb3ae1 nightly audit: a harness that fails in its first audit gets an issue too
An issue was opened only for a harness that passed in the previous audit,
so one merged while its pull request's parity job failed or was skipped
never got one: nine such harnesses failed on main unreported (#15158).

Part of #15316.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:51:13 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 38ce5cfa4e parity-main: the bisect stops on a time budget and the next run goes on from there
Seven runs between 2026-11-09 and 11-11 searched up to 1,500 merges until
the job's 240 minutes ended, named no merge, and the next run started again
from nothing. The search now has --budget minutes (90), then says between
which merges each harness broke. What it learnt is kept in
.local/parity-runner/bisect.json while the base is the same, so the next
run narrows it further, and a merge already named gets no second comment.

Part of #15316.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:50:21 +01:00
rdbt 2be5079ae1 Merge pull request #14237 from itarqos5/test/vanilla-redstone-rules
Tests: vanilla's piston, tripwire hook and observer rules, each pinned (8 tests)
2026-11-11 15:48:35 +00:00
rdbt 1d5957fb9d Merge pull request #14222 from NikitaMGrimm/parity-process-tree
Harnesses take what they started with them: a dead harness or runner no longer leaves games running
2026-11-11 15:47:56 +00:00
rdbt 9c03f504a2 Merge pull request #14375 from DylanGonzaloFerreyra/split-play-advancements
Move the advancement packets out of play.rs into play/advancements.rs
2026-11-11 15:47:36 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 fc78a29fee parity: eight harnesses that never passed run by name only
They merged while their pull request's parity job failed or was skipped and
fail on main whatever a change touches (#15158), so every pull request that
selects them fails and parity-main cannot pass. They still run when named and
in the nightly audit, and come off the list with their fix.

Part of #15316.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:47:23 +01:00
rdbtCVSandCodex GPT-6.1 Sol d05a66c6f2 Merge current main before command fixture validation
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:46:51 +00:00
rdbtCVSandCodex GPT-6.1 Sol 461c878b6a Clarify the Rust cooldown field reference
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:46:51 +00:00
rdbt 216aca09f6 Merge pull request #15182 from masa-masa-0314/refactor/friends-social-model
Friends: separate existing account-social models and parsing
2026-11-11 15:46:25 +00:00
rdbt ff11e449d3 Merge pull request #15147 from MSedgeMC/fix/wheel-sensitivity-log
The mouse wheel sensitivity slider is logarithmic
2026-11-11 15:46:20 +00:00
rdbtCVSandCodex GPT-6.1 Sol 31c5e22e7f Merge current main into parity fix #15291
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:45:17 +00:00
rdbt 687f3f6d3f Merge pull request #14397 from MSedgeMC/fix/port-focus
The LAN port box is focusable while LAN is off
2026-11-11 15:45:16 +00:00
rdbt 6e5784e4fa Merge pull request #14464 from Solenopsisbot/t3/fix-saved-present-mode
Apply saved present mode before first gameplay frame
2026-11-11 15:44:39 +00:00
rdbtCVSandCodex GPT-6.1 Sol e653778213 Merge current main into parity fix #15250
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:44:35 +00:00
rdbtCVS 82134c2755 Merge main and preserve both command comparison test suites
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>

# Conflicts:
#	tests/test_command_exec_comparison.py
2026-11-11 15:44:10 +00:00
rdbtCVSandCodex GPT-6.1 Sol cd4e6696fa Merge current main before attacker reference validation
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:43:42 +00:00
rdbtCVSandCodex GPT-6.1 Sol 06560cdd7d Keep deferred-hit timer guidance beside its ordering helper
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:43:16 +00:00
rdbtCVSandCodex GPT-6.1 Sol 1420b5bd29 Merge current main before hoglin regression validation
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:37:42 +00:00
rdbtCVSandCodex GPT-6.1 Sol cef4066d7e Keep the allay pickup predicate in its feature module
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:36:55 +00:00
rdbt 77e510c3ab Merge pull request #14005 from horizon-rust/layers/movement-record
Movement prediction: the client-tick record moves into the engine (layer plan M6)
2026-11-11 15:34:21 +00:00
rdbtCVSandCodex GPT-6.1 Sol 20cd32f3a4 Gate allay item pickup on cooldown memory presence
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:29:38 +00:00
rdbtCVSandCodex GPT-6.1 Sol f0fb237cc2 Merge current main before validating allay cooldowns
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:29:22 +00:00
rdbtandCodex GPT-6.1 Sol eebd726751 Merge pull request #14085 from horizon-rust/fix/invulnerable-time-attribute-order
Canonicalize root entity attribute-list order while preserving all values, styles and other record fields.

Validation: 9 focused comparison tests and 105/105 strict vanilla parity records pass; current-head required verify is green.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:28:36 +00:00
rdbt 3acf30529b Merge pull request #15079 from angel2rider/docs/prerequisites
docs: the prerequisites say Python 3.11+ and git 2.38+
2026-11-11 15:28:23 +00:00
rdbt 197fa1e294 Merge pull request #14683 from NikitaMGrimm/hdr-pass-rules-blend
docs: HDR pass rules, tone-weighted blending is for linear light and sharpening eases off above white
2026-11-11 15:28:18 +00:00
rdbtandCodex GPT-6.1 Sol cd0b263747 Merge pull request #15092 from MSedgeMC/fix/worker-count-hybrid-test
Correct the automatic-worker assertion for machines with fewer performance cores.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:26:31 +00:00
rdbtCVSandCodex GPT-6.1 Sol 1fce5ccdbf Shrink the new sulfur size fixture before its cleanup
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:24:48 +00:00
rdbtCVSandCodex GPT-6.1 Sol 244b166781 Merge main into command fixture isolation
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:24:32 +00:00
rdbtCVSandCodex GPT-6.1 Sol f2b247edda Preserve attacker field ordering and wrapping timestamps
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:23:09 +00:00
rdbtCVSandCodex GPT-6.1 Sol 7baaa34948 Count down deferred hurt timers only after a reset
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:22:20 +00:00
rdbtCVSandCodex GPT-6.1 Sol 517889c893 Merge main into attacker reference loading fix
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:22:20 +00:00
rdbtCVSandCodex GPT-6.1 Sol 95fe2c81f2 Merge main into hoglin attack fix
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:21:30 +00:00
rdbtCVSandCodex GPT-6.1 Sol 6d783a4116 Cover full integer values in saved allay cooldowns
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:21:02 +00:00
rdbtCVS 9f32ef4233 Merge main into allay cooldown fix
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/allay.rs
2026-11-11 15:20:29 +00:00
levzzz f23176af0f Merge pull request #15285 from levzzz5154/policy/read-restricted-source
Allow reading source under restrictive licenses
2026-11-11 19:36:18 +05:00
KBroneZandClaude Opus 5.5 909d7a2134 fix: a missing inline feature field names the whole map, as vanilla does
In 26.3 Feature.DIRECT_CODEC dispatches on `type` straight to the type's
own map codec: there is no `config` wrapper, so `config` is just another
unread key and a missing field's DFU error names the whole map. The
command codec still fell back to a `config` sub-map, so
`{type:"minecraft:simple_block",config:{}}` reported `MapLike[{}]`. The
inline_feature_parse parity harness (#15158) now matches 84/84 records.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 16:27:39 +02:00
levzzzandOpenAI GPT-6.1 7ca851a78d Allow reading source under restrictive licenses
Co-Authored-By: OpenAI GPT-6.1 <[email protected]>
2026-11-11 19:24:37 +05:00
rdbtCVSandGPT 6.1 Sol 8f27aaeb01 test: retain summoned zombie villager profession domain coverage
Replace only the two unseeded equality probes with deterministic built-in profession-domain checks. Keep saved VillagerData and all finalized reads exact, with regression tests for the corpus and strict feedback/callback comparison.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:35:33 +00:00
rdbtCVSandGPT 6.1 Sol 3e75c0f077 Constrain attribute canonicalization to root entity data
Keep quoted text, nested custom lists, modifier order and every styled
separator exact. Cover values, duplicate entries, modifier IDs and actual
state differences with regression tests; preserve the contributor commit.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:33:38 +00:00
AkirabaneandClaude Sonnet 5.5 a64e9c8037 Identifier: refuse the namespace ".." as vanilla does
Identifier.isValidNamespace returns false for the namespace "..", and
otherwise accepts a-z, 0-9, '_', '-' and '.'. from_parts only checked the
characters, so Identifier::parse("..:x") succeeded.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:28:47 +01:00
levzzz 1bec6d447a Merge pull request #15211 from horizon-rust/docs/owners-council
Docs: two org owners (P28), council code owners and security managers
2026-11-11 18:23:06 +05:00
AkirabaneandClaude Sonnet 5.5 d08c04020c RCON: cut a command's output at 4096 UTF-16 units, as vanilla does
RconClient.sendCmdResponse cuts the Java string with substring at 4096
UTF-16 units, so a character outside the BMP can straddle a cut, and
String.getBytes(UTF_8) writes each lone half as '?'. Horizon counted
characters instead. Split the output on UTF-16 units and write a lone
surrogate half as '?'.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:20:49 +01:00
KBroneZandClaude Opus 5.5 d83b716d13 fix: print an Interaction's width before its interaction record
`width` and `interaction` share a HashMap bucket at every table size, and
keys sharing a bucket iterate in insertion order. java_hash_order ranked
neither, so they fell back to key order and `interaction` came first,
while Interaction.addAdditionalSaveData writes width, height, attack,
interaction and response in that order. The interaction_data parity
harness (#15158) now matches 120/120 records.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 15:16:58 +02:00
yahya-nusseibehandClaude Opus 5.5 8ded4de3fe Telemetry: refuse an impossible official player count
The official server's players.online (any u64 from a plain status ping) went into the history
as i64: above i64::MAX it wrapped negative, and near it SUM() overflowed, failing
/v1/live/history?source=official until the rows were pruned. A count above a million is now
treated as no answer: that minute stays empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:59:39 +02:00
yahya-nusseibehandClaude Opus 5.5 db69868ebb Friends and relay: bounded handshakes, endpoints, STUN and UPnP input
- Host: the inbound handshake slot is an RAII guard, so a failed spawn can't leak one of
  MAX_HANDSHAKES until the direct port takes nothing.
- Sessions authenticate at most 16 direct-race streams, each under one 6 s deadline instead of a
  per-read timeout a peer could drip past.
- Invite decode takes at most MAX_ENDPOINTS (8), as encode writes: a crafted code can't make the
  guest dial thousands of addresses.
- A STUN attempt has one deadline, so datagrams arriving faster than the read timeout can't keep
  the query, and the host's setup, going forever.
- A UPnP description's controlURL, URLBase or serviceType with control characters or spaces is
  ignored instead of reaching a request line or header.
- The guest's direct-race listener handles each inbound stream on its own thread with a deadline.
- Relay: the connection count is given back by a guard, and attach tokens compare in constant time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:45:28 +02:00
yahya-nusseibehandClaude Opus 5.5 adb97c68d7 Server: list edits report a failed save, server.properties is written atomically, plain params
- UserList add/remove only log a failed save, so with a read-only or full disk the CLI and the
  stopped server's API said "Banned X" or "Made X a server operator" though the file still said
  otherwise. Each change is now saved and checked (saved()).
- ServerProperties::store wrote server.properties in place, default mode: a crash at start left a
  short file read as defaults (a new management secret, settings dropped), readable by other
  accounts. It now writes a new file (0600 when new, an existing file's mode kept) renamed over.
- system_message/kick translatable parameters are plain text, as vanilla's Message takes them,
  not components tellraw would resolve with the console's rights.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:42:44 +02:00
yahya-nusseibehandClaude Opus 5.5 e0d880754e Server: per-address RCON places, write timeouts for event streams, digest token compare
- One address could hold all 16 RCON places idle and keep the operator's own client out; each
  address now gets at most 4.
- Management notifications went to every client from one thread with blocking writes: a client
  that stopped reading stalled them for everyone. Management sockets get a 10 s write timeout
  and a client whose write fails is closed.
- The event stream (SSE) gets a write timeout too, so a client that stops reading doesn't pin
  its thread forever.
- same_token compares SHA-256 digests, so its time doesn't show the token's length.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:36:44 +02:00
Horizon agentandClaude Opus 5.5 e415db6e71 Docs: two org owners (P28), council code owners (P35) and security managers (P36)
The council's polls are applied: rdbtCVS is the second organisation owner,
the council team holds CODEOWNERS and the security-manager role. The pages
that said Ludovic alone held these powers now say who does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 13:35:38 +01:00
yahya-nusseibehandClaude Opus 5.5 c72e912d73 Port-pack: report odd worldgen JSON instead of panicking, and end a panicking modpack step
serde_json's index panics on a Map missing the key and on a non-object Value: a density
function "spline" without its "spline" field, a template pool whose elements aren't objects, or
a structure whose horizon:piece_limited isn't an object panicked the port. In the launcher that
killed pack_job's thread before PackDone, so the Modpacks page stayed busy until a restart.

Those reads now use get/get_mut and skip what isn't there, and pack_job turns a panic in a step
into an error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:55:10 +02:00
yahya-nusseibehandClaude Opus 5.5 63f703ccb8 Server: a --system unit runs as the account that ran sudo, never root
`sudo horizon-server service install --system`, as the help says to run it, wrote `User=root`:
sudo resets USER to root, so the network-facing server (game port, RCON, management API, panel)
ran as root and left root-owned files in the operator's folder. The unit now runs as SUDO_USER,
else USER, and refuses to be written for root (from a root shell).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:36:49 +02:00
yahya-nusseibehandClaude Opus 5.5 e559d499fb Relay: keep real codes joinable and registrations bounded per site
- The relay-wide unknown-code budget now refuses only joins whose code turns out unknown:
  a handful of /24s or /48s could keep it empty and every guest with a real code got
  RateLimited. Per-address and per-site budgets are still checked before the lookup.
- Behind the PROXY protocol, a connection from outside RELAY_PROXY_FROM is closed before
  anything is read, as the docs say, instead of holding a slot for first_line.
- Registrations get a per-site budget (IPv4 /24, IPv6 /48; 30 a minute, burst 10) on top of
  the per-/64 one, and ended nameplates expire oldest first from a queue instead of a scan of
  the whole map under the global lock on every registration.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:17:55 +02:00
masa-masa-0314andZCode 08095e81cb refactor(friends): separate account-social models and parsing
Move the existing DTOs and response parsing into a private model module,
retaining public social paths and the existing parsing tests. Keep wire
and presence helpers visible only inside the social module tree.

Document the small pure-move preparation sequence, including a mandatory
manager module and a vanilla audit gate after all structural steps.
No cache, scheduling, action or UI behavior changes are included.

Fixes #15179

Co-Authored-By: ZCode <[email protected]>
2026-11-11 18:16:07 +08:00
rdbt b880384691 Merge pull request #15161 from horizon-rust/fix/parity-revert-13953 2026-11-11 10:00:11 +00:00
TheGTandOpenCode 5ee4540940 tools: port_ledger.py reads the pinned version from the lock file
Its two version lookups fell back to a literal "26.3" when the lock
file was missing or had no version, so a version bump that touched
only research-lock.json left the ledger looking in the wrong folder.
tools/lab.py already reads the pinned version from there; the
fallback now uses it.

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 09:56:38 +00:00
Ludovic Marie 77a34310b0 Merge pull request #15159 from horizon-rust/governance/codeowners-council
🤖 Agent: CODEOWNERS moves from @ludovic111 to @horizon-rust/council, as the council decided (P35); the team exists with the six members and write access; reviewer tests pass. Paths guarded by /nightly are unchanged.
2026-11-11 10:39:41 +01:00
Horizon agentandClaude Opus 5.5 7627df18b0 CODEOWNERS: the council team reviews release paths (P35)
The council decided (P35, 2026-11-10) that code-owner reviews move from the
founder to a GitHub team of the six council members. The team
horizon-rust/council exists and has write access, which code owners need.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 10:17:07 +01:00
Horizon agent ee66246131 Merge remote-tracking branch 'origin/main' into fix/parity-revert-13953
# Conflicts:
#	tools/parity_hosts.json
2026-11-11 10:06:20 +01:00
Horizon agentandClaude Opus 5.5 766a5d4c87 Parity: container_screen's oracle skips the first release; stand_lodestone is named-only
container_screen: the oracle makes its screens with Unsafe.allocateInstance,
which skips AbstractContainerScreen's constructor and so its
`skipNextRelease = true`. #13723 made Horizon's Clicks start that way,
as the real screen does, and the oracle then disagreed on every case
whose first event is a release (373 cases). The oracle now sets the
field the constructor would.

stand_lodestone needs --work-dir, a frozen Java run (--reference-dir)
and a built game (--binary), which an automatic run never passes, so it
failed in 0 s on every runner. It joins the other capture-preserving
harnesses in parity_hosts.json's named_only: it runs when named, and
in the nightly audit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 10:06:01 +01:00
MSedgeMC 5add9e31c0 refactor: the wheel module declares itself after mod shell_pages
The #[path] mod wheel block sat right after pub use Opt, the anchor the
widgets PR uses. It now sits after mod shell_pages, and its test moved into
the module's own #[cfg(test)] mod tests instead of appending to
options_ui_graphics_tests.rs, so the three siblings touch disjoint lines. No
behaviour changes.
2026-11-11 08:45:48 +00:00
MSedgeMC 0779ef7099 port_map: Options.mouseWheelSensitivity's logarithmic slider 2026-11-11 08:07:20 +00:00
MSedgeMC f6850ed4a5 test: the mouse wheel sensitivity slider steps by 10^(i/100)
The default 1.0 sits two thirds along, 0.01 and 10.0 at the ends, a third of
the way is 0.1, and the value setter goes through the same mapping.
2026-11-11 08:07:20 +00:00
MSedgeMC ec1ab4c1a9 fix: the mouse wheel sensitivity slider is logarithmic
Options.mouseWheelSensitivity is an IntRange(-200, 100) through
logMouse/unlogMouse: the value is 10^(i/100), so the slider's handle sits at
(i + 200) / 300 and a drag picks the step by floor(map(v, 0, 1, -200, 101)).
Horizon used a straight line from 0.01 to 10.0, which put the default 1.0 at
0.099 of the track and made every drag pick a different sensitivity.
2026-11-11 08:07:16 +00:00
TheGT ffd67ee750 Merge remote-tracking branch 'origin/main' into ci/run-cargo-with-locked 2026-11-11 07:33:14 +00:00
TheGT ef207822ad Merge remote-tracking branch 'origin/main' into docs/prerequisites 2026-11-11 07:31:49 +00:00
TheGTandOpenCode 45bfbc8a5f tools: the port modules say how they are used, and the port report finds every version site
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:45:59 +00:00
yahya-nusseibehandClaude Opus 5.5 7145f30254 Commands: a server's registry names can't leak without bound
Every Commands packet becomes a dispatcher, and every resource-style
argument the vanilla tree doesn't match goes through intern(), which
Box::leaks its registry name for the life of the game. The name is the
server's, up to 32767 bytes and unchecked, so a server sending trees of
new names packet after packet leaked megabytes each time until the
client ran out of memory.

intern() now keeps only resource-location names of at most 256 bytes,
and at most 1024 of them; anything else is kept as "" (no suggestions
for that argument).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:45:22 +02:00
TheGTandOpenCode e43c0f75dc tools: say git 2.38 or newer is required, instead of printing git's usage
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:32:53 +00:00
yahya-nusseibehandClaude Opus 5.5 7d84ad3777 Server files: the management secret comes from OS randomness, not the seed's stream
SystemEntropy is splitmix64 seeded from 8 bytes. On a first start with
no secret and no level-seed (both defaults), the 40 characters of
management-server-secret were outputs 1-40 of that stream and the world
seed was output 41; splitmix64's mixer is a bijection, so the world
seed (which ops read with /seed, and seed crackers recover from
terrain) gave back the state and every secret character. The secret is
kept when the owner later enables the management server. On Windows the
seed fell back to the clock, so the secret depended only on the start
time.

Secrets (the management secret, the TLS keystore password) now come
from OsEntropy, every draw straight from getrandom with rejection
sampling, as vanilla's SecureRandom. SystemEntropy itself is seeded
from getrandom too, the clock only if that fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:32:18 +02:00
MSedgeMC 14bc8fbebe graphics: the automatic worker count is not always one per available processor 2026-11-11 04:29:25 +00:00
yahya-nusseibehandClaude Opus 5.5 56a25648ac horizon-server: a space in a config key can't get past the API's deny-list
PATCH /api/v1/config checked the raw JSON key with changeable_remotely,
which doesn't trim it, then applied "{key}={value}" through
parse_change, which does. A key " engine.path" (or "\tapi.allow_public",
" discord.webhook", " properties.enable-rcon") passed the deny-list and
was written to server.toml: at the next restart the API or panel opened
to the internet, chat went to another webhook, RCON came on, or the
supervisor ran another program.

changeable_remotely now refuses any key with whitespace or control
characters in it, so what it checks is what gets applied.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:28:23 +02:00
yahya-nusseibehandClaude Opus 5.5 5323f98c42 horizon-server: a backup always turns saving back on
A backup turned autosave off, then flushed. If the flush failed (the
control connection's 30 s reply timeout on a big world or a slow disk),
the ? returned before `console save-on` was sent, and the game kept
running with autosave off until the next backup that succeeded;
scheduled backups take this path, so a crash in that window lost
everything played since.

Once save-off has been asked, saving is now always turned back on: over
the same connection after a good backup, and over a fresh one when the
flush failed (a reply that timed out leaves the old one out of step).
The flush gets its own 300 s reply timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:23:41 +02:00
TheGTandOpenCode 70a011535b ci: run cargo with --locked so a stale Cargo.lock fails instead of being rewritten
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:16:07 +00:00
SenjinandClaude Opus 5.5 9b006dcab6 Shader packs: one program's solid and cutout terrain draw in one pass
A pack whose one terrain program (gbuffers_terrain) draws both solid and
cutout terrain ran it twice over the solid list, each pass moving the other
part's quads off-screen: every solid-terrain vertex was shaded twice (0.6 ms a frame measured with a path-traced pack on an RTX 2080).

Now the game also gives the loader terrain's inputs for one pass
(GameInputs::terrain_one_pass, GbuffersInputs::parts): the vertex stage
works out its quad's part from the sprite layer, as before, and hands it to
the fragment stage as a flat varying, which applies that part's alpha test.
alphaTestRef becomes a per-part value in both stages (0 for solid, 0.5 for
cutout, as Iris 1.11.7 did in #9307's probes), and alphaTest.gbuffers_terrain
still replaces both parts' tests. When both tests are the same, nothing
changes in the shader. Packs with gbuffers_terrain_solid or _cutout keep
their two passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:09:25 +01:00
TheGTandOpenCode minimax-m3.1-flash-free 172965a9a0 docs: the prerequisites say Python 3.11+ and git 2.38+
Co-Authored-By: OpenCode minimax-m3.1-flash-free <[email protected]>
2026-11-11 04:06:30 +00:00
MSedgeMC 8d47c940e6 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:22 +00:00
MSedgeMC 0d92a3d997 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:19 +00:00
MSedgeMC 7d6b37b8e6 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:12 +00:00
MSedgeMC 59a74c49ac port map: the citations the later splits left behind (feeding, step, section_fade, remote_removal, minecarts/nbt, Fox.SleepGoal) 2026-11-11 01:18:03 +00:00
MSedgeMC a899c56c45 Merge main; keep both sides' rows 2026-11-11 00:59:17 +00:00
MSedgeMC cff772e57a Merge main; keep both sides' rows 2026-11-11 00:59:15 +00:00
MSedgeMC f5541f7c45 Merge upstream/main into fix/social-search-caret 2026-11-11 00:41:23 +00:00
MSedgeMC 9ce725cca5 fix: the social screen's click routing names the class that dispatches it
`SocialInteractionsScreen` declares no `mouseClicked` at all: its clicks come
from `ContainerEventHandler.mouseClicked`, the inherited child dispatch, which
reaches the search box's `EditBox.onClick` before the tabs, the rows' buttons
and Done. The body and the port map had attributed that to an override on the
screen.

`social_screen::click_kind` now answers which child the click belongs to -
`Click::Search` for the box, `Click::Action` for the list - and `Game::social_click`
performs it. The test drives `click_kind`: both corners of the box are the
box's, the pixels left of and just below it are nobody's, and Done's centre is
the list's `Action::Done`. Without the search branch the two inside corners
read `None`.

The caret move itself still needs the GUI atlas (`EditBox::click`), so it sits
in `Game::social_click` and is not asserted: every `Game` test in this crate
needs prepared resources and is `#[ignore]`d.
2026-11-10 22:40:35 +00:00
Mayonesu0andClaude Sonnet 5.5 9528d81b2f The last_hurt_by_mob corpus gains a pig naming its own UUID
Adds a fourth NoAI pig (self) summoned with last_hurt_by_mob set to its
own UUID, read at the same points as echo, so vanilla 26.3 shows that
EntityReference.getEntity resolves the holder too and baseTick ends the
record after 100 ticks.

Part of #12830

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 19:17:45 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 8df65734b7 frame_map_writes and wolf_anger_writes run in work folders of their own
Both wrapped the command_exec driver without --work-dir, so they shared
.local/command-exec-parity with each other and with command_exec, and two
running at once read each other's scripts and worlds.

Fixes #14855

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 17:20:39 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 1b5800c3ab command_exec cleanup keeps the End's dragon; bird flap sensors seeds each bird itself
The End's own dragon flies above the cleanup's floor at times, and killing it started vanilla's
death sequence. bird flap sensors ran on item honey bounce's seed sync, 36 groups earlier, and
failed in more than half of the runs once the cleanup ran between them (0 of 9 without it): each
bird now gets its own sync, with entity ids clear of the world's loaded entities.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:08:46 +01:00
Mayonesu0andClaude Sonnet 5.5 2a67bd988c A saved last_hurt_by_mob naming the mob itself resolves like any other
EntityReference.getEntity looks the UUID up in the level and returns any
matching LivingEntity, the holder included, so LivingEntity.baseTick ends
such a record after 100 ticks. resolve_last_hurt_by_refs dropped a
reference equal to the mob's own UUID, leaving it unresolved and saved for
ever. Remove that exclusion and cover it with a test.

Part of #12830

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:30:47 +01:00
Nikita Maximilian Grimm 66ead25e8d Merge remote-tracking branch 'origin/main' into fix/command-exec-group-isolation 2026-11-10 16:21:50 +01:00
Horizon agentandClaude Opus 5.5 938dcd673c Revert #13953: entity ticking waits for chunks two columns away
This reverts merge 5248e83f28 (#13953).

Since it merged (2026-11-09 15:51Z), entities in the command_exec
fixture world stop ticking: they no longer fall, die, despawn or pick
items up, and command_exec fails 159/184 (it passes at the merge's
parent, and fails at the merge, both run here). Every PR whose change
selects command_exec has failed parity since.

Vanilla's gate is right, but Horizon does not keep the ring around a
forceloaded or ticketed chunk resident the way vanilla's ticket levels
do (a FORCED ticket gives its neighbours FULL), so the residency check
holds back chunks vanilla ticks. #13852 needs that ring modelled first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 14:02:07 +01:00
HiuoyandClaude f7c7e37999 Layer split: the "no prepared files" screen gets its page through a hook
A new get_the_game hook (horizon-hooks, re-exported by horizon-platform)
answers with the page to open and the screen's texts as translation keys.
horizon-official-servers answers it with Horizon's launcher text and
horizon_addresses::WEBSITE. With no hook, the engine screen and
missing_resources say how to prepare the files and offer only Quit.
The screen's strings are now translation keys. The no_resources.rs entry
leaves tools/addresses.json's allowed list.

Closes #14479

Co-Authored-By: Claude <[email protected]>
2026-11-10 09:59:36 +04:00
Nikita Maximilian GrimmandClaude Fable 5.2 c8d9a748ac docs: HDR pass rules, tone-weighted blending is for linear light and sharpening eases off above white
Rule 3 told every pass that keeps a history to blend tone-weighted. Measured on Horizon's
own TAA, which accumulates the float frame's gamma, that raised the sun scene's largest
per-pixel standard deviation from 11 to 27 PQ code values. The rule now goes by the encoding
a pass accumulates: tone-weighted for linear light (a 0.2 pack's post buffers, or the frame
under linear storage), as stored for vanilla's gamma.

Rule 2 named the sharpening's limit above 1 as a fault to fix. Measured, sharpening the sun's
edge as the rest raises its flicker under upscaling by 55 to 69% for 1 to 6% more detail, so
the limit stays and the rule says why.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 05:22:27 +01:00
MSedgeMC aa25cc6cb3 port_map: the game2-009 rows whose gaps are gone
Six rows still described behaviour the code has, and two of them cited
`Game::use_spawn_egg` in `creatures.rs`, where it no longer lives (it is
in `creatures/spawn_eggs.rs`):

- `SpawnEggItem.spawnMob`: the spawn path posts
  `minecraft:entity_place` at the spawn position (the dispenser path does
  too), so sculk sensors hear the egg; the path is now cited.
- `PotionContents.getAllEffects`: `brewing::contents_effects` is the
  potion's effects plus the stack's `custom_effects`, and
  `finish_drinking` applies them.
- `PiercingWeapon.makeHitSound`: the jab's sound plays at the target
  through `audio.play`, shared with the LAN guests.
- `TooltipDisplay.withHidden`: the loot table's
  `minecraft:toggle_tooltips` is applied in `loot.rs::Context::run`, next
  to the `/item modify` path.
- `SlotDisplay.Composite.resolve`: `Book::stacks` flattens a composite
  into its members' stacks.
2026-11-10 01:35:55 +00:00
MSedgeMC c06d152243 port_map: the spawn egg block-entity rows point at spawn_eggs.rs
`Game::apply_spawn_egg_entity_data` moved from `creatures.rs` to
`creatures/spawn_eggs.rs`, so `EntityType.appendCustomEntityStackConfig`
and `EntityType.updateCustomEntityTag` cited a function that is not
there. The leash row's gap is gone too: the tick checks the leashed
mob's life and its player holder's life and spectator state, so a dead
or spectating holder drops the lead.
2026-11-10 01:08:52 +00:00
voduffandClaude Opus 5.5 7373b8cf73 The preedit tests take turns with the composition state (#14577)
preedit's state is one static for the process, and three tests change it while
cargo runs them in parallel: one's reset or should_suppress_key could land in
another, which failed at random in CI. They now hold a test-only lock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:29:33 +00:00
Mayonesu0andClaude Sonnet 5.5 68ad338029 A saved last_hurt_by_mob is read back and ends as in vanilla
LivingEntity.readAdditionalSaveData reads last_hurt_by_mob as a lazy
EntityReference (the UUID) and sets lastHurtByMobTimestamp to
tickCount - ticks_since_last_hurt_by_mob. getLastHurtByMob only answers once
the UUID resolves to a living entity in the level, and baseTick ends the
record when that entity is dead or more than 100 ticks old.

- Mob.ai keeps the bare UUID (last_hurt_by_ref) until the game resolves it
  into last_hurt_by (a mob, or the player) before the mobs' baseTicks.
- baseTick (mobs/hurt_by.rs) ends a resolved record as vanilla does; a
  reference naming nothing stays, keeps aging, and is saved as it is.
- setLastHurtByMob(who/null) clears an unresolved reference.
- The tests' world loads the 2-column neighbourhood entities need to tick.
- The corpus gains a pig naming an entity that arrives later.

Part of #12830

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 01:28:46 +01:00
MSedgeMC f888d26173 port map: finish the moved-module citations, including the render and remote splits 2026-11-10 00:23:20 +00:00
MSedgeMC c9cf9cb18b port_map: SocialInteractionsScreen.init and mouseClicked 2026-11-09 23:57:24 +00:00
MSedgeMC 33754abe21 test: a click in the social search box is the box's own
The box's rectangle and the pixels just outside it; the list starts below, so
nothing else can claim the click.
2026-11-09 23:57:24 +00:00
MSedgeMC 5a67941d3f fix: a click in the social screen's search box places its caret
SocialInteractionsScreen.mouseClicked gives super.mouseClicked the widgets
first, so the search EditBox's onClick runs on a click inside it and puts the
caret under the pointer. Horizon's social click only knew the tabs, the rows
and Done; clicking the search box moved nothing.
2026-11-09 23:57:24 +00:00
SolenopsisbotandCodex 62ed49d4a4 fix: satisfy game file size check
Co-Authored-By: Codex <[email protected]>
2026-11-10 10:05:52 +11:00
SolenopsisbotandCodex 9482ae7472 Apply saved present mode before gameplay
Co-Authored-By: Codex <[email protected]>
2026-11-10 09:20:12 +11:00
voduffandClaude Opus 5.5 b0074207ca A test's world-pack loot tables stay on its own thread (#14266)
The loot data is one slot per process. A test that installed a world pack's
armor_stand table warmed that slot, and every other test's stand break rolled
it too. load_thread_loot_data keeps such a test's loot data on its own thread,
ahead of the slot; a game never sets it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:56:22 +00:00
MSedgeMC 58abbd0e4a port_map: WorldOptionsScreen.updatePortControlsState is ported 2026-11-09 20:45:32 +00:00
MSedgeMC e6bc09eeb9 test: the LAN-off port box is read-only but focusable
The caret still moves and Backspace changes nothing while LAN is off; turning
LAN on makes the same key edit the port.
2026-11-09 20:45:32 +00:00
MSedgeMC 3ea0ce07a2 fix: the LAN port box is focusable while LAN is off
WorldOptionsScreen.updatePortControlsState makes the port box read-only
without LAN (setEditable) but leaves it focusable: clicking it selects and
copies the port. Horizon dropped the focus instead, so with LAN off the port
could not even be selected or copied.
2026-11-09 20:45:32 +00:00
voduffandClaude Opus 5.5 07a82eccf3 port map: rows follow remove_entity and the minecart save fns; PlayerEntry is a class row
remove_entity moved to game/remote_removal.rs, Minecart::to_nbt/from_nbt to
game/minecarts/nbt.rs. A class row takes home, not port.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:37:56 +00:00
Cx188andCodex 0bdc77efb6 Remove obsolete manual vanilla parity job (#14209)
Use the existing nightly and reusable parity runners instead of a second
manual inventory. Keep the JFR checks and describe their JDK fixtures.
All 84 removed harness commands remain in parity discovery.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 23:31:54 +03:00
Dylan FerreyraandClaude Opus 5.5 3914d39e80 Move the advancement packets out of play.rs into play/advancements.rs
AdvancementDisplay, Advancement, UpdateAdvancements and
SeenAdvancements, with their codecs, move byte for byte into
packets/play/advancements.rs. play.rs re-exports the four public items,
so every `packets::play::…` path still resolves.

The port map follows: the two rows that name UpdateAdvancements::read
and SeenAdvancements::write point at the new file.

Part of #13063.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:15 -03:00
Nikita Maximilian Grimm 021bbb8225 Merge commit '716988614' into parity-process-tree 2026-11-09 20:59:17 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4da0ea5197 parity: start sccache's server outside the harness jobs on Windows
A server started by a harness's build joined that harness's job (sccache
doesn't ask to break away) and ended with it, failing other harnesses'
in-flight compiles (os error 10054). The runner now starts it before any
build, in a job of its own, with no idle timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:58:50 +01:00
itarqos5andClaude Sonnet 5.5 41d1c3adfe Tests: an observer ignores changes on the sides it does not face
`ObserverBlock.updateShape` reacts only when the direction to the changed neighbour is its
FACING. A change behind it, beside it, above it or below it starts no pulse.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 df7437d02b Tests: an observer pulses from two to four ticks after its faced side changes
`ObserverBlock.updateShape` schedules a tick two out when the block it faces changes; that tick
turns it on and schedules another two out, which turns it off. A lamp behind it is lit by the
pulse (`getSignal` answers 15 only for its facing); lamps in front of it, beside it and above it
are not.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 622eda982a Tests: tripwire hooks attach from two to forty-one blocks apart
`TripWireHookBlock.calculateState` scans `for i in 1..42` along the hook's facing for a hook
facing back and ends with `attached &= receiverPos > 1`: hooks attach 2 to 41 blocks apart and
never side by side or 42 apart. Both hooks are placed the way a player places them, because
vanilla runs the calculation from `setPlacedBy`, not from `setBlock`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 cdc8772455 Tests: a piston refuses the blocks that have a block entity
`PistonBaseBlock.isPushable` ends in `!state.hasBlockEntity()`, so a chest, furnace, barrel or
hopper ahead of a powered piston keeps it retracted and stays where it is.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 d289ee5de2 Tests: a piston pushes a retracted piston and refuses an extended one
`PistonBaseBlock.isPushable` answers false for a piston with EXTENDED set, so a push that meets
it fails and the pusher stays retracted, while a retracted one is pushed along. Both kinds.

The extended piston is held extended by a power source of its own, out of the pusher's reach:
unpowered it retracts on the tick, and that retraction updates the pusher, which then pushes it
(vanilla does the same; the first draft of this test asserted the wrong end state).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 3a5bc7ef94 Tests: glazed terracotta is pushed in line and never dragged sideways
26.3 gives `PushReaction.PUSH` to glazed terracotta alone, and `PistonBaseBlock.isPushable`
answers `direction == connectionDirection` for it: a slime block beside it cannot drag it, where
it drags an ordinary block, and in line with the push it moves like any other.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 7725516a3e Tests: slime and honey each drag their own kind and never each other
`PistonStructureResolver.canStickToEachOther`: a slime block drags slime and a honey block drags
honey, but a slime block and a honey block never stick to each other. Four pairings, each with
the block beside the one the piston faces either dragged or left where it was.

Built in open air: a slime block on stone drags the floor along too (vanilla's as well), which
would make the check about something else.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 577c82ffd3 Tests: a piston moves twelve blocks and refuses a thirteenth
`PistonStructureResolver.addBlockLine` refuses once `toPush` would hold
more than twelve blocks, and then the whole push fails: the piston stays
retracted, it does not move twelve of them. Pins 11 and 12 moving, with
the head where the first block stood, and 13 moving none.

The port matched vanilla; the ledger lists `PistonStructureResolver.resolve`
among the ported methods no parity harness covers.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 401373bc22 Tests: the moving-piston tests of level/tests.rs get a file of their own
The five tests from `pistons_pop_fragile_blocks_and_refuse_obsidian` to
`replacing_a_moving_piston_leaves_its_extended_base` move, unchanged, to
level/tests/pistons.rs, beside the barrel and trigger suites that were split
out the same way. They are the piston tests that stood in the middle of a
4176-line file; the piston rules the next commits pin go in the same place,
so one file holds them. This takes 191 lines out of tests.rs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
Horizon agent 1324281e8b Merge remote-tracking branch 'rv3n/14141' into rv3-fix-14141 2026-11-09 19:21:20 +01:00
Nikita Maximilian Grimm 2f1ad94bf7 Merge remote-tracking branch 'origin/main' into parity-process-tree 2026-11-09 19:12:43 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 a0f6aeafce pr_ready: the Windows check reaper is a daemon and holds its own process
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:10:00 +01:00
Nikita Maximilian Grimm 2ec731aadf Merge remote-tracking branch 'origin/main' into fix/command-exec-group-isolation 2026-11-09 19:06:02 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 de08483605 process_tree: a Windows check's exit ends what it left; close a job Popen never used
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:03:35 +01:00
MSedgeMC 81b88d62be Merge upstream/main 2026-11-09 18:00:17 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 ace3563955 command_exec cleanup reaches the other levels in Horizon; groups shrink the cubes they kill last
A dying cube mob is out of every selector's reach and splits 20 ticks later, inside the next group, so
the three groups that end by killing their size-2+ sulfur cubes shrink them first. Horizon resolves an
@s of another level only from that level, so each level's cleanup runs in it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:59:32 +01:00
Horizon agentandClaude Opus 5.5 28214f02a7 Move the perched-dragon test into the module that has dragon()
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:59:09 +01:00
Horizon agentandClaude Opus 5.5 5bb1789373 Name the test's Tag: dragon.rs's test module has no import of it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:54:00 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4c593f0ca7 Harnesses take what they started with them: games, servers and builds
A harness that exited or crashed left the rest of its process group running, and on Windows a parity.py or
pr_ready.py that died left its harnesses and their games running too (taskkill /T can't follow a dead parent).
tools/process_tree.py starts each harness in its own Job Object on Windows (KILL_ON_JOB_CLOSE, started suspended
so nothing escapes before it joins) and, on POSIX, kills the rest of its session's group once it exits, before
it is reaped. pr_ready.py runs its Windows checks the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:49:37 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 ac90e00807 command_exec group cleanup test: the zombie burned in daylight, it was not unloaded
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:37:25 +01:00
MSedgeMC 8f2b122754 port_map: the dragon's sitting damage is read back
`tools/port_map/game2-001.tsv` recorded `EnderDragon.readAdditionalSaveData`
with a gap: `sitting_damage_received` was not read. The row now says
`Dragon::from_nbt` reads it with `getFloatOr`, 0 when absent.
2026-11-09 17:22:03 +00:00
MSedgeMC cbd47615bc test: a perched dragon's stored damage survives the chunk save
`Dragon::to_nbt`/`Dragon::from_nbt` are the pair the dragon's chunk entity
uses. A perched dragon with `sitting_damage = 12.5` saves
`sitting_damage_received: 12.5f` and `from_nbt` gives 12.5 back. A compound
with no such key loads 0.0.

Without the read the reloaded dragon has 0.0: `left: 0.0`, `right: 12.5`.
2026-11-09 17:22:03 +00:00
MSedgeMC 04822910b5 fix: a perched dragon keeps its saved damage through a reload
`EnderDragon.addAdditionalSaveData` writes `sitting_damage_received` (the
damage a perched dragon has taken since it sat down, which makes it take off
at a quarter of its health), and `readAdditionalSaveData` reads it back with
`getFloatOr("sitting_damage_received", 0)`.

Horizon wrote the field in `Dragon::to_nbt` but `Dragon::from_nbt` ignored it:
a perched dragon saved after some damage but before taking off reloaded with
`sitting_damage` 0 and had to be hit for the whole quarter again.

`Dragon::from_nbt` now reads the float, 0 when absent, as vanilla does. The
health read is folded into one line so the over-3000-line file does not grow.
2026-11-09 17:22:03 +00:00
Nikita Maximilian Grimm 070902e9f5 Merge remote-tracking branch 'origin/main' into fix/command-exec-group-isolation 2026-11-09 18:00:50 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 6c4df147a7 command_exec removes what each group leaves alive before the next group
Groups share one world, so an entity a group left stayed visible to later groups' unbounded selectors.
The bell group's persistent zombie burned in daylight and died at a random tick in each engine, which
made the entities group's PersistenceRequired check flaky. The harness now kills, between two groups,
the non-player entities above the groups' floor (y 100 in the overworld, 128 elsewhere), shrinking
cube mobs first and killing twice so drops go too. World-generated entities below stay out of it.
The wolf anger clock diagnostic continues the group before it and is named in CONTINUES_PREVIOUS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:59:59 +01:00
Horizon agentandClaude Opus 5.5 3f044a9aca The command harness records only what a command's own broadcastChanges announced
run_command checks InventoryChangeTrigger after every command; vanilla's
/loot give calls no broadcastChanges, so its Diamonds! comes on the
player's next tick, outside the command's record (inline_loot_reference).
/item replace on a player marks what its own check announced
(cmds.broadcast_announced); the harness records those and leaves the
rest for the tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:57:19 +01:00
Horizon agentandClaude Opus 5.5 e749b17104 mob_attacks_mob and the hit catch-up move to combat.rs: creatures.rs may not grow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:44:46 +01:00
Horizon agentandClaude Opus 5.5 1340e54376 cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:08 +01:00
Horizon agentandClaude Opus 5.5 5ae9f06129 cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:06:26 +01:00
Horizon agentandClaude Opus 5.5 13061c109f lan_bot: the record is the guest's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:41:16 +01:00
Horizon agentandClaude Opus 5.5 40eb5c7fd7 Record: the teleport's restart reads the guest's record
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:40:35 +01:00
Horizon agentandClaude Opus 5.5 04a8acc5a3 Movement prediction: the client-tick record moves from horizon-anticheat into the engine's replay
Layer plan M6, step toward dropping horizon-client-lib -> horizon-anticheat. The record
(horizon_anticheat::prediction) is what the engine's movement replay (lan::prediction) plays again:
it moves to lan/prediction/record.rs unchanged. The guest holds it and the client's sprint
(was Player.record / Player.sprinting). Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:32:06 +01:00
Horizon agentandClaude Opus 5.5 add5b203c6 A hoglin hits from its fight activity, 41 ticks apart
HoglinAi.updateActivity runs after the brain, so a hoglin given an attack
target enters the fight activity at the end of that tick and its
MeleeAttack hits from the next one: the goal now acts only in the fight
activity (brain_step runs after the goals). MeleeAttack.create(40) sets
ATTACK_COOLING_DOWN for 40 brain ticks and MemorySlot.tick erases it on
the 41st, so hits come 41 ticks apart (16 for a baby).

A hit one mob's tick deals another is handled after the mob loop, after
the target's own tick; vanilla deals it inside the attacker's tick, so a
target later in the list counts HurtTime and its damage cooldown down
once more that tick. The pig hit by a hoglin read HurtTime 10 on the tick
of the hit; vanilla's reads 9.

hoglin_repeat_attack reports its rand- rows instead of comparing them:
each hit is attack / 2 + level.getRandom().nextInt(attack), and
Level.random is RandomSource.create(); two vanilla runs disagree on the
pigs' Health.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:05:50 +01:00
Horizon agentandClaude Opus 5.5 a93975ceb5 An allay stays on its pickup cooldown at 0; /item replace triggers inventory_changed
CountDownCooldownTicks keeps ITEM_PICKUP_COOLDOWN_TICKS while it counts
down and erases it the tick after it reached 0 (stop), and
Allay.isOnPickupCooldown is the memory's presence: an allay loaded with
{value:30} saves {value:0} on its 31st tick, then nothing. ints[3] now
holds the value plus one (allay::pickup_cooldown).

EntityItemAccessor calls containerMenu.broadcastChanges for a player
whose slots /item replace changed, so InventoryChangeTrigger (here the
Diamonds! announcement) comes before the command's message. The command
harness now announces what a command earned before its feedback, as a
guest's command does (announce_guest_advancements); it left the
announcement for the next tick, outside the command's record.

allay_pickup_cooldown reports its rand- rows instead of comparing them:
the natural allay's flight starts at its first item scan
(Sensor.randomlyDelayStart with the allay's own random, which is
RandomSource.create()), and two vanilla runs of the script disagree on
those 30 rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:05:01 +01:00
Horizon agentandClaude Opus 5.5 3ef15455e3 command_exec reads the zombie villagers' random profession only where it is not random
ZombieVillager.defineSynchedData picks the profession with
VILLAGER_PROFESSION.getRandom(this.random), and Entity.random is
RandomSource.create(): the first two subjects' profession:none check
passed 1 time in 15 on either side, so 'zombie villager summoned data'
matched 9/10 from run to run. The third subject's profession comes from
its save and stays checked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:49:15 +01:00
Horizon agentandClaude Opus 5.5 ec08a90d43 invulnerable_time compares a mob's attributes list unordered
AttributeMap.pack lists the instances in the iteration order of an
Object2ObjectOpenHashMap keyed by Holder.Reference, which keeps
Object.hashCode: an identity hash. The same script run under another
main class saves the pig's list in another order, so the oracle's
order is the JVM's, not the game's. All 11 differences on main were
that order. The driver gets unordered_attributes(), which sorts the
entries of every attributes list in a record and keeps their text and
styles; the harness compares through it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:22:31 +01:00
Tran Anh Van c4a108b5c3 Keep original f64 PI constant in rod_hand_position 2026-11-09 15:36:22 +07:00
Tran Anh Van 4628acd092 Restore original PI constant type in rod_hand_position 2026-11-09 15:29:29 +07:00
Tran Anh Van 71cf748116 Show other players' fishing rods as cast while their bobber is out
When drawing held items for non-local entities in held_item_geometry,
set fishing_cast_with_this_item from the remote hooks' active owner
state and the holding hand (with the main hand taking precedence when
both hold fishing rods), matching FishingRodCast and FishingHook's
client owner tracking.
2026-11-09 15:24:37 +07:00
232 changed files with 5872 additions and 4489 deletions

No files matched your search

+12 -12
View File
@@ -1,16 +1,16 @@
# Changes to these files wait for Ludovic's review once branch protection requires code owners' reviews
# Changes to these files wait for a council member's review (council poll P35, 2026-11-10) 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/ @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
/.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
-7
View File
@@ -32,13 +32,6 @@
<!-- 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). -->
+10 -360
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-latest' }}
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
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 -- -D warnings
cargo +stable clippy --workspace --all-targets --locked -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
cargo +stable clippy -p horizon-client --all-targets --locked --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, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
# cargo test --locked, 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 --workspace --lib --bins --tests
cargo +stable test --locked --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 --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
cargo +stable test --locked --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --locked --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-latest' }}
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
@@ -313,358 +313,8 @@ 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') }}
# 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 uses JDK-generated fixture recordings on the self-hosted Linux runner.
# Start a manual checks dispatch with: gh workflow run checks --ref <branch>.
jfr-analysis:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
+12 -9
View File
@@ -58,6 +58,18 @@ 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:
@@ -65,13 +77,4 @@ 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
+31 -4
View File
@@ -1,7 +1,9 @@
# Horizon: a map for agents
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
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 → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
## Rules that bite
@@ -42,7 +44,6 @@ 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;
@@ -54,6 +55,29 @@ 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.
@@ -103,6 +127,7 @@ 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.
@@ -159,7 +184,8 @@ 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.
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)).
## horizon-client: where things live
@@ -222,7 +248,8 @@ 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.
Test the server thread when you touch player-facing gameplay:
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:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
+26 -34
View File
@@ -1,7 +1,8 @@
# 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 exactly like vanilla**. This page is the short version of how we work; questions are
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
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.
@@ -23,7 +24,7 @@ here.
## Build it
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
- **Prerequisites:** Rust (stable), Python 3.11+, JDK 25, git 2.38 or newer, 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).
@@ -36,7 +37,10 @@ here.
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
## Vanilla 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.
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
@@ -49,8 +53,10 @@ 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.
- **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.
- **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.
- **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.**
@@ -67,40 +73,26 @@ 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.
## Only original code, or code whose license allows it
### When it doesn't have to be 1:1
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**:
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:
- **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.
- **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.
## Vendor libraries and Java mods
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository, like any code under an allowed license.
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository.
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
+1 -46
View File
@@ -706,25 +706,6 @@ 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"
@@ -945,12 +926,6 @@ 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"
@@ -1579,7 +1554,6 @@ dependencies = [
"open",
"png",
"pollster",
"rayon",
"rustix 1.1.5",
"serde",
"serde_json",
@@ -1973,6 +1947,7 @@ name = "horizon-server-files"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"getrandom 0.3.4",
"horizon-core",
"horizon-protocol",
"horizon-world",
@@ -3690,26 +3665,6 @@ 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) | 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 |
| **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 |
| **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.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:
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:
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,6 +23,10 @@ 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";
+2 -12
View File
@@ -21,7 +21,6 @@ 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.
@@ -162,8 +161,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
/// (`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
/// (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
/// 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,
@@ -279,15 +278,6 @@ 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,7 +320,12 @@ 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",
@@ -337,6 +342,12 @@ 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"];
@@ -436,6 +447,20 @@ 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,9 +27,6 @@ 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" }
+19 -6
View File
@@ -439,20 +439,33 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
/// 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> {
/// 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()
}
};
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack: String::new(),
stack,
thread: thread_name(side),
side,
details: system_details(side),
details,
log: crate::logging::recent_lines(),
},
)
+2 -5
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::terrain_parts`).
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::game_terrain`).
pack_lava_layers: Vec<u16>,
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
@@ -7540,10 +7540,6 @@ 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()),
@@ -7553,6 +7549,7 @@ 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,34 +666,52 @@ const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
}\n \
}\n";
/// `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`.
/// 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`.
///
/// 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.
pub fn terrain_parts(
terrain: &horizon_shaders::GbuffersInputs,
lava: &[u16],
) -> [horizon_shaders::GbuffersInputs; 2] {
fn solid(lava: &[u16]) -> String {
let opaque = crate::mesher::OPAQUE_LAYER;
let lava: String = lava
.iter()
.map(|l| format!(" || hz_v_layer == {l}u"))
.collect();
let part = |solid: bool| {
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 mut inputs = terrain.clone();
inputs.after.push_str(&format!(
" 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"
" if (({solid}) != {part}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
));
inputs
};
[part(true), part(false)]
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()
}
}
impl PackAtlas {
@@ -852,6 +870,7 @@ impl PackAtlas {
.collect(),
after: String::new(),
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -125,6 +125,7 @@ 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).
@@ -192,6 +193,7 @@ 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,6 +50,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after,
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -2964,17 +2964,13 @@ impl PackPost {
p.name
));
}
match (&terrain, &mut terrain_cutout) {
match (&terrain, &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) => {}
}
@@ -3911,8 +3907,8 @@ impl PackPost {
}
/// Terrain (or, `translucent`, translucent terrain) with the pack's program; `draw` issues
/// the game's draws. Solid terrain in two parts has its cutout part drawn second, from the
/// same draws.
/// 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.
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,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,parts: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,6 +717,7 @@ 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,6 +48,7 @@ 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,6 +44,7 @@ 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,14 +89,7 @@ fn strips_of(
.into(),
..Default::default()
};
let inputs = horizon_shaders::GameInputs {
terrain_parts: Some(super::super::pack_atlas::terrain_parts(
&terrain,
&[LAVA, LAVA + 1],
)),
terrain: Some(terrain),
..Default::default()
};
let inputs = super::super::pack_atlas::game_terrain(Some(terrain), &[LAVA, LAVA + 1]);
const ATTRS: [wgpu::VertexAttribute; 3] =
wgpu::vertex_attr_array![0=>Float32x3,2=>Uint32,3=>Unorm8x4];
let layouts = [Some(wgpu::VertexBufferLayout {
@@ -346,3 +339,64 @@ 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,6 +102,7 @@ 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,
@@ -132,6 +133,7 @@ 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,6 +44,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
+5 -3
View File
@@ -1897,10 +1897,12 @@ 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()));
if !cfg!(target_vendor = "apple") {
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
}
// 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());
// A manual count is the player's own choice, kept as asked.
let manual = Graphics { workers: 3, ..g };
assert_eq!(manual.worker_threads(), 3);
+6 -19
View File
@@ -20,6 +20,10 @@ 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, &[])
}
@@ -365,7 +369,7 @@ impl Opt {
),
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
Opt::Sensitivity => Kind::Slider(s.sensitivity),
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
Opt::WheelSensitivity => wheel::kind(s.mouse_wheel_sensitivity),
Opt::Volume(i) => Kind::Slider(if i == 0 {
s.master_volume
} else {
@@ -916,7 +920,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 = 0.01 + v * 9.99,
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = wheel::from_slider(v),
Opt::Volume(0) => s.master_volume = v,
Opt::Volume(i) => s.volumes[i] = v,
Opt::Gamma => s.gamma = v,
@@ -1604,23 +1608,6 @@ 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,
@@ -0,0 +1,71 @@
//! `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,6 +277,14 @@ 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);
@@ -352,6 +360,7 @@ 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());
@@ -362,6 +371,7 @@ 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());
@@ -385,6 +395,7 @@ 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();
+7 -157
View File
@@ -12,6 +12,13 @@
//! 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::{
@@ -23,69 +30,6 @@ 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 {
@@ -726,104 +670,10 @@ 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);
@@ -0,0 +1,164 @@
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,4 +1,13 @@
{
"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,7 +1208,6 @@ 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,7 +8,6 @@ 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,
+104 -2
View File
@@ -84,9 +84,7 @@ 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 {
@@ -1113,6 +1111,110 @@ 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)]
@@ -1,122 +0,0 @@
//! 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);
}
}
}
}
+8 -27
View File
@@ -39,6 +39,8 @@ 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"))?)
@@ -284,28 +286,6 @@ 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 {
@@ -1257,11 +1237,11 @@ pub fn mob_to_nbt(
));
}
// ITEM_PICKUP_COOLDOWN_TICKS (`Codec.INT`), which a throw sets to 60,
// while it runs.
if m.extra.ints[3] > 0 {
// while the memory is there (its value 0 included).
if let Some(ticks) = crate::mobs::allay::pickup_cooldown(m) {
memories.push((
"minecraft:item_pickup_cooldown_ticks",
Tag::compound([("value", Tag::Int(m.extra.ints[3]))]),
Tag::compound([("value", Tag::Int(ticks))]),
));
}
// Always written, so a forgotten note block does not come back
@@ -2063,6 +2043,7 @@ 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()) {
@@ -2488,8 +2469,8 @@ pub fn mob_from_nbt_with_random(
};
m.extra.ints[2] =
number(memory("minecraft:liked_noteblock_cooldown_ticks")).unwrap_or(0) as i32;
m.extra.ints[3] =
number(memory("minecraft:item_pickup_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);
if let Some(st) = m.extra.allay.as_mut()
&& let Some(noteblock) = memory("minecraft:liked_noteblock")
&& let Some(Tag::IntArray(p)) = noteblock.get("pos")
@@ -0,0 +1,85 @@
//! `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,7 +758,6 @@ 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
@@ -8482,6 +8481,7 @@ 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) {
if let Some(error) = missing_resources(&vanilla_root, horizon_hooks::engine::get_the_game()) {
// 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,6 +4,7 @@
//! 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;
@@ -84,7 +85,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!(game.mobs[i].extra.ints[3], 30, "loaded");
assert_eq!(allay::pickup_cooldown(&game.mobs[i]), Some(30), "loaded");
assert_eq!(
saved(&mut game, i),
Some(Tag::compound([("value", Tag::Int(30))])),
@@ -100,6 +101,53 @@ 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).
@@ -135,10 +183,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);
game.mobs[supplied].extra.ints[3] = 0;
allay::set_pickup_cooldown(&mut game.mobs[supplied], None);
assert_eq!(saved(&mut game, supplied), None, "stale raw memory");
// A model mutation, as a throw does (`allays.rs`): saved with its value.
game.mobs[plain].extra.ints[3] = 60;
allay::set_pickup_cooldown(&mut game.mobs[plain], Some(60));
let tag = saved(&mut game, plain);
assert_eq!(
tag,
@@ -149,7 +197,11 @@ 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!(reloaded.extra.ints[3], 60, "load after save");
assert_eq!(
allay::pickup_cooldown(&reloaded),
Some(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);
m.extra.ints[3] = 60;
allay::set_pickup_cooldown(m, Some(60));
// ItemEntity.setDefaultPickUpDelay.
self.spawn_item(stack, from, v, 10);
// BehaviorUtils.throwItem: ItemEntity.setThrower(the allay).
@@ -239,19 +239,6 @@ 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:
+10 -28
View File
@@ -682,14 +682,19 @@ 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 {
fall_reset(
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(
&self.world,
self.res.motion(),
self.res.facts(),
fall,
&self.res.motion().fluids,
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
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
@@ -2077,26 +2082,3 @@ 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,6 +737,54 @@ 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,6 +274,8 @@ 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") {
@@ -290,7 +292,24 @@ impl Game<'_> {
g.command_source = previous;
Ok(())
})?;
let messages = self.chat_capture.take().unwrap_or_default();
// 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 results = self.cmds.results.take().unwrap_or_default();
if op["record"].as_bool() != Some(false) || op["trace"].as_bool() == Some(true)
{
@@ -380,7 +399,7 @@ impl Game<'_> {
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::lan::mob_meta(
m,
hub.sync_data(),
hub,
None,
g.world.game_time,
&mut meta,
@@ -147,6 +147,11 @@ 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,6 +1424,13 @@ 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(
+288 -106
View File
@@ -4,8 +4,6 @@
//! (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;
@@ -125,6 +123,8 @@ 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,11 +1051,18 @@ impl Game<'_> {
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.items()),
lid_openers: &self.lid_openers,
mod_effects: self.remote.is_none().then_some(self as _),
mod_effects: if self.remote.is_none() {
Some(self)
} else {
None
},
// 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: self.remote.is_none().then_some(self as _),
foreign: None,
who_lookup: if self.remote.is_none() {
Some(self)
} else {
None
},
border: Some(self.border.geometry()),
// The first player in the level: a server has no player of its own.
difficulty: self.local_difficulty(
@@ -1141,8 +1148,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<> {
self.tickets
.entity_ticking_area(&self.world.chunks, self.remote.is_none())
let levels = self.tickets.levels();
move |x, z| levels.entity_ticking_at(x, z)
}
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
/// projectiles and effects that follow from them.
@@ -1161,6 +1168,9 @@ 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();
@@ -1183,32 +1193,50 @@ impl Game<'_> {
self.init_sulfur_attribute_maps();
let uuid = self.level.player_uuid;
let level_id = self.dim.id();
// Each mob's climbing, refreshed, and its snapshot entry (creatures/prep.rs).
// 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;
}
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
self.prepare_mobs(uuid, &mut snapshot);
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
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 mobs = std::mem::take(&mut self.mobs);
let mut 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 died = std::mem::take(&mut self.died);
let mut dead = Vec::new();
let mut corpses = Vec::new();
let mut 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);
// 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() {
let mut 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
@@ -1218,9 +1246,23 @@ 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));
let group = {
// 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));
// 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
@@ -1230,52 +1272,87 @@ 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;
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);
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
};
if !base.world.loaded(bx, bz) {
group.unloaded(i);
keep.push(!m.hostile() || m.persistent);
continue;
}
let nearest_player = single_player.unwrap_or_else(|| nearest.at(i, position));
let nearest_player = single_player.unwrap_or_else(|| {
nearest
.nearest(m.position.x, m.position.y, m.position.z)
.map(|p| p.position)
});
// ServerLevel.tick: every loaded entity checks despawning, but
// only those in entity-ticking chunks (within the simulation
// distance) tick.
// distance) tick. The despawn check reads the nearest player
// and the difficulty, not the player the mob sees.
if !ticking(bx, bz) {
despawn_w.nearest_player = nearest_player;
group.rest(i, &despawn_w);
let kept = horizon_client::mobs::ai::despawn_check(m, &despawn_w);
if !kept {
died.remove(&m.id);
snapshot[i].alive = false;
}
keep.push(kept);
continue;
}
let controlling = group.controlling(i);
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();
// A mod canceled its tick: it stays as it is.
if round.canceled(group.mobs[i].id) {
group.keep.push(true);
if round.canceled(m.id) {
keep.push(true);
continue;
}
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
@@ -1285,34 +1362,175 @@ impl Game<'_> {
Some((key, difficulty)) if key == chunk_key => difficulty,
_ => {
let difficulty =
self.local_difficulty([position.x, position.y, position.z]);
self.local_difficulty([m.position.x, m.position.y, m.position.z]);
cached_difficulty = Some((chunk_key, difficulty));
difficulty
}
};
let player = (seen_guest, nearest_player, controlling);
group.tick(i, player, difficulty, &base, &shared);
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);
}
// 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 group.mobs.iter_mut().enumerate() {
let Some(&before) = group.targets.get(i) else {
for (i, m) in mobs.iter_mut().enumerate() {
let Some(&before) = targets.get(i) else {
break;
};
if m.ai.target == before {
continue;
}
let seen_guest = if group.guests.is_empty() {
let seen_guest = if guests.is_empty() {
None
} else {
group.bindings.get(&m.id).copied()
bindings.get(&m.id).copied()
};
let named = target_named(
m.ai.target,
seen_guest,
&group.guests,
&guests,
&guest_ids,
group.player.as_ref(),
base.player.as_ref(),
);
if let Some((player, mob)) = named {
let kind = super::native_hooks::mob_kind(m);
@@ -1331,29 +1549,13 @@ impl Game<'_> {
}
}
laps.lap("creature_loop");
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);
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);
}
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;
level_random = scratch;
}
self.world.random = level_random;
if let Some(host) = self.lan.as_mut()
&& !guests.is_empty()
@@ -1369,7 +1571,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.into();
self.mobs = mobs;
} else {
self.mobs.append(&mut mobs);
}
@@ -1413,7 +1615,9 @@ 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);
@@ -1927,18 +2131,6 @@ 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()
@@ -1949,12 +2141,19 @@ 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.
@@ -2026,22 +2225,6 @@ 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
@@ -8171,7 +8354,6 @@ mod pearl_tests {
dragon: None,
leash_boats: &[],
who_lookup: None,
foreign: None,
others_grid: None,
others_index: None,
others_villagers: None,
@@ -1,338 +0,0 @@
//! 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);
}
}
@@ -1,247 +0,0 @@
//! 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,6 +44,8 @@ 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,
@@ -53,9 +55,11 @@ 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),
@@ -84,6 +88,8 @@ 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}",
@@ -106,6 +112,7 @@ impl Watchdog {
std::path::Path::new("."),
"server",
message,
stack,
) {
Some(path) => log_from(
"Server Watchdog",
+22 -3
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.
if let Some(h) = nbt_value::float(tag.get("Health")) {
d.health = h as f32;
}
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(p) = nbt_value::number(tag.get("DragonPhase")) {
d.phase = Phase::from_id(p as i32);
}
@@ -5082,6 +5082,25 @@ 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,
+35 -6
View File
@@ -614,9 +614,24 @@ impl Game<'_> {
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
) -> (bool, bool) {
let no_physics = escape_speed.is_some();
let step = self
.item_tables()
.move_item(item, escape_speed, entity_boxes, splash);
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)
});
if let Some(pos) = step.vertical_contact {
let state = self.world.block(pos.x, pos.y, pos.z);
let position = item.position;
@@ -635,9 +650,23 @@ impl Game<'_> {
return (false, true);
}
}
let alive = self
.item_tables()
.settle_item(item, no_physics, step, splash);
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);
(alive, false)
}
+47 -13
View File
@@ -1448,7 +1448,8 @@ impl Game<'_> {
/// running to that player's hand.
pub(super) struct RemoteHook {
id: u32,
owner: u32,
pub(super) owner: u32,
pub(super) owner_fishing: bool,
pub(super) position: Vec3,
pub(super) previous: Vec3,
velocity: Vec3,
@@ -1458,6 +1459,34 @@ 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.
@@ -1604,17 +1633,7 @@ impl Game<'_> {
hook.velocity = velocity;
self.hook = Some(hook);
} else {
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(),
});
push_remote_hook(&mut self.remote_hooks, id, owner as u32, at, velocity);
}
}
@@ -1920,7 +1939,7 @@ impl Game<'_> {
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
self.hook = None;
}
self.remote_hooks.retain(|h| h.id != id);
remove_remote_hook_from(&mut self.remote_hooks, id);
}
pub(super) fn clear_remote_hooks(&mut self) {
@@ -2310,4 +2329,19 @@ 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,6 +133,58 @@ 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,7 +82,8 @@ fn game() -> Game<'static> {
.unwrap();
game.world_root = dir;
game.world_tables = game.build_world_tables(false, &[]).unwrap().0;
let _ = horizon_client::structure_blocks::world_loot_data(
// The packs' tables for this test's thread only (#14266).
let _ = horizon_client::structure_blocks::load_thread_loot_data(
&game.options.resources,
&game.pack_files(),
);
@@ -133,44 +133,4 @@ 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);
}
}
}
}
+29 -49
View File
@@ -2,8 +2,6 @@
//! `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.
@@ -45,13 +43,6 @@ 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();
@@ -74,35 +65,33 @@ impl Game<'_> {
{
continue;
}
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,
// `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,
&nearby,
false,
&mut |splash| entered.push(splash),
Some(self.border.geometry()),
);
(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),
};
// `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),
);
// Entity.doWaterSplashEffect: the server plays the sound (broadcast
// to clients). Its addParticle is empty.
for splash in entered {
@@ -996,14 +985,9 @@ 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());
// 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.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.update_chunk_tickets();
assert!(game.entity_ticking_area()(8, 8));
let stone = game
@@ -1017,17 +1001,13 @@ 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.
// 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!(item.velocity.y, expected * 0.98);
assert_eq!(game.world.random.next_float(), level.next_float());
}
}
@@ -1,452 +0,0 @@
//! 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()
);
}
}
+23 -16
View File
@@ -4,7 +4,6 @@
//! 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;
@@ -364,12 +363,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,
sync: &SyncData,
hub: &Hub,
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| sync.id(registry, &format!("minecraft:{name}"));
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
match kind {
// Axolotl.DATA_VARIANT (after AgeableMob's two).
"minecraft:axolotl" => {
@@ -502,7 +501,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,
sync: &SyncData,
hub: &Hub,
player_entity: Option<i32>,
game_time: i64,
meta: &mut Vec<(u8, i32, Value)>,
@@ -559,7 +558,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(
sync.effect_meta(
hub.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))),
@@ -591,7 +590,7 @@ pub(super) fn mob_meta(
}
}
let e = &m.extra;
let id = |registry: &str, name: &str| sync.id(registry, &format!("minecraft:{name}"));
let id = |registry: &str, name: &str| hub.registry_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
@@ -599,7 +598,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, sync, meta, defaults);
mob_variant_meta(m, hub, meta, defaults);
mob_raider_meta(m, meta, defaults);
match kind {
"minecraft:guardian" | "minecraft:elder_guardian" => {
@@ -1949,15 +1948,14 @@ impl Game<'_> {
mut kept: Option<&mut super::lan_idle::IdlePass<'_>>,
) {
out.clear();
let mut states = mob_states::Later::new(self.world.game_time);
let mut speeds = super::lan_attributes::SpeedByType::default();
// 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 (index, m) in (self.mobs.iter().enumerate()).filter(|(_, m)| !m.removed && m.id > after)
{
for m in self.mobs.iter().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 = || {
@@ -1988,7 +1986,6 @@ 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;
@@ -2002,11 +1999,21 @@ impl Game<'_> {
if bare {
continue;
}
let (sync, sources) = (hub.sync_data(), self.attr_sources());
states.state((slot, index), m, player_entity(), sync, sources, e);
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);
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 {
@@ -4109,7 +4116,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.sync_data(), None, 0, &mut meta, &mut defaults);
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
@@ -4138,7 +4145,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.sync_data(), None, 0, &mut meta, &mut defaults);
super::mob_meta(&m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
for (template, id) in [
@@ -1,286 +0,0 @@
//! 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.sync_data(), None, 0, &mut meta, &mut defaults);
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
(meta, defaults)
};
@@ -103,32 +103,10 @@ 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.catalog;
let catalog = &self.motion.attributes;
let id = catalog.id(name)?;
(catalog.attribute(id).syncable && catalog.default_base(entity, id).is_some())
.then(|| id.registry_id())
@@ -136,7 +114,7 @@ impl AttrSources<'_> {
/// The type's base value of an attribute, as the instance starts.
fn attribute_default(&self, entity: &str, name: &str) -> f64 {
let catalog = self.catalog;
let catalog = &self.motion.attributes;
catalog
.id(name)
.and_then(|id| catalog.default_base(entity, id))
@@ -154,7 +132,7 @@ impl AttrSources<'_> {
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 {
@@ -328,16 +306,6 @@ impl AttrSources<'_> {
}
}
/// 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(
@@ -347,19 +315,18 @@ impl Game<'_> {
) -> Vec<AttributeSnapshot> {
const KIND: &str = "minecraft:armor_stand";
let mut out = Vec::new();
let sources = self.attr_sources();
if let Some(a) = sources.synced_attribute(KIND, "minecraft:movement_speed") {
if let Some(a) = self.synced_attribute(KIND, "minecraft:movement_speed") {
instance(
&mut out,
a,
sources.attribute_default(KIND, "minecraft:movement_speed"),
self.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)));
sources.equipment_attributes(KIND, |_| None, slots, &mut out);
sources.generic_attributes_of(Target::Stand(id), &mut out);
self.equipment_attributes(KIND, |_| None, slots, &mut out);
self.generic_attributes_of(Target::Stand(id), &mut out);
out
}
@@ -405,6 +372,14 @@ 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)]
+34 -3
View File
@@ -146,6 +146,12 @@ 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);
@@ -942,7 +948,7 @@ impl Game<'_> {
if !o.remote {
o.port.x = x + 158.0;
o.port.y = top + PORT_TOP;
o.port.focused = o.lan && o.port.focused;
o.update_port_controls();
o.port.text_color = if !o.lan {
rgba(0x70, 0x70, 0x70, 255)
} else if o.parsed_port().is_some() {
@@ -1166,7 +1172,6 @@ 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,
@@ -1401,7 +1406,7 @@ impl Game<'_> {
let reverse = shift;
let next = match o.focused_button {
Some(cur) => {
if !o.remote && o.lan {
if !o.remote {
if !reverse && cur == Button::Lan {
o.port.focused = true;
o.focused_button = None;
@@ -1922,4 +1927,30 @@ 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,12 +47,18 @@ fn game() -> Game<'static> {
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
// 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();
}
}
for command in [
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
@@ -153,8 +159,10 @@ 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 the summon, no live record: both keys go.
// A raw copy from a stale compound, no live record and no read-back
// reference: 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;
@@ -189,3 +197,121 @@ 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,6 +316,7 @@ 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
@@ -466,17 +467,26 @@ 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.
pub(super) fn missing_resources(root: &std::path::Path) -> Option<String> {
/// 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> {
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\
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>",
{launcher}horizon-prepare --prepare, or pass --resources <folder>",
shown.display(),
horizon_client::no_resources::DOWNLOAD_URL
));
}
damaged_prepared(root).map(|problem| {
@@ -32,6 +32,7 @@ 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,
@@ -79,6 +80,10 @@ 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()
+32 -7
View File
@@ -8,6 +8,16 @@ 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))
@@ -461,13 +471,17 @@ impl Game<'_> {
mob.body_yaw,
&mob.kind.id,
);
// 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.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,
)
};
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;
@@ -703,6 +717,17 @@ 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 the launcher): a window that says where to get the launcher
//! through a launcher): a window that says where to get them
//! (`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, DOWNLOAD_URL, NoResources};
use horizon_client::no_resources::{Action, NoResources};
use winit::event::MouseButton;
struct Missing {
@@ -19,6 +19,11 @@ 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,
@@ -26,7 +31,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),
screen: NoResources::new(looked_in, horizon_hooks::engine::get_the_game()),
pointer: (0.0, 0.0),
api: settings.horizon.api,
error: None,
@@ -81,7 +86,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::Download) => packs::open_uri(DOWNLOAD_URL),
Some(Action::Open(address)) => packs::open_uri(address),
Some(Action::Quit) => event_loop.exit(),
None => {}
}
+1
View File
@@ -388,6 +388,7 @@ 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
+1 -36
View File
@@ -4,6 +4,7 @@
#[cfg(test)]
mod anger_tests;
mod display;
mod hurt_by_refs;
use super::{FallingBlock, Game};
use horizon_client::{
@@ -1614,42 +1615,6 @@ 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) {
@@ -0,0 +1,86 @@
//! `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,7 +266,6 @@ 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");
+1 -2
View File
@@ -66,8 +66,7 @@ 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.anticheat.record.as_deref_mut(), g.motion.as_deref())
else {
let (Some(record), Some(server)) = (g.record.as_deref_mut(), g.motion.as_deref()) else {
return;
};
let mut run = |body: &mut PlayerMotion, keys, sprinting| {
@@ -38,6 +38,7 @@ 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
+15 -1
View File
@@ -177,8 +177,22 @@ 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(action) = social_screen::click(self.gui_size(), self.cursor, &view) else {
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 {
return;
};
self.audio.play_button_click();
+2
View File
@@ -380,6 +380,7 @@ impl<'a> Split<'a> {
None,
api,
batching,
self.host.settings.vsync,
&self.host.settings.horizon,
shader_pack,
old.display_reading(),
@@ -464,6 +465,7 @@ impl<'a> Split<'a> {
None,
api,
batching,
self.host.settings.vsync,
&self.host.settings.horizon,
shader_pack,
old.display_reading(),
@@ -518,6 +518,7 @@ 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,8 +77,9 @@ 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.
let _ = horizon_client::structure_blocks::world_loot_data(
// 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(
&game.options.resources,
&game.pack_files(),
);
@@ -4,26 +4,40 @@ 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 missing = missing_resources(&root.join(".local/horizon-client/26.3"))
.expect("no manifest is reported");
let unprepared = root.join(".local/horizon-client/26.3");
let missing = missing_resources(&unprepared, None).expect("no manifest is reported");
assert!(missing.contains("horizon-resources.json"), "{missing}");
assert!(missing.contains("horizon-unprepared-"), "{missing}");
assert!(missing.contains("launcher"), "{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}"
);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("horizon-resources.json"), "{}").unwrap();
assert_eq!(missing_resources(&root), None);
assert_eq!(missing_resources(&root, None), 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).expect("an empty prepared file is reported");
let damaged = missing_resources(&root, None).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);
assert_eq!(missing_resources(&root, None), None);
std::fs::write(root.join("horizon-resources.json"), " \n").unwrap();
let damaged = missing_resources(&root).expect("an empty manifest is reported");
let damaged = missing_resources(&root, None).expect("an empty manifest is reported");
assert!(damaged.contains("horizon-resources.json"), "{damaged}");
std::fs::remove_dir_all(&root).unwrap();
}
+4 -34
View File
@@ -1351,16 +1351,10 @@ mod tests {
)
.unwrap();
game.options.dedicated = Some(Vec::new());
// 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.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
game.streaming.forced.insert((0, 0));
Some(game)
}
@@ -1847,30 +1841,6 @@ 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
+5 -8
View File
@@ -157,11 +157,8 @@ impl Hub {
};
if ac.config.runs(Check::Prediction) {
let g = &mut self.guests[i];
let (input, sprinting) = (g.input, g.anticheat.sprinting);
g.anticheat
.record
.get_or_insert_default()
.tick_end(input, sprinting);
let (input, sprinting) = (g.input, g.client_sprinting);
g.record.get_or_insert_default().tick_end(input, sprinting);
}
if !ac.config.runs(Check::Timer) {
return;
@@ -249,7 +246,7 @@ impl Hub {
flags: u8,
) {
if self.recording() {
let record = self.guests[i].anticheat.record.get_or_insert_default();
let record = self.guests[i].record.get_or_insert_default();
record.moved(pos, rot, flags);
}
}
@@ -259,7 +256,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].anticheat.record.get_or_insert_default();
let record = self.guests[i].record.get_or_insert_default();
// Ids are server ticks, cut to 32 bits.
record.pong(u64::from(id as u32), ticks);
}
@@ -280,7 +277,7 @@ impl Hub {
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner),
);
if let Some(record) = g.anticheat.record.as_deref_mut() {
if let Some(record) = g.record.as_deref_mut() {
for change in changes {
record.changed(ticks, change);
}
-53
View File
@@ -4,7 +4,6 @@
//! (`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},
};
@@ -507,58 +506,6 @@ 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"
+14 -3
View File
@@ -1,6 +1,7 @@
//! 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,
@@ -221,10 +222,19 @@ 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<horizon_anticheat::prediction::Change>>,
pub changes: std::sync::Mutex<Vec<Change>>,
/// It accepted a move since the last entity range checks
/// (ServerChunkCache.move), which then check all of its pairs again.
pub moved: bool,
@@ -515,6 +525,8 @@ 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,
@@ -626,7 +638,7 @@ impl Guest {
if self.fake && !super::conn::fake_wants_id(packet.id()) {
return;
}
if self.anticheat.record.is_some() {
if self.record.is_some() {
self.note_change(packet);
}
self.sender.send(packet.encode());
@@ -653,7 +665,6 @@ 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 {
+40 -16
View File
@@ -24,7 +24,7 @@ use horizon_core::{
};
use horizon_protocol::ids::play::clientbound as ids;
use horizon_protocol::{
entity_data::{Value, serializer},
entity_data::{Particle, ParticleData, 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.anticheat.record.as_deref_mut() else {
let Some(record) = g.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.anticheat.record.as_deref_mut() else {
let Some(record) = g.record.as_deref_mut() else {
continue;
};
let slack = super::prediction::slack(std::array::from_fn(|i| {
@@ -3250,12 +3250,39 @@ impl Hub {
&self,
effects: impl IntoIterator<Item = (&'a str, bool)>,
) -> Vec<(u8, i32, Value)> {
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
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
}
fn player_state(
@@ -3561,7 +3588,7 @@ impl Hub {
}));
for at in gone.drain(..) {
let block = at.map(|v| v.floor() as i32);
if let Some(record) = g.anticheat.record.as_deref_mut()
if let Some(record) = g.record.as_deref_mut()
&& super::prediction::near(g.position, block, slack)
{
record.entity_gone(ticks);
@@ -3963,7 +3990,7 @@ impl Hub {
fn set_sprinting(&mut self, i: usize, sprinting: bool) {
let g = &mut self.guests[i];
g.sprinting = sprinting;
g.anticheat.sprinting = sprinting;
g.client_sprinting = sprinting;
if let Some(m) = g.motion.as_mut() {
let _ = m.set_sprint_modifier(sprinting);
}
@@ -4051,10 +4078,7 @@ impl Hub {
// The prediction's replay starts again where the client now is.
if recording {
let kept = g.awaiting_keeps_motion;
g.anticheat
.record
.get_or_insert_default()
.teleported(at, kept);
g.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);
@@ -5040,7 +5064,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.anticheat.sprinting = false;
g.client_sprinting = false;
// The respawned player is a new one, without effects; so is the
// client's (ClientPacketListener.handleRespawn).
g.effects = Default::default();
+5 -3
View File
@@ -1,5 +1,5 @@
//! The movement prediction's replay (docs/anticheat.md): each client tick a player's record holds
//! ([`horizon_anticheat::prediction::Record`]) is played again with the client's own movement code,
//! ([`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,8 +21,10 @@
use std::collections::VecDeque;
use horizon_anticheat::prediction::{Change, ClientTick, KEPT, Record, Restart};
pub mod record;
use horizon_core::geometry::Vec3;
use record::{Change, ClientTick, KEPT, Record, Restart};
use crate::motion::PlayerMotion;
use crate::sprint::Keys;
@@ -650,13 +652,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.
+54 -33
View File
@@ -5,10 +5,6 @@
//! 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;
@@ -52,6 +48,7 @@ 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;
@@ -2927,6 +2924,28 @@ 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`]).
@@ -2954,28 +2973,50 @@ 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<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>, from: usize) {
pub fn apply_brain_events(mobs: &mut [Mob], 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().map(MobSlot::mob_mut).find(|m| m.id == mob) {
if let Some(m) = mobs.iter_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().map(MobSlot::mob_mut).find(|m| m.id == mob) {
if let Some(m) = mobs.iter_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().map(MobSlot::mob_mut).find(|m| m.id == mob) {
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
piglin::receive_retreat(m, from, at);
}
events.remove(i);
@@ -2989,11 +3030,7 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
events.remove(i);
}
MobEvent::SchoolSize { leader, by } => {
if let Some(m) = mobs
.iter_mut()
.map(MobSlot::mob_mut)
.find(|m| m.id == leader)
{
if let Some(m) = mobs.iter_mut().find(|m| m.id == leader) {
m.extra.followers += by;
}
events.remove(i);
@@ -3003,11 +3040,7 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
MobEvent::Breed {
parent, partner, ..
} => {
if let Some(m) = mobs
.iter_mut()
.map(MobSlot::mob_mut)
.find(|m| m.id == partner)
{
if let Some(m) = mobs.iter_mut().find(|m| m.id == partner) {
m.extra.age = 6000;
m.extra.in_love = 0;
animal_brain::forget_breed_target(m, parent);
@@ -3029,7 +3062,6 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
let mut raised = Vec::new();
let (aim, fish_pos, fish_silent) = mobs
.iter()
.map(MobSlot::mob)
.find(|m| m.id == mob)
.map(|m| {
(
@@ -3044,10 +3076,7 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
})
.map(|(aim, pos, silent)| (Some(aim), Some(pos), silent))
.unwrap_or((None, None, true));
if let Some(t) = mobs
.iter_mut()
.map(MobSlot::mob_mut)
.find(|m| m.id == target)
if let Some(t) = mobs.iter_mut().find(|m| m.id == target)
&& t.damage_aimed(amount, source, aim)
{
t.add_effect("minecraft:poison", poison_ticks, 0);
@@ -3262,9 +3291,6 @@ 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>,
@@ -3279,10 +3305,6 @@ 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>;
}
@@ -3489,7 +3511,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 => self.extra.main_hand.is_some() && self.extra.ints[3] <= 0,
Species::Allay => allay::can_pick_up_loot(self),
Species::SulfurCube => !sulfur_cube::has_body(self),
_ => self.extra.pick_up_loot,
}
@@ -3916,8 +3938,7 @@ impl Mob {
self.last_damage = Some((source, i64::from(self.age)));
self.last_direct = None;
if let Some(who) = source.attacker {
self.ai.last_hurt_by = Some(who);
self.ai.last_hurt_by_time = self.age as i32;
self.ai.hurt_by(who, self.age as i32);
if who == Who::Player {
self.ai.last_hurt_by_player = 100;
}
+5 -9
View File
@@ -90,6 +90,8 @@ 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,
@@ -624,7 +626,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.last_hurt_by = None;
m.ai.forget_hurt_by(m.ai.last_hurt_by_time);
}
}
}
@@ -1357,12 +1359,7 @@ 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);
}
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
if m.invulnerable > 0 {
m.invulnerable -= 1;
}
super::hurt_by::base_tick(m, w);
// 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
@@ -2957,8 +2954,7 @@ 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.last_hurt_by = None;
m.ai.last_hurt_by_time = m.age as i32;
m.ai.forget_hurt_by(m.age as i32);
m.ai.target = None;
}
}
+25 -6
View File
@@ -22,9 +22,25 @@ 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,
// `ints[3]` the pickup cooldown, `flag` dancing with `ints[4]` the dance clock,
// `pickup_cooldown` 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
@@ -157,6 +173,8 @@ 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`);
@@ -326,7 +344,7 @@ impl Goal for Fetch {
MOVE
}
fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool {
m.extra.point.is_some() && m.extra.ints[3] <= 0
m.extra.point.is_some() && pickup_cooldown(m).is_none()
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
if let Some(p) = m.extra.point
@@ -491,11 +509,12 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld) {
tick_liked_player(m);
passive_heal(m);
stop_dancing_away(m, w);
for i in [2, 3] {
if m.extra.ints[i] > 0 {
m.extra.ints[i] -= 1;
}
if m.extra.ints[2] > 0 {
m.extra.ints[2] -= 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);
}
+5 -22
View File
@@ -794,35 +794,18 @@ 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<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 {
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 {
return;
};
if current.is_some_and(|l| alive(mobs, l)) {
return;
}
if let Some(m) = mobs
.iter_mut()
.map(super::MobSlot::mob_mut)
.find(|m| m.id == fish)
{
if let Some(m) = mobs.iter_mut().find(|m| m.id == fish) {
m.extra.leader = Some(leader);
}
if let Some(m) = mobs
.iter_mut()
.map(super::MobSlot::mob_mut)
.find(|m| m.id == leader)
{
if let Some(m) = mobs.iter_mut().find(|m| m.id == leader) {
m.extra.followers += 1;
}
}
+1
View File
@@ -203,6 +203,7 @@ 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 + Send + Sync {
pub trait Goal: std::fmt::Debug {
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.
+1 -2
View File
@@ -48,8 +48,7 @@ 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.last_hurt_by = None;
m.ai.last_hurt_by_time = m.age as i32;
m.ai.forget_hurt_by(m.age as i32);
m.ai.target = None;
m.extra.angry_at = None;
// `TimeUtil.rangeOfSeconds(20, 39)`.
+42
View File
@@ -0,0 +1,42 @@
//! 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,7 +174,6 @@ 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,
@@ -292,7 +291,6 @@ 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,
@@ -413,7 +411,6 @@ 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,
@@ -1,29 +0,0 @@
//! [`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
}
}
@@ -1,103 +0,0 @@
//! 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}");
}
}
}
+44 -2
View File
@@ -1159,6 +1159,46 @@ 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.
@@ -1457,8 +1497,10 @@ 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.
g.add(2, MeleeAttack::new(1.0, true).intervals(40, 15));
// 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)));
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,6 +200,7 @@ 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