Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 a190dbbc0d Crates ship their own lang files: horizon_hooks::lang (layer plan M5)
horizon_hooks::lang::register(&'static [(code, json)]), re-exported as
horizon_platform::lang. The game's entry installs the registered files
into horizon_client::lang; Language::load reads them in the horizon
namespace right after the engine's bundled file for the same language,
before packs, and the engine's file wins on a key both have. Regional
variants share one alias table with the bundled files (fr_ca -> fr_fr,
de_ch -> de_de without ß). Nothing registered: languages load as before.

tools/lang_coverage.py reports crates/*/assets/lang beside the game's
folder; a crate's review record is tools/lang_review/<crate>/, and
--mark-reviewed puts each key in the folder that holds it. lang.rs's
file checks run on every crate folder, and a crate key the engine also
has fails them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:30:16 +01:00
Ludovic Marie 726726a1fc Merge pull request #14186 from horizon-rust/layers/replay-state
🤖 Agent: playback state (driver, camera type to restore, standalone, playback clock) moves to game/replay.rs Replay; Studio keeps the editor; callers ask replay.playing(); game.rs does not grow.
2026-11-09 22:14:22 +01:00
Ludovic Marie 82da4379bd Merge pull request #14161 from horizon-rust/layers/studio-calls
🤖 Agent: pure move: game.rs's inline Studio blocks and the replay key shortcuts become game/studio.rs methods with the same bodies and early returns; the engine asks replaying()/flying()/active().
2026-11-09 22:09:29 +01:00
Ludovic Marie 7627e37dba Merge pull request #14351 from horizon-rust/split/pages-plain-rows
🤖 Agent: Row::Button/Text are #[non_exhaustive] variants built through constructors, so later options do not break crates; the options shell pairs two half buttons into one row and a press on an inactive one reaches nothing (tested).
2026-11-09 22:09:25 +01:00
Ludovic Marie cd8eb96cef Merge pull request #14214 from horizon-rust/split/cosmetics-draw
🤖 Agent: step 2a-i of spec #14091 adds PlayerDraw to horizon-hooks (plain types only) so crates can draw on player models in the world pass; creatures.rs is main's, with the swap moved to creatures/mob_geometry.rs.
2026-11-09 22:04:26 +01:00
Ludovic Marie 829aec1dba Merge pull request #14080 from rdbtCVS/screenshot-off-render-thread
🤖 Agent: F2 and world-icon PNGs are written on a thread after map_async; a second F2 in the same second now skips the name still being written (agent fix 1ffa9ed), and agent screenshots keep the synchronous path.
2026-11-09 22:04:22 +01:00
Ludovic Marie 82a6e896d1 Merge pull request #13627 from vnsmoreira/fix/armor-stand-player-break-loot
🤖 Agent: the player break now pops the named stand item and then goes through brokenByAnything, which plays the break sound before the loot as ArmorStand.brokenByAnything orders it; stand_break_loot, stand_command_hurt and armor_stand_equip_events pass with it, and command_exec on a union with main shows no armor stand mismatch.
2026-11-09 22:04:18 +01:00
Ludovic Marie 0300ba0a76 Merge pull request #14339 from horizon-rust/team/drop-itarqos5
🤖 Agent: removes itarqos5 from .github/trusted-reviewers.txt, the Team and reviewer lists in docs/release-flow.md and the pinned login in tests/test_nightly_set.py; the nightly, claim and triage test modules (102 tests) passed per the PR.
2026-11-09 21:59:04 +01:00
Ludovic Marie 3a3c182aed Merge pull request #14208 from horizon-rust/split/cosmetics-names
🤖 Agent: step 3b makes names.rs, scoreboard.rs and perspective.rs call the engine's dispatch (Game::hooks_name_marks builds one PlayerModel per tag drawn) instead of the cosmetics directly.
2026-11-09 21:56:53 +01:00
Ludovic Marie e76394c2cf Merge pull request #14107 from horizon-rust/split/official-servers-crate
🤖 Agent: the official server now reaches the list only through horizon-official's started hook, so horizon-client's own executable (plain builds, harnesses) has neither the official server nor the withdrawn partner's address; verify and launcher green on this head.
2026-11-09 21:53:06 +01:00
Ludovic Marie d9cfca426c Merge pull request #14207 from horizon-rust/split/cosmetics-sprites
🤖 Agent: the API half of cosmetics step 3: Hooks::name_decoration returns a Copy NameDecoration (fixed &'static mark rectangles, optional badge, tag lift) so no heap data or engine type crosses the hook.
2026-11-09 21:53:02 +01:00
Ludovic Marie 763d4868d7 Merge pull request #14181 from horizon-rust/split/cosmetics-hooks
🤖 Agent: cosmetics step 1 of spec #14091: the emote feed and presence go through plain-type frame and tick hooks in horizon-hooks, so no engine or renderer type crosses the seam.
2026-11-09 21:44:17 +01:00
Ludovic Marie 110a92a1aa Merge pull request #14096 from horizon-rust/layers/seat
🤖 Agent: seat() is a thread-local the engine sets at Split::turn and around a guest's join/profile load (the profile used to load before the guest's turn, into P1's seat); a new profile saves on P1's seat and reads its stores back into the guest's. Without splitscreen it is always 0. Main merged in (horizon_platform::hooks exports both seat and main's tunnel).
2026-11-09 21:35:51 +01:00
Ludovic Marie 3a8d647c8c Merge pull request #14154 from Skr1zzzz/falling-removals
🤖 Agent: one pass over the big lists per RemoveEntities packet, per-id cleanup order kept (dragon before its crystal); moved to remote_removal.rs so remote.rs shrinks (agent fix 8cf1e6b).
2026-11-09 21:30:12 +01:00
Ludovic Marie 3c346ba1a4 Merge pull request #13810 from NikitaMGrimm/hdr-lighting-one-meaning
🤖 Agent: re-checked the new head: only main merged in, and the PR's own diff is unchanged. shader_pack_kind().is_none() is still exactly the old native/pack_post test, so HDR Lighting greys only where the running pack lights the world.
2026-11-09 21:25:43 +01:00
Ludovic Marie b41db51bf9 Merge pull request #13808 from vnsmoreira/packs/polytone-fluid-fog
🤖 Agent: re-checked the new head: the only change is that FluidFog/FluidFogs moved to the render crate's pack_palette after #13959, converted from horizon_polytone's type once when the pack loads. The fog still runs last and is vanilla's without a modifier.
2026-11-09 21:25:38 +01:00
Ludovic Marie 1ecc5a9bdc Merge pull request #13811 from Mik00000/perf/natural-spawning-cache
🤖 Agent: Util.shuffle runs every pass again, so the level random keeps vanilla's draws, and nothing is cached across ticks any more. What stays saves allocations: Counted streamed instead of collected, the SpawnPlayer buffer reused, and a single-player collect without the neighbour scan. Fake players stay out of the anchors and in the level, as on main. Merged with main on a branch together with #13795 and #13800: clippy and 3827 tests pass; natural_spawn runs in CI parity.
2026-11-09 21:21:02 +01:00
Ludovic Marie d05675d755 Merge pull request #13800 from Mik00000/perf/block-entities-opt
🤖 Agent: every hopper again records tickedGameTime each tick, so the 8/7 transfer cooldown matches vanilla, and is_full keeps #13763's insertion_item for pots. The gains stay: slot iterators instead of a Vec per transfer, empty hoppers with nothing above them or within reach skip the whole pass, one block lookup per tick. Merged with main on a branch together with #13795 and #13811: clippy and 3827 tests pass; the hopper harnesses run in CI parity.
2026-11-09 21:20:57 +01:00
Ludovic Marie 2568835cc6 Merge pull request #13795 from TheMrZZ/fix/command-spawnpoint-respawn-config
🤖 Agent: with the offline capture tools removed, the pitch now goes through RespawnConfig, the personal respawn save (old records load pitch +0.0) and only the free-point branch of find_respawn_and_use; bed and anchor keep pitch 0, as RespawnData's block cases do. Merged with main on a branch together with #13800 and #13811: clippy and 3827 Rust tests pass on the desktop.
2026-11-09 21:20:53 +01:00
Ludovic Marie f0cb1e42c0 Merge pull request #14156 from horizon-rust/layers/studio-gather
🤖 Agent: the verify failure was the two rustc-compiled fixtures (test_compute_fov_hook, test_world_input_capture) still modelling game.studio_freecam/studio_replay; they now carry the Studio struct game.rs reads, and both pass on this head.
2026-11-09 21:06:48 +01:00
Ludovic Marie 38174db59b Merge pull request #14170 from MSedgeMC/port_map/barrier-pickup-rule
🤖 Agent: Sim::pickup_block already gates a waterlogged barrier on the breaker being creative, as BarrierBlock.pickupBlock does.
2026-11-09 20:59:57 +01:00
Ludovic Marie 093f5efda8 Merge pull request #14001 from horizon-rust/layers/input-feedback
🤖 Agent: gameplay now asks controllers only through input_move, input_holds, input_look and input_feedback in play_input.rs, which still call the same pad functions, so the pad modules can later answer through a registered input source.
2026-11-09 20:59:12 +01:00
Ludovic Marie b444a6b64f Merge pull request #14171 from MSedgeMC/worldlane/chest-opener-rechecks
🤖 Agent: same rows as #14128 without the stray worldgen change: Sim::tick_block's chest arm is recheckOpen, scheduled every 5 ticks from count_opener.
2026-11-09 20:59:03 +01:00
Ludovic Marie e8d192f2ac Merge pull request #14218 from horizon-rust/layers/containers-spec
🤖 Agent: docs only: adds the Look and container-screen API specs to docs/layers.md under Platform registries, following #14217's look::update.
2026-11-09 20:58:59 +01:00
Ludovic Marie 6ad39aa412 Merge pull request #14127 from horizon-rust/fix/offline-names-dev-only
🤖 Agent: the game now plays only as the selected account (else the first), which must own the game itself (launch_for_game); old accounts.json/settings.json load and drop offline, offline_selected, looks, skins/ and dev_offline on the next save, covered by files_from_launchers_with_offline_names_load_and_lose_them_on_save.
2026-11-09 20:58:55 +01:00
Ludovic Marie 6342ce83b2 Merge pull request #14002 from MrCloudy2/fix/bounced-projectile-tick-order
🤖 Agent: a bounced arrow goes back by join number through JoinList::put_back (unit-tested), so it keeps its EntityTickList place; moved out of creatures.rs, which may not grow.
2026-11-09 20:58:51 +01:00
Horizon agent dff332b257 Merge the drag hook fixture fix into layers/replay-state 2026-11-09 20:56:40 +01:00
Horizon agentandClaude Opus 5.5 c1dc47e41f Tests: the drag hook fixture cuts Studio's drag at studio_mouse_dragged
game.rs's CursorMoved arm now asks self.studio_mouse_dragged(b.width)
instead of the inline timeline drag; the fixture leaves that call out of
its slice as it left the inline block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:56:39 +01:00
Horizon agentandClaude Opus 5.5 7311870047 Pages: inactive, half and footer buttons, text rows and space (spec #14167)
Generic row options a page drawn as its own screen needs (Play with Friends'
page, #14167 step 2a): Row::button(..).active(false)/.half()/.footer(),
Row::text(..).quiet()/.groups(), Row::Space. The options shell shows text as
notes, pairs half buttons and ignores space; Extras shows active buttons only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:55:32 +01:00
Ludovic Marie 595421d8ee Merge pull request #14018 from MSedgeMC/fix/stonecutter-result-components
🤖 Agent: a stonecutting result with a components patch goes through SmithingResult's template create, so a pack's custom-named result keeps its name; plain results stay on the cheap path.
2026-11-09 20:51:44 +01:00
Ludovic Marie 7169886142 Merge pull request #14122 from MSedgeMC/fix/anvil-selection-colour
🤖 Agent: AnvilScreen.init's setInvertHighlightedTextColor(false) on the rename box; moved apart from #14094's EditBox lines so both merge in either order.
2026-11-09 20:48:53 +01:00
Horizon agentandClaude Opus 5.5 a8bf4c1474 Team: itarqos5 leaves the Horizon Team
Drops the trusted-reviewers line, the Team lists in docs/release-flow.md and the pinned login in
test_nightly_set. GitHub team memberships (horizon-team, contributors) are already removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:46:41 +01:00
Ludovic Marie cdcac3463e Merge pull request #14123 from Mik00000/perf/async-audio-raycasts
🤖 Agent: bee sounds no longer rescan every mob per bee each tick and the sound engine takes one lock per channel update; silent one-frame/empty sounds stop again (agent fix 23ca4a8).
2026-11-09 20:45:15 +01:00
Ludovic Marie e71c626484 Merge pull request #14097 from vnsmoreira/packs/polytone-expr-fast
🤖 Agent: the fast path only takes one-expression programs whose kind is known and is checked bit for bit against the interpreter; 468 lines after the timing test moved to #14185. Queue AFTER 14089.
2026-11-09 20:45:10 +01:00
Ludovic Marie 194ede9376 Merge pull request #14077 from DUzzL/fix/13935-iron-golem
🤖 Agent: OfferFlowerGoal's (6,2,6) box plus forNonCombat().range(6) with sight, isBrightOutside's fixed-time check, the golem's own random for the repair pitch; the defence sight test the body named is now in the diff (agent fix 4643ea9).
2026-11-09 20:45:06 +01:00
Ludovic Marie 12578352dc Merge pull request #14203 from Evan-lhm/rt/ray-query-opt-in
🤖 Agent: EXPERIMENTAL_RAY_QUERY is requested only with HORIZON_RAY_TRACING=1 in a horizon-extras build; without it the device request is main's.
2026-11-09 20:41:33 +01:00
Ludovic Marie 11ebc3f199 Merge pull request #13960 from horizon-rust/layers/packs-emissive
🤖 Agent: the emissive suffix is read once per pack load through PackFormats::emissive_suffix; atlas building only compares a stored string per sprite as before. layer plan M6 step 3.
2026-11-09 20:41:27 +01:00
Vinícius MoreiraandClaude Opus 5.5 29ac1e17b9 Polytone: fluid modifiers' fog
A fluid modifier's fog_colormap (a flat colour), fog_radius and fog_fade apply to the
water's or lava's fog after everything else: the colour replaces it, the radius scales the
fog, and a fade of 0 makes it a hard wall. Measured on the real mod (red fog at 0.3 of the
radius, within the scene's variance of the vanilla baseline). Colormap ids and expressions
aren't read yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:41:22 -03:00
Horizon agent 115f5235b9 Merge remote-tracking branch 'origin/main' into split/official-servers-crate
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-official/Cargo.toml
#	crates/horizon-official/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:24:48 +01:00
Horizon agent e2afd13715 Merge branch 'split/cosmetics-names' into split/cosmetics-draw
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:19:51 +01:00
Horizon agent 00aba43d81 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 20:19:06 +01:00
Horizon agentandClaude Opus 5.5 e893fd3eac Hooks doc: the frame rule is the player methods', not tunnel's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:18:59 +01:00
Horizon agent ee986c3e62 Merge branch 'split/cosmetics-hooks' into split/cosmetics-sprites
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:18:48 +01:00
Horizon agent 19aabc08c2 Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:18:27 +01:00
Horizon agent ca3649534d Merge remote-tracking branch 'origin/main' into layers/seat
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:15:33 +01:00
Horizon agentandClaude Opus 5.5 4881a0862f Container screens spec: container_key gets the hovered slot, as the other inputs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:59:40 +01:00
Horizon agentandClaude Opus 5.5 4c52129f7d Tests: capture fixture follows the Replay split
Replay playback moved out of Studio into game/replay.rs's Replay; the
pointer-capture fixture carries game.replay.driver and Game::studio_up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:57:53 +01:00
Horizon agent 243d765452 Merge the studio fixture fix into layers/replay-state 2026-11-09 19:56:58 +01:00
Horizon agentandClaude Opus 5.5 ff52e244ae Tests: fixture Studio gets flying/replaying/active
fov.rs and the pointer-capture methods now ask self.studio.flying() and
replaying(); the fixtures mirror those three methods of game/studio.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:56:54 +01:00
Horizon agent bb712cca1a Merge the studio fixture fix into layers/studio-calls 2026-11-09 19:56:20 +01:00
Horizon agentandClaude Opus 5.5 1498fdecd3 Tests: fixtures read Studio's state from game.studio
test_compute_fov_hook and test_world_input_capture paste fov.rs and the
pointer-capture methods into a fixture Game; those now read
self.studio.{freecam,replay,rmb_down}, so the fixture carries a Studio.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:55:14 +01:00
Horizon agentandClaude Opus 5.5 8cf1e6b4d6 Move a packet's entity removal out of remote.rs
remote.rs is over 3000 lines and may not grow (verify's "Big files don't
grow" failed): remove_entity, remove_entities and their test move to
game/remote_removal.rs, a child module of remote, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:55:05 +01:00
Horizon agentandClaude Opus 5.5 e859476293 test_world_input_capture: the move read is input_move now
tick_remote_level asks the input source (input_move) instead of pad_move, so
the order check (load completes, then the player moves) looks for that call
(verify run 42649031940).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:45:27 +01:00
Horizon agentandClaude Opus 5.5 89235891ce Container screens spec: the API as implemented (&mut reads, spread, Equipment, every menu's slots)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:38:19 +01:00
Horizon agentandClaude Opus 5.5 2081a0dd95 Merge main into #14127; be.json is main's translation minus the offline-name keys
Main retranslated 252 Belarusian launcher strings; this PR removes the 28
offline-name keys. be.json is main's file with those 28 keys taken out (7 of
them were among the retranslated ones); every lang file parses and none keeps
a removed key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:35:38 +01:00
Evan-lhm 3f1a50ccb2 Merge remote-tracking branch 'origin/main' into rt/ray-query-opt-in
# Conflicts:
#	crates/horizon-client/Cargo.toml
2026-11-09 19:25:47 +01:00
Horizon agentandClaude Opus 5.5 8534f12db0 Look spec: update, each crate its own fields
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:25:12 +01:00
Horizon agentandClaude Opus 5.5 7e95a1f57f stations tests: keep the registration-order doc on its own test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:23:37 +01:00
Horizon agentandClaude Opus 5.5 e0e4c5e9db docs/layers.md: spec of the look and the container screen API (layer plan M5)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:22:25 +01:00
Horizon agent 0c77cd5898 Merge branch 'split/cosmetics-names' into split/cosmetics-draw 2026-11-09 19:22:00 +01:00
Horizon agent 4848a9eff5 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 19:21:59 +01:00
Horizon agentandClaude Opus 5.5 95b0cb05e7 horizon-hooks test: call SpriteStore::has by its trait
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:58 +01:00
Horizon-agent 723c4e7c2b Merge remote-tracking branch 'origin/main' into pr-13811 2026-11-09 19:18:44 +01:00
Horizon-agent c32f43b4b0 Merge remote-tracking branch 'origin/main' into pr-13800 2026-11-09 19:18:37 +01:00
Evan-lhmandClaude Opus 5.5 75071c8b9b gfx.rs may not grow: ray_query is declared beside its one user
Its `mod` line moves from gfx.rs (over 3000 lines, docs/layers.md#file-size) into
surface_setup.rs, the device request that reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:18:37 +01:00
Horizon agentandClaude Opus 5.5 641a5859a2 horizon-platform re-exports the PlayerDraw types
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:18:34 +01:00
Horizon-agent b26a050b2b Merge remote-tracking branch 'origin/main' into pr-13795 2026-11-09 19:18:26 +01:00
Horizon agentandClaude Opus 5.5 7d17f516e5 Hooks: PlayerDraw, crates draw on player models in the world pass (spec #14091 step 2a-i)
horizon-hooks gains Hooks::player_layers with a plain PlayerDraw (bone
matrices, the frame's ticks, the body pose, sprites, draw of an Arc<Mesh>
with its parts posed) and the Mesh/Quad/Bone/Pass types. The engine
implements it on the GPU skin path (each mesh made a Part once, cached by
its address while a crate holds it) with the vertex path as fallback, and
dispatches it from creatures.rs's world pass one call for one call; the
cosmetics still draw there until 2a-ii.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:15:18 +01:00
Horizon agent 0f0ca381d4 Merge remote-tracking branch 'origin/main' into split/official-servers-crate
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 19:14:23 +01:00
Evan-lhmandClaude Opus 5.5 63a6a92bef Ray tracing on request is a Horizon extra (#13780)
As ludovic111 asked on #13780: asking the device for ray queries, and the log line
saying why it is on or off, happen only in a `horizon-extras` build
(horizon-client's feature now enables horizon-client-render's). A plain build
requests the device it requested before, whatever HORIZON_RAY_TRACING says
(`only_an_extras_build_asks_for_ray_tracing`, run with and without the feature).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:07:35 +01:00
Horizon agentandClaude Opus 5.5 8902d94a19 Cosmetics: name tags, the tab mark and the camera pull through the hooks (spec #14091 step 3b)
names.rs, scoreboard.rs and perspective.rs call the engine dispatch
(name_decoration, player_mark, camera) instead of the cosmetics; the
cosmetics answer from their per-seat state: badges become sprites by id
put in the atlas from the frame hook, covered slots read PlayerModel's
gear, the camera pull comes from the emote that pulled it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:07:06 +01:00
Horizon agentandClaude Opus 5.5 ca6140d05e Hooks: name decoration, player-list mark, camera and sprites by id (spec #14091 step 3a)
horizon-hooks gains Hooks::name_decoration (a Copy NameDecoration: mark
rectangles, a badge cut from the crate's sprite, the lift over the body),
player_mark for the player list, camera (a share of the third-person
distance), and sprites by id: Sprites read while drawing, SpriteStore
filled in Hooks::frame (skin slots, hooks/<hex>). PlayerModel carries
gear and invisibility; Frame says whether the world is the window's own.
horizon-platform re-exports them. The engine fills the store; no crate
answers the new hooks yet (step 3b).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:05:10 +01:00
Evan-lhmandClaude Opus 5.5 cf93d0dd3a Renderer: ask for hardware ray queries only with HORIZON_RAY_TRACING=1 (#13780)
Step 1 of #13780 (carrying on the #1180 spike): the device requests wgpu's
EXPERIMENTAL_RAY_QUERY when the variable is set and the adapter has it, and
logs why it stays off otherwise. Nothing draws with it yet; without the
variable the device opens exactly as before. The unsafe opt-in lives in
horizon-hdr, which already holds the GPU calls the game crates can't make.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:03:49 +01:00
MSedgeMC 2e9f807458 Merge remote-tracking branch 'upstream/main' into fix/stonecutter-result-components 2026-11-09 18:01:23 +00:00
Horizon agentandClaude Opus 5.5 a7f07a08de rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:00:49 +01:00
Horizon agentandClaude Opus 5.5 0e528f7287 Cosmetics: name the per-seat state Seat (the tests' Service is a transport)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:25 +01:00
MSedgeMC 8d7ca30ea4 Merge remote-tracking branch 'upstream/main' into fix/stonecutter-result-components 2026-11-09 17:58:10 +00:00
Horizon agentandClaude Opus 5.5 e39ec11e59 Merge branch 'layers/packs-colors' into layers/packs-emissive
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:56:37 +01:00
Horizon agentandClaude Opus 5.5 6bb4c0eda9 Put a bounced projectile back through JoinList::put_back
creatures.rs is over 3000 lines and may not grow (verify's file-size check):
the join-order insertion moves to join_seq.rs, with a unit test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:53:40 +01:00
Horizon agentandClaude Opus 5.5 2f06f532e5 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:52 +01:00
Horizon agentandClaude Opus 5.5 ac0a2f1275 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:51 +01:00
MSedgeMC 092dee299b Merge remote-tracking branch 'upstream/main' into fix/stonecutter-result-components 2026-11-09 17:52:13 +00:00
Horizon agentandClaude Opus 5.5 963782a17b Replay playback's state leaves Studio: game/replay.rs's Replay (layer plan M6)
Recording and playback are platform, Studio's editor is Horizon
(docs/layers.md, Replays): the driver, the camera type to restore,
standalone and the playback clock move to Replay; Studio keeps the free
camera, timeline and top bar. Callers ask self.replay.playing(); the own
player's tick fraction is Game::own_player_alpha(). No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:07 +01:00
Horizon agent 7ca4fd68bd Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks 2026-11-09 18:48:40 +01:00
Horizon agentandClaude Opus 5.5 23ca4a855a audio: silent short sounds stop again; drop the unused camera_epoch
- A channel of one frame or none was skipped before the mixing loop, so it
  never stopped and kept its channel; the inaudible fast path also computed
  (frames - 1) in usize, which underflows for an empty sound. Both now stop
  it, as the loop does; a step of 0 no longer stops a sound the loop keeps.
- Mixer::camera_epoch was written and never read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:46:45 +01:00
MSedgeMC 16172da6b7 port_map: the chest and ender chest recheckOpen rows are ports
`Sim::tick_block`'s chest arm is `ChestBlock.tick`/`EnderChestBlock.tick`:
it recounts the openers through `recheck_container_openers`
(`ContainerOpenersCounter.recheckOpeners`), broadcasts the lid event and
re-schedules the five-tick tick while anyone has the container open. The
rows still said only the copper golems' openers were rechecked.
2026-11-09 17:46:19 +00:00
Horizon agentandClaude Opus 5.5 5720b15eff ui: the highlight flag and its test sit apart from #14094's
#14094 adds EditBox::centered beside text_color and a test at the end of
ui.rs's tests; this PR's field, initialiser and test touched the same lines.
Moved further down so both merge in either order. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:45:49 +01:00
Horizon agentandClaude Opus 5.5 2e754b1297 Polytone: the ticker timing test moves to its own PR; field() last
Keeps this PR under the project's 500-line limit (519 -> 468): the ignored
custom_ticker_cost timing test follows in a separate PR. Ctx's field() moves
to the end of its Host impl, so this merges cleanly beside #14089, which
changes the impl's own line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:45:18 +01:00
MSedgeMC 70c8d7089d port_map: the barrier pickup row's creative rule is checked
`Sim::pickup_block` returns false for a waterlogged barrier unless the
breaker is creative, which is `BarrierBlock.pickupBlock`'s gate; the row
said the rule was not there.
2026-11-09 17:45:14 +00:00
Horizon agentandClaude Opus 5.5 1ffa9ed25f Screenshots: a second F2 in the same second takes the next name
The PNG is now written on its own thread, so the first press's file may not
exist yet when a second press picks its name: both took the same path and
two writers raced on one file. screenshot_name also skips the paths of the
screenshots still being written (UiState::screenshot_messages).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:44:35 +01:00
Horizon agentandClaude Opus 5.5 4643ea9357 Iron golem: the defence goal sees the hating villager too
DefendVillageTargetGoal.canUse finds the villagers through attackTargeting
(forCombat().range(64)), which tests the golem's line of sight to each: a
villager behind a wall no longer sets the golem on the player. The PR body
named this change but the commit missed it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:44:12 +01:00
Horizon agent 547b1d4959 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 18:40:43 +01:00
Horizon agentandClaude Opus 5.5 e2918de5aa Seat: a splitscreen guest's profile loads into its own seat
Split::enter loaded a guest's profile before its turn began, so the
crates' settings stores read the guest's lines into P1's seat. The load
now runs on the guest's seat; a new profile is saved on P1's seat (the
stores write P1's values, as the rest of the profile copies P1's) and
its stores are read back into the guest's (Settings::load_stores).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:35 +01:00
Horizon agentandClaude Opus 5.5 e6b0a03270 Cosmetics: emote feed and presence behind frame/tick hooks (spec #14091 step 1)
horizon-hooks gains Hooks::frame and Hooks::tick with a plain player-model
view (PlayerModels, BodyPose moves here), re-exported by horizon-platform.
The cosmetics' client, emote feed, owner check and presence live in a
per-seat static behind those hooks; only a window's game calls them, so
the server thread no longer runs tick_emotes. net::Session gets a serial
that names the connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:24 +01:00
Horizon agentandClaude Opus 5.5 38865e907e Studio: game.rs asks it through a few calls (layer plan M6)
game.rs's inline Studio blocks (bars, camera path, timeline drag, mouse
buttons, free-camera flight) and play_keys.rs's replay shortcuts move into
game/studio.rs methods; the engine reads Studio through replaying(),
flying() and active(). Pure move, no behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:37:38 +01:00
Nikita Maximilian Grimm b7615761ee Merge remote-tracking branch 'origin/main' into hdr-lighting-one-meaning
# Conflicts:
#	crates/horizon-client-render/src/gfx/output.rs
2026-11-09 18:34:44 +01:00
Ludovic Marie 8f198d9cbd Merge origin/main into split/cosmetics-hooks 2026-11-09 18:30:32 +01:00
Horizon agentandClaude Opus 5.5 93e0536aca Studio: its state in one struct, game/studio.rs's Studio (layer plan M6)
Game's ten studio_* fields and last_replay_tick become one field, studio:
studio::Studio. Pure gather: every access is renamed, nothing else changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:29:54 +01:00
Skr1zzzzandClaude Opus 5.5 396353c6b1 Client: one pass over the big entity lists per RemoveEntities packet
Part of #13007. Each removed id ran its own retain over the mobs, orbs, dropped
items, falling blocks and primed TNT, and a scan of the passenger map, so a
packet of n ids made n passes over lists that hold thousands of falling blocks
while sand drops. RemoveEntities now removes its ids together: the per-id side
effects keep their order (the dragon's nearest crystal still follows the
crystals one id at a time), and those five lists and the passenger map are
filtered once against the packet's ids.

8,000 falling blocks, packets of 256 ids (debug test build): 26.1 ms -> 1.3 ms
per packet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:26:14 +03:00
Horizon agent 4c28a9b5b9 Merge remote-tracking branch 'origin/main' into pr-13795 2026-11-09 18:26:09 +01:00
Horizon agentandClaude Opus 5.5 1e789800af launcher: remove offline names; play only as an account that owns the game
The project decided that Horizon plays only with a Microsoft account that
owns Minecraft: Java Edition. The launcher loses, in every build:

- the hidden Developer options section (seven clicks on the sidebar
  version) and Settings::dev_offline;
- offline names: adding, selecting and playing as one, the game's made-up
  name as a player, their skins and capes (skins.rs, skin_page.rs), the
  quick switch's and Settings > Account's offline entries, and the CLI's
  `account offline`, `account forget-offline` and `account skin`;
- the `--name` and HORIZON_OFFLINE_SKIN* it gave the game for them.

The game plays as the selected account (else the first), and
account::launch_for_game requires that account to own the game itself:
another account's ownership no longer lets it start under a name of its
own. With no network, its last answer still stands, as before.

Files from older launchers still load: accounts.json's `offline`,
`offline_selected` and `looks` and settings.json's `dev_offline` are
ignored on read and gone from the next save, which also deletes the old
names' skin and cape files.

The 28 strings only these used leave the 86 lang files and the
lang_review copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:18:05 +01:00
MSedgeMC 74e0245c47 port_map: EditBox.setInvertHighlightedTextColor 2026-11-09 17:17:21 +00:00
MSedgeMC e5eacec45c test: the anvil's selection keeps its colour
The box reports its own inversion off; a default box inverts; the selection
it would draw is the value.
2026-11-09 17:17:15 +00:00
Mik00000andClaude Opus 5.5 cf23e5001a perf(audio): optimize sound attenuation, spatial math, and batch channel updates
- Early-out for stationary listener in Audio::set_listener (bypasses mutex lock contention completely when camera does not move, ~5.25 ns vs ~36 ns)
- Eliminate redundant distance sqrt in stereo_gains: projections onto orthonormal basis preserve Euclidean distance, so reuse d.length()
- Replace asin in elevation with sqrt(1 - u^2) using identity cos(asin(u)) = sqrt(1 - u^2), avoiding inverse trig entirely
- Single-camera fast path in mix to avoid splitscreen camera search loop in normal single-player / multiplayer mode
- Distance squared early-out for linear attenuation (bypasses basis projections and stereo gains for inaudible sounds outside attenuation radius)
- Fast sample skipping for silent / zero-gain audio frames without decoding interpolation
- Batch channel parameter updates via Backend::update_channel, reducing 3 mutex lock acquisitions per ticking instance to 1
- Batch stopped query via Backend::filter_stopped, replacing N individual channel mutex locks per tick with a single query pass
- Avoid O(N*M) entity loop in Game::start_mob_sounds by collecting bee indices in initial filter pass

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:16:52 +02:00
MSedgeMC 0e9233b230 fix: the anvil's selected name keeps its colour
AnvilScreen sets setInvertHighlightedTextColor(false) on its rename box, so a
selection leaves the white text alone instead of painting it black on the
highlight. EditBox always inverted it.
2026-11-09 17:16:42 +00:00
Horizon agentandClaude Opus 5.5 466201a864 Cargo.lock: horizon-official-servers after the merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:06:31 +01:00
Horizon agent ddb4f9df58 Merge branch 'split/official-servers-module' into split/official-servers-crate
# Conflicts:
#	Cargo.lock
#	docs/forking.md
2026-11-09 18:02:48 +01:00
Horizon agent bd90908a72 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 17:44:40 +01:00
rdbtCVSandClaude Sonnet 5.5 1b4f056692 Merge main into hdr-lighting-one-meaning
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 16:44:13 +00:00
rdbtCVSandClaude Opus 5.5 15d2dd04c4 Screenshots: write F2's and the world icon's PNG off the render thread
The render thread waited for the GPU to finish the screenshot's frame
(poll wait_indefinitely), then converted the pixels and encoded the PNG
inline: at 4K or a Screenshot Quality above 1x, a second or more of frozen
game. F2's and the world icon's readbacks are now mapped asynchronously and
written on a thread of their own; the result reaches the chat a frame or a
few later. Agent screenshots and screenshot-sequence keep the synchronous
path, which hands them the result with the frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:43:50 +00:00
Vinícius MoreiraandClaude Opus 5.5 3b03b98836 Polytone: expression fields answered without their names
The fast path resolves the methods without arguments expressions read most (age, life,
custom, x, y, z, temperature, downfall) when the program compiles; a host may answer them by
that Field (Host::field) instead of comparing names, and one that doesn't, or a case it
doesn't cover, goes through the name as before, with the same value. The particles' host
answers a particle's own fields, the colormaps' block host its position and biome climate.
The equivalence test now also runs a host that answers fields.

Custom particle tick (custom_ticker_cost, release): 376 ns before, 344 ns now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:42:26 -03:00
Vinícius MoreiraandClaude Opus 5.5 4e4767bbfe Polytone: a numeric fast path for expressions
A Polytone expression ran through the interpreter's Values and Results: about 263 ns for a
firefly's alpha that costs 7 ns written in Rust. An expression of one statement whose every
part is a number (constants, arithmetic, comparisons of numbers, math functions, ternaries,
and root.method(...) calls) now compiles a second time into a tree of f64s. It answers
exactly what the interpreter would: the result's kind (number, boolean, or a method's own
value at the top), method arguments as the interpreter passes them, Value::truthy for
methods in conditions; anything that could tell them apart (strings, == on a method's
value, locals, game functions) stays in the interpreter. A test runs a corpus of
expressions through both and compares every value and every host call.

Custom particle tick (custom_ticker_cost, release): 715 ns before, 376 ns now. A section's
colormap tints (colormap_tint_cost): 178 µs before, 135 µs now, same colours.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:42:26 -03:00
Vinícius MoreiraandClaude Opus 5.5 a2d34c7dbd Polytone: a timing test for custom particle tickers
custom_ticker_cost (ignored) ticks a firefly-like custom particle (five expressions with
sines and a named custom) and prints the time a tick takes, about 0.7 µs in release.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:42:26 -03:00
DUzzL e42acfc7ea Give the iron golem's poppy offer vanilla's range, sight and daylight gate, sight-test villagers in the defence goal, draw the repair pitch from the golem 2026-11-09 17:37:02 +01:00
Horizon agent 9fd16efb45 Merge branch 'layers/packs-colors' into layers/packs-emissive 2026-11-09 17:34:46 +01:00
Horizon agentandClaude Opus 5.5 a8d445f56c Hooks: seat(), the splitscreen player whose turn it is
Splitscreen players share one process and thread, so a crate's statics
are shared by them. The engine now says whose turn it is
(horizon_hooks::seat(), re-exported in horizon_platform::hooks; 0 for
P1), set where split.rs switches turns and while a joining player's game
starts, so settings stores, pages and HUD layers can keep per-player
state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:33:09 +01:00
Horizon agent d82a6941d1 Merge remote-tracking branch 'origin/main' into pr-13800
# Conflicts:
#	crates/horizon-client/src/game/machines.rs
2026-11-09 17:19:40 +01:00
Horizon agentandClaude Opus 5.5 f7772b587f Natural spawning: the level keeps every player, the spawn state the anchoring ones
After main filtered fake players out of the spawn anchors, building the
level from that list would drop them from natural_level too; main builds
the level from every player and SpawnPlayer from the filtered list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:17:42 +01:00
Horizon-agent d1098c9a91 Merge remote-tracking branch 'origin/main' into pr-13811 2026-11-09 17:17:21 +01:00
Horizon-agentandClaude Opus 5.5 d50d56d837 Natural spawning: shuffle every pass, no candidate cache keyed on chunk count
Util.shuffle in ServerChunkCache.tickChunks draws from the level's random
whether or not a category may spawn, so skipping it moved every later draw
of that random off vanilla's. The candidate cache keyed on player x/z and
the chunk count went stale when a chunk's ticket level or entity loading
changed under a player standing still (an AFK farm). Both go; the allocation
savings (streamed Counted, reused SpawnPlayer buffer, the single-player
collect) stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:17:21 +01:00
Horizon agentandClaude Opus 5.5 8e2780a8f0 Cargo.lock: horizon-official-servers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:15:07 +01:00
Horizon agentandClaude Opus 5.5 1feb0c7b0b Keep every hopper's cooldown entry, as vanilla keeps tickedGameTime
With get_mut, an idle hopper never recorded the tick it ticked, so a
hopper that had already ticked this tick and then received an item from
another hopper took cooldown 8 instead of 7 (HopperBlockEntity.tryMoveInItem
skips one when its tickedGameTime >= the source's). Restore the per-tick
entry and the original prune; the other optimizations stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:15:07 +01:00
Horizon agentandClaude Opus 5.5 8a5af429d7 Drop the offline capture reporting tools
They only re-project captures kept on the author's machine and start no
engine, so the test_*_parity.py name would register a harness CI cannot
run. The Rust fix and its unit and guest-death tests stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:08:50 +01:00
Horizon agentandClaude Opus 5.5 2cd4ec9888 horizon-official-servers: the official server leaves the engine for a Horizon crate
Layer plan M5: official_servers.rs moves (pure file move plus its hook) to
the new Horizon crate horizon-official-servers, which the official
executable registers through the platform's hooks: Hooks::started now gets
the game folder (Started::game_dir), and horizon-platform re-exports
servers.dat as horizon_platform::servers. Its addresses come from
horizon-addresses: the withdrawn partner's joins the list file, so its
allowed entry in tools/addresses.json goes. horizon-client's own executable
(plain builds, harnesses) no longer touches servers.dat at startup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:24 +01:00
MSedgeMC f0c1ec9d84 A stonecutter result keeps its components
StonecuttingRecipe.assemble creates the result from ItemStackTemplate, so a
result's components (a data pack's custom name, damage, ...) belong on the
output. Horizon built the result from its id and count only.

Test: a stonecutting recipe whose stone result names it "Cut Stone" yields a
named stone.
2026-11-09 15:59:33 +00:00
Horizon agentandClaude Opus 5.5 755b7800c1 Input source: what gameplay tells the pad goes through engine methods too
input_keyboard_used, input_made_pointer_event, input_player_replaced,
input_takes_stack_drop and input_hud_begin replace gameplay's direct writes to
the pad's state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:51:53 +01:00
MrCloudy2andClaude Opus 5.5 30149bfb41 Harness: an arrow bouncing off a wind charge keeps its place in the tick order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:47:59 +01:00
Ludovic Marie 93ecc8d0c1 Merge branch 'layers/packs-colors' into layers/packs-emissive 2026-11-09 16:46:39 +01:00
Ludovic MarieandClaude Opus 5.5 0031c67d04 rustfmt explosion.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:46:14 +01:00
MrCloudy2andClaude Opus 5.5 2726614191 A projectile put back after its hit keeps its place in the tick order
An arrow that bounces off an entity (deflect REVERSE) never leaves the level
in vanilla, so it keeps its place in EntityTickList. Horizon's projectile
pass drops it and finish_arrow_hit adds it again with add_projectile, which
appended it behind every projectile already waiting: those then ticked
before it. add_projectile now puts a projectile that has already joined back
at its join position; a new one still goes last.

Found with the Orbital Skybender: the arrow that reads each digit missed
every other wind charge, so only half the digits were read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:41:23 +01:00
Horizon agentandClaude Opus 5.5 2e45a0750d rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:26 +01:00
Horizon agent 8ad2c758ad Merge branch 'layers/packs-colors' into layers/packs-emissive 2026-11-09 16:27:26 +01:00
Ludovic MarieandClaude Opus 5.5 5152376c4b input_feedback: commands already have a feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:14 +01:00
Horizon agentandClaude Opus 5.5 8f331a10f5 Input source: gameplay asks controllers through engine methods (layer plan M6)
Gameplay reads the analog move vector, held mappings and the view turn, and
reports what the player may feel (Feedback), through input_move, input_holds,
input_look and feedback in play_input.rs. Only these four bodies name the pad
modules, which answer today; Feedback is an engine type the pad maps to Rumble.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:15:28 +01:00
Horizon agentandClaude Opus 5.5 cbba6ead3a Emissive overlays' suffix through the pack-format hook
layer plan M6 (pack formats), step 3: the terrain's emissive overlays take
the pack's suffix from pack_hooks (OptiFine's emissive.properties, _e by
default); with no pack formats installed a pack has none. atlas_sources
checks a texture path stays in the pack root itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:39:02 +01:00
Mik00000 0f8225e608 perf(spawning): optimize natural mob spawning density scan and chunk cache 2026-11-09 12:59:02 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 b2ba0cb395 HDR settings: HDR Lighting greyed under a shader pack, one switch for it
HDR Lighting is one thing: all of Horizon's own lift above paper white, light
sources, the sun and the lit surfaces. HORIZON_HDR_LIGHTING=0 switched off only
the surfaces' part, a second switch with another scope; it is gone, and the
setting does the job. docs/graphics.md now describes the setting.

Under a shaders/ pack, and under a 0.2 pack on the float frame, Horizon lifts
nothing, so the row did nothing there. It is now greyed and says "The shader
pack lights the world itself." Under a 0.1 pack on the 8- or 16-bit frame the
last pass still lifts the light sources and the sun, so the row stays live
there, as it does for a pack that is refused, missing or whose mod is off. The
page reads this from the renderer's output, which now carries whether the
running pack lights the world. HDR's own reasons still come first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:21:31 +01:00
Mik00000 72f748a785 perf(machines): optimize block entity ticking and hopper transfers 2026-11-09 12:11:26 +02:00
TheMrZZandCodex 9ad798b74b Preserve personal spawnpoint pitch for dedicated guests
Part of #13625. Keep owner physical pitch open and document recovered offline arrival evidence.

Co-Authored-By: Codex <[email protected]>
2026-11-09 10:33:51 +01:00
Vinícius MoreiraandClaude Opus 5.5 41fc640974 A player's break of an armor stand rolls its loot table
ArmorStand.hurtServer breaks a stand through brokenByPlayer on a second
player hit within five ticks, and at once for an always_kills_armor_stands
type: the stand item (with the stand's name) pops at its block, then
brokenByAnything plays the sound, rolls the loot table and pops the
equipment. Horizon's punch dropped the item and equipment without the
loot table, and /damage refused every can_break / always_kills type.

Part of #12514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:35:28 -03:00
278 changed files with 6681 additions and 6620 deletions

No files matched your search

-1
View File
@@ -13,4 +13,3 @@
87152661 rdbtCVS # added by Ludovic, 2026-10-27
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
Generated
+13 -7
View File
@@ -1401,9 +1401,7 @@ dependencies = [
name = "horizon-bench"
version = "0.1.0"
dependencies = [
"horizon-platform",
"horizon-telemetry",
"log",
"serde",
"serde_json",
]
@@ -1441,6 +1439,7 @@ dependencies = [
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
@@ -1462,7 +1461,6 @@ dependencies = [
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1524,6 +1522,7 @@ dependencies = [
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
@@ -1794,22 +1793,30 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-platform",
"horizon-telemetry",
"log",
"winresource",
]
[[package]]
name = "horizon-official-servers"
version = "0.1.0"
dependencies = [
"horizon-addresses",
"horizon-platform",
"log",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-system",
"log",
"regex",
]
@@ -1973,7 +1980,6 @@ dependencies = [
"getrandom 0.3.4",
"horizon-addresses",
"horizon-platform",
"log",
"ruzstd",
"serde",
"serde_json",
+1
View File
@@ -34,6 +34,7 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
+4
View File
@@ -15,6 +15,10 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// Afri-Network, a partner server the game added once below the official server (#9299) until
/// the partnership ended: kept so the game can take back out the entry it added.
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
+1 -9
View File
@@ -1,7 +1,7 @@
[package]
name = "horizon-bench"
version = "0.1.0"
edition.workspace = true
edition = "2021"
rust-version.workspace = true
license = "MIT"
description = "The benchmark report format (docs/benchmark-report.md), shared by the game, the launcher and the upload"
@@ -11,11 +11,3 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
# The game's part (`game`): the hooks it plugs into and the probes, from the platform's API.
horizon-platform = { path = "../horizon-platform", optional = true }
log = { version = "0.4", optional = true }
[features]
# The game's `--benchmark` driver, started through the platform's hooks. The official executable
# turns it on.
game = ["dep:horizon-platform", "dep:log"]
+117 -3
View File
@@ -1,7 +1,6 @@
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
//! come from the program that rendered (the game knows its adapter, the launcher its window).
#[cfg(any(target_os = "macos", target_os = "linux"))]
use horizon_telemetry::system::command_output as output;
use serde::{Deserialize, Serialize};
@@ -69,12 +68,12 @@ impl Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: horizon_telemetry::system::cpu(),
cpu: cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
cpu_cores: probes.cores,
cpu_base_mhz: probes.base,
cpu_boost_mhz: probes.boost,
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
ram_mb: ram_bytes().map(|b| b >> 20),
storage: None,
form: probes.form,
gpu: None,
@@ -110,16 +109,84 @@ fn probes() -> Probes {
}
}
/// The one-minute load average: above about the number of threads the machine was busy.
pub fn load_average() -> Option<f64> {
if cfg!(target_os = "macos") {
// "{ 1.23 1.10 1.00 }"
output("sysctl", &["-n", "vm.loadavg"])?
.trim_start_matches('{')
.split_whitespace()
.next()?
.parse()
.ok()
} else {
std::fs::read_to_string("/proc/loadavg")
.ok()?
.split_whitespace()
.next()?
.parse()
.ok()
}
}
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
horizon_telemetry::system::power()
}
/// Resident memory of a process, in bytes (macOS, Linux).
pub fn rss_bytes(pid: u32) -> Option<u64> {
if cfg!(target_os = "macos") {
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024);
}
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
line.split_whitespace()
.nth(1)?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024)
}
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
pub fn cpu_seconds(pid: u32) -> Option<f64> {
if cfg!(target_os = "macos") {
// "[[dd-]hh:]mm:ss.cc"
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
let (rest, days) = match time.split_once('-') {
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
None => (time, 0.0),
};
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
})?;
return Some(days * 86_400.0 + seconds);
}
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
Some(ticks / 100.0)
}
#[cfg(target_os = "macos")]
fn os_version() -> Option<String> {
output("sw_vers", &["-productVersion"])
}
#[cfg(target_os = "macos")]
fn cpu() -> Option<String> {
output("sysctl", &["-n", "machdep.cpu.brand_string"])
}
#[cfg(target_os = "macos")]
fn ram_bytes() -> Option<u64> {
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
}
#[cfg(target_os = "linux")]
fn os_version() -> Option<String> {
let release = std::fs::read_to_string("/etc/os-release").ok()?;
@@ -132,6 +199,22 @@ fn os_version() -> Option<String> {
})
}
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
fn ram_bytes() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let kb = key_value(&info, "MemTotal", ':')?
.trim_end_matches("kB")
.trim();
kb.parse::<u64>().ok().map(|kb| kb * 1024)
}
#[cfg(target_os = "linux")]
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
text.lines().find_map(|line| {
@@ -145,11 +228,39 @@ fn os_version() -> Option<String> {
horizon_telemetry::system::windows_version()
}
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
horizon_telemetry::system::windows_cpu()
}
#[cfg(target_os = "windows")]
fn ram_bytes() -> Option<u64> {
let out = output(
"powershell",
&[
"-NoProfile",
"-Command",
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
],
)?;
out.parse().ok()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn os_version() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn cpu() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn ram_bytes() -> Option<u64> {
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -163,6 +274,9 @@ mod tests {
assert!(hw.cpu.is_some(), "{hw:?}");
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
assert!(hw.os_version.is_some(), "{hw:?}");
assert!(load_average().is_some());
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
+1 -5
View File
@@ -11,13 +11,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[cfg(feature = "game")]
pub mod game;
mod hardware;
pub use hardware::{Display, Gpu, Hardware, power_source};
/// The process probes, from the platform (`horizon-system`).
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
pub const FORMAT: &str = "horizon-benchmark-report";
pub const VERSION: u32 = 1;
+172 -37
View File
@@ -74,11 +74,70 @@ pub trait ResourceSource {
vec!["minecraft".into()]
}
}
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
fn bundled_code(code: &str) -> (&str, bool) {
match code {
"ko" => ("ko_kr", false),
"hr" => ("hr_hr", false),
"de_at" => ("de_de", false),
"de_ch" => ("de_de", true),
"fr_ca" | "fr_ch" => ("fr_fr", false),
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
code => (code, false),
}
}
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
std::sync::OnceLock::new();
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
/// counts.
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
let _ = CRATE_FILES.set(files);
}
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
if files.is_empty() {
return;
}
let (file, swiss) = bundled_code(code);
let mut own = HashMap::new();
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
read_lang_file(&bytes, &mut own);
}
for (c, json) in files.iter().flat_map(|f| f.iter()) {
if *c != file {
continue;
}
let mut theirs = HashMap::new();
if swiss {
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
} else {
read_lang_file(json.as_bytes(), &mut theirs);
}
for (key, value) in theirs {
if !own.contains_key(&key) {
map.insert(key, value);
}
}
}
}
/// Horizon's own strings, compiled in so they need no prepared resources.
struct Bundled;
impl ResourceSource for Bundled {
fn read(&self, path: &str) -> Option<Vec<u8>> {
let body = match path {
let code = path
.strip_prefix("assets/horizon/lang/")?
.strip_suffix(".json")?;
let (file, swiss) = bundled_code(code);
let path = format!("assets/horizon/lang/{file}.json");
let body = match path.as_str() {
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
@@ -103,10 +162,10 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
"assets/horizon/lang/ko_kr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
}
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
"assets/horizon/lang/hr_hr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
}
"assets/horizon/lang/pt_pt.json" => {
@@ -331,30 +390,20 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/vec_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
"assets/horizon/lang/de_de.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
"assets/horizon/lang/de_ch.json" => {
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
return Some(de.replace('ß', "ss").into_bytes());
}
"assets/horizon/lang/fr_fr.json"
| "assets/horizon/lang/fr_ca.json"
| "assets/horizon/lang/fr_ch.json" => {
"assets/horizon/lang/fr_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
| "assets/horizon/lang/es_ec.json"
| "assets/horizon/lang/es_mx.json"
| "assets/horizon/lang/es_uy.json"
| "assets/horizon/lang/es_ve.json" => {
"assets/horizon/lang/es_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
}
_ => return None,
};
if swiss {
return Some(body.replace('ß', "ss").into_bytes());
}
Some(body.as_bytes().to_vec())
}
fn namespaces(&self) -> Vec<String> {
@@ -438,6 +487,16 @@ impl Language {
/// from every namespace and every source in priority order (later sources
/// override).
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
Self::load_with(sources, code, crate_files)
}
/// [`Language::load`] with these crates' lang files.
fn load_with(
sources: &[&dyn ResourceSource],
code: &str,
crate_files: &[&[(&str, &str)]],
) -> Self {
let mut map = HashMap::new();
let mut codes = vec![DEFAULT];
if code != DEFAULT {
@@ -453,12 +512,14 @@ impl Language {
sources.iter().flat_map(|s| s.namespaces()).collect();
for c in codes {
for namespace in &namespaces {
for source in sources {
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
else {
continue;
};
read_lang_file(&bytes, &mut map);
for (i, source) in sources.iter().enumerate() {
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
read_lang_file(&bytes, &mut map);
}
// The crates' text right after the engine's own (`Bundled`, the first source).
if i == 0 && namespace == "horizon" {
read_crate_files(crate_files, c, &mut map);
}
}
}
}
@@ -688,6 +749,43 @@ mod tests {
);
}
#[test]
fn a_crates_text_loads_after_the_engines_and_before_packs() {
static CRATE: &[(&str, &str)] = &[
(
"en_us",
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
),
(
"de_de",
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
),
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
];
let pack = Mem(HashMap::from([(
"assets/horizon/lang/en_us.json".to_owned(),
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
)]));
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
let en = load("en_us");
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
// The engine's own file wins on a key both have; a pack wins over both.
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
let de = load("de_de");
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
// Regional variants read their language's file, as the engine's do.
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
// A language the crate lacks falls back to its English.
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
// Without crate files, nothing changes.
let plain = Language::load_with(&[&pack], "de_de", &[]);
assert_eq!(plain.get("test.crate.hello"), None);
assert_eq!(plain.len() + 1, de.len());
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
@@ -739,6 +837,20 @@ mod tests {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-client/assets/horizon/lang")
}
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
/// registered through `horizon_hooks::lang`), with that folder.
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
.unwrap()
.flatten()
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
.map(|(name, path)| (name, path.join("assets/lang")))
.filter(|(_, dir)| dir.join("en_us.json").is_file())
.collect();
dirs.sort();
dirs
}
/// The keys of a flat bundled file in file order, duplicates included (a map
/// would keep only the last of two equal keys). One `"key": "value"` per line.
fn file_keys(body: &str) -> Vec<String> {
@@ -776,11 +888,33 @@ mod tests {
#[test]
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
let dir = bundled_dir();
let engine = check_lang_dir(&bundled_dir(), true);
assert!(
engine.1 >= 7,
"found {} bundled lang files in {}",
engine.1,
bundled_dir().display()
);
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
// files also have, the engine's text would win and hide the crate's.
for (name, dir) in crate_lang_dirs() {
let (en, _) = check_lang_dir(&dir, false);
for key in en.keys() {
assert!(
!engine.0.contains_key(key),
"{name}: {key} is also the engine's"
);
}
}
}
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
/// same placeholders as English, no empty text. Its en_us and how many files it has.
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
let mut seen = 0;
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let name = entry.file_name().into_string().unwrap();
let Some(code) = name.strip_suffix(".json") else {
continue;
@@ -789,7 +923,7 @@ mod tests {
// A file that Bundled::read does not name is never loaded.
let path = format!("assets/horizon/lang/{name}");
assert!(
Bundled.read(&path).is_some(),
!bundled || Bundled.read(&path).is_some(),
"{name} is not served by Bundled"
);
let body = std::fs::read_to_string(entry.path()).unwrap();
@@ -815,11 +949,7 @@ mod tests {
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
}
}
assert!(
seen >= 7,
"found {seen} bundled lang files in {}",
dir.display()
);
(en, seen)
}
#[test]
@@ -834,10 +964,10 @@ mod tests {
}
}
}
let en: HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
)
.unwrap();
let english = |dir: &std::path::Path| -> HashMap<String, String> {
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
};
let mut en = english(&bundled_dir());
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
for krate in [
@@ -847,6 +977,11 @@ mod tests {
] {
rust_files(&crates.join(krate).join("src"), &mut files);
}
// A crate that ships lang files names its keys in its own sources.
for (name, dir) in crate_lang_dirs() {
en.extend(english(&dir));
rust_files(&crates.join(name).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
let mut found = 0;
for file in files {
@@ -1441,6 +1441,16 @@ pub enum Rumble {
},
Totem,
}
impl From<crate::input_source::Feedback> for Rumble {
fn from(event: crate::input_source::Feedback) -> Self {
use crate::input_source::Feedback;
match event {
Feedback::BlockBroken => Rumble::BlockBroken,
Feedback::Explosion { distance, radius } => Rumble::Explosion { distance, radius },
Feedback::Totem => Rumble::Totem,
}
}
}
/// A vibration: the heavy (low-frequency) and light motors, 0..1, and how
/// long it lasts.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -0,0 +1,15 @@
//! The engine's side of the input source (docs/layers.md, "Controllers, splitscreen, replays and
//! UI pages"): what gameplay tells a device beyond the keyboard and mouse. Gameplay names these
//! types, never a controller's; the controller code answers.
/// Something that happened to the player that a device may let them feel (a controller's
/// vibration). The device decides how, or whether.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Feedback {
/// The player broke a block.
BlockBroken,
/// An explosion this far away (blocks) with this power.
Explosion { distance: f64, radius: f32 },
/// A totem of undying saved the player.
Totem,
}
+1
View File
@@ -9,6 +9,7 @@ pub mod gamepad;
pub mod gameplay_guide;
pub mod held;
pub mod input;
pub mod input_source;
pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
+3 -3
View File
@@ -71,8 +71,9 @@ mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# The official server's address (multiplayer.rs) until it moves to the Horizon layer.
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
@@ -114,8 +115,7 @@ default = ["audio", "controllers", "horizon-extras"]
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
# server in the list (`official_servers`).
# Horizon's own parts still in this crate (docs/forking.md, docs/layers.md): the features below.
horizon-extras = ["discord"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
+5 -2
View File
@@ -7,8 +7,7 @@ rust-version.workspace = true
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
# The crates above the engine: their settings stores (docs/layers.md, "Platform registries"),
# and the hooks that hear each crash (`crash.rs`).
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
@@ -77,6 +76,10 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
# the variable.
horizon-extras = []
[lints]
workspace = true
@@ -0,0 +1,158 @@
//! Audio occlusion, attenuation and mixing benchmark.
//! Run with: `cargo run --profile fast -p horizon-client-render --example audio_bench`
use horizon_client_render::{
audio::Audio,
sound_engine::{MobSound, SoundInstance, Sounds, TickContext},
};
use std::{path::Path, time::Instant};
fn main() {
println!("=== Horizon Audio Attenuation & Mixing Benchmark ===");
let mut audio = Audio::silent(Path::new("/nonexistent"));
audio.engine.loaded = true;
audio.backend_live_for_bench();
audio.record_heard = false;
// Register a sound event with attenuation distance 16
audio.engine.registry.add_file(
"minecraft",
br#"{"bench.loop":{"attenuation_distance":16,"sounds":[{"name":"bench/loop","stream":false}]}}"#,
&|_, _| true,
);
// Insert 1 second of audio into cache (48000 mono samples)
let samples: Vec<f32> = (0..48_000)
.map(|i| ((i as f32) * 440.0 * 2.0 * std::f32::consts::PI / 48000.0).sin() * 0.5)
.collect();
audio.insert_cached_sound("minecraft:bench/loop", samples, 48_000, 1);
// 1. Benchmark set_listener (called every client render frame)
let listener_iterations = 200_000;
let start = Instant::now();
for i in 0..listener_iterations {
let angle = (i as f32) * 0.01;
audio.set_listener(
[
100.0 + (angle.cos() as f64) * 5.0,
64.0,
200.0 + (angle.sin() as f64) * 5.0,
],
angle,
0.0,
);
}
let listener_elapsed = start.elapsed();
println!(
"1. set_listener moving (render frame): {:?} for {} calls ({:.2} ns/call)",
listener_elapsed,
listener_iterations,
(listener_elapsed.as_nanos() as f64) / (listener_iterations as f64)
);
// 1b. Benchmark set_listener stationary (lock elimination early-out)
let stationary_iterations = 200_000;
let start = Instant::now();
for _ in 0..stationary_iterations {
audio.set_listener([100.0, 64.0, 200.0], 0.0, 0.0);
}
let stationary_elapsed = start.elapsed();
println!(
"1b. set_listener stationary (lock early-out): {:?} for {} calls ({:.2} ns/call)",
stationary_elapsed,
stationary_iterations,
(stationary_elapsed.as_nanos() as f64) / (stationary_iterations as f64)
);
// 2. Play 128 sounds in the world at various distances from listener at [100.0, 64.0, 200.0]
// 64 sounds within attenuation distance (<16 blocks)
// 64 sounds outside attenuation distance (>16 blocks)
for i in 0..128 {
let dist = if i < 64 {
(i as f64) * 0.24 // 0 to 15.1 blocks (audible)
} else {
17.0 + ((i - 64) as f64) * 1.5 // 17 to 111.5 blocks (out of attenuation range)
};
let pos = [100.0 + dist, 64.0, 200.0];
let instance = SoundInstance::new(
"minecraft:bench.loop",
crate_sound_source(i),
1.0,
1.0,
horizon_core::random::LegacyRandom::new(i as i64),
true, // looping
0,
horizon_client_render::sound_engine::Attenuation::Linear,
pos,
false,
);
audio.play_instance(instance, false);
}
// 3. Benchmark mix_buffer (5000 buffers of 1024 stereo frames @ 48kHz = ~106 seconds of audio mixing)
let mut buffer = vec![0.0f32; 1024 * 2];
let mix_iterations = 5_000;
let start = Instant::now();
for _ in 0..mix_iterations {
audio.mix_buffer(&mut buffer, 2, 48_000);
}
let mix_elapsed = start.elapsed();
println!(
"2. mix_buffer (128 active channels, 1024 stereo frames): {:?} for {} buffers ({:.2} µs/buffer)",
mix_elapsed,
mix_iterations,
(mix_elapsed.as_micros() as f64) / (mix_iterations as f64)
);
// 4. Benchmark SoundEngine::tick with ticking mob sounds
let mut mobs = Vec::new();
for i in 0..64 {
mobs.push(MobSound {
id: 2000 + i,
position: [100.0 + (i as f64) * 0.5, 64.0, 200.0 + (i as f64) * 0.5],
speed: 0.25,
baby: false,
angry: i % 2 == 0,
silent: false,
attack_scale: 0.5,
sniffer_digging: false,
});
}
let tick_ctx = TickContext {
player_underwater: false,
player_removed: false,
vehicle: None,
carts: Vec::new(),
mobs: mobs.clone(),
frozen: false,
player_position: [100.0, 64.0, 200.0],
player_speed_sqr: 0.1,
player_fall_flying: false,
ridden: None,
camera_position: [100.0, 64.0, 200.0],
};
let tick_iterations = 10_000;
let start = Instant::now();
for _ in 0..tick_iterations {
audio.tick(false, &tick_ctx);
}
let tick_elapsed = start.elapsed();
println!(
"3. SoundEngine::tick (64 channels + ticking): {:?} for {} ticks ({:.2} µs/tick)",
tick_elapsed,
tick_iterations,
(tick_elapsed.as_micros() as f64) / (tick_iterations as f64)
);
}
fn crate_sound_source(i: u32) -> usize {
match i % 6 {
0 => 0, // master
1 => 1, // music
2 => 2, // records
3 => 3, // weather
4 => 4, // blocks
_ => 5, // hostile
}
}
@@ -386,6 +386,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
for _ in 0..32 {
draw(&mut renderer)?;
@@ -207,6 +207,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("cpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
let expected = read(&out.join("cpu.png"));
@@ -251,6 +252,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("gpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
assert_eq!(
@@ -167,6 +167,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
// twice: the first frame of a new warp builds it
renderer.render(&frame).unwrap();
@@ -336,6 +337,7 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
path: out.join("bench.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
+2 -2
View File
@@ -382,8 +382,8 @@ impl RenderAssets {
};
// After the connected tiles: a tile identical to a block shares its layer, and the
// overlays skip shared layers.
if draws {
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
}
sprites.save_disk_cache();
if draws {
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
}
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
let rel = format!("assets/{namespace}/{directory}/{path}.png");
root.join(if horizon_optifine::paths::valid(&rel) {
&rel
} else {
NOWHERE
})
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
let stays = rel
.split('/')
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
root.join(if stays { &rel } else { NOWHERE })
}
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
+167 -53
View File
@@ -478,6 +478,7 @@ pub struct Audio {
pub record_heard: bool,
pub listener: [f64; 3],
pub listener_yaw: f32,
pub listener_pitch: f32,
pub master: f32,
/// Seeds of positional plays (the client level random's `nextLong`).
random: LegacyRandom,
@@ -722,6 +723,29 @@ impl Backend for MixerBackend {
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
self.with(channel, |c| c.unmuffled = unmuffled);
}
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.gain = volume;
c.pitch = pitch;
c.at = [position[0] as f32, position[1] as f32, position[2] as f32];
}
}
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
if let Ok(m) = self.mixer.lock() {
for &(h, c) in live {
if m.channels
.get(&c)
.is_none_or(|ch| ch.state == State::Stopped)
{
stopped.push(h);
}
}
} else {
stopped.extend(live.iter().map(|&(h, _)| h));
}
}
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
/// `getStream` (decoded on a thread of its own), then attach and
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
@@ -822,18 +846,24 @@ impl Backend for MixerBackend {
/// pinned client's) rendered through `ALC_SOFT_loopback` within 1e-5.
fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
let length = (right * right + up * up + forward * forward).sqrt();
stereo_gains_with_length(right, up, forward, length)
}
#[inline]
fn stereo_gains_with_length(right: f32, up: f32, forward: f32, length: f32) -> [f32; 2] {
if length <= f32::EPSILON {
return stereo_gains(0.0, 0.0, 1.0);
return stereo_gains_with_length(0.0, 0.0, 1.0, 1.0);
}
let elevation = (up / length).clamp(-1.0, 1.0).asin();
let u = (up / length).clamp(-1.0, 1.0);
let cos_elev = (1.0 - u * u).max(0.0).sqrt();
let mut azimuth = right.atan2(forward);
if azimuth.abs() < std::f32::consts::FRAC_PI_2 {
azimuth = (azimuth.abs() * 1.5)
.min(std::f32::consts::FRAC_PI_2)
.copysign(azimuth);
}
let side = azimuth.sin() * elevation.cos();
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
let (sin_az, cos_az) = azimuth.sin_cos();
let side = sin_az * cos_elev;
let front = cos_az * cos_elev * 0.095_660_8;
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
}
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
@@ -853,16 +883,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
// and OpenAL's right = forward x up.
let basis = |yaw: f32, pitch: f32| {
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let (yaw_sin, yaw_cos) = yaw.sin_cos();
let (pitch_sin, pitch_cos) = pitch.sin_cos();
let forward = [-yaw_sin * pitch_cos, -pitch_sin, yaw_cos * pitch_cos];
let up = [-yaw_sin * pitch_sin, pitch_cos, yaw_cos * pitch_sin];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
@@ -896,7 +920,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
// Gains and pans per source for this buffer.
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
for (id, c) in &mixer.channels {
for (id, c) in &mut mixer.channels {
let Some((_, source_rate, source_channels)) = &c.samples else {
continue;
};
@@ -907,6 +931,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
if c.state != State::Playing {
continue;
}
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
let away = |listener: [f64; 3]| {
[
@@ -915,45 +940,56 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
at[2] - listener[2],
]
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
.map(|camera| (camera.0, camera.1));
// Unheard sources still advance: returning to a world must not resume stale sounds.
let length_sq = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
let camera = if !shared {
let cam = &mixer.cameras[0];
if c.relative || cam.2 == c.world {
Some((cam.0, cam.1))
} else {
None
}
} else {
mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length_sq(away(a.0)).total_cmp(&length_sq(away(b.0))))
.map(|camera| (camera.0, camera.1))
};
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
let d = if c.relative { at } else { away(listener) };
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = if camera.is_some() {
c.gain.clamp(0.0, 1.0)
* if direct {
1.0
} else {
distance_gain(c.linear, distance)
let d_sqr = length_sq(d);
let (gain, [left, right]) = if camera.is_none() {
(0.0, [0.0, 0.0])
} else if direct {
let gain = c.gain.clamp(0.0, 1.0);
(gain, [gain, gain])
} else {
let mut zero = false;
if let Some(max) = c.linear {
let max_d = max as f64;
if max <= 0.0 || (!c.relative && d_sqr >= max_d * max_d) {
zero = true;
}
} else {
0.0
};
let [left, right] = if direct {
[gain, gain]
} else {
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [mut left, mut right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
} else {
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
};
if shared {
// Several players on the speakers: half the stereo width, as left and right are
// nobody's in particular.
let middle = (left + right) / 2.0;
left = middle + (left - middle) / 2.0;
right = middle + (right - middle) / 2.0;
}
[left * gain, right * gain]
if zero {
(0.0, [0.0, 0.0])
} else {
let distance = d_sqr.sqrt() as f32;
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
let [mut left, mut right] = if c.relative {
stereo_gains_with_length(d[0] as f32, d[1] as f32, -d[2] as f32, distance)
} else {
stereo_gains_with_length(dot(d, right), dot(d, up), dot(d, forward), distance)
};
if shared {
let middle = (left + right) * 0.5;
left = middle + (left - middle) * 0.5;
right = middle + (right - middle) * 0.5;
}
(gain, [left * gain, right * gain])
}
};
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
@@ -974,6 +1010,40 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let source_channels = source_channels as usize;
let frames = samples.len() / source_channels;
let inaudible = if channels >= 2 {
left == 0.0 && right == 0.0
} else {
gain == 0.0
};
if inaudible {
let buffer_frames = data.len() / channels;
// As the loop below: a sound of one frame or none stops, looping or not.
if !c.looping || frames <= 1 {
// `frames` may be 0: no `frames - 1` in usize. A step of 0 never
// reaches the end, as in the loop below.
let needed = frames as f64 - 1.0 - c.position;
if needed <= 0.0 {
c.state = State::Stopped;
} else if step > 0.0 {
let steps_to_stop = needed / step;
if (buffer_frames as f64) >= steps_to_stop {
c.state = State::Stopped;
} else {
c.position += step * (buffer_frames as f64);
}
}
} else {
for _ in 0..buffer_frames {
let i = c.position as usize;
if i + 1 >= frames {
c.position = 0.0;
}
c.position += step;
}
}
continue;
}
let out: &mut [f32] = if muffling && !c.unmuffled {
&mut bus
} else {
@@ -992,8 +1062,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
}
let t = (c.position - i as f64) as f32;
if source_channels > 1 {
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
let l = samples[i * 2] + (samples[(i + 1) * 2] - samples[i * 2]) * t;
let r = samples[i * 2 + 1] + (samples[(i + 1) * 2 + 1] - samples[i * 2 + 1]) * t;
if channels >= 2 {
frame[0] += l * left;
frame[1] += r * right;
@@ -1001,7 +1071,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
frame[0] += (l + r) * 0.5 * gain;
}
} else {
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
let s = samples[i] + (samples[i + 1] - samples[i]) * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
@@ -1412,6 +1482,7 @@ impl Audio {
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
listener_pitch: 0.0,
master: 1.0,
random: crate::random_source::create(),
source_override: None,
@@ -1535,8 +1606,12 @@ impl Audio {
/// `SoundManager.updateSource`: the camera position and rotation
/// (`Listener.setTransform` takes its forward and up vectors).
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
if self.listener == position && self.listener_yaw == yaw && self.listener_pitch == pitch {
return;
}
self.listener = position;
self.listener_yaw = yaw;
self.listener_pitch = pitch;
if let Ok(mut m) = self.backend.mixer.lock() {
if self.backend.owner == 0 {
m.listener = position;
@@ -1576,6 +1651,7 @@ impl Audio {
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
listener_pitch: 0.0,
master: self.master,
random: crate::random_source::create(),
source_override: None,
@@ -2109,6 +2185,25 @@ impl Audio {
})
.collect()
}
/// Mixes one buffer of audio into `data` (channels: 1 or 2, rate in Hz).
pub fn mix_buffer(&mut self, data: &mut [f32], channels: usize, rate: u32) {
if let Ok(mut m) = self.backend.mixer.lock() {
mix(&mut m, data, channels, rate);
}
}
#[doc(hidden)]
pub fn insert_cached_sound(&mut self, key: &str, samples: Vec<f32>, rate: u32, channels: u16) {
if let Ok(mut c) = self.backend.cache.lock() {
c.insert(
key.to_owned(),
Some((std::sync::Arc::new(samples), rate, channels)),
);
}
}
#[doc(hidden)]
pub fn backend_live_for_bench(&mut self) {
self.backend.live = true;
}
}
impl Sounds for Audio {
fn engine(&mut self) -> &mut SoundEngine {
@@ -2613,6 +2708,25 @@ mod tests {
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
}
/// A sound of one frame or none, heard or not, looping or not, stops at its first
/// buffer as the mixing loop stops it: none stays playing (and holds its channel).
#[test]
fn sounds_too_short_to_mix_stop_even_unheard() {
for (samples, looping) in [(vec![], false), (vec![1.0], false), (vec![1.0], true)] {
let mut mixer = Mixer::default();
let mut source = channel(samples.clone(), None, [0.0; 3], looping);
source.gain = 0.0;
mixer.channels.insert(1, source);
let mut data = [0.0; 4];
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(
mixer.channels[&1].state,
State::Stopped,
"{samples:?} {looping}"
);
}
}
#[test]
fn variants_of_one_sound_are_the_same_sound() {
assert_eq!(
+20 -32
View File
@@ -204,23 +204,6 @@ impl Report {
}
}
/// The renderer's adapter ([`crate::gfx::adapter`]), for the hooks above the engine.
pub fn gpu() -> Option<horizon_hooks::Gpu> {
crate::gfx::adapter().map(|a| horizon_hooks::Gpu {
name: a.name,
vendor: a.vendor,
kind: a.kind,
driver: a.driver,
backend: a.backend,
})
}
/// The build, for what leaves the machine: the release (`v0.2.3`), or `dev <crate version>` for a
/// build from source.
pub fn build() -> String {
release_tag().unwrap_or_else(|| format!("dev {}", env!("CARGO_PKG_VERSION")))
}
/// The release this binary is (`v0.2.3`); a nightly's label (`Nightly 2026-10-28 · 0c02067`); for a
/// release from before 0.2, its tag and the client's crate version; for a build from source, that
/// version alone.
@@ -400,20 +383,19 @@ fn thread_name(side: &str) -> String {
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
/// The hooks above the engine hear the report before it is saved (`Hooks::crashed`; Horizon's
/// opt-in telemetry keeps it). They run inside the crash handler: they must not panic or block.
fn tell_hooks(home: &Path, report: &Report) {
let version = build();
let gpu = gpu();
horizon_hooks::engine::crashed(&horizon_hooks::Crash {
home,
version: &version,
error: &report.error,
stack: &report.stack,
thread: &report.thread,
details: &report.details,
gpu: gpu.as_ref(),
});
/// What runs on each report before it is saved ([`on_report`]).
pub type ReportHook = fn(&Path, &Report);
static REPORT_HOOKS: std::sync::RwLock<Vec<ReportHook>> = std::sync::RwLock::new(Vec::new());
/// Runs `hook` with every crash report, before it is saved: the game folder and the report. An
/// extension point for what lives above the engine (Horizon's opt-in telemetry keeps the
/// report). A hook runs inside the crash handler: it must not panic or block.
pub fn on_report(hook: ReportHook) {
REPORT_HOOKS
.write()
.unwrap_or_else(|e| e.into_inner())
.push(hook);
}
/// Writes the report and tells where it is; the log gets the error too.
@@ -436,7 +418,13 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
),
);
crate::logging::flush();
tell_hooks(home, &report);
for hook in REPORT_HOOKS
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
{
hook(home, &report);
}
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
Ok(path) => {
@@ -569,6 +569,25 @@ pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
fn field(
&mut self,
root: horizon_polytone::expr::Root,
field: horizon_polytone::expr::Field,
) -> Option<horizon_polytone::expr::Value> {
use horizon_polytone::expr::Field as F;
if root != horizon_polytone::expr::Root::Object {
return None;
}
Some(horizon_polytone::expr::Value::Num(match field {
F::X => f64::from(self.at[0]),
F::Y => f64::from(self.at[1]),
F::Z => f64::from(self.at[2]),
F::Temperature => f64::from(self.biome.temperature),
F::Downfall => f64::from(self.biome.downfall),
_ => return None,
}))
}
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::Root;
horizon_polytone::expr::Value::Object(match root {
@@ -112,7 +112,11 @@ fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
};
Row::new(id, lang::get(label), value)
}
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
added::Row::Button {
label,
active: true,
..
} => Row::new(id, lang::get(label), Value::Press),
added::Row::Key { label, key } => {
let key = crate::keybinds::display_name(&key).plain();
Row::new(id, lang::get(label), Value::Read(key))
@@ -490,9 +494,7 @@ mod tests {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Button {
label: "test.elsewhere.button",
});
rows.push(added::Row::button("test.elsewhere.button"));
}
fn act(&self, _: usize, _: Act) {}
}
+53
View File
@@ -233,6 +233,8 @@ pub struct FogInputs<'a> {
pub night_vision: f32,
/// The packs' `environment` fog settings (Polytone's).
pub environment: crate::pack_palette::Environment,
/// The packs' fluid modifiers' fog.
pub fluid_fog: crate::pack_palette::FluidFogs,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -397,6 +399,24 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
}
}
}
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
let fluid = match input.fog_type {
FogType::Lava => input.fluid_fog.lava,
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
input.fluid_fog.water
}
_ => None,
};
if let Some(f) = fluid {
if let Some(rgb) = f.color {
let [r, g, b] = self::rgb(rgb);
fog.color = [r, g, b, 1.0];
}
let end = fog.environmental_end * f.radius;
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
(fog.environmental_start, fog.environmental_end) = (start, end);
(fog.sky_end, fog.cloud_end) = (end, end);
}
let span = (rd / 10.0).clamp(4.0, 64.0);
fog.render_distance_start = rd - span;
fog.render_distance_end = rd;
@@ -495,6 +515,7 @@ mod tests {
full_water_vision: false,
night_vision: 0.0,
environment: Default::default(),
fluid_fog: Default::default(),
}
}
#[test]
@@ -611,6 +632,38 @@ mod tests {
let kept = setup_fog(&tables, &state, &input).color;
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
}
#[test]
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
let sky = Sky {
fog_start: 10.0,
fog_end: 96.0,
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&sky);
input.fog_type = FogType::Lava;
input.fire_resistance = true;
let vanilla = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(
(vanilla.environmental_start, vanilla.environmental_end),
(0.0, 5.0)
);
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
color: Some(0x00FF00),
radius: 2.0,
fade: 0.5,
});
let fog = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
// The end doubles; half the fade from 0 to 10 starts it at 5.
assert_eq!(
(fog.environmental_start, fog.environmental_end),
(5.0, 10.0)
);
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: crate::pack_palette::Environment,
+40 -107
View File
@@ -201,6 +201,7 @@ pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
mod hold;
pub use hold::WorldHold;
pub use output::{DisplayReading, EffectiveOutput, FrameConfiguration, FrameRequest};
pub use screenshot_save::Saved as SavedScreenshot;
pub mod block_light;
mod custom_sky;
mod displace;
@@ -236,6 +237,7 @@ pub mod panorama;
mod readback;
mod resident;
mod reuse;
mod screenshot_save;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
@@ -1084,6 +1086,9 @@ pub struct ScreenshotRequest {
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
/// Written on a thread of its own (F2's, the world icon's): its outcome comes back a
/// frame or a few later in `Renderer::saved_screenshots`, not `screenshot_result`.
pub background: bool,
}
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
/// for the frames HDR is off, and with it where the display offers HDR output: the
@@ -1578,6 +1583,9 @@ pub struct Renderer {
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
pub screenshot_hdr: Option<std::path::PathBuf>,
/// `background` screenshots written since the caller last took them.
pub saved_screenshots: Vec<SavedScreenshot>,
screenshot_saves: screenshot_save::Saves,
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
/// times the game's resolution (1, 2, 4 or 8).
pub screenshot_quality: u32,
@@ -5077,6 +5085,7 @@ impl Renderer {
));
}
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
let screenshot_saves = screenshot_save::Saves::new(&device);
Ok(Self {
display: None,
reading: display,
@@ -5348,6 +5357,8 @@ impl Renderer {
screenshot: None,
screenshot_result: None,
screenshot_hdr: None,
saved_screenshots: Vec::new(),
screenshot_saves,
screenshot_quality: 1,
screenshot_halvings: 0,
gamma: 0.5,
@@ -7041,8 +7052,10 @@ impl Renderer {
}
}
/// This renderer's output as applied, with what its destination offers and reports.
pub fn output(&self) -> &output::EffectiveOutput {
&self.output
pub fn output(&self) -> output::EffectiveOutput {
let native = self.native.as_ref().map(|n| n.frame.api);
self.output
.with_pack(self.shader_pack_kind() == Some("iris"), native)
}
/// What this renderer's display last reported: a renderer made again for the window
/// starts from it (`Renderer::new`'s `display`).
@@ -7975,6 +7988,8 @@ impl Renderer {
if self.device_lost() {
return Err("the graphics device was lost".into());
}
let saved = self.screenshot_saves.finished();
self.saved_screenshots.extend(saved);
if let Some(request) = self.capture.take() {
let result = self.write_capture(&request, frame);
self.capture_result = Some(result.map_err(|e| e.to_string()));
@@ -8319,9 +8334,7 @@ impl Renderer {
let taa = !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
let hdr_lighting = self.native.is_none()
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
let hdr_lighting = self.shader_pack_kind().is_none();
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting && hdr.look;
}
@@ -12044,24 +12057,18 @@ impl Renderer {
self.screenshot = Some(request);
} else {
self.screenshot_halvings = 0;
self.screenshot_hdr = None;
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
self.screenshot_hdr = Some(hdr_path);
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
let size = [self.config.width, self.config.height];
let hdr = hdr_readback
.filter(|_| self.hdr.is_some())
.map(|b| (hdr_screenshot_path(&request.path), b));
let shot = screenshot_save::Shot::new(&request, size, self.config.format, hdr);
if request.background {
self.screenshot_saves.start(shot, buffer);
} else {
let saved = screenshot_save::save_now(&self.device, shot, &buffer);
self.screenshot_hdr = saved.hdr;
self.screenshot_result = Some(saved.result);
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
}
// The frame's own waits for the submit thread (its queue writes and passes).
@@ -12664,96 +12671,22 @@ impl Renderer {
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
self.screenshot_result = Some(Err(format!(
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
let (sender, receiver) = std::sync::mpsc::channel();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = sender.send(r);
});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string())?;
receiver
.recv()
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let (width, height, row) = (
self.config.width as usize,
self.config.height as usize,
padded_row(self.config.width) as usize,
);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
let bgra = matches!(
self.config.format.remove_srgb_suffix(),
wgpu::TextureFormat::Bgra8Unorm
);
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
for px in self.read_frame(buffer)? {
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match request.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, self.config.width, self.config.height, size),
),
None => (self.config.width, self.config.height, pixels),
};
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
width,
height,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header()?.write_image_data(&pixels)?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
if self.hdr.is_none() {
return Ok(());
}
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
let mut info = png::Info::with_size(self.config.width, self.config.height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let encoder =
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
let mut writer = encoder.write_header()?;
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)?;
Ok(())
}
}
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
+1 -1
View File
@@ -191,7 +191,7 @@ pub struct Hdr {
pub masks: Masks,
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
/// and with HDR Lighting (`look`) off.
pub lighting: bool,
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
/// sun included, so the picture is SDR's on the HDR canvas.
@@ -144,6 +144,9 @@ pub struct EffectiveOutput {
pub(crate) nits: [f32; 3],
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
pub(crate) ui_brightness: f32,
/// The running shader pack lights the world itself: Horizon lifts none of it, so HDR
/// Lighting has nothing to switch (`with_pack`).
pub(crate) pack_lights: bool,
}
impl Default for EffectiveOutput {
fn default() -> Self {
@@ -166,7 +169,22 @@ impl EffectiveOutput {
},
nits: [203.0, 1000.0, 0.0],
ui_brightness: 1.0,
pack_lights: false,
};
/// This output under the running shader pack: a `shaders/` one (`shaders`), or one of
/// Horizon's own of API `native`. Under a 0.1 pack on the 8- or 16-bit frame the last pass
/// still lifts the light sources and the sun its marks hold (`lifts_world` in
/// `Renderer::render`); under the rest Horizon lifts nothing.
pub(crate) fn with_pack(self, shaders: bool, native: Option<horizon_shader_api::Api>) -> Self {
let pack_lights = shaders
|| native.is_some_and(|api| {
api != horizon_shader_api::Api::V0_1 || self.frame.format == super::LINEAR_FRAME
});
Self {
pack_lights,
..self
}
}
/// Whether the destination offers HDR output.
pub fn hdr_offered(&self) -> bool {
self.offered.is_some()
@@ -273,6 +291,7 @@ pub(crate) fn resolve_output(
],
ui_brightness: settings.ui_nits(reading.luminance) as f32
/ settings.paper_white_nits(reading.luminance) as f32,
pack_lights: false,
}
}
@@ -0,0 +1,79 @@
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
//! extra, docs/layers.md), the device is opened exactly as before.
/// The ray query feature.
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
fn requested() -> bool {
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
}
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
/// build.
fn asked(var: Option<&str>) -> bool {
cfg!(feature = "horizon-extras") && var == Some("1")
}
/// The features to request on `adapter`: ray queries, when asked for and offered.
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
let features = pick(requested(), adapter.features());
if requested() {
let info = adapter.get_info();
if features.is_empty() {
log::warn!(
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
queries; it stays off",
info.name,
info.backend
);
} else {
log::info!(
"Horizon renderer: ray queries on ({} on {:?})",
info.name,
info.backend
);
}
}
features
}
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
if requested {
offered & RAY_QUERY
} else {
wgpu::Features::empty()
}
}
/// The device's opt-in to `features`' experimental ones.
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
if features.contains(RAY_QUERY) {
horizon_hdr::experimental_features()
} else {
wgpu::ExperimentalFeatures::disabled()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_an_extras_build_asks_for_ray_tracing() {
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
assert!(!asked(None));
assert!(!asked(Some("0")));
}
#[test]
fn ray_queries_only_when_asked_and_offered() {
let all = wgpu::Features::all();
assert_eq!(pick(true, all), RAY_QUERY);
assert!(pick(false, all).is_empty());
assert!(pick(true, all - RAY_QUERY).is_empty());
assert!(!experimental(wgpu::Features::empty()).is_enabled());
}
}
@@ -0,0 +1,321 @@
//! Writing a screenshot's PNG once its frame is read back: on the render thread for the
//! requests that want the result with the frame (an agent's, a `screenshot-sequence`'s),
//! on a thread of its own for F2's and the world icon's (`ScreenshotRequest::background`).
//! The render thread then neither waits for the GPU to finish the frame nor encodes the
//! PNG, which at 4K or a Screenshot Quality above 1x froze the game for a second or more.
use super::{ScreenshotRequest, hdr_icc, hdr10_to_rgb16, padded_row, square_downscale};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
/// What a frame's readback holds and where its PNG goes.
pub(super) struct Shot {
pub path: PathBuf,
/// `ScreenshotRequest::size`: the world icon's square.
pub size: Option<[u32; 2]>,
/// The frame's size, which the readback's rows hold (padded to `padded_row`).
pub frame: [u32; 2],
/// Blue first (`Bgra8Unorm`), swapped to PNG's red first.
pub bgra: bool,
/// The HDR10 copy's readback and its PNG's path, beside the SDR one.
pub hdr: Option<(PathBuf, wgpu::Buffer)>,
}
impl Shot {
pub fn new(
request: &ScreenshotRequest,
frame: [u32; 2],
format: wgpu::TextureFormat,
hdr: Option<(PathBuf, wgpu::Buffer)>,
) -> Self {
Self {
path: request.path.clone(),
size: request.size,
frame,
bgra: format.remove_srgb_suffix() == wgpu::TextureFormat::Bgra8Unorm,
hdr,
}
}
}
/// A written screenshot: its path, its size or the error, and the HDR copy written beside it.
pub struct Saved {
pub path: PathBuf,
pub result: Result<[u32; 2], String>,
pub hdr: Option<PathBuf>,
}
/// Waits for `buffer`'s mapping on the render thread and writes the PNG there.
pub(super) fn save_now(device: &wgpu::Device, shot: Shot, buffer: &wgpu::Buffer) -> Saved {
let (sender, receiver) = channel();
map(buffer, &sender);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &sender);
}
let waited = device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string());
let count = 1 + usize::from(shot.hdr.is_some());
let mapped = waited.and_then(|_| wait_mapped(&receiver, count));
write(shot, buffer, mapped)
}
/// Screenshots being written on their thread.
pub(super) struct Saves {
device: wgpu::Device,
sender: Sender<Saved>,
receiver: Receiver<Saved>,
in_flight: usize,
}
impl Saves {
pub fn new(device: &wgpu::Device) -> Self {
let (sender, receiver) = channel();
Self {
device: device.clone(),
sender,
receiver,
in_flight: 0,
}
}
/// Maps `buffer` (and the HDR copy's), whose copy was just submitted, and writes the
/// PNG on a thread of its own once the GPU has finished the frame.
pub fn start(&mut self, shot: Shot, buffer: wgpu::Buffer) {
let (mapped, on_mapped) = channel();
map(&buffer, &mapped);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &mapped);
}
let done = self.sender.clone();
let path = shot.path.clone();
let spawned = std::thread::Builder::new()
.name("Screenshot writer".into())
.spawn(move || {
let count = 1 + usize::from(shot.hdr.is_some());
let ready = wait_mapped(&on_mapped, count);
let _ = done.send(write(shot, &buffer, ready));
});
match spawned {
Ok(_) => {}
Err(e) => {
let _ = self.sender.send(Saved {
path,
result: Err(format!("no thread to write it: {e}")),
hdr: None,
});
}
}
self.in_flight += 1;
}
/// The screenshots written since the last call. Polls the device so a finished frame's
/// mapping reaches its writer: the render thread never waits for it.
pub fn finished(&mut self) -> Vec<Saved> {
if self.in_flight == 0 {
return Vec::new();
}
let _ = self.device.poll(wgpu::PollType::Poll);
let saved: Vec<Saved> = self.receiver.try_iter().collect();
self.in_flight -= saved.len();
saved
}
}
impl Drop for Saves {
/// A screenshot taken right before quitting is still written whole.
fn drop(&mut self) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while self.in_flight > 0 && std::time::Instant::now() < deadline {
let _ = self.device.poll(wgpu::PollType::Poll);
if let Ok(saved) = self.receiver.recv_timeout(Duration::from_millis(20)) {
log_saved(&saved);
self.in_flight -= 1;
}
}
}
}
fn log_saved(saved: &Saved) {
match &saved.result {
Ok(_) => log::info!("Saved screenshot to {}", saved.path.display()),
Err(e) => log::warn!("Horizon renderer: screenshot {}: {e}", saved.path.display()),
}
}
fn map(buffer: &wgpu::Buffer, mapped: &Sender<Result<(), String>>) {
let mapped = mapped.clone();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = mapped.send(r.map_err(|e| e.to_string()));
});
}
/// `count` mappings done, or the first one that failed. A frame the GPU never finishes
/// (a lost device) gives up after a while instead of keeping its thread.
fn wait_mapped(mapped: &Receiver<Result<(), String>>, count: usize) -> Result<(), String> {
for _ in 0..count {
mapped
.recv_timeout(Duration::from_secs(30))
.map_err(|_| "the frame never came back from the GPU".to_string())??;
}
Ok(())
}
/// The PNG (and the HDR copy's beside it) from the mapped readbacks.
fn write(shot: Shot, buffer: &wgpu::Buffer, mapped: Result<(), String>) -> Saved {
let mut hdr_written = None;
let result = mapped.and_then(|()| {
if let Some((hdr_path, hdr_buffer)) = &shot.hdr {
let pixels = read_rows(hdr_buffer, shot.frame);
match pixels.and_then(|p| write_hdr_png(hdr_path, shot.frame, &p)) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
hdr_written = Some(hdr_path.clone());
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
let pixels = read_rows(buffer, shot.frame)?;
write_png(&shot, &pixels)
});
Saved {
path: shot.path,
result,
hdr: hdr_written,
}
}
/// The pixels `copy_frame` read back into the mapped `buffer`, 4 bytes each, row after
/// row; unmaps it.
fn read_rows(buffer: &wgpu::Buffer, [width, height]: [u32; 2]) -> Result<Vec<[u8; 4]>, String> {
let (width, height, row) = (width as usize, height as usize, padded_row(width) as usize);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_png(shot: &Shot, frame: &[[u8; 4]]) -> Result<[u32; 2], String> {
let [frame_w, frame_h] = shot.frame;
let mut pixels = Vec::with_capacity(frame.len() * 4);
for px in frame {
let (r, b) = if shot.bgra {
(px[2], px[0])
} else {
(px[0], px[2])
};
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match shot.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, frame_w, frame_h, size),
),
None => (frame_w, frame_h, pixels),
};
let file = std::fs::File::create(&shot.path).map_err(|e| e.to_string())?;
let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.and_then(|mut w| w.write_image_data(&pixels))
.map_err(|e| e.to_string())?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_png(
path: &std::path::Path,
[width, height]: [u32; 2],
frame: &[[u8; 4]],
) -> Result<(), String> {
let pixels = hdr10_to_rgb16(frame);
let mut info = png::Info::with_size(width, height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
let written = png::Encoder::with_info(std::io::BufWriter::new(file), info)
.and_then(|encoder| encoder.write_header())
.and_then(|mut writer| {
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)
});
written.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_background_save_writes_the_png_off_the_render_thread() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let dir = std::env::temp_dir().join(format!("horizon-shot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("shot.png");
let [w, h] = [3u32, 2];
// A frame of blue-first pixels, each row padded as `copy_frame` reads them back.
let row = padded_row(w) as usize;
let mut bytes = vec![0u8; row * h as usize];
for y in 0..h as usize {
for x in 0..w as usize {
bytes[y * row + x * 4..][..4].copy_from_slice(&[10 * x as u8, 20, 200, 0]);
}
}
let source = wgpu::util::DeviceExt::create_buffer_init(
&device,
&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::COPY_SRC,
},
);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: bytes.len() as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &buffer, 0, bytes.len() as u64);
queue.submit([encoder.finish()]);
let request = ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
background: true,
};
let mut saves = Saves::new(&device);
saves.start(
Shot::new(&request, [w, h], wgpu::TextureFormat::Bgra8Unorm, None),
buffer,
);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let saved = loop {
if let Some(saved) = saves.finished().pop() {
break saved;
}
assert!(std::time::Instant::now() < deadline, "never written");
std::thread::sleep(Duration::from_millis(5));
};
assert_eq!(saved.path, path);
assert_eq!(saved.result, Ok([w, h]));
let decoder =
png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut out = vec![0; reader.output_buffer_size().unwrap()];
reader.next_frame(&mut out).unwrap();
// Red first, opaque: the last pixel of the first row was [20, 20, 200] blue-first.
assert_eq!(&out[8..12], &[200, 20, 20, 255]);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -5,6 +5,9 @@ use super::{
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
timing,
};
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
#[path = "ray_query.rs"]
mod ray_query;
use std::sync::Arc;
use winit::window::Window;
@@ -134,6 +137,7 @@ pub(super) async fn open_adapter(
let features = timing::GpuTimer::features(adapter)
| cull::Cull::features(adapter)
| oit::features(adapter)
| ray_query::features(adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
@@ -166,6 +170,7 @@ pub(super) async fn open_adapter(
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
memory_hints: wgpu::MemoryHints::MemoryUsage,
experimental_features: ray_query::experimental(features),
..Default::default()
};
#[cfg(not(target_os = "macos"))]
@@ -1110,7 +1110,8 @@ impl Item {
/// active; saved values wait for it. Translucent Batching switches where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't. HDR
/// Lighting has nothing to switch where the running shader pack lights the world itself.
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -1141,6 +1142,9 @@ impl Item {
} else {
"Turn HDR on to use it."
}),
Item::Horizon(H::HdrLighting) if out.pack_lights => {
Some("The shader pack lights the world itself.")
}
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
@@ -31,6 +31,49 @@ fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
assert_eq!(item.blocked(&s, &out), Some("Turn HDR on to use it."));
}
#[test]
fn hdr_lighting_is_greyed_only_where_the_running_pack_lights_the_world() {
use horizon_shader_api::Api;
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let item = Item::Horizon(H::HdrLighting);
let lights = Some("The shader pack lights the world itself.");
let out = offering(true, None);
// No pack running, as with one picked but refused, missing or its mod off: live.
assert_eq!(item.blocked(&s, &out.with_pack(false, None)), None);
// A 0.1 pack on the 16-bit frame: the light sources and the sun still shine.
assert_eq!(
item.blocked(&s, &out.with_pack(false, Some(Api::V0_1))),
None
);
assert_eq!(
item.blocked(&s, &out.with_pack(false, Some(Api::V0_2))),
lights
);
assert_eq!(item.blocked(&s, &out.with_pack(true, None)), lights);
let float = EffectiveOutput {
frame: crate::gfx::FrameConfiguration {
format: wgpu::TextureFormat::Rgba16Float,
..out.frame
},
..out
};
assert_eq!(
item.blocked(&s, &float.with_pack(false, Some(Api::V0_1))),
lights
);
// HDR's own reasons come first.
assert_eq!(
item.blocked(&s, &NONE.with_pack(true, None)),
Some("Your display or system doesn't offer HDR output.")
);
s.horizon.hdr = HdrMode::Off;
assert_eq!(
item.blocked(&s, &out.with_pack(true, None)),
Some("Turn HDR on to use it.")
);
}
#[test]
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
let mut s = Settings::default();
@@ -1,6 +1,7 @@
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
//! terrain fragment: native mods' (`<ns>:emissive/...`) and a pack's emissive block textures
//! (OptiFine's `block/stone_e.png` beside `block/stone.png`, the suffix from the pack formats,
//! [`crate::pack_hooks`]). No extra draw calls or world light changes.
use crate::sprites::Sprites;
use std::{collections::BTreeMap, path::Path};
@@ -35,14 +36,12 @@ fn tile_file(name: &str) -> Option<&str> {
name.strip_suffix(".png")
}
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// the table). A pack without such files adds nothing.
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
let suffix = horizon_optifine::emissive::suffix(root);
/// A pack's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each `<ns>:block/<name>`, or connected-texture tile
/// `assets/<ns>/.../<name>.png`, whose `<name><suffix>.png` sits beside it, the overlay is loaded
/// too and paired with it, blocks first, then tiles, each by name, at most [`MAX`] - [`NATIVE`]
/// (native mods keep their share of the table). A pack without such files adds nothing.
pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(String, String)> {
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
// base would glow on every block that shares it, so it gets no overlay.
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
@@ -56,8 +55,8 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
sharing[layer] == 1
&& (name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(suffix)))
})
.map(|(name, _)| name.clone())
.collect();
@@ -110,7 +109,7 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
pub const CUTOUT: u32 = 1 << 31;
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_overlays`] loads),
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
pub fn table<'a>(
layers: &BTreeMap<String, u32>,
@@ -204,7 +203,7 @@ mod tests {
// Items draw from the HUD atlas, not the terrain: no pair.
sprites.insert(&root, "minecraft:item/stick").unwrap();
// glowstone isn't loaded: its overlay pairs with nothing.
let pairs = load_optifine(&root, &mut sprites);
let pairs = load_overlays(&root, "_e", &mut sprites);
assert_eq!(
pairs,
[(
@@ -215,9 +214,6 @@ mod tests {
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
// The pack's own suffix.
let props = root.join("assets/minecraft/optifine/emissive.properties");
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
// Connected-texture tiles pair with the overlay beside them.
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
png(&tiles.join("0.png"), 230);
@@ -234,7 +230,7 @@ mod tests {
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
.unwrap();
}
let pairs = load_optifine(&root, &mut sprites);
let pairs = load_overlays(&root, "_glow", &mut sprites);
assert_eq!(
pairs,
// Blocks first, then tiles.
@@ -162,10 +162,38 @@ impl OptionsUi {
};
let mut rows = Vec::new();
registered.rows(&mut rows);
rows.into_iter()
.enumerate()
.map(|(r, row)| self.added_row(page, r, row))
.collect()
let mut out = Vec::new();
// The row a half-width button started, waiting for the next to sit beside it.
let mut half_open = false;
for (r, row) in rows.into_iter().enumerate() {
match row {
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
added::Row::Text { text, .. } => {
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
}
added::Row::Space(_) => continue,
added::Row::Button {
label,
active,
half: true,
..
} => {
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
if !active {
half = inactive(half);
}
match out.last_mut() {
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
_ => out.push(Row::Buttons(half, None)),
}
half_open = !half_open;
continue;
}
row => out.push(self.added_row(page, r, row)),
}
half_open = false;
}
out
}
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
@@ -177,7 +205,10 @@ impl OptionsUi {
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
added::Row::Button { label } => wide(tr(label)),
added::Row::Button { label, active, .. } => {
let wide = button(0.0, 0.0, 310.0, tr(label), act);
Row::Buttons(if active { wide } else { inactive(wide) }, None)
}
added::Row::Key { label, key } => {
let mut message = keybinds::display_name(&key);
if self.selected_key == Some(PAGE_KEY)
@@ -461,4 +492,84 @@ mod tests {
Some(tr("test.sample.switch.tooltip").plain())
);
}
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
struct Shaped(Mutex<Vec<usize>>);
impl added::Page for Shaped {
fn id(&self) -> &'static str {
"test.shaped"
}
fn title(&self) -> &'static str {
"test.shaped.title"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
rows.push(added::Row::text("grey").quiet());
rows.push(added::Row::Space(6));
rows.push(added::Row::button("test.shaped.copy").half());
rows.push(added::Row::button("test.shaped.stop").half().active(false));
rows.push(added::Row::button("test.shaped.help").footer());
}
fn act(&self, row: usize, _: added::Act) {
self.0.lock().unwrap().push(row);
}
}
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
#[test]
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
let mut s = Settings::default();
TEST_PAGES.set(Some(&SHAPED_PAGES));
let ui = OptionsUi::default();
let rows = ui.added_rows(0);
// The two texts as notes (space shows nothing here), the halves on one row, the
// footer button below them (the settings shell has no footer of its own).
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
panic!("the halves sit side by side: {:?}", rows.len());
};
assert_eq!((copy.w, stop.w), (150.0, 150.0));
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
assert_eq!(rows.len(), 4);
let mut ui = ui;
ui.show(Page::Added(0));
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
let stop = widgets
.iter()
.find(|w| {
matches!(
&w.kind,
WKind::Button {
act: Act::PageRow(0, 4),
..
}
)
})
.expect("Stop is drawn");
let atlas = super::super::tests::atlas();
ui.click(
&mut s,
&atlas,
stop.x + 1.0,
stop.y + 1.0,
(480.0, 240.0),
[480, 240],
false,
false,
);
assert_eq!(
*SHAPED.0.lock().unwrap(),
[3, 5],
"the inactive half heard nothing"
);
}
}
@@ -7,6 +7,14 @@ use horizon_polytone::colors::PackColors;
use crate::block_entity_render::DYES;
use crate::pack_palette::{self, Environment};
fn fluid(fog: horizon_polytone::colors::FluidFog) -> pack_palette::FluidFog {
pack_palette::FluidFog {
color: fog.color,
radius: fog.radius,
fade: fog.fade,
}
}
/// The colours of every namespace's file under the pack overlay at `root`.
pub fn read(root: &Path) -> pack_palette::PackColors {
let colors = PackColors::read(root);
@@ -29,6 +37,10 @@ pub fn read(root: &Path) -> pack_palette::PackColors {
water_fog_brightening_time: env.water_fog_brightening_time,
horizon_height: env.horizon_height,
},
fluid_fog: pack_palette::FluidFogs {
water: colors.fluid_fog.water.map(fluid),
lava: colors.fluid_fog.lava.map(fluid),
},
}
}
@@ -20,6 +20,10 @@ impl PackFormats for Formats {
Some(colors::read(root))
}
fn emissive_suffix(&self, root: &Path) -> Option<String> {
Some(horizon_optifine::emissive::suffix(root))
}
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
let sky = custom_sky::CustomSky::load(root);
(!sky.is_empty()).then(|| Arc::new(sky) as _)
@@ -15,6 +15,12 @@ pub trait PackFormats: Send + Sync {
None
}
/// The name ending of the overlay's emissive textures (`stone_e.png` beside `stone.png`),
/// if its format has them.
fn emissive_suffix(&self, _root: &Path) -> Option<String> {
None
}
/// The sky layers the overlay draws over the vanilla sky, if it has any.
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
None
@@ -59,6 +59,24 @@ pub struct PackColors {
pub xp_bar: [Option<u32>; 2],
/// The effect fogs' settings: the fog reads them every frame.
pub env: Environment,
/// The fog the packs' fluid modifiers give water and lava.
pub fluid_fog: FluidFogs,
}
/// A fluid modifier's fog: a flat colour (RGB), and the fog's radius and fade multipliers (1 is
/// vanilla's).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FluidFog {
pub color: Option<u32>,
pub radius: f32,
pub fade: f32,
}
/// The fog the packs' fluid modifiers give water and lava.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct FluidFogs {
pub water: Option<FluidFog>,
pub lava: Option<FluidFog>,
}
impl Default for PackColors {
@@ -72,6 +90,7 @@ impl Default for PackColors {
entity_shadows: true,
xp_bar: [None; 2],
env: Environment::default(),
fluid_fog: FluidFogs::default(),
}
}
}
@@ -801,6 +801,26 @@ impl Host for Ctx<'_, '_> {
_ => Err(format!("{name} is not available here")),
}
}
/// A particle's own fields, as `particle_method` answers them.
fn field(&mut self, root: Root, field: horizon_polytone::expr::Field) -> Option<V> {
use horizon_polytone::expr::Field as F;
let Subject::Particle(p) = &self.subject else {
return None;
};
if root != Root::Object {
return None;
}
Some(V::Num(match field {
F::Age => f64::from(p.age),
F::Life => f64::from(p.lifetime),
F::Custom => *self.custom,
F::X => p.position[0],
F::Y => p.position[1],
F::Z => p.position[2],
F::Temperature | F::Downfall => return None,
}))
}
}
fn eval(program: &Program, ctx: &mut Ctx) -> Option<f64> {
@@ -337,6 +337,13 @@ impl Settings {
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
.map_or(-1, |i| i as i32)
}
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
/// settings were made from P1's rather than loaded.
pub fn load_stores(path: &Path) {
let dir = path.parent();
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
}
pub fn load(path: &Path) -> Self {
let mut horizon = path
.parent()
@@ -913,6 +913,20 @@ pub trait Backend {
fn unpause(&mut self, channel: ChannelId);
/// `Channel.stopped`.
fn stopped(&mut self, channel: ChannelId) -> bool;
/// Update volume, pitch, and position in a single backend call.
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
self.set_volume(channel, volume);
self.set_pitch(channel, pitch);
self.set_position(channel, position);
}
/// Filter which handles have stopped playing in a single pass.
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
for &(h, c) in live {
if self.stopped(c) {
stopped.push(h);
}
}
}
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
/// event is one the mod exempted. A backend without a muffle ignores it.
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
@@ -1494,13 +1508,14 @@ impl SoundEngine {
.iter()
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
);
for (h, c) in live.drain(..) {
if backend.stopped(c) {
self.release_handle(h, backend);
}
let mut stopped = std::mem::take(&mut self.mapped_scratch);
stopped.clear();
backend.filter_stopped(&live, &mut stopped);
for h in stopped.drain(..) {
self.release_handle(h, backend);
}
// Released handles nobody maps any more.
let mut mapped = std::mem::take(&mut self.mapped_scratch);
let mut mapped = stopped;
mapped.clear();
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
self.handles
@@ -1550,9 +1565,7 @@ impl SoundEngine {
h.pitch_edited = pitch_edited;
}
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
backend.set_volume(c, volume);
backend.set_pitch(c, pitch);
backend.set_position(c, position);
backend.update_channel(c, volume, pitch, position);
}
let instance = self.instances.get_mut(&id).expect("present");
if instance.looping
+2 -7
View File
@@ -51,7 +51,7 @@ pub fn enabled() -> bool {
}
/// Record this thread's spans even with spans off, their wall time only (no CPU clock read):
/// what the render thread's slow frames spent their time in, for the opt-in performance
/// statistics (`play_session::Session::frame`). The thread takes them every frame ([`take`]).
/// statistics (`telemetry::Session::frame`). The thread takes them every frame ([`take`]).
pub fn record_here(on: bool) {
HERE.with(|h| h.set(on));
}
@@ -466,7 +466,7 @@ pub mod startup {
Mutex, OnceLock,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::{Duration, Instant},
time::Instant,
};
static ON: AtomicBool = AtomicBool::new(false);
@@ -489,11 +489,6 @@ pub mod startup {
ON.store(sink.is_some(), Ordering::Relaxed);
}
/// The time since the process started ([`init`]'s origin; zero before it).
pub fn since_start() -> Duration {
ORIGIN.get().map_or(Duration::ZERO, Instant::elapsed)
}
/// Whether a trace is being written.
#[inline]
pub fn on() -> bool {
+23 -1
View File
@@ -1251,6 +1251,9 @@ pub struct EditBox {
/// `getMessage`: what the narrator calls the box ("<message> edit box:
/// <value>").
pub message: String,
/// `EditBox.setInvertHighlightedTextColor`: a selection draws its text
/// inverted (the default) or in the box's own colour (`AnvilScreen`).
pub invert_highlighted_text_color: bool,
/// Where a borderless box drawn in a frame of its own focuses: a controller's outline goes
/// round the frame, not over the text.
pub frame: Option<[f32; 4]>,
@@ -1543,6 +1546,7 @@ impl EditBox {
text_color_uneditable: rgba(112, 112, 112, 255),
editable: true,
message: String::new(),
invert_highlighted_text_color: true,
frame: None,
}
}
@@ -1918,7 +1922,11 @@ impl EditBox {
let right = self.x + self.width;
let (x0, x1) = (cursor_x.min(right), (hx - 1.0).min(right));
let (a, b) = (x0.min(x1), x0.max(x1));
batch.text_highlight(atlas, (a, ty - 1.0, b - a, 11.0), true);
batch.text_highlight(
atlas,
(a, ty - 1.0, b - a, 11.0),
self.invert_highlighted_text_color,
);
}
if show_cursor {
if insert {
@@ -2628,6 +2636,20 @@ mod tests {
);
}
/// `EditBox.setInvertHighlightedTextColor`: a selection inverts its text
/// unless the box asks it not to (the anvil's rename box).
#[test]
fn a_selection_inverts_its_text_unless_the_box_says_not_to() {
let mut edit = EditBox::new(0.0, 0.0, 100.0, 20.0);
assert!(edit.invert_highlighted_text_color);
edit.invert_highlighted_text_color = false;
edit.set_value("name");
edit.set_cursor(0, false);
edit.set_cursor(4, true);
assert_eq!(edit.selected_text(), "name");
assert!(!edit.invert_highlighted_text_color);
}
/// `EditBox.setEditable(false)`: the box keeps the focus, the caret keys
/// and copy, but `charTyped`/paste/Backspace/Delete do nothing, and Cut
/// copies without taking the text out. A value and the caret still show,
+6 -6
View File
@@ -68,11 +68,10 @@ log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# The official server's address (multiplayer.rs) until it moves to the Horizon layer. Telemetry,
# the "I'm playing" ping and the benchmark are Horizon parts the official executable registers.
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md). The
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
# The machine and process probes (the dedicated server's performance report).
horizon-system = { path = "../horizon-system" }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
@@ -120,10 +119,11 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
@@ -287,6 +287,7 @@
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
"Turn HDR on to use it.": "Turn HDR on to use it.",
"The shader pack lights the world itself.": "The shader pack lights the world itself.",
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
@@ -5,54 +5,16 @@
//!
//! The scenes, the world (seed 1234, creative) and every step and figure match `tools/perf_bench.py`,
//! so reports from the two can be compared.
//!
//! Behind the `game` feature: the game starts it through the platform's hooks ([`HOOKS`],
//! `horizon_platform::hooks`), which the official executable registers (`horizon_official::PARTS`).
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Sender};
use std::thread;
use std::time::{Duration, Instant};
use horizon_platform::hooks::{self, Hooks};
use horizon_bench::{App, Frames, Kind, Report};
use serde_json::Value;
use crate::{App, Frames, Kind, Report};
/// The benchmark's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: Benchmark = Benchmark;
/// Runs `--benchmark SCENARIO`.
pub struct Benchmark;
impl Hooks for Benchmark {
fn knows_benchmark(&self, scenario: &str) -> bool {
scenes(scenario).is_some()
}
fn benchmark(&self, run: &hooks::Benchmark<'_>, control: hooks::Control) {
start(
control,
Setup {
scenario: run.scenario.to_owned(),
started: run.started,
app: App {
name: "horizon-client".into(),
version: run.version.to_owned(),
commit: option_env!("HORIZON_COMMIT").map(str::to_owned),
mods: Vec::new(),
},
simulation_distance: run.simulation_distance,
vsync: false,
fps_cap: None,
offscreen: run.offscreen,
game_dir: run.game_dir.to_owned(),
home: run.home.to_owned(),
build: run.build.to_owned(),
},
);
}
}
use crate::control::Request;
pub const SEED: i64 = 1234;
/// The world the benchmark creates under `saves/`, replaced on every run.
@@ -111,18 +73,14 @@ pub struct Setup {
pub fps_cap: Option<u32>,
pub offscreen: bool,
pub game_dir: PathBuf,
/// The game's home folder: the telemetry outbox.
pub home: PathBuf,
/// The build, as an upload names it (`horizon_platform::hooks::Crash::version`).
pub build: String,
}
/// Starts the driver; the game quits when it is done, and the report's path is printed.
pub fn start(control: hooks::Control, setup: Setup) {
pub fn start(requests: Sender<Request>, setup: Setup) {
thread::Builder::new()
.name("horizon-benchmark".into())
.spawn(move || {
let control = Control(control);
let control = Control(requests);
match run(&control, setup) {
Ok(path) => println!("Horizon: benchmark report {}", path.display()),
Err(e) => eprintln!("Horizon: benchmark failed: {e}"),
@@ -132,12 +90,21 @@ pub fn start(control: hooks::Control, setup: Setup) {
.expect("spawn the benchmark thread");
}
struct Control(hooks::Control);
struct Control(Sender<Request>);
impl Control {
fn call(&self, line: &str) -> Result<Value, String> {
let (reply, response) = mpsc::channel();
self.0
.send(Request {
line: line.to_owned(),
reply,
})
.map_err(|_| "the game has exited".to_owned())?;
// Flights and loads reply when they finish.
let text = (self.0)(line, LOAD_LIMIT).ok_or_else(|| format!("{line}: no reply"))?;
let text = response
.recv_timeout(LOAD_LIMIT)
.map_err(|_| format!("{line}: no reply"))?;
let value: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
match value.get("error").and_then(Value::as_str) {
Some(e) => Err(format!("{line}: {e}")),
@@ -187,8 +154,8 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
let mut report = Report::new(Kind::Game, setup.app.clone());
let pid = std::process::id();
report.conditions.load_avg_1m = crate::load_average();
report.conditions.power = crate::power_source().map(str::to_owned);
report.conditions.load_avg_1m = horizon_bench::load_average();
report.conditions.power = horizon_bench::power_source().map(str::to_owned);
let rss = RssSampler::start(pid);
let title = poll(control, LOAD_LIMIT, |s| {
@@ -196,7 +163,7 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
})?;
report.metric("startup.title_ms", &[ms(setup.started.elapsed())]);
let renderer = &title["renderer"];
report.hardware.gpu = Some(crate::Gpu {
report.hardware.gpu = Some(horizon_bench::Gpu {
name: text(&renderer["gpu"]),
vendor: match text(&renderer["vendor"]) {
// Metal reports no vendor ID.
@@ -209,7 +176,7 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
vram_mb: None,
});
if let Some(gpu) = report.hardware.gpu.as_mut() {
let vendor = horizon_platform::system::gpu_vendor(&gpu.name, 0);
let vendor = horizon_telemetry::system::gpu_vendor(&gpu.name, 0);
let vendor = if gpu.vendor.is_empty() {
vendor
} else {
@@ -217,10 +184,11 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
}
.to_owned();
if gpu.kind.contains("iscrete") {
gpu.vram_mb = horizon_platform::system::vram_mb(&vendor);
gpu.vram_mb = horizon_telemetry::system::vram_mb(&vendor);
}
}
report.hardware.storage = horizon_platform::system::storage(&setup.game_dir).map(str::to_owned);
report.hardware.storage =
horizon_telemetry::system::storage(&setup.game_dir).map(str::to_owned);
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
replace_world(&setup.game_dir)?;
@@ -343,7 +311,7 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
if let Ok(meta) = std::env::current_exe().and_then(std::fs::metadata) {
report.metric("game.binary_bytes", &[meta.len() as f64]);
}
keep_for_upload(&setup.home, &setup.build, &report);
crate::telemetry::keep_benchmark(&crate::logging::game_home(), &report);
report
.write_to(&setup.game_dir.join("benchmarks"))
.map_err(|e| e.to_string())
@@ -488,7 +456,7 @@ impl RssSampler {
while let Err(mpsc::RecvTimeoutError::Timeout) =
stopped.recv_timeout(Duration::from_millis(250))
{
samples.extend(crate::rss_bytes(pid).map(|b| (Instant::now(), b)));
samples.extend(horizon_bench::rss_bytes(pid).map(|b| (Instant::now(), b)));
}
samples
});
@@ -500,80 +468,10 @@ impl RssSampler {
}
}
/// Keeps the report for the launcher to send, if the player allows benchmark uploads: the report
/// as docs/benchmark-report.md defines it, dated to the day (docs/telemetry.md).
fn keep_for_upload(home: &Path, build: &str, report: &Report) {
use horizon_telemetry::{Item, Payload, Source, outbox};
let Ok(data) = serde_json::to_value(report.for_upload()) else {
return;
};
let item = Item::new(Source::Game, build.to_owned(), Payload::Benchmark(data));
if let Err(e) = outbox::push(home, &item) {
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A control line and where its reply goes, as the game's command queue takes them.
struct Request {
line: String,
reply: Sender<String>,
}
/// A control that hands each line to `requests`, as the game does.
fn control(requests: Sender<Request>) -> Control {
Control(Box::new(move |line, wait| {
let (reply, response) = mpsc::channel();
let request = Request {
line: line.to_owned(),
reply,
};
requests.send(request).ok()?;
response.recv_timeout(wait).ok()
}))
}
#[test]
fn reports_kept_for_upload_need_their_own_consent_and_go_dated_to_the_day() {
use horizon_telemetry::{Choices, Consent, outbox};
let home = std::env::temp_dir().join(format!(
"horizon-client-benchmark-upload-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let allow = |choices: Choices| {
let mut consent = Consent::load(&home);
consent.answer(choices);
consent.save(&home).unwrap();
};
let mut report = Report::new(Kind::Game, Default::default());
report.created = "2026-10-15T14:03:27Z".into();
report.label = Some("my machine".into());
keep_for_upload(&home, "0.2.31", &report);
allow(Choices {
performance: true,
..Choices::NONE
});
keep_for_upload(&home, "0.2.31", &report);
assert!(outbox::pending(&home).is_empty());
allow(Choices {
benchmarks: true,
..Choices::NONE
});
keep_for_upload(&home, "0.2.31", &report);
let pending = outbox::pending(&home);
assert_eq!(pending.len(), 1);
let text = serde_json::to_string(&pending[0].1).unwrap();
assert!(
text.contains("2026-10-15T00:00:00Z") && !text.contains("my machine"),
"{text}"
);
}
#[test]
fn scenarios() {
assert_eq!(scenes("standard").unwrap().len(), 4);
@@ -626,7 +524,7 @@ mod tests {
}
}
});
let control = control(tx);
let control = Control(tx);
wait_loaded(&control, |_| true).expect("the scene loads");
assert!(
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
@@ -658,15 +556,15 @@ mod tests {
}
lines
});
let hardware = crate::Hardware {
gpu: Some(crate::Gpu {
let hardware = horizon_bench::Hardware {
gpu: Some(horizon_bench::Gpu {
backend: backend.into(),
..Default::default()
}),
..Default::default()
};
let mut report = Report::with_hardware(Kind::Game, App::default(), hardware);
let control = control(tx);
let control = Control(tx);
let timed = time_passes(&control, "forest", &mut report).expect("the wait runs");
drop(control);
(timed, server.join().unwrap(), report.metrics)
+3 -3
View File
@@ -33,6 +33,7 @@ pub mod attribute_updates;
pub mod auto_jump;
pub mod banner;
pub mod beehive;
pub mod benchmark;
pub mod block_entity_item;
pub mod block_pattern;
pub mod block_transform;
@@ -101,15 +102,12 @@ pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
#[cfg(feature = "horizon-extras")]
pub mod official_servers;
pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod play_session;
pub mod player_sync;
pub mod portal_sections;
pub mod pose;
@@ -139,6 +137,7 @@ pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod studio;
pub mod telemetry;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
@@ -170,6 +169,7 @@ pub use horizon_client_input::gamepad;
pub use horizon_client_input::gameplay_guide;
pub use horizon_client_input::held;
pub use horizon_client_input::input;
pub use horizon_client_input::input_source;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
+9 -4
View File
@@ -20,11 +20,16 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::options_ui::HORIZON_EXTRAS
.store(false, std::sync::atomic::Ordering::Relaxed);
}
// The process's start, for the startup trace and the start's milestones.
horizon_client::spans::startup::init(std::time::Instant::now());
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Crates above the engine registered their hooks before this (horizon-official's `main`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
horizon_hooks::engine::seal();
// The text crates ship (`horizon_hooks::lang`), loaded with the game's own languages.
horizon_client::lang::install_crate_files(horizon_hooks::lang::engine::all());
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
+42 -209
View File
@@ -373,6 +373,7 @@ mod place_feature_tests;
mod play_input;
mod play_keys;
mod player_equip;
pub(crate) mod player_hooks;
mod player_spawn;
mod pointer_capture;
#[cfg(test)]
@@ -402,6 +403,7 @@ mod remote_vehicles;
mod render_control;
mod render_settings;
mod render_tick;
mod replay;
mod repro;
mod resource_load;
mod resource_loader;
@@ -2801,7 +2803,7 @@ struct Game<'a> {
agent_last: Option<Instant>,
frame_stats: horizon_client::control::FrameStats,
/// This play session's frame and tick times, for opt-in performance statistics.
perf: horizon_client::play_session::Session,
perf: horizon_client::telemetry::Session,
/// Control replies waiting for a rendered frame or a benchmark deadline.
pending_screenshot: Option<std::sync::mpsc::Sender<String>>,
/// `screenshot-sequence` while it runs.
@@ -2882,19 +2884,9 @@ struct Game<'a> {
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
/// service's presence status.
realm_session: bool,
/// Active packet replay session (game/studio.rs).
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
pub(super) studio_timeline: horizon_client::studio::TimelineUi,
pub(super) studio_topbar: horizon_client::studio::TopBarUi,
pub(super) studio_rmb_down: bool,
studio_camera_track_dirty: bool,
/// Camera perspective to restore after leaving replay playback.
studio_replay_previous_camera_type: Option<perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
studio_replay_standalone: bool,
last_replay_tick: Option<Instant>,
pub(super) studio_virtual_tick_ms: f32,
/// Replay playback (game/replay.rs), and Horizon Studio's editor on it (game/studio.rs).
pub(super) replay: replay::Replay,
pub(super) studio: studio::Studio,
/// This world is open to the network (game/lan.rs).
lan: Option<lan::LanHost>,
/// Single player's server thread, which `remote` is connected to.
@@ -5026,7 +5018,7 @@ impl Game<'_> {
if canceled {
return Ok(false);
}
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
self.input_feedback(horizon_client::input_source::Feedback::BlockBroken);
let broken = state;
let facts = self.res.facts().get(state).clone();
let state = self.crack_mined_pot(pos, state);
@@ -5845,7 +5837,7 @@ impl Game<'_> {
batch
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
self.pad.guide_shown.clear();
self.input_hud_begin();
let mut batch = HudBatch::new(size);
batch.glint_colour = self.settings.extras.pvp.tweaks.glint_texel();
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
@@ -5942,8 +5934,7 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
let in_studio = self.studio_replay.is_some();
let show_hud = !self.ui.hide_gui && !in_studio;
let show_hud = !self.ui.hide_gui && !self.replay.playing();
// Hud.extractRenderState asks `Screen.isInGameUi` twice: such a screen
// defers the subtitles over itself, and keeps them while F1 hides the
// rest of the HUD.
@@ -6282,23 +6273,7 @@ impl Game<'_> {
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
}
if !self.ui.hide_gui
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
{
self.studio_topbar.render(
&mut batch,
&self.gui.atlas,
self.cursor,
self.studio_freecam.locked_to_path,
);
self.studio_timeline.render(
&mut batch,
&self.gui.atlas,
self.cursor,
&self.studio_freecam,
);
}
self.draw_studio_bars(&mut batch);
if show_hud {
self.draw_pad_guide(&mut batch, guide_from);
}
@@ -6521,12 +6496,12 @@ impl Game<'_> {
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
if self.studio_freecam.active {
if self.studio.flying() {
return camera_rotation(
perspective::CameraType::FirstPerson,
self.studio_freecam.yaw,
self.studio_freecam.pitch,
) * glam::Quat::from_rotation_z(self.studio_freecam.roll.to_radians());
self.studio.freecam.yaw,
self.studio.freecam.pitch,
) * glam::Quat::from_rotation_z(self.studio.freecam.roll.to_radians());
}
match self.posed_rotation {
// The frame's camera, already turned round in front view.
@@ -6733,11 +6708,7 @@ impl Game<'_> {
self.mark_player(out.len(), !self.own_body_in_view());
let player_start = out.len();
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
let player_alpha = if self.studio_replay.is_some() {
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
} else {
self.clock.camera_fraction()
};
let player_alpha = self.own_player_alpha();
self.player_model_geometry(camera, player_alpha, &mut out);
// A native pack's shadow pass alone gets the first-person model (no per-entity draws).
if self.pack_ids.is_none() && !self.own_body_in_view() {
@@ -7466,7 +7437,6 @@ impl Game<'_> {
self.spawn_phantoms();
self.tick_advancements();
self.tick_player_model();
self.tick_emotes();
Ok(())
}
@@ -9236,7 +9206,7 @@ impl Game<'_> {
let camera_alpha = self.clock.camera_fraction();
let vanilla = self.camera_view(camera_alpha as f32);
let view = vanilla.map(|view| {
if self.shows_panorama() || self.studio_freecam.active {
if self.shows_panorama() || self.studio.flying() {
view
} else {
self.native_camera_pose_now(camera_alpha as f32, view)
@@ -9414,14 +9384,7 @@ impl Game<'_> {
// hitboxes (F3+G, F3+B).
let mut lines = self.fishing_line(alpha, size);
let mut g = self.debug_gizmos(alpha as f32, camera.to_array());
if self.studio_freecam.active || self.studio_replay.is_some() {
horizon_client::studio::render_camera_path(
&self.studio_freecam.track,
&mut g,
None,
100,
);
}
self.studio_camera_path(&mut g);
lines.extend(g.lines);
let on_top = g.on_top;
pr.lap("pr_debug");
@@ -9443,7 +9406,7 @@ impl Game<'_> {
Vec::new()
};
pr.lap("pr_native_visuals");
let view = if self.studio_freecam.active {
let view = if self.studio.flying() {
glam::Mat4::from_quat(self.camera_orientation().conjugate())
} else {
horizon_client::camera::view_matrix(view_yaw, view_pitch)
@@ -9569,7 +9532,7 @@ impl Game<'_> {
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
};
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
let camera_effects = fov::camera_effects_enabled(title, self.studio.flying());
let bob = if !camera_effects {
glam::Mat4::IDENTITY
} else {
@@ -10074,7 +10037,7 @@ impl Game<'_> {
)
)
{
self.pad.keyboard_took_over = true;
self.input_keyboard_used();
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Pressed
@@ -10462,21 +10425,7 @@ impl Game<'_> {
let b = HudBatch::new([r.config.width, r.config.height]);
let previous = self.cursor;
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
if self.screen == Screen::Playing
&& (self.studio_timeline.dragging_playhead
|| self.studio_timeline.dragging_keyframe.is_some())
{
let action = self.studio_timeline.handle_mouse_drag(
self.cursor,
b.width,
&mut self.studio_freecam,
);
if matches!(
action,
horizon_client::studio::TimelineAction::KeyframeMoved
) {
self.studio_camera_track_dirty = true;
}
if self.studio_mouse_dragged(b.width) {
return Ok(());
}
if self.screen_mouse_dragged(previous) {
@@ -10527,7 +10476,7 @@ impl Game<'_> {
}
}
WindowEvent::MouseWheel { .. }
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
if self.screen == Screen::Playing && self.replay.playing() => {}
WindowEvent::MouseWheel { delta, .. } => {
let (raw_x, raw_y) = match delta {
MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
@@ -10804,90 +10753,7 @@ impl Game<'_> {
}
WindowEvent::MouseInput { state, button, .. } => {
let pressed = state == ElementState::Pressed;
if !pressed && button == MouseButton::Left {
let action = self.studio_timeline.handle_mouse_up();
if !matches!(action, horizon_client::studio::TimelineAction::None) {
self.handle_studio_timeline_action(action);
}
}
if (self.studio_freecam.active || self.studio_replay.is_some())
&& button == MouseButton::Right
{
if let Some(renderer) = &self.renderer {
let batch =
HudBatch::new([renderer.config.width, renderer.config.height]);
if self.studio_timeline.visible
&& self.cursor.1 >= batch.height - self.studio_timeline.height()
{
if pressed
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
self.cursor,
batch.width,
batch.height,
&self.studio_freecam,
)
{
if index < self.studio_freecam.track.keyframes.len() {
self.studio_freecam.track.keyframes.remove(index);
self.studio_camera_track_dirty = true;
}
self.studio_timeline.selected_keyframe = None;
}
return Ok(());
}
}
if pressed {
self.studio_topbar.close_dropdown();
self.studio_rmb_down = true;
self.capture(true);
return Ok(());
} else if self.studio_rmb_down {
self.studio_rmb_down = false;
self.capture(false);
return Ok(());
}
}
if pressed
&& button == MouseButton::Left
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
{
let size = self
.renderer
.as_ref()
.map(|renderer| [renderer.config.width, renderer.config.height])
.unwrap_or([1, 1]);
let batch = HudBatch::new(size);
let had_open_menu = self.studio_topbar.open_menu.is_some();
let topbar_action = self.studio_topbar.handle_mouse_click(
self.cursor,
batch.width,
batch.height,
);
if !matches!(topbar_action, horizon_client::studio::TopBarAction::None) {
self.handle_studio_topbar_action(topbar_action);
return Ok(());
}
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
return Ok(());
}
if self.studio_timeline.visible {
let action = self.studio_timeline.handle_mouse_down(
self.cursor,
batch.width,
batch.height,
&self.studio_freecam,
);
if !matches!(action, horizon_client::studio::TimelineAction::None) {
self.handle_studio_timeline_action(action);
return Ok(());
}
if self.cursor.1 >= batch.height - self.studio_timeline.height() {
return Ok(());
}
}
}
if self.screen == Screen::Playing && self.studio_replay.is_some() {
if self.studio_mouse_input(pressed, button) {
return Ok(());
}
if matches!(self.screen, Screen::Inventory | Screen::Creative)
@@ -11357,24 +11223,7 @@ impl Game<'_> {
let between = now.saturating_duration_since(self.frame_end);
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
self.previous = now;
if self.studio_freecam.active
&& self.screen == Screen::Playing
&& !self.dialog_open()
{
let held = |key| f64::from(u8::from(self.keys.raw(&key)));
let forward = held(KeyCode::KeyW) - held(KeyCode::KeyS);
let strafe = held(KeyCode::KeyD) - held(KeyCode::KeyA);
let vertical =
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
let fast = self.keys.raw(&KeyCode::ControlRight);
self.studio_freecam.update_flight_motion(
forward,
strafe,
vertical,
fast,
elapsed.as_secs_f64().min(0.1),
);
}
self.studio_fly(elapsed);
// Arrow-key camera controls belong to gameplay; chat and
// other screens use the same keys for their own navigation.
if self.screen == Screen::Playing
@@ -11447,7 +11296,7 @@ impl Game<'_> {
self.extras_frame(elapsed);
self.extras_retry()?;
self.apply_mouse_look();
self.pad_look(elapsed.as_secs_f32());
self.input_look(elapsed.as_secs_f32());
self.advance_flight();
self.tick_world_icon();
let ticked = Instant::now();
@@ -11559,17 +11408,18 @@ impl Game<'_> {
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
}
self.clock.end_frame(self.pause);
let mut consumed = false;
let saved = self
.renderer
.as_mut()
.map(|r| std::mem::take(&mut r.saved_screenshots))
.unwrap_or_default();
for saved in saved {
self.finish_screenshot(saved);
}
let finished = self
.renderer
.as_mut()
.and_then(|r| r.screenshot_result.take());
if let Some(result) = &finished
&& self.ui.screenshot_message.is_some()
{
self.finish_screenshot(result);
consumed = true;
}
if let Some(result) = &finished
&& let Some(reply) = self.pending_screenshot.take()
{
@@ -11582,7 +11432,6 @@ impl Game<'_> {
}
.to_string(),
);
consumed = true;
}
if let Some(result) =
self.renderer.as_mut().and_then(|r| r.capture_result.take())
@@ -11591,12 +11440,6 @@ impl Game<'_> {
let value = result.unwrap_or_else(|e| serde_json::json!({ "error": e }));
let _ = reply.send(value.to_string());
}
// A result nobody asked for was the world icon's
// (`tick_world_icon`): a failure tries again a second
// later, as `tryTakeScreenshotIfNeeded` does.
if !consumed && let Some(Err(error)) = finished {
log::warn!("Horizon: couldn't save the world icon: {error}");
}
if rendered {
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
self.debug_charts.frame_times.log(elapsed.as_nanos() as i64);
@@ -12157,16 +12000,8 @@ impl<'a> Game<'a> {
entity_attachments: Default::default(),
remote: None,
realm_session: false,
studio_replay: None,
studio_freecam: Default::default(),
studio_timeline: Default::default(),
studio_topbar: Default::default(),
studio_rmb_down: false,
studio_camera_track_dirty: false,
studio_replay_previous_camera_type: None,
studio_replay_standalone: false,
last_replay_tick: None,
studio_virtual_tick_ms: 0.0,
replay: Default::default(),
studio: Default::default(),
lan: None,
integrated: None,
level_shapes: Default::default(),
@@ -12635,11 +12470,11 @@ pub fn run_with() -> Result<()> {
options.spikes = Some(dedicated::SLOW_TICK_MS);
}
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
// A hook that wants to know what slow frames spent their time in (a player who allows
// performance statistics): this thread, which draws the frames, times its steps (wall time
// only).
// A player who allows performance statistics also says what their slow frames spent their
// time in: this thread, which draws the frames, times its steps (wall time only).
if options.execution_role.is_client()
&& horizon_hooks::engine::wants_frame_steps(&horizon_client::logging::game_home())
&& horizon_telemetry::Consent::load(&horizon_client::logging::game_home())
.allows(horizon_telemetry::Kind::Perf)
{
horizon_client::spans::record_here(true);
}
@@ -12664,11 +12499,9 @@ pub fn run_with() -> Result<()> {
// for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::crash::build();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
// horizon-extras: the official server is Horizon's (docs/forking.md).
#[cfg(feature = "horizon-extras")]
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
let version = horizon_client::telemetry::version();
let game_dir = horizon_client::logging::player_dir();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
+14 -28
View File
@@ -848,34 +848,20 @@ impl Game<'_> {
self.armor_stands[i].last_hit = time;
return true;
}
// brokenByPlayer: the stand item, then brokenByAnything: the equipment.
let a = self.armor_stands.remove(i);
let (width, height) = stand_box(&a);
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]];
let stand = ItemStack {
item: Arc::from("minecraft:armor_stand"),
count: 1,
damage: 0,
extra: None,
};
self.spawn_item(stand, center, [0.0; 3], 10);
for stack in a.equipment.into_iter().flatten() {
let r = &mut self.world.random;
let v = [
f64::from(r.next_float()) * 0.2 - 0.1,
0.2,
f64::from(r.next_float()) * 0.2 - 0.1,
];
self.spawn_item(stack, center, v, 10);
// brokenByPlayer: the stand item, with the stand's name, pops at its
// block, then brokenByAnything: the loot table and the equipment.
let (width, height) = stand_box(&self.armor_stands[i]);
let p = self.armor_stands[i].position;
let pos = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let mut stand = ItemStack::new("minecraft:armor_stand", 1);
if let Some(name) = &self.armor_stands[i].custom_name {
stand.data_mut().custom_name =
Some(horizon_client::scoreboard::component_tag(&name.component()));
}
self.pop_resource(pos, stand);
// ArmorStand.kill(level, the causing entity), after the drops.
self.stand_game_event("minecraft:entity_die", at, by);
self.world
.modified
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
self.break_armor_stand_with_source(i, by);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
true
}
/// `Entity.gameEvent(event, attributedTo)` for a stand at `at`: the
@@ -1202,6 +1188,8 @@ impl Game<'_> {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
let a_id = a.id;
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
// brokenByAnything's dropAllDeathLoot, before the equipment.
if !a.small && self.ui.rules.bool("mob_drops") {
self.armor_stand_death_loot(at, a.fire_ticks > 0);
@@ -1210,8 +1198,6 @@ impl Game<'_> {
for stack in drops {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.entity_killed_event_by(at, source.or(Some(super::commands::Target::Stand(a_id))));
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
@@ -42,12 +42,25 @@ impl Game<'_> {
self.trial_spawner_tick_positions.clear();
self.item_columns = Some(Default::default());
self.pass_mobs = Some(Default::default());
for slot in self.world.block_entity_tickers().iter().copied() {
let tickers = self.world.block_entity_tickers();
let mut last_chunk = (i32::MIN, i32::MIN);
let mut last_chunk_ticking = false;
for slot in tickers.iter().copied() {
let pos = slot.0;
let chunk = (pos.0 >> 4, pos.2 >> 4);
let chunk_ticking = if chunk == last_chunk {
last_chunk_ticking
} else {
last_chunk = chunk;
last_chunk_ticking = ticking(pos.0, pos.2);
last_chunk_ticking
};
if !chunk_ticking {
continue;
}
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
if self.world.block_entities.is_asleep(&pos)
|| !self.world.block_entities.is_live(slot)
|| !ticking(pos.0, pos.2)
|| !self.border.contains(f64::from(pos.0), f64::from(pos.2))
{
continue;
@@ -210,8 +223,14 @@ impl Game<'_> {
/// (`ServerLevel.areEntitiesLoaded`).
pub(super) fn block_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
let levels = self.tickets.levels();
let pending = self.world.entities_pending.clone();
move |x, z| levels.block_ticking_at(x, z) && !pending.contains(&(x >> 4, z >> 4))
let has_pending = !self.world.entities_pending.is_empty();
let pending = has_pending.then(|| self.world.entities_pending.clone());
move |x, z| {
levels.block_ticking_at(x, z)
&& pending
.as_ref()
.is_none_or(|p| !p.contains(&(x >> 4, z >> 4)))
}
}
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
fn tick_daylight_detector(&mut self, pos: Pos) {
+43 -10
View File
@@ -255,8 +255,8 @@ pub struct UiState {
/// What this frame's HUD shows that Horizon's overlays keep clear of (`Game::hud_room`),
/// set by `hud_layers` before anything draws.
pub room: horizon_client::hud_kit::room::Room,
/// Path of an F2 screenshot waiting for its frame.
pub screenshot_message: Option<std::path::PathBuf>,
/// F2 screenshots not written yet: each reports in chat once its PNG is.
pub screenshot_messages: Vec<std::path::PathBuf>,
/// The options screens.
pub options: horizon_client::options_ui::OptionsUi,
/// The prepared resources before resource packs are layered on.
@@ -1982,15 +1982,17 @@ impl Game<'_> {
if std::fs::create_dir_all(&dir).is_err() {
return None;
}
let name = screenshot_name(&dir);
// A screenshot still being written has no file yet: its name is taken all the same.
let name = screenshot_name(&dir, &self.ui.screenshot_messages);
let renderer = self.renderer.as_mut()?;
let path = dir.join(name);
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
scaled: true,
background: true,
});
self.ui.screenshot_message = Some(path.clone());
self.ui.screenshot_messages.push(path.clone());
Some(path)
}
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
@@ -2014,12 +2016,23 @@ impl Game<'_> {
self.take_screenshot();
true
}
/// After a frame: report a finished F2 screenshot in chat.
pub(super) fn finish_screenshot(&mut self, result: &std::result::Result<[u32; 2], String>) {
let Some(path) = self.ui.screenshot_message.take() else {
/// A screenshot written on its thread: F2's reports in chat. Any other was the world
/// icon's (`tick_world_icon`): a failure tries again a second later, as
/// `tryTakeScreenshotIfNeeded` does.
pub(super) fn finish_screenshot(&mut self, saved: horizon_client::gfx::SavedScreenshot) {
let Some(at) = self
.ui
.screenshot_messages
.iter()
.position(|p| *p == saved.path)
else {
if let Err(error) = saved.result {
log::warn!("Horizon: couldn't save the world icon: {error}");
}
return;
};
let message = match result {
let path = self.ui.screenshot_messages.remove(at);
let message = match &saved.result {
Ok(_) => {
log::info!("Saved screenshot to {}", path.display());
let name = path.file_name().unwrap_or_default().to_string_lossy();
@@ -2055,7 +2068,7 @@ fn chat_box_text(text: &str) -> String {
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
.collect()
}
pub fn screenshot_name(dir: &std::path::Path) -> String {
pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) -> String {
// Util.getFilenameFormattedDateTime: the local date and time.
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
let mut n = 1;
@@ -2065,7 +2078,8 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
} else {
format!("{base}_{n}.png")
};
if !dir.join(&name).exists() {
let path = dir.join(&name);
if !path.exists() && !pending.contains(&path) {
return name;
}
n += 1;
@@ -2107,6 +2121,25 @@ mod tests {
assert!(should_discard_draft(ChatExit::Done, true));
}
/// `Screenshot.getFile` picks the first free name of the second; an F2
/// screenshot still being written on its thread has no file yet, so a
/// second press in the same second takes the next name, not the same one.
#[test]
fn a_screenshot_being_written_keeps_its_name() {
let dir = std::env::temp_dir().join(format!("horizon-shots-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let first = screenshot_name(&dir, &[]);
let second = screenshot_name(&dir, &[dir.join(&first)]);
assert_ne!(first, second);
// Unless the clock turned to the next second in between.
let same_second = second.starts_with(first.trim_end_matches(".png"));
assert!(
!same_second || second == first.replace(".png", "_2.png"),
"{second}"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn resource_ids_are_listed_once_per_root() {
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
+3 -6
View File
@@ -3831,12 +3831,9 @@ impl Game<'_> {
return;
}
g.spawn = None;
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: level,
pos,
yaw,
forced: true,
});
g.respawn_config = Some(super::respawn_anchor::RespawnConfig::forced(
level, pos, yaw, pitch,
));
});
}
let dim = Text::literal(self.dim.id());
+3 -2
View File
@@ -644,7 +644,7 @@ impl Game<'_> {
.into());
}
if arg(2)? != "up" {
self.pad.keyboard_took_over = true;
self.input_keyboard_used();
}
// The command confirmation takes the keys; none go past it.
if self.command_prompt.is_some() {
@@ -800,7 +800,7 @@ impl Game<'_> {
// controller still holding one keeps it.
let options = [self.settings.toggle_crouch, self.settings.toggle_sprint];
for (slot, name) in [(0, "key.sneak"), (1, "key.sprint")] {
if !self.pad.holds_action(&self.settings.controller, name) {
if !self.input_holds(name) {
self.toggles.key(
slot,
horizon_client::keymapping::KeyAction::Release,
@@ -1039,6 +1039,7 @@ impl Game<'_> {
path: path.trim().into(),
size: None,
scaled: false,
background: false,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
+497 -285
View File
@@ -20,13 +20,17 @@ use horizon_cosmetics::follow::Follow;
use horizon_cosmetics::prefs::{self, Others};
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{
Badge, CameraOverride, CameraView, Frame, Gear, Kind, Mark, NameDecoration, PlayerModel,
PlayerModels, SpriteStore, Sprites,
};
use std::cell::{Cell, RefCell};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
pub(super) const MARK: horizon_client::tab_list::NameMark = &horizon_cosmetics::mark::MARK;
const MARK: Mark = &horizon_cosmetics::mark::MARK;
/// An item as the renderer takes it.
struct Baked {
@@ -55,7 +59,8 @@ impl Baked {
#[derive(Default)]
pub(super) struct Cosmetics {
client: Option<horizon_cosmetics::Cosmetics>,
/// The hooks' client (`Seat`), taken once it started.
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
/// By the item's address; each entry holds its item, so the address isn't reused.
baked: RefCell<FxHashMap<usize, Baked>>,
/// Items drawn whose texture isn't in the atlas yet.
@@ -65,16 +70,6 @@ pub(super) struct Cosmetics {
poses: RefCell<(Poses, Poses)>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
local: bool,
/// Show my cosmetics on its way to the service.
mine: AccountSync,
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
feed: Option<horizon_cosmetics::Feed>,
/// My emote on its way to the service.
emote_out: EmoteOut,
/// Companions, trails and auras.
fx: RefCell<Fx>,
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
@@ -405,17 +400,13 @@ fn slot_image(item: &Item) -> Vec<u8> {
/// Which slots an avatar's gear covers: a helmet or head item hides a hat, an elytra or a cape
/// what is worn on the back. A name tag style is never drawn on the body.
fn covered(m: &Mob, slot: Slot) -> bool {
fn covered(gear: Gear, slot: Slot) -> bool {
match slot {
Slot::Head => m.extra.head.is_some(),
Slot::Back => m.cape.is_some() || m.extra.chest.as_deref() == Some("minecraft:elytra"),
Slot::Head => gear.head,
Slot::Back => gear.back,
Slot::Shoulder => false,
// A chestplate, not an elytra.
Slot::Torso => m
.extra
.chest
.as_deref()
.is_some_and(|c| c != "minecraft:elytra"),
Slot::Torso => gear.chest,
Slot::NameTag | Slot::Emote => true,
// On no bone: drawn by a pass of their own, never on the body.
Slot::Companion | Slot::Trail | Slot::Aura => true,
@@ -427,12 +418,12 @@ fn covered(m: &Mob, slot: Slot) -> bool {
/// (`worn` is None), another player's at "name tags only" or hidden, or every item `covered` (a
/// helmet over a hat). One max over at most five items, read when the item was parsed.
fn body_top(
extras: &horizon_client::extras::Extras,
others: Others,
own: bool,
worn: Option<&horizon_cosmetics::Worn>,
covered: impl Fn(Slot) -> bool,
) -> Option<f32> {
if !own && extras.effective_others() != Others::Shown {
if !own && others != Others::Shown {
return None;
}
worn?
@@ -449,115 +440,424 @@ pub(super) fn enabled() -> bool {
cfg!(feature = "horizon-extras") && horizon_cosmetics::enabled()
}
/// What the cosmetics' hooks keep for one splitscreen player (`horizon_hooks::seat`): the
/// service's client, the emote feed and what the account tells the service.
#[derive(Default)]
struct Seat {
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`).
local: bool,
/// Show my cosmetics on its way to the service.
mine: AccountSync,
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
feed: Option<horizon_cosmetics::Feed>,
/// My emote on its way to the service.
emote_out: EmoteOut,
/// What the game's settings and the player's emote say (`Game::cosmetics_say`).
said: Said,
/// Kept between ticks so a tick allocates nothing: the player models in view.
models: Vec<PlayerModel>,
/// The world is the window's own single-player one (`Frame::solo`).
solo: bool,
/// Name tag badges' sprites, by the item's address: their id (never reused) and the item,
/// held so the address isn't reused while it is there.
sprites: FxHashMap<usize, (u64, Arc<Item>)>,
last_sprite: u64,
/// Sprites drawing missed, put in the atlas next frame.
wanted: Vec<u64>,
}
/// What the game tells the hooks before each call, until the settings move here (spec #14091
/// step 5a) and the player's emote with them (step 4).
#[derive(Default)]
struct Said {
mine: bool,
listed: bool,
/// Other players' cosmetics are shown (not "name tags only", not hidden).
others_shown: bool,
/// Other players' cosmetics are hidden (not even name tag styles).
others_hidden: bool,
/// Horizon's mark by Horizon players' names.
marks: bool,
/// Cosmetics in single player: the signed-in account's on the player's own model.
solo: bool,
/// My emote pulled the camera back: when it started (the own model's age).
pull: Option<f32>,
/// My emote while it plays: its id and whether it loops.
playing: Option<(String, bool)>,
/// A folder's first emote, which mannequins play.
first: Option<Arc<Item>>,
}
/// One [`Seat`] per splitscreen player.
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
/// Runs `f` on this seat's [`Seat`]. Never called inside itself.
fn service<R>(f: impl FnOnce(&mut Seat) -> R) -> R {
let seat = horizon_hooks::seat();
let mut all = SERVICES.lock().unwrap_or_else(PoisonError::into_inner);
if all.len() <= seat {
all.resize_with(seat + 1, Seat::default);
}
f(&mut all[seat])
}
/// The cosmetics' hooks, registered when [`enabled`].
pub(super) struct Hooks;
pub(super) static HOOKS: Hooks = Hooks;
impl horizon_hooks::Hooks for Hooks {
fn frame(&self, frame: &Frame, sprites: &mut dyn SpriteStore) {
service(|s| {
s.solo = frame.solo;
s.frame();
s.put_sprites(sprites);
});
}
fn tick(&self, models: &mut dyn PlayerModels) {
service(|s| s.tick(models));
}
fn name_decoration(
&self,
model: &PlayerModel,
sprites: &dyn Sprites,
) -> Option<NameDecoration> {
service(|s| s.name_decoration(model, sprites))
}
fn player_mark(&self, uuid: u128) -> Option<Mark> {
service(|s| {
let client = s.client.as_ref().filter(|_| s.said.marks)?;
client.frame().horizon(uuid).then_some(MARK)
})
}
/// An emote pulls the camera back: it eases out over the emote's first quarter second.
fn camera(&self, own: &CameraView) -> Option<CameraOverride> {
let start = service(|s| s.said.pull)?;
let t = ((own.age - start) / 5.0).clamp(0.0, 1.0);
Some(CameraOverride::new(0.35 + 0.65 * t * t * (3.0 - 2.0 * t)))
}
}
impl Seat {
/// Once a frame: starts the service's client on first use, asks whether the account owns a
/// cosmetic, and tells the service what the account shows.
fn frame(&mut self) {
if self.client.is_none() {
let (client, local) = Cosmetics::client();
self.client = Some(Arc::new(client));
self.local = local;
// Others' emotes come from the service only (a local folder has no one playing).
if !local {
self.feed = Some(horizon_cosmetics::Feed::new(http()));
}
}
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
if self.local {
self.owner.owns = true;
return;
}
// Whether the account owns a cosmetic: what the last run learnt, then the service.
let o = &mut self.owner;
if !o.started {
o.started = true;
o.owns = account()
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
}
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
o.asking = None;
match done {
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
Err(_) => o.retry = backoff(o.retry),
}
}
// One sign-in at a time: Mojang keeps one joined server id per account.
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
if due && o.asking.is_none() && self.mine.sending.is_none() && self.listed.sending.is_none()
{
let (tx, rx) = channel();
o.asking = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(check_owner());
})
.ok();
}
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
// Show me as a Horizon player: also said again every hour while on.
let every = horizon_cosmetics::account::PRESENCE_EVERY;
self.mine.settle(None);
self.listed.settle(Some(every));
// One sign-in at a time, as for the ownership check above.
let busy = self.owner.asking.is_some()
|| self.mine.sending.is_some()
|| self.listed.sending.is_some();
let said = &self.said;
if let Some(done) = self
.emote_out
.sending
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
self.emote_out.sending = None;
match done {
Ok(()) => self.emote_out.retry = None,
Err(_) => {
self.emote_out.retry = backoff(self.emote_out.retry);
self.emote_out.sent = None;
}
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = said.playing.as_ref();
let looping = playing.is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id.as_str()).filter(|_| said.listed);
let o = &mut self.emote_out;
let changed = o.sent.as_ref().map(Option::as_deref) != Some(wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
// was sent: the service ends a one-shot by itself, a loop within a minute).
let quiet = wish.is_none() && o.sent.is_none();
let emote_due = !quiet && waited && (changed || again);
// One sign-in at a time, the emote's included.
if busy || o.sending.is_some() {
} else if emote_due {
let wish = wish.map(str::to_owned);
o.sent = Some(wish.clone());
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
let (tx, rx) = channel();
o.sending = Some(rx);
let kept = o.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emote".into())
.spawn(move || {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if self.mine.due(said.mine) {
self.mine.start(said.mine, send_mine);
} else if self.listed.due(said.listed) {
self.listed.start(said.listed, send_listed);
}
}
/// What goes by `m`'s name: Horizon's mark if they play Horizon (and the mark is on), the
/// name tag style they wear if cosmetics are shown for them (name tags only counts), and how
/// high what is drawn on their body gets (`body_top`). One look in the cache for all three.
fn name_decoration(
&mut self,
m: &PlayerModel,
sprites: &dyn Sprites,
) -> Option<NameDecoration> {
let said = &self.said;
let own = m.kind == Kind::Own;
let styles = if own { said.mine } else { !said.others_hidden };
if !(styles || said.marks) {
return None;
}
let uuid = match m.kind {
Kind::Own => own_uuid(m.uuid, self.solo, said.solo, || {
horizon_client::auth::current().map(|s| s.uuid)
}),
Kind::Mannequin if self.local => Some(u128::from(m.id)),
Kind::Player => m.uuid,
_ => None,
}?;
let mut frame = self.client.as_ref()?.frame();
let mark = (said.marks && frame.horizon(uuid)).then_some(MARK);
let worn = if styles { frame.worn(uuid) } else { None };
drop(frame);
let others = if said.others_shown {
Others::Shown
} else {
Others::NameTags
};
let top = body_top(others, own, worn.as_ref(), |slot| covered(m.gear, slot))
.filter(|_| !m.invisible);
let badge = worn.and_then(|w| self.badge(&w, sprites));
Some(NameDecoration::new(mark, badge, top))
}
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
/// until the badge is in the atlas (it is put there next frame).
fn badge(&mut self, worn: &horizon_cosmetics::Worn, sprites: &dyn Sprites) -> Option<Badge> {
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
let [r, g, b] = item.tag?.line;
let next = self.last_sprite + 1;
let (id, _) = *self
.sprites
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| (next, item.clone()));
self.last_sprite = self.last_sprite.max(id);
let Some([u0, v0, u1, v1]) = sprites.get(id) else {
if !self.wanted.contains(&id) {
self.wanted.push(id);
}
return None;
};
// The mark sits in the slot's top left corner.
let u1 = u0 + (u1 - u0) * item.texture.width as f32 / 64.0;
let v1 = v0 + (v1 - v0) * item.texture.height as f32 / 64.0;
Some(Badge::new(
[u0, v0, u1, v1],
u32::from_be_bytes([0, r, g, b]),
))
}
/// Puts the sprites drawing missed in the atlas; forgets those of items no one wears.
fn put_sprites(&mut self, store: &mut dyn SpriteStore) {
for id in self.wanted.drain(..) {
let item = self.sprites.values().find(|(i, _)| *i == id);
if let Some((_, item)) = item.filter(|_| !store.has(id)) {
store.put(id, slot_image(item));
}
}
self.sprites
.retain(|_, (_, item)| Arc::strong_count(item) > 1);
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
fn tick(&mut self, models: &mut dyn PlayerModels) {
let mut seen = std::mem::take(&mut self.models);
seen.clear();
models.for_each(&mut |m| {
if m.kind != Kind::Own {
seen.push(m);
}
});
self.play_others(models, &mut seen);
self.models = seen;
}
fn play_others(&self, models: &mut dyn PlayerModels, view: &mut Vec<PlayerModel>) {
let shown = self.said.others_shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.local && shown {
let Some(first) = &self.said.first else {
return;
};
for m in view.iter().filter(|m| m.kind == Kind::Mannequin) {
let age = m.age as f32;
let now = emotes::pose_playing(models.body_pose(m.id));
if now.is_none_or(|p| p.at(age).is_none()) {
let playing = horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
};
emotes::set_model_playing(models, m.id, Some(playing));
}
}
return;
}
let (Some(feed), Some(client)) = (&self.feed, &self.client) else {
return;
};
if !shown {
feed.ask([]);
for m in view.iter() {
emotes::set_model_playing(models, m.id, None);
}
return;
}
let mut frame = client.frame();
view.retain(|m| m.kind == Kind::Player && m.uuid.is_some_and(|u| frame.horizon(u)));
drop(frame);
feed.ask(view.iter().filter_map(|m| m.uuid));
let now = Instant::now();
for m in view.iter() {
let Some(uuid) = m.uuid else { continue };
let seen = feed.playing(uuid);
let age = m.age as f32;
let mut next = emotes::pose_playing(models.body_pose(m.id)).cloned();
match (seen, &mut next) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if m.moved && p.stop.is_none() {
p.stop = Some(age);
}
}
(Some(seen), slot) if !m.moved => {
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
*slot = Some(horizon_cosmetics::Playing {
item: seen.item,
start: age - ticks,
stop: None,
});
}
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
emotes::set_model_playing(models, m.id, next.filter(|p| p.at(age).is_some()));
}
}
}
impl Game<'_> {
/// Once a frame, before drawing: starts the service's client on first use and puts the
/// Hands the hooks what the game's settings and the player's emote say (allocating only when
/// the emote changes).
pub(super) fn cosmetics_say(&mut self) {
if !enabled() {
return;
}
let first = if self.cosmetics.local {
service(|s| s.said.first.is_none())
.then(|| self.owned_emotes().first().cloned())
.flatten()
} else {
None
};
let extras = &self.settings.extras;
let playing = self.cosmetics.emotes.sending();
let pull = self.cosmetics.emotes.pull();
service(|s| {
let said = &mut s.said;
said.mine = extras.cosmetics_mine;
said.listed = extras.horizon_listed;
said.others_shown = extras.effective_others() == Others::Shown;
said.others_hidden = extras.effective_others() == Others::Hidden;
said.marks = extras.horizon_marks;
said.solo = extras.cosmetics_solo;
said.pull = pull;
if said.playing.as_ref().map(|(id, l)| (id.as_str(), *l)) != playing {
said.playing = playing.map(|(id, l)| (id.to_owned(), l));
}
if first.is_some() {
said.first = first;
}
});
}
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
/// textures the last frame wanted into the atlas.
pub(super) fn cosmetics_frame(&mut self) {
if !enabled() {
return;
}
let c = &mut self.cosmetics;
if c.client.is_none() {
let (client, local) = Cosmetics::client();
c.client = Some(client);
c.local = local;
// Others' emotes come from the service only (a local folder has no one playing).
if !local {
c.feed = Some(horizon_cosmetics::Feed::new(http()));
let owns = service(|s| {
if c.client.is_none() {
c.client = s.client.clone();
c.local = s.local;
}
}
s.owner.owns
});
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = owns;
c.frame.set(c.frame.get().wrapping_add(1));
c.fx.get_mut().frame(c.frame.get());
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
if c.local {
c.owner.owns = true;
} else {
// Whether the account owns a cosmetic: what the last run learnt, then the service.
let o = &mut c.owner;
if !o.started {
o.started = true;
o.owns = account()
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
}
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
o.asking = None;
match done {
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
Err(_) => o.retry = backoff(o.retry),
}
}
// One sign-in at a time: Mojang keeps one joined server id per account.
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
if due && o.asking.is_none() && c.mine.sending.is_none() && c.listed.sending.is_none() {
let (tx, rx) = channel();
o.asking = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(check_owner());
})
.ok();
}
}
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = c.owner.owns;
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
// Show me as a Horizon player: also said again every hour while on.
if !c.local {
let every = horizon_cosmetics::account::PRESENCE_EVERY;
c.mine.settle(None);
c.listed.settle(Some(every));
// One sign-in at a time, as for the ownership check above.
let busy =
c.owner.asking.is_some() || c.mine.sending.is_some() || c.listed.sending.is_some();
let extras = &self.settings.extras;
if let Some(done) = c
.emote_out
.sending
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
c.emote_out.sending = None;
match done {
Ok(()) => c.emote_out.retry = None,
Err(_) => {
c.emote_out.retry = backoff(c.emote_out.retry);
c.emote_out.sent = None;
}
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = c.emotes.sending();
let looping = playing.as_ref().is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id).filter(|_| extras.horizon_listed);
let o = &mut c.emote_out;
let changed = o.sent.as_ref() != Some(&wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
// was sent: the service ends a one-shot by itself, a loop within a minute).
let quiet = wish.is_none() && o.sent.is_none();
let emote_due = !quiet && waited && (changed || again);
// One sign-in at a time, the emote's included.
if busy || o.sending.is_some() {
} else if emote_due {
o.sent = Some(wish.clone());
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
let (tx, rx) = channel();
o.sending = Some(rx);
let kept = o.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emote".into())
.spawn(move || {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if c.mine.due(extras.cosmetics_mine) {
c.mine.start(extras.cosmetics_mine, send_mine);
} else if c.listed.due(extras.horizon_listed) {
c.listed.start(extras.horizon_listed, send_listed);
}
}
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
for item in uploads {
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
@@ -572,88 +872,6 @@ impl Game<'_> {
.retain(|_, b| Arc::strong_count(&b.item) > 1);
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
pub(super) fn tick_others_emotes(&mut self) {
let shown = self.settings.extras.effective_others() == Others::Shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.cosmetics.local && shown {
let Some(first) = self.owned_emotes().first().cloned() else {
return;
};
for m in self.mobs.iter_mut() {
let age = m.age as f32;
if &*m.kind.id == "minecraft:mannequin"
&& emotes::playing(m).is_none_or(|p| p.at(age).is_none())
{
let playing = horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
};
emotes::set_playing(m, Some(playing));
}
}
return;
}
let (Some(feed), Some(client)) = (&self.cosmetics.feed, &self.cosmetics.client) else {
return;
};
if !shown {
feed.ask([]);
for m in self.mobs.iter_mut() {
emotes::set_playing(m, None);
}
return;
}
let mut frame = client.frame();
let mut horizon = Vec::new();
for m in self.mobs.iter() {
if &*m.kind.id == "minecraft:player"
&& let Some(uuid) = self.remote_player_uuid(m.id)
&& frame.horizon(uuid)
{
horizon.push((m.id, uuid));
}
}
drop(frame);
feed.ask(horizon.iter().map(|(_, u)| *u));
let now = Instant::now();
for (id, uuid) in horizon {
let seen = feed.playing(uuid);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
continue;
};
let age = m.age as f32;
let moved = {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
dx * dx + dz * dz > 1e-4
};
let mut next = emotes::playing(m).cloned();
match (seen, &mut next) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if moved && p.stop.is_none() {
p.stop = Some(age);
}
}
(Some(seen), slot) if !moved => {
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
*slot = Some(horizon_cosmetics::Playing {
item: seen.item,
start: age - ticks,
stop: None,
});
}
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
emotes::set_playing(m, next.filter(|p| p.at(age).is_some()));
}
}
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
/// every 10 minutes (a folder's emotes need no asking; an offline account has none).
pub(super) fn refresh_owned_emotes(&mut self) {
@@ -669,7 +887,7 @@ impl Game<'_> {
}
let (tx, rx) = channel();
e.loading = Some(rx);
let kept = self.cosmetics.emote_out.kept.clone();
let kept = service(|s| s.emote_out.kept.clone());
std::thread::Builder::new()
.name("Cosmetics emotes owned".into())
.spawn(move || {
@@ -678,81 +896,6 @@ impl Game<'_> {
.ok();
}
/// Items from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins are dressed and marked too.
pub(super) fn cosmetics_local(&self) -> bool {
self.cosmetics.local
}
/// What goes by `m`'s name: Horizon's mark if they play Horizon (and the mark is on), the
/// name tag style they wear if cosmetics are shown for them (name tags only counts), and how
/// high what is drawn on their body gets (`body_top`). One look in the cache for all three.
pub(super) fn cosmetics_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
let mut marks = super::names::Marks::default();
let extras = &self.settings.extras;
let styles = if own {
extras.cosmetics_mine
} else {
extras.effective_others() != Others::Hidden
};
if !(styles || extras.horizon_marks) {
return marks;
}
let (Some(client), Some(uuid)) = (&self.cosmetics.client, self.cosmetics_uuid(m, own))
else {
return marks;
};
let mut frame = client.frame();
marks.horizon = extras.horizon_marks && frame.horizon(uuid);
let worn = if styles { frame.worn(uuid) } else { None };
drop(frame);
if !horizon_client::mobs::trader::is_invisible(m) {
marks.top = body_top(extras, own, worn.as_ref(), |slot| covered(m, slot));
}
marks.style = worn.and_then(|w| self.cosmetics_tag(&w));
marks
}
/// Those of `uuids` who play Horizon, with the mark on (the player list): one look each.
pub(super) fn horizon_players(
&self,
uuids: impl Iterator<Item = u128>,
) -> std::collections::HashSet<u128> {
match &self.cosmetics.client {
Some(c) if self.settings.extras.horizon_marks => {
let mut frame = c.frame();
uuids.filter(|u| frame.horizon(*u)).collect()
}
_ => Default::default(),
}
}
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
/// until the badge is in the atlas.
fn cosmetics_tag(&self, worn: &horizon_cosmetics::Worn) -> Option<super::names::TagStyle> {
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
let [r, g, b] = item.tag?.line;
let frame = self.cosmetics.frame.get();
let mut baked = self.cosmetics.baked.borrow_mut();
let entry = baked
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| Baked::new(item, frame));
let Some(slot) = self.gui.atlas.sprites.get(&entry.location) else {
self.cosmetics.uploads.borrow_mut().push(item.clone());
return None;
};
if entry.marked.replace(frame) != frame {
self.skin_downloads.borrow_mut().drawn(&entry.location);
}
// The mark sits in the slot's top left corner.
let mut mark = *slot;
mark.u1 = mark.u0 + (slot.u1 - slot.u0) * item.texture.width as f32 / 64.0;
mark.v1 = mark.v0 + (slot.v1 - slot.v0) * item.texture.height as f32 / 64.0;
Some(super::names::TagStyle {
mark,
line: u32::from_be_bytes([0, r, g, b]),
})
}
/// What `m` wears, if cosmetics are shown for it: `own` for the player's own model.
fn worn(&self, m: &Mob, own: bool) -> Option<horizon_cosmetics::Worn> {
let client = self.cosmetics.client.as_ref()?;
@@ -844,7 +987,7 @@ impl Game<'_> {
}
continue;
}
if emoting.is_none() && covered(m, item.slot) {
if emoting.is_none() && covered(super::player_hooks::gear(m), item.slot) {
continue;
}
let Some((models, uv)) = self.cosmetics_baked(item) else {
@@ -962,7 +1105,7 @@ impl Game<'_> {
mod tests {
use super::*;
use horizon_client::extras::Extras;
use std::sync::Mutex;
use horizon_client::mobs::anim::BodyPose;
/// A name tag style with a 1×1 mark.
const TAG: &str = r##"{"format": 1, "id": "tag", "slot": "name_tag", "parts": [],
@@ -1150,13 +1293,18 @@ mod tests {
let (short, long) = (Duration::from_millis(300), Duration::from_secs(10));
let top_of = |extras: &Extras, patience| {
let (worn, _) = other_player_from(Service(Arc::default(), both), extras, patience);
body_top(extras, false, worn.as_ref(), |_| false)
body_top(extras.effective_others(), false, worn.as_ref(), |_| false)
};
// No cosmetics drawn on the body (a name tag style only): vanilla's height exactly.
let (worn, _) = other_player(&shown, long);
assert!(worn.is_some());
assert_eq!(
tag(body_top(&shown, false, worn.as_ref(), |_| false)),
tag(body_top(
shown.effective_others(),
false,
worn.as_ref(),
|_| false
)),
VANILLA
);
// A small hat under the tag: vanilla's height too.
@@ -1170,7 +1318,9 @@ mod tests {
);
// A helmet over it: vanilla's height.
let (worn, _) = other_player_from(Service(Arc::default(), both), &shown, long);
let helmet = body_top(&shown, false, worn.as_ref(), |slot| slot == Slot::Head);
let helmet = body_top(shown.effective_others(), false, worn.as_ref(), |slot| {
slot == Slot::Head
});
assert_eq!(tag(helmet), VANILLA);
// Others' cosmetics on name tags only, or hidden: nothing on the body, vanilla's height.
for others in [Others::NameTags, Others::Hidden] {
@@ -1186,4 +1336,66 @@ mod tests {
assert_eq!(tag(top_of(&extras, patience)), VANILLA, "{others:?}");
}
}
/// Player models as the hooks see them, with the pose laid over each.
struct View(Vec<(PlayerModel, Option<Arc<dyn BodyPose>>)>);
impl PlayerModels for View {
fn connection(&self) -> u64 {
1
}
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
self.0.iter().for_each(|(m, _)| f(*m));
}
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
self.0.iter().find(|(m, _)| m.id == id)?.1.as_ref()
}
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
if let Some((_, p)) = self.0.iter_mut().find(|(m, _)| m.id == id) {
*p = pose;
}
}
}
#[test]
fn with_a_folder_mannequins_play_its_first_emote_out_of_step_and_players_do_not() {
let item = Arc::new(
Item::parse(
br#"{"format": 1, "id": "up", "slot": "emote", "parts": [],
"emote": {"length": 20, "blend": 0, "tracks": [
{"part": "right_arm", "keys": [{"t": 10, "rotation": [0, 0, 90]}]}]}}"#,
)
.unwrap(),
);
let mut service = Seat {
local: true,
said: Said {
others_shown: true,
first: Some(item.clone()),
..Said::default()
},
..Seat::default()
};
let model = |id, kind, uuid| (PlayerModel::new(id, kind, uuid, 50, false), None);
let mut view = View(vec![
model(1, Kind::Own, None),
model(41, Kind::Mannequin, None),
model(2, Kind::Player, Some(7)),
]);
service.tick(&mut view);
let playing = emotes::pose_playing(view.0[1].1.as_ref()).expect("the mannequin plays");
assert!(Arc::ptr_eq(&playing.item, &item));
// Out of step by its id: 50 - 41 % 40.
assert_eq!(playing.start, 49.0);
assert!(view.0[0].1.is_none() && view.0[2].1.is_none());
// Still playing: the same pose stays.
let first = view.0[1].1.clone().unwrap();
service.tick(&mut view);
assert!(Arc::ptr_eq(&first, view.0[1].1.as_ref().unwrap()));
// Others' cosmetics not shown: a new mannequin plays nothing.
service.said.others_shown = false;
view.0.push(model(3, Kind::Mannequin, None));
service.tick(&mut view);
assert!(view.0[3].1.is_none());
}
}
@@ -10,6 +10,7 @@ use horizon_client::mobs::Mob;
use horizon_client::mobs::anim::BodyPose;
use horizon_cosmetics::account::OwnedEmote;
use horizon_cosmetics::{Item, Playing, Slot};
use horizon_hooks::players::PlayerModels;
use std::sync::Arc;
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
@@ -37,10 +38,15 @@ pub(in crate::game) struct Emotes {
impl Emotes {
/// The emote to tell the service about, while it plays: its id and whether it loops.
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
pub(in crate::game) fn sending(&self) -> Option<(&str, bool)> {
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
Some((p.item.id.clone(), looping))
Some((&p.item.id, looping))
}
/// When the emote that pulled the camera back started (the own model's age), while it plays.
pub(in crate::game) fn pull(&self) -> Option<f32> {
self.camera_from.and(self.playing.as_ref()).map(|p| p.start)
}
}
@@ -59,23 +65,48 @@ impl BodyPose for EmotePose {
}
}
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
/// The emote a model's pose plays, if it is one.
pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<&Playing> {
let any: &dyn std::any::Any = &**pose?;
any.downcast_ref::<EmotePose>().map(|e| &e.0)
}
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
let same = match (playing(m), &next) {
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
pose_playing(m.emote.as_ref())
}
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
/// it; a new one allocates).
fn replaced(
now: Option<&Arc<dyn BodyPose>>,
next: Option<Playing>,
) -> Option<Option<Arc<dyn BodyPose>>> {
let same = match (pose_playing(now), &next) {
(None, None) => true,
(Some(a), Some(b)) => {
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
}
_ => false,
};
if !same {
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
(!same).then(|| next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>))
}
/// Makes `m` play `next`, unless it plays that already.
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
if let Some(pose) = replaced(m.emote.as_ref(), next) {
m.emote = pose;
}
}
/// Makes model `id` play `next`, unless it plays that already.
pub(in crate::game) fn set_model_playing(
models: &mut dyn PlayerModels,
id: u32,
next: Option<Playing>,
) {
if let Some(pose) = replaced(models.body_pose(id), next) {
models.set_body_pose(id, pose);
}
}
@@ -229,7 +260,7 @@ impl Game<'_> {
}
}
/// Once a tick: my emote, then the other players'.
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
pub(in crate::game) fn tick_emotes(&mut self) {
if let Some(done) = self
.cosmetics
@@ -250,20 +281,6 @@ impl Game<'_> {
}
}
self.tick_emote();
self.tick_others_emotes();
}
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
/// out over the emote's first quarter second.
pub(in crate::game) fn emote_camera_pull(&self, partial: f32) -> f32 {
let (Some(p), Some(m)) = (&self.cosmetics.emotes.playing, &self.player_model) else {
return 1.0;
};
if self.cosmetics.emotes.camera_from.is_none() {
return 1.0;
}
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
}
}
@@ -9,7 +9,7 @@ impl Game<'_> {
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
keybinds::key_code(self.settings.keys.get(mapping))
.is_some_and(|k| self.physical_keys.contains(&k))
|| self.pad.holds_action(&self.settings.controller, mapping)
|| self.input_holds(mapping)
}
/// `CreativeModeInventoryScreen.selectTab` on the HOTBAR tab: each saved
+2 -2
View File
@@ -2317,7 +2317,7 @@ impl Game<'_> {
a.spawn_velocity = Some(p.velocity);
a.spawn_rot = Some((p.pitch, p.yaw));
}
self.creatures.projectiles.push(p);
self.creatures.projectiles.put_back(p);
self.native_projectile_launch(player, &name, id, kind);
}
/// `MushroomCow.shear`'s `sendParticles(EXPLOSION, x, getY(0.5), z, 1, 0, 0, 0, 0)`:
@@ -6949,7 +6949,7 @@ impl Game<'_> {
{
m.heal(25.0);
consume = true;
sound = Some("entity.iron_golem.repair");
eat = Some(("entity.iron_golem.repair", 1.0, m.golem_repair_pitch()));
}
Species::Creeper if creeper_igniter => {
m.extra.flag = true;
@@ -280,25 +280,18 @@ impl Game<'_> {
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
// What crates draw on players and mannequins (`game/player_hooks.rs`: cosmetics),
// posed on the GPU with the mob's own jobs: culled with it.
if !layers_only
&& super::super::cosmetics::enabled()
&& horizon_hooks::engine::any()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
if let Some((lo, hi)) =
self.player_layers_geometry(mob, base, camera, alpha as f32, packed, gpu, out)
{
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
@@ -58,7 +58,7 @@ impl Recording {
Self {
seconds,
started: Instant::now(),
cpu_s: horizon_system::cpu_seconds(std::process::id()),
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
spans_were_on,
ticks: Vec::with_capacity(seconds as usize * 20),
sampled_ticks: 0,
@@ -363,7 +363,7 @@ impl Game<'_> {
let costs = tick_profile::take();
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
let jobs = job_stats::take();
let cpu = horizon_system::cpu_seconds(std::process::id())
let cpu = horizon_bench::cpu_seconds(std::process::id())
.zip(r.cpu_s)
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
@@ -382,7 +382,7 @@ impl Game<'_> {
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
"players": players,
"levels": self.report_levels(),
"memory": { "rss_mb": horizon_system::rss_bytes(std::process::id()).map(|b| b >> 20) },
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
"process": process_limits(),
"cpu_cores_used": cpu,
"config": {
@@ -395,12 +395,9 @@ impl Game<'_> {
// The machine's description runs commands: off the tick.
std::thread::spawn(move || {
let mut report = report;
let hw = horizon_bench::Hardware::detect();
report["machine"] = json!({
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"cpu": horizon_system::cpu(),
"cpu_threads": std::thread::available_parallelism().ok().map(|n| n.get()),
"ram_mb": horizon_system::ram_bytes().map(|b| b >> 20),
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
});
match write_report(Path::new("reports"), &report, &text) {
Ok(path) => log::info!(
@@ -353,7 +353,7 @@ impl Game<'_> {
let found =
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
used.push(match found {
Some((p, yaw)) => format!(
Some((p, yaw, _pitch)) => format!(
"respawn {} {} {} {}",
num(p.x),
num(p.y),
@@ -579,6 +579,7 @@ impl Game<'_> {
dim: self.dim,
pos: [p(0), p(1), p(2)],
yaw: 0.0,
pitch: 0.0,
forced: false,
});
}
+2 -1
View File
@@ -739,7 +739,8 @@ impl Game<'_> {
// anchor puts the player, facing it; else onto the spawn column.
let mut target = target;
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
// This existing owner transition adapter carries yaw only; its pitch path remains open.
if let Some((at, yaw, _pitch)) = self.find_respawn_and_use(c, false)? {
return Ok(Some((at, Some(yaw))));
}
target = fallback_spawn(self.spawn, self.level.world_spawn);
+4 -1
View File
@@ -1008,7 +1008,10 @@ impl Game<'_> {
p.position.z - center[2],
];
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
self.pad_rumble(horizon_client::gamepad::Rumble::Explosion { distance, radius });
self.input_feedback(horizon_client::input_source::Feedback::Explosion {
distance,
radius,
});
}
// Explosion.explode: the blast is heard.
let ctx = horizon_client::sculk::Context::default();
@@ -70,9 +70,10 @@ fn overworld_spawnpoint_is_validated_at_both_player_blocks() {
assert_eq!(actual.is_some(), allowed, "{block} at height {height}");
assert_eq!(game.respawn_config.is_some(), allowed);
assert_eq!(game.level.world_spawn, Some([0, 80, 0]));
if let Some((at, yaw)) = actual {
if let Some((at, yaw, pitch)) = actual {
assert_eq!(at, Vec3::new(5.5, 64.1, 5.5));
assert_eq!(yaw, 0.0);
assert_eq!(pitch.to_bits(), 0.0f32.to_bits());
}
}
}
+2 -2
View File
@@ -135,8 +135,8 @@ impl Game<'_> {
}
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
if self.studio_freecam.active {
return self.studio_freecam.fov.clamp(1.0, 179.0);
if self.studio.flying() {
return self.studio.freecam.fov.clamp(1.0, 179.0);
}
let fov = self.view.calculate_fov(
partial,
@@ -193,7 +193,7 @@ impl Game<'_> {
// replay player's hands remain visible in first person; freecam has
// no player-view hand.
if !fov::studio_draws_hand(
self.studio_freecam.active,
self.studio.flying(),
self.ui.hands_drawn(
self.spectator(),
self.sleeping.is_some(),
@@ -4190,8 +4190,8 @@ impl Game<'_> {
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
});
let at = found.map_or(world, |(at, yaw)| {
host.hub.guests[i].yaw = yaw;
let at = found.map_or(world, |(at, yaw, pitch)| {
(host.hub.guests[i].yaw, host.hub.guests[i].pitch) = (yaw, pitch);
[at.x, at.y, at.z]
});
if found.is_none() {
@@ -324,7 +324,7 @@ impl Options {
"--player-dir" => {}
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
"--benchmark" => {
if !horizon_hooks::engine::knows_benchmark(&value) {
if horizon_client::benchmark::scenes(&value).is_none() {
return Err(format!(
"unknown benchmark {value}: standard, village, forest, ocean or mountain"
)
@@ -123,7 +123,7 @@ impl Loading {
};
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
drop(window_step);
horizon_client::play_session::note_display(&window);
horizon_client::telemetry::note_display(&window);
// The renderer takes seconds to build and winit's window sits white
// until the first frame presents, so paint the boot screen on it and
// keep repainting it as the console's lines arrive; the wgpu frames
@@ -314,31 +314,27 @@ pub(super) fn start_game<'a>(
game.preload_registries();
game.start_agent_control()?;
if let Some(scenario) = game.options.benchmark.clone() {
// A part above the engine runs it (docs/benchmark.md), driving the game through the
// command queue horizon-ctl uses.
let requests = game
let control = game
.control
.get_or_insert_with(horizon_client::control::Server::local)
.sender();
let control: horizon_hooks::Control = Box::new(move |line, wait| {
let (reply, response) = std::sync::mpsc::channel();
let line = line.to_owned();
requests
.send(horizon_client::control::Request { line, reply })
.ok()?;
response.recv_timeout(wait).ok()
});
let run = horizon_hooks::Benchmark {
scenario: &scenario,
started,
home: &horizon_client::logging::game_home(),
game_dir: &std::env::current_dir()?,
version: &horizon_client::crash::version(),
build: &horizon_client::crash::build(),
simulation_distance: game.settings.simulation_distance as u32,
offscreen: game.options.offscreen,
};
horizon_hooks::engine::benchmark(&run, control);
.get_or_insert_with(horizon_client::control::Server::local);
horizon_client::benchmark::start(
control.sender(),
horizon_client::benchmark::Setup {
scenario,
started,
app: horizon_bench::App {
name: "horizon-client".into(),
version: horizon_client::crash::version(),
commit: option_env!("HORIZON_COMMIT").map(str::to_owned),
mods: Vec::new(),
},
simulation_distance: game.settings.simulation_distance as u32,
vsync: false,
fps_cap: None,
offscreen: game.options.offscreen,
game_dir: std::env::current_dir()?,
},
);
}
if let Some(directory) = std::env::var_os("HORIZON_NATIVE_CLIENT_PARTICLES_FIXTURE") {
game.run_native_client_particles_fixture(std::path::Path::new(&directory))?;
+174 -75
View File
@@ -118,35 +118,79 @@ impl View {
}
None
}
/// `WorldlyContainer.getSlotsForFace` or every slot.
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
/// `WorldlyContainer.getSlotsForFace` or every slot, zero-allocation iterator.
pub(super) fn slots_for_face_iter(&self, face: Option<Direction>) -> Slots<'_> {
match (self.kind, face) {
(Kind::Furnace, Some(Direction::Down)) => vec![2, 1],
(Kind::Furnace, Some(Direction::Up)) => vec![0],
(Kind::Furnace, Some(_)) => vec![1],
(Kind::Brewing, Some(Direction::Up)) => vec![3],
(Kind::Brewing, Some(Direction::Down)) => vec![0, 1, 2, 3],
(Kind::Brewing, Some(_)) => vec![0, 1, 2, 4],
(Kind::Composter(8), Some(Direction::Down)) => vec![0],
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => vec![0],
(Kind::Composter(_), _) => vec![],
(Kind::Furnace, Some(Direction::Down)) => Slots::Slice(&[2, 1]),
(Kind::Furnace, Some(Direction::Up)) => Slots::Slice(&[0]),
(Kind::Furnace, Some(_)) => Slots::Slice(&[1]),
(Kind::Brewing, Some(Direction::Up)) => Slots::Slice(&[3]),
(Kind::Brewing, Some(Direction::Down)) => Slots::Slice(&[0, 1, 2, 3]),
(Kind::Brewing, Some(_)) => Slots::Slice(&[0, 1, 2, 4]),
(Kind::Composter(8), Some(Direction::Down)) => Slots::Slice(&[0]),
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => Slots::Slice(&[0]),
(Kind::Composter(_), _) => Slots::Slice(&[]),
(Kind::Mod, Some(face)) => self
.container
.as_ref()
.map_or_else(Vec::new, |c| c.faces[face as usize].clone()),
_ => (0..self.len()).collect(),
.map_or(Slots::Slice(&[]), |c| Slots::Mod(&c.faces[face as usize])),
_ => Slots::Range(0..self.len()),
}
}
/// The slots `HopperBlockEntity.addItem` tries through `face`, zero-allocation iterator.
pub(super) fn slots_to_add_iter(&self, face: Option<Direction>) -> Slots<'_> {
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
self.slots_for_face_iter(face)
} else {
Slots::Range(0..self.len())
}
}
/// `WorldlyContainer.getSlotsForFace` or every slot.
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
self.slots_for_face_iter(face).collect()
}
/// The slots `HopperBlockEntity.addItem` tries through `face`.
fn slots_to_add(&self, face: Option<Direction>) -> Vec<usize> {
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
self.slots_for_face(face)
} else {
(0..self.len()).collect()
}
self.slots_to_add_iter(face).collect()
}
}
pub(super) enum Slots<'a> {
Range(std::ops::Range<usize>),
Slice(&'static [usize]),
Mod(&'a [usize]),
}
impl Iterator for Slots<'_> {
type Item = usize;
#[inline]
fn next(&mut self) -> Option<usize> {
match self {
Slots::Range(r) => r.next(),
Slots::Slice(s) => {
let (&first, rest) = s.split_first()?;
*s = rest;
Some(first)
}
Slots::Mod(s) => {
let (&first, rest) = s.split_first()?;
*s = rest;
Some(first)
}
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = match self {
Slots::Range(r) => r.len(),
Slots::Slice(s) => s.len(),
Slots::Mod(s) => s.len(),
};
(len, Some(len))
}
}
impl ExactSizeIterator for Slots<'_> {}
pub(super) fn dir_from_name(name: &str) -> Option<Direction> {
Some(match name {
"down" => Direction::Down,
@@ -180,8 +224,6 @@ struct Containers<'a> {
registry: &'static horizon_world::registry::BlockRegistry,
/// Brewing recipes, for what a brewing stand accepts (not needed for signals).
brewing: Option<&'a horizon_client::brewing::Brewing>,
/// The stack of a full composter's `OutputContainer`.
bone_meal: ItemStack,
}
impl Containers<'_> {
fn prop(&self, state: BlockStateId, key: &str) -> Option<&str> {
@@ -346,7 +388,7 @@ impl Containers<'_> {
fn get(&self, view: &View, slot: usize) -> Option<&ItemStack> {
if let Kind::Composter(level) = view.kind {
// OutputContainer holds one bone meal; InputContainer composts at once.
return (level == 8 && slot == 0).then_some(&self.bone_meal);
return (level == 8 && slot == 0).then_some(bone_meal_stack());
}
let (pos, i) = view.locate(slot)?;
self.world
@@ -452,7 +494,7 @@ impl Containers<'_> {
}
/// `HopperBlockEntity.isFullContainer`.
fn is_full(&self, view: &View, face: Direction) -> bool {
view.slots_for_face(Some(face)).into_iter().all(|i| {
view.slots_for_face_iter(Some(face)).all(|i| {
self.insertion_item(view, i)
.is_some_and(|s| s.count >= self.max_stack(&s))
})
@@ -498,6 +540,9 @@ impl ItemColumns {
x: i32,
z: i32,
) -> bool {
if items.is_empty() {
return false;
}
let (len, last) = (items.len(), items.last().map(|i| i.id));
let stale = self
.seen
@@ -556,13 +601,14 @@ impl StampedItemColumns {
}
}
fn bone_meal() -> ItemStack {
ItemStack {
fn bone_meal_stack() -> &'static ItemStack {
static STACK: std::sync::LazyLock<ItemStack> = std::sync::LazyLock::new(|| ItemStack {
item: "minecraft:bone_meal".into(),
count: 1,
damage: 0,
extra: None,
}
});
&STACK
}
/// Comparator input from a container block entity (0 when there is none).
@@ -579,7 +625,6 @@ pub(super) fn container_signal(
items,
registry,
brewing: None,
bone_meal: bone_meal(),
};
c.view((pos.x, pos.y, pos.z), true)
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
@@ -635,7 +680,6 @@ impl Game<'_> {
items: self.items(),
registry: self.pack.assets.registry,
brewing: Some(&self.stations().brewing),
bone_meal: bone_meal(),
}
}
pub(super) fn block_can_place(&self, pos: [i32; 3], slot: usize, stack: &ItemStack) -> bool {
@@ -847,7 +891,7 @@ impl Game<'_> {
return self.composter_container_insert([x, y, z], stack, face == Some(Direction::Up));
}
let mut rest = Some(stack);
for slot in view.slots_to_add(face) {
for slot in view.slots_to_add_iter(face) {
let Some(stack) = rest.take() else { break };
rest = self.try_move_in_item(from_hopper, view, stack, slot, face);
}
@@ -880,32 +924,31 @@ impl Game<'_> {
/// is still a hopper (`BlockEntityType.isValid`).
pub(super) fn tick_hopper(&mut self, pos: Pos) {
let time = self.world.game_time;
let waiting = {
let state = self.hoppers.entry(pos).or_default();
state.cooldown = state.cooldown.wrapping_sub(1);
state.ticked = time;
state.cooldown > 0
};
if waiting {
// Every hopper keeps its cooldown and the tick it last ticked
// (`HopperBlockEntity.tickedGameTime`), idle ones too: a hopper that
// receives an item reads it for its cooldown (8, or 7 once it ticked).
let state = self.hoppers.entry(pos).or_default();
state.cooldown = state.cooldown.wrapping_sub(1);
state.ticked = time;
if state.cooldown > 0 {
return;
}
state.cooldown = 0;
// Most ticks only advance the cooldown: the block is checked when the
// hopper is about to move, not on every tick it waits.
let hopper = self
.res
.facts()
.get(self.world.block(pos.0, pos.1, pos.2))
.name
.as_ref()
== "minecraft:hopper";
if !hopper {
let state = self.world.block(pos.0, pos.1, pos.2);
let Some(info) = self.pack.assets.registry.state(state) else {
self.hoppers.remove(&pos);
return;
};
if info.name != "minecraft:hopper" {
self.hoppers.remove(&pos);
return;
}
if let Some(state) = self.hoppers.get_mut(&pos) {
state.cooldown = 0;
if info.properties.get("enabled").is_some_and(|v| v == "false") {
return;
}
self.hopper_try_move_items(pos);
self.hopper_try_move_items_with_state(pos, state);
}
/// Drops the cooldowns of hoppers whose block entity is gone.
pub(super) fn forget_gone_hoppers(&mut self) {
@@ -917,23 +960,69 @@ impl Game<'_> {
/// `HopperBlockEntity.tryMoveItems` with `suckInItems`.
pub(super) fn hopper_try_move_items(&mut self, pos: Pos) {
let state = self.world.block(pos.0, pos.1, pos.2);
let c = self.containers();
let enabled = c.prop(state, "enabled") != Some("false");
let Some(view) = c.view(pos, false) else {
self.hopper_try_move_items_with_state(pos, state);
}
fn hopper_try_move_items_with_state(&mut self, pos: Pos, state: BlockStateId) {
let Some(info) = self.pack.assets.registry.state(state) else {
return;
};
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
if info.properties.get("enabled").is_some_and(|v| v == "false") {
return;
}
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
let mut changed = false;
if !c.is_empty(&view) {
changed = self.hopper_eject(pos, &view);
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
return;
}
let c = self.containers();
let full =
(0..view.len()).all(|i| c.get(&view, i).is_some_and(|s| s.count == c.max_stack(s)));
if !full {
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else {
return;
};
if items.len() != HOPPER_SLOTS {
return;
}
let is_empty = items
.iter()
.all(|s| s.as_ref().is_none_or(|s| s.count == 0));
let has_empty_slot = items
.iter()
.any(|s| s.as_ref().is_none_or(|s| s.count == 0));
let above = (pos.0, pos.1 + 1, pos.2);
// Fast path for empty hopper with no source container and no item entities:
// if empty, cannot eject; if no container above, cannot suck container;
// if no dropped items nearby, cannot suck entities -> nothing can happen!
if is_empty
&& self.world.block_entities.get(&above).is_none()
&& self.carts.is_empty()
&& self.boats.is_empty()
&& (self.dropped.is_empty()
|| self
.item_columns
.as_mut()
.is_some_and(|c| !c.may_reach(&self.dropped, pos.0, pos.2)))
&& self
.pack
.assets
.registry
.state(self.world.block(above.0, above.1, above.2))
.is_none_or(|s| s.name != "minecraft:composter")
{
return;
}
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
let view = View {
parts: vec![(pos, HOPPER_SLOTS)],
kind: Kind::Hopper,
container: None,
};
let mut changed = false;
if !is_empty {
changed = self.hopper_eject_with_state(pos, state, &view);
}
let is_full = !changed && !has_empty_slot && {
let c = self.containers();
(0..HOPPER_SLOTS).all(|i| c.get(&view, i).is_some_and(|s| s.count >= c.max_stack(s)))
};
if !is_full {
changed |= self.hopper_suck(pos, &view);
}
if changed {
@@ -995,19 +1084,20 @@ impl Game<'_> {
/// `tryMoveItems(…, () -> addItem(hopper, itemEntity))`; true when the
/// item entity was taken whole.
fn hopper_take_item(&mut self, pos: Pos, i: usize) -> bool {
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
return false;
}
let state = self.world.block(pos.0, pos.1, pos.2);
let c = self.containers();
if c.prop(state, "enabled") == Some("false") {
return false;
}
let Some(view) = c.view(pos, false) else {
return false;
};
if c.prop(state, "enabled") == Some("false")
|| self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0)
{
return false;
}
let mut changed = false;
if !c.is_empty(&view) {
changed = self.hopper_eject(pos, &view);
changed = self.hopper_eject_with_state(pos, state, &view);
}
let c = self.containers();
let full =
@@ -1043,8 +1133,7 @@ impl Game<'_> {
}
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
/// a container entity there (`getContainerAt`).
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
let state = self.world.block(pos.0, pos.1, pos.2);
fn hopper_eject_with_state(&mut self, pos: Pos, state: BlockStateId, hopper: &View) -> bool {
let c = self.containers();
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
return false;
@@ -1156,6 +1245,9 @@ impl Game<'_> {
if blocked {
return false;
}
if self.dropped.is_empty() {
return false;
}
// Hopper.SUCK_AABB: the hopper bowl and the block above.
let (min, max) = (
[
@@ -1214,7 +1306,7 @@ impl Game<'_> {
// second setChanged after the move or, when the hopper has no
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = c.bone_meal.clone();
let bone_meal = bone_meal_stack().clone();
if !horizon_client::mod_api::mods().is_empty() {
let mut cancel = false;
self.native_inventory_move_item(
@@ -1244,13 +1336,15 @@ impl Game<'_> {
source: &View,
hopper: &View,
) -> bool {
for slot in source.slots_for_face(Some(Direction::Down)) {
let c = self.containers();
let Some(stack) = c.get(source, slot) else {
continue;
};
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
for slot in source.slots_for_face_iter(Some(Direction::Down)) {
{
let c = self.containers();
let Some(stack) = c.get(source, slot) else {
continue;
};
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
}
}
if self.hopper_move_canceled(source, slot, source, hopper) {
continue;
@@ -1269,7 +1363,9 @@ impl Game<'_> {
}
return true;
}
Some(rest) => self.put_back(source, slot, rest),
Some(rest) => {
self.put_back(source, slot, rest);
}
}
}
false
@@ -1279,6 +1375,9 @@ impl Game<'_> {
/// minecarts, chest boats) whose box meets the 1x1x1 box around `at`,
/// picked at random.
fn entity_container_at(&mut self, at: [f64; 3]) -> Option<u32> {
if self.carts.is_empty() && self.boats.is_empty() {
return None;
}
let meets = |b: horizon_core::geometry::Aabb| {
let (lo, hi) = (b.min_position(), b.max_position());
lo.x < at[0] + 0.5
+27 -24
View File
@@ -61,46 +61,49 @@ impl Game<'_> {
/// that are gone, and read what the instances follow.
pub(super) fn start_mob_sounds(&mut self) -> Vec<MobSound> {
let game_time = self.world.game_time;
let mobs: Vec<MobSound> = self
.mobs
.iter()
.filter(|m| {
!m.removed
&& matches!(
m.species(),
Species::Bee
| Species::Guardian
| Species::ElderGuardian
| Species::Sniffer
)
})
.map(|m| MobSound {
let mut mobs = Vec::new();
let mut bee_indices = Vec::new();
for m in &self.mobs {
if m.removed {
continue;
}
let species = m.species();
if !matches!(
species,
Species::Bee | Species::Guardian | Species::ElderGuardian | Species::Sniffer
) {
continue;
}
let is_bee = species == Species::Bee;
let sound = MobSound {
id: m.id,
position: [m.position.x, m.position.y, m.position.z],
// Vec3.horizontalDistance.
speed: m.velocity.x.hypot(m.velocity.z),
baby: m.is_baby(),
// NeutralMob.isAngry: the anger end time is ahead.
angry: m.species() == Species::Bee
angry: is_bee
&& bee::get(m).is_some_and(|b| b.anger_end > 0 && b.anger_end - game_time > 0),
silent: m.silent,
attack_scale: guardian::attack_scale(m),
// SnifferSoundInstance.tick.
sniffer_digging: m.species() == Species::Sniffer
sniffer_digging: species == Species::Sniffer
&& m.ai.target.is_none()
&& sniffer::can_play_digging_sound(m),
})
.collect();
};
if is_bee {
bee_indices.push(mobs.len());
}
mobs.push(sound);
}
self.guardian_sounds
.retain(|id| mobs.iter().any(|m| m.id == *id));
self.bee_sounds
.retain(|id, _| mobs.iter().any(|m| m.id == *id));
for sound in &mobs {
let is_bee = self
.mobs
.iter()
.any(|m| m.id == sound.id && m.species() == Species::Bee);
if is_bee && self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
for &idx in &bee_indices {
let sound = &mobs[idx];
if self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
self.bee_sounds.insert(sound.id, sound.angry);
let instance = SoundInstance::bee(sound, sound.angry, random_source::create());
self.audio.play_instance(instance, false);
+6 -9
View File
@@ -300,11 +300,7 @@ impl Game<'_> {
) {
continue;
}
let marks = if player || self.cosmetics_local() {
self.cosmetics_marks(m, Some(m.id) == own)
} else {
Marks::default()
};
let marks = self.hooks_name_marks(m, Some(m.id) == own);
let at = match marks.top {
Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()),
None => p,
@@ -770,10 +766,10 @@ impl Game<'_> {
if background { BACKGROUND } else { 0 },
),
};
if n == last && marks.horizon {
if let Some(mark) = marks.mark.filter(|_| n == last) {
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
// 8 text pixels square, a pixel left of the background.
for &(mx, my, w, h, argb) in super::cosmetics::MARK {
for &(mx, my, w, h, argb) in mark {
let a = text_alpha * (argb >> 24) / 255;
batch.fill(
&self.gui.atlas,
@@ -937,10 +933,11 @@ struct NameTag {
marks: Marks,
}
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
/// What a hook draws by a player's name (`horizon_hooks::players::NameDecoration`): a mark
/// before it (Horizon's: they play Horizon) and a name tag style.
#[derive(Clone, Copy, Default)]
pub(super) struct Marks {
pub horizon: bool,
pub mark: Option<horizon_hooks::players::Mark>,
pub style: Option<TagStyle>,
/// How high the cosmetics drawn on their body get above the neck, in model pixels: the tag
/// goes up over a tall one (`cosmetic_lift`).
@@ -471,6 +471,7 @@ pub(crate) mod tests {
dim: game.dim,
pos: [1, 2, 3],
yaw: 0.0,
pitch: 0.0,
forced: false,
};
let spawn = Vec3::new(1.0, 2.0, 3.0);
@@ -46,6 +46,8 @@ pub(super) struct SpawnScratch {
/// dx/dz order, that no earlier player's square holds.
segments: Vec<Vec<(i32, i32)>>,
stale: Vec<bool>,
/// Reusable buffer for spawn players across ticks to avoid heap allocation.
pub(super) spawn_players: Vec<SpawnPlayer>,
}
impl SpawnScratch {
fn chunk(p: &LevelPlayer) -> (i32, i32) {
@@ -128,6 +130,20 @@ impl SpawnScratch {
self.prepare(players);
self.candidates.clear();
let mut count = 0;
if players.len() == 1 {
let p = &players[0];
count = self.segments.first().map_or(0, |s| s.len());
if !p.spectator
&& let Some(segment) = self.segments.first()
{
for &c in segment {
if close_enough_for_spawning(p, c) && keep(c) {
self.candidates.push(c);
}
}
}
return count as i32;
}
for (i, segment) in self.segments.iter().enumerate() {
count += segment.len();
let near = &self.neighbours[i];
@@ -650,8 +666,10 @@ fn spawn_cost_types(biomes: &horizon_client::biome::Biomes) -> Vec<&str> {
types
}
impl<'a> Game<'a> {
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
let players = self.level_players();
pub(super) fn natural_level_with_players(
&self,
players: Vec<LevelPlayer>,
) -> GameLevel<'_, 'a> {
let nearest = players
.iter()
.filter(|p| !p.spectator)
@@ -670,6 +688,9 @@ impl<'a> Game<'a> {
costed: spawn_cost_types(world_biomes!(self)),
}
}
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
self.natural_level_with_players(self.level_players())
}
/// Creates a mob the spawner accepted, finalized the game's way; the
/// caller adds it with [`Game::add_spawned`].
fn finalize_spawned(&self, r: &SpawnRequest, pending: &mut Vec<Mob>) -> Option<Spawned> {
@@ -773,6 +794,8 @@ impl<'a> Game<'a> {
scratch: &mut SpawnScratch,
) -> i32 {
let levels = self.tickets.levels();
// Recomputed each pass: a chunk's ticket level and entity loading change without the
// players moving or the chunk count changing.
scratch.collect(players, |(cx, cz)| {
levels.loaded_ticking(cx, cz) && super::ticking_chunk(&self.world, cx, cz)
})
@@ -819,31 +842,28 @@ impl<'a> Game<'a> {
{
let level = self.natural_level();
laps.lap("sn_level");
let counted: Vec<Counted> = self
.mobs
.iter()
.map(|m| Counted {
entity: &m.kind.id,
category: Category::from_name(&m.kind.category),
block: [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
],
is_mob: true,
persistent: m.persistent
|| horizon_client::mobs::ai::requires_custom_persistence(m),
})
.collect();
let players = players
.iter()
.map(|p| SpawnPlayer {
let counted = self.mobs.iter().map(|m| Counted {
entity: &m.kind.id,
category: Category::from_name(&m.kind.category),
block: [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
],
is_mob: true,
persistent: m.persistent
|| horizon_client::mobs::ai::requires_custom_persistence(m),
});
scratch.spawn_players.clear();
scratch
.spawn_players
.extend(players.iter().map(|p| SpawnPlayer {
position: [p.position.x, p.position.y, p.position.z],
spectator: p.spectator,
})
.collect();
}));
let spawn_players = std::mem::take(&mut scratch.spawn_players);
laps.lap("sn_materialize");
let mut state = SpawnState::new(&level, spawnable, players, &counted, |cx, cz| {
let mut state = SpawnState::new(&level, spawnable, spawn_players, counted, |cx, cz| {
self.world.chunks.contains_key(&(cx, cz))
});
laps.lap("sn_state");
@@ -854,7 +874,8 @@ impl<'a> Game<'a> {
Vec::new()
};
laps.lap("sn_categories");
// Util.shuffle.
// Util.shuffle, every pass: it draws from the level's random even when no
// category may spawn.
for i in (1..scratch.candidates.len()).rev() {
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
scratch.candidates.swap(i, j);
@@ -885,6 +906,7 @@ impl<'a> Game<'a> {
);
}
}
scratch.spawn_players = state.into_players();
}
spawn_selection::attempt_counters(true);
laps.lap("sn_attempts");
+1 -1
View File
@@ -185,7 +185,7 @@ impl Game<'_> {
let output = self
.renderer
.as_ref()
.map_or(Default::default(), |r| *r.output());
.map_or(Default::default(), |r| r.output());
ui.draw(&self.settings, output, batch, &self.gui.atlas, self.cursor);
self.ui.options = ui;
self.show_calibration((batch.width, batch.height));
-20
View File
@@ -679,26 +679,6 @@ impl Game<'_> {
gamepad::move_vector(self.pad.state.left, settings.move_deadzone)
}
/// Whether `key` is held, a movement key by the left stick too: as the keys the stick holds
/// (`eight_direction_keys`), whatever moves the player. For readers of the keys that aren't
/// the movement itself.
pub(super) fn movement_key_held(&self, key: KeyCode) -> bool {
if self.keys.raw(&key) {
return true;
}
let Some(stick) = self.pad_move() else {
return false;
};
let [forward, backward, left, right] = gamepad::eight_direction_keys(stick);
match key {
KeyCode::KeyW => forward,
KeyCode::KeyS => backward,
KeyCode::KeyA => left,
KeyCode::KeyD => right,
_ => false,
}
}
/// The right stick turns the view, once per frame (`Entity.turn`).
pub(super) fn pad_look(&mut self, seconds: f32) {
let settings = &self.settings.controller;
+10 -9
View File
@@ -280,11 +280,11 @@ impl Game<'_> {
/// front of) it, pulled in where visual shapes are in the way. Returns the
/// position, yaw and pitch to render from.
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
if self.studio_freecam.active {
if self.studio.flying() {
return Some((
self.studio_freecam.position,
self.studio_freecam.yaw,
self.studio_freecam.pitch,
self.studio.freecam.position,
self.studio.freecam.yaw,
self.studio.freecam.pitch,
));
}
// Minecraft.cameraEntity: a server's spectator camera on a mob or
@@ -311,8 +311,8 @@ impl Game<'_> {
}
};
let mut view = self.view;
// An emote pulls the camera back from the player's face (`game/cosmetics/emotes.rs`).
let pull = self.emote_camera_pull(partial);
// A hook may pull the camera back (an emote, from the player's face).
let pull = self.hooks_camera_distance(partial);
let kind = match self.camera_type {
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
@@ -643,8 +643,9 @@ impl Game<'_> {
/// The model's old pitch is the player's `xRotO`, which the turn moves
/// with `xRot` (`turn_rotation`).
pub(super) fn turn_player(&mut self, xo: f64, yo: f64) {
if self.studio_freecam.active {
self.studio_freecam
if self.studio.flying() {
self.studio
.freecam
.turn((xo * 0.15) as f32, (yo * 0.15) as f32);
return;
}
@@ -697,7 +698,7 @@ impl Game<'_> {
/// `LevelExtractor`'s skip of the camera entity: the player's own body
/// is drawn unless the camera is in first person, or while it sleeps.
pub(super) fn own_body_in_view(&self) -> bool {
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio_freecam.active
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio.flying()
}
/// Whether the player's own model shows in the world view (its body or,
/// while invisible, only its armour and held items).
+77 -6
View File
@@ -101,7 +101,7 @@ impl Game<'_> {
};
// A controller's stick click acts as the key without setting the modifier: it is held
// while the pad holds it (or its release waits for a tick), not when the keyboard says.
let pad = self.pad.holds_action(&self.settings.controller, mapping);
let pad = self.input_holds(mapping);
if !held && !pad {
self.toggles.0[slot].set_down(false, needs_toggle);
self.sync_toggle(slot);
@@ -180,7 +180,7 @@ impl Game<'_> {
raw: KeyCode,
source: Source,
) -> Result<()> {
if self.screen == Screen::Playing && self.studio_replay.is_some() {
if self.screen == Screen::Playing && self.replay.playing() {
if !pressed {
self.keys.release(&key, source);
return Ok(());
@@ -191,7 +191,7 @@ impl Game<'_> {
}
return Ok(());
}
if !studio_freecam_movement_key(key) || !self.studio_freecam.active {
if !studio_freecam_movement_key(key) || !self.studio.flying() {
return Ok(());
}
}
@@ -352,9 +352,7 @@ impl Game<'_> {
_ => return false,
};
match source {
Source::Keyboard | Source::Agent => {
self.pad.holds_action(&self.settings.controller, mapping)
}
Source::Keyboard | Source::Agent => self.input_holds(mapping),
Source::Pad => {
let keys = &self.settings.keys;
self.physical_buttons
@@ -450,6 +448,79 @@ impl Game<'_> {
}
}
/// The input source (docs/layers.md, "Controllers, splitscreen, replays and UI pages"): what
/// gameplay asks of a device beyond the keyboard and mouse, and tells it. Gameplay calls these,
/// never the controller modules; today `pad` answers.
impl Game<'_> {
/// The analog move vector (`[left, forward]`, -1..1) while a device moves the player.
pub(super) fn input_move(&self) -> Option<[f32; 2]> {
self.pad_move()
}
/// Whether a device holds the key mapping named `mapping` (`key.use`).
pub(super) fn input_holds(&self, mapping: &str) -> bool {
self.pad.holds_action(&self.settings.controller, mapping)
}
/// Turns the view by what the devices give this frame (`Entity.turn`).
pub(super) fn input_look(&mut self, seconds: f32) {
self.pad_look(seconds);
}
/// The keyboard or mouse acted in play (a key or button pressed): it has control again, though
/// only moving the mouse takes a pad out of use.
pub(super) fn input_keyboard_used(&mut self) {
self.pad.keyboard_took_over = true;
}
/// Whether the pointer event being handled is one a device made (a pad's menu cursor
/// clicking), not the mouse's.
pub(super) fn input_made_pointer_event(&self) -> bool {
self.pad.menu.synthetic
}
/// A new player entity took over (spawn, respawn, a server joined, a dimension change).
pub(super) fn input_player_replaced(&mut self) {
self.pad.player_replaced();
}
/// Whether the drop about to happen is a device's whole-stack drop (it has no Control key to
/// hold). Asking answers once.
pub(super) fn input_takes_stack_drop(&mut self) -> bool {
std::mem::take(&mut self.pad.drop_stack)
}
/// The HUD starts a frame: what a device drew on the last one is gone.
pub(super) fn input_hud_begin(&mut self) {
self.pad.guide_shown.clear();
}
/// Something happened to the player that a device may let them feel.
pub(super) fn input_feedback(&mut self, event: horizon_client::input_source::Feedback) {
self.pad_rumble(event.into());
}
/// Whether `key` is held, a movement key by the analog move vector too: as the keys it holds
/// (`eight_direction_keys`), whatever moves the player. For readers of the keys that aren't
/// the movement itself.
pub(super) fn movement_key_held(&self, key: KeyCode) -> bool {
if self.keys.raw(&key) {
return true;
}
let Some(stick) = self.input_move() else {
return false;
};
let [forward, backward, left, right] = horizon_client::gamepad::eight_direction_keys(stick);
match key {
KeyCode::KeyW => forward,
KeyCode::KeyS => backward,
KeyCode::KeyA => left,
KeyCode::KeyD => right,
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::{studio_freecam_movement_key, studio_replay_shortcut};
+5 -39
View File
@@ -10,41 +10,8 @@ impl Game<'_> {
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
pub(super) fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
self.direct_craft_stop();
if self.screen == Screen::Playing && self.studio_replay.is_some() {
match key {
KeyCode::Escape => {
self.close_studio_replay();
return Ok(());
}
KeyCode::KeyP => {
self.pause_focus = None;
self.screen = Screen::Paused;
self.pause_menu = true;
self.capture(false);
}
KeyCode::KeyF => self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::ToggleFreecam,
),
KeyCode::KeyT => self.studio_timeline.visible = !self.studio_timeline.visible,
KeyCode::KeyM => self.add_studio_marker(),
KeyCode::Home => self.handle_studio_topbar_action(
horizon_client::studio::TopBarAction::ResetCameraToPlayer,
),
KeyCode::KeyL => {
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
}
KeyCode::KeyK => self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::AddCameraKeyframe,
),
KeyCode::Delete => {
if let Some(index) = self.studio_timeline.selected_keyframe {
self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::RemoveCameraKeyframe(index),
);
}
}
_ => {}
}
if self.screen == Screen::Playing && self.replay.playing() {
self.studio_key(key);
return Ok(());
}
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
@@ -78,7 +45,7 @@ impl Game<'_> {
KeyCode::Escape => match self.screen {
Screen::Options => self.options_key(KeyCode::Escape, None)?,
Screen::Playing => {
if self.studio_replay.is_some() && self.captured {
if self.replay.playing() && self.captured {
self.capture(false);
return Ok(());
}
@@ -213,7 +180,7 @@ impl Game<'_> {
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
KeyCode::F11 => self.toggle_fullscreen(),
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
KeyCode::F5 if self.screen == Screen::Playing && !self.replay.playing() => {
self.cycle_camera()
}
KeyCode::KeyE
@@ -279,8 +246,7 @@ impl Game<'_> {
KeyCode::KeyQ if self.screen == Screen::Playing => {
// handleKeybinds: the number keys select before key.drop.
self.select_queued_hotbar();
let all =
drop_whole_stack(self.modifiers) || std::mem::take(&mut self.pad.drop_stack);
let all = drop_whole_stack(self.modifiers) || self.input_takes_stack_drop();
self.drop_held(all);
}
KeyCode::Digit1
@@ -0,0 +1,448 @@
//! Where the game hands the player models to crates above the engine (`horizon_hooks::Hooks`'s
//! `frame`, `tick`, `name_decoration`, `player_mark`, `camera`; spec #14091): only a window's
//! game calls them, never the integrated server's thread or a dedicated server. With no hook
//! registered nothing is built.
use super::Game;
use horizon_client::fast_hash::FxHashMap;
use horizon_client::join_seq::JoinList;
use horizon_client::mesher::Vertex;
use horizon_client::mobs::anim::{self, BodyPose, PooledPoses, Poses};
use horizon_client::mobs::{Mob, Part, Polygon, render};
use horizon_hooks::engine;
use horizon_hooks::players::{
Bone, CameraView, Frame, Gear, Kind, Mesh, PartPoses, Pass, PlayerDraw, PlayerModel,
PlayerModels, SpriteStore, Sprites,
};
use std::sync::Arc;
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
/// moves them out). Before the hooks are sealed.
pub(crate) fn register() {
if super::cosmetics::enabled()
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
{
log::error!("Cosmetics: their hooks came too late: {e}");
}
}
/// The models of the world a window shows: its own first, then the other players and mannequins.
struct Models<'a> {
own: Option<&'a mut Mob>,
mobs: &'a mut JoinList<Mob>,
remote: &'a super::remote::Remote,
}
impl Models<'_> {
fn find(&mut self, id: u32) -> Option<&mut Mob> {
match self.own.as_deref_mut() {
Some(m) if m.id == id => Some(m),
_ => self
.mobs
.iter_mut()
.find(|m| m.id == id && kind(m).is_some()),
}
}
}
/// What kind of player model `m` is, if it is one (the window's own model aside).
fn kind(m: &Mob) -> Option<Kind> {
match &*m.kind.id {
"minecraft:player" => Some(Kind::Player),
"minecraft:mannequin" => Some(Kind::Mannequin),
_ => None,
}
}
/// What `m` wears where a crate may draw: a helmet or head item, a chestplate, a cape or elytra.
pub(super) fn gear(m: &Mob) -> Gear {
let elytra = m.extra.chest.as_deref() == Some("minecraft:elytra");
Gear::new(
m.extra.head.is_some(),
m.extra.chest.is_some() && !elytra,
m.cape.is_some() || elytra,
)
}
fn model(m: &Mob, kind: Kind, uuid: Option<u128>) -> PlayerModel {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
PlayerModel::new(m.id, kind, uuid, m.age, dx * dx + dz * dz > 1e-4)
.looks(gear(m), horizon_client::mobs::trader::is_invisible(m))
}
impl PlayerModels for Models<'_> {
fn connection(&self) -> u64 {
self.remote.session.serial()
}
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
if let Some(m) = self.own.as_deref() {
f(model(m, Kind::Own, Some(self.remote.profile())));
}
for m in self.mobs.iter() {
if let Some(kind) = kind(m) {
let uuid = (kind == Kind::Player)
.then(|| self.remote.player_uuid(m.id))
.flatten();
f(model(m, kind, uuid));
}
}
}
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
match self.own.as_deref() {
Some(m) if m.id == id => m.emote.as_ref(),
_ => self
.mobs
.iter()
.find(|m| m.id == id && kind(m).is_some())?
.emote
.as_ref(),
}
}
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
if let Some(m) = self.find(id) {
m.emote = pose;
}
}
}
/// Sprite `id`'s place in the atlas's names (`hooks/<hex>`), written into `buf`: no allocation.
fn location(id: u64, buf: &mut [u8; 22]) -> &str {
use std::io::Write;
let mut out = std::io::Cursor::new(&mut buf[..]);
let _ = write!(out, "hooks/{id:x}");
let len = out.position() as usize;
std::str::from_utf8(&buf[..len]).unwrap_or_default()
}
/// The crates' sprites in the GUI atlas, in the skin slots: read while drawing.
struct HookSprites<'a, 'g>(&'a Game<'g>);
impl Sprites for HookSprites<'_, '_> {
fn get(&self, id: u64) -> Option<[f32; 4]> {
let mut buf = [0; 22];
let at = location(id, &mut buf);
let r = self.0.gui.atlas.sprites.get(at)?;
// Kept this frame (`skin_slots.next_frame`).
self.0.skin_downloads.borrow_mut().drawn(at);
Some([r.u0, r.v0, r.u1, r.v1])
}
}
/// Where a crate's frame hook puts its sprites.
struct HookStore<'a, 'g>(&'a mut Game<'g>);
impl SpriteStore for HookStore<'_, '_> {
fn has(&self, id: u64) -> bool {
self.0
.gui
.atlas
.sprites
.contains_key(location(id, &mut [0; 22]))
}
fn put(&mut self, id: u64, rgba: Vec<u8>) {
let at = location(id, &mut [0; 22]).to_owned();
self.0.store_skin(at, rgba);
}
}
thread_local! {
/// The crates' meshes as the renderer takes them, by the mesh's address (each entry holds its
/// mesh, so the address isn't reused); render thread only.
static MESHES: std::cell::RefCell<FxHashMap<usize, (Arc<Mesh>, Arc<Part>)>> =
Default::default();
}
/// The game's model of `mesh`, made once.
fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
fn part(m: &Mesh) -> Part {
Part {
name: m.name.clone(),
pose: m.pose,
polygons: m
.quads
.iter()
.map(|q| Polygon {
normal: q.normal,
vertices: q.vertices,
cull_back: false,
})
.collect(),
children: m.children.iter().map(part).collect(),
}
}
MESHES.with_borrow_mut(|meshes| {
let (_, part_of) = meshes
.entry(Arc::as_ptr(mesh) as usize)
.or_insert_with(|| (mesh.clone(), Arc::new(part(mesh))));
part_of.clone()
})
}
/// Forgets the meshes no crate holds any more (once a frame).
fn forget_meshes() {
let _ = MESHES.try_with(|m| {
m.borrow_mut()
.retain(|_, (mesh, _)| Arc::strong_count(mesh) > 1)
});
}
/// `Poses` as a crate poses a mesh's parts.
struct PosesOf<'a>(&'a mut Poses);
impl PartPoses for PosesOf<'_> {
fn pose(&mut self, name: &str) -> &mut [f32; 9] {
self.0.get(name)
}
}
/// A player model drawn in the world pass, as a crate draws on it: posed on the GPU's skin path
/// when `gpu` (the mob pass's skin frame, begun; the bounds of what went there grow, for
/// culling), else its vertices go to `out`.
struct DrawPlayer<'a, 'g> {
game: &'a Game<'g>,
m: &'a Mob,
pass: Pass,
/// The model's matrix (`render::base_transform`).
base: glam::Mat4,
alpha: f32,
light: u32,
gpu: bool,
out: &'a mut Vec<Vertex>,
bounds: Option<(glam::Vec3, glam::Vec3)>,
/// The body as it is drawn, posed on the first `bone`.
body: Option<PooledPoses>,
poses: PooledPoses,
}
impl DrawPlayer<'_, '_> {
fn body(&self) -> &Part {
let (key, _) = render::body(self.m);
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
}
}
impl PlayerDraw for DrawPlayer<'_, '_> {
fn pass(&self) -> Pass {
self.pass
}
fn ticks(&self) -> f32 {
self.m.age as f32 + self.alpha
}
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
if self.body.is_none() {
let mut poses = Poses::pooled();
poses.reset(self.body());
let data = self.game.res.mob_data();
let state = anim::state(self.m, self.alpha);
anim::setup(self.m, &state, &mut poses, &data.animations);
self.body = Some(poses);
}
let poses = self.body.as_ref()?;
let at = render::part_matrix(self.body(), self.base, poses, bone.part(), 0.0)?;
Some(at.to_cols_array_2d())
}
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>> {
self.m.emote.as_ref()
}
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
HookSprites(self.game).get(id)
}
fn draw(
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 4],
pose: &mut dyn FnMut(&mut dyn PartPoses),
) {
let model = part_of(mesh);
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
self.poses.reset(&model);
pose(&mut PosesOf(&mut self.poses));
if self.gpu {
let mut models = self.game.skin_models.borrow_mut();
let mut frame = self.game.skin_frame.borrow_mut();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
skin.bounds = self.bounds;
let paint = horizon_client::mobs::skin::Paint {
uv,
scroll: 0.0,
color: 0xFFFF_FFFF,
light: self.light,
overlay: 0,
};
if skin.draw(&model, matrix, &self.poses, paint) {
self.bounds = skin.bounds;
return;
}
}
let poses = &self.poses;
horizon_client::mobs::render_model(
&model,
matrix,
&|p: &Part| poses.pose(p),
uv,
self.light,
0,
self.out,
);
}
}
impl Game<'_> {
/// What crates draw on player model `m` in the world pass (`creatures.rs`), then the
/// cosmetics still drawn here: see [`DrawPlayer`]. The bounds of what went to the GPU.
#[allow(clippy::too_many_arguments)]
pub(super) fn player_layers_geometry(
&self,
m: &Mob,
base: glam::Mat4,
camera: glam::DVec3,
alpha: f32,
light: u32,
gpu: bool,
out: &mut Vec<Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let kind = kind(m)?;
let mut bounds = None;
if engine::any() {
let model = model(m, kind, self.remote_player_uuid(m.id));
let mut draw = DrawPlayer {
game: self,
m,
pass: Pass::World,
base,
alpha,
light,
gpu,
out: &mut *out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
bounds = draw.bounds;
}
let worn = super::cosmetics::enabled()
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
.flatten();
match (bounds, worn) {
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
(b, w) => b.or(w),
}
}
/// Once a frame, before drawing.
pub(super) fn client_hooks_frame(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
if !engine::any() {
return;
}
self.cosmetics_say();
let connection = self.remote.as_ref().map_or(0, |r| r.session.serial());
let frame = Frame::new(connection, self.integrated.is_some());
engine::frame(&frame, &mut HookStore(self));
forget_meshes();
self.cosmetics_frame();
}
/// Once a client tick in a world: the player's own emote, then the hooks.
pub(super) fn client_hooks_tick(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
self.tick_emotes();
if !engine::any() {
return;
}
self.cosmetics_say();
let Some(remote) = self.remote.as_ref() else {
return;
};
engine::tick(&mut Models {
own: self.player_model.as_mut(),
mobs: &mut self.mobs,
remote,
});
}
/// What goes by `m`'s name (`own`: the window's own player), from the hooks.
pub(super) fn hooks_name_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let mut marks = super::names::Marks::default();
if !engine::any() {
return marks;
}
let model = match (own, kind(m)) {
(true, _) => model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile())),
(false, Some(kind)) => model(m, kind, self.remote_player_uuid(m.id)),
(false, None) => return marks,
};
if let Some(d) = engine::name_decoration(&model, &HookSprites(self)) {
marks.mark = d.mark;
marks.top = d.top;
marks.style = d.badge.map(|b| {
let [u0, v0, u1, v1] = b.uv;
super::names::TagStyle {
mark: horizon_client::hud::Rect {
u0,
v0,
u1,
v1,
w: 8,
h: 8,
},
line: b.line,
}
});
}
marks
}
/// How far the third-person camera stands back, as a share of the usual distance: a hook's
/// (an emote pulls it back), else 1.
pub(super) fn hooks_camera_distance(&self, partial: f32) -> f32 {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
match &self.player_model {
Some(m) if engine::any() => {
engine::camera(&CameraView::new(m.age as f32 + partial)).map_or(1.0, |c| c.distance)
}
_ => 1.0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sprite_locations_fit_the_buffer_and_differ_by_id() {
let mut a = [0; 22];
let mut b = [0; 22];
assert_eq!(location(u64::MAX, &mut a), "hooks/ffffffffffffffff");
assert_eq!(location(10, &mut b), "hooks/a");
}
}
@@ -111,7 +111,7 @@ impl Game<'_> {
self.stop_sounds_for_new_level();
}
self.player = Some(player);
self.pad.player_replaced();
self.input_player_replaced();
self.sway = Default::default();
self.apply_equipment();
self.eye = 1.62;
@@ -26,7 +26,7 @@ impl Game<'_> {
let pointer = self.captured || self.agent_pointer;
let grabbed = pointer
&& self.screen == Screen::Playing
&& (self.player.is_some() || self.studio_freecam.active);
&& (self.player.is_some() || self.studio.flying());
let active = self.focused || self.agent_pointer;
if let Some((xo, yo)) = self
.mouse
@@ -75,10 +75,7 @@ impl Game<'_> {
.is_some_and(|t| t.elapsed() > Duration::from_millis(500))
&& self.control.is_none()
&& self.screen == Screen::Playing
&& !studio_suppresses_focus_pause(
self.studio_replay.is_some(),
self.studio_freecam.active,
)
&& !studio_suppresses_focus_pause(self.replay.playing(), self.studio.flying())
&& self.player.is_some()
&& !self.options.no_pause
&& self.settings.file.bool("pauseOnLostFocus")
@@ -149,7 +146,7 @@ impl Game<'_> {
self.reset_inventory_clicks();
if !capture {
self.stop_mining();
self.studio_rmb_down = false;
self.studio.rmb_down = false;
}
// The real pointer takes over from an agent's.
self.agent_pointer = false;
@@ -353,9 +350,9 @@ impl Game<'_> {
/// click. A screen that is open keeps it free, as releasing the pointer
/// for that screen did: it is the screen, not the focus, that shows it.
pub(super) fn regrab_on_refocus(&mut self) {
if self.studio_replay.is_some() || self.studio_freecam.active {
if self.studio_up() {
self.regrab_on_refocus = false;
self.studio_rmb_down = false;
self.studio.rmb_down = false;
self.capture(false);
return;
}
+22 -47
View File
@@ -2433,7 +2433,7 @@ impl Game<'_> {
/// view centre, `calculateStorageRange` (the server's radius, at least
/// two, plus three) chunks each way.
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
if self.studio_replay.is_some() {
if self.replay.playing() {
return true;
}
self.remote
@@ -3110,33 +3110,6 @@ impl Game<'_> {
index.get(mobs, id as u32).map(mob_tracking_body)
});
}
fn remove_entity(&mut self, id: i32) {
self.detach_remote_tracking_entity(id);
self.audio.remove_sound_entity(id as u32);
// Entity.setRemoved detaches every passenger, even when no empty
// SetPassengers packet preceded the vehicle's removal. Otherwise
// mobs and other players keep their passenger pose after it is gone.
self.remote_set_passengers(id, &[]);
self.mobs.retain(|m| m.id != id as u32);
self.orbs.retain(|o| o.id != id as u32);
self.dropped.retain(|d| d.id != id as u32);
// Before the crystals go: the dragon's beam index follows them.
self.remove_remote_dragon(id);
self.remove_remote_object(id as u32);
self.remove_remote_projectile(id as u32);
self.persist.uuids.remove(&(id as u32));
if let Some(r) = self.remote.as_mut() {
r.glowing.remove(&id);
r.entities.remove(&id);
r.passenger_vehicles.remove(&(id as u32));
r.passenger_vehicles.retain(|_, v| *v != id as u32);
r.lerps.remove(&id);
r.cart_lerps.remove(&id);
r.stepped.remove(&id);
r.head_lerps.remove(&id);
r.pending_items.remove(&id);
}
}
/// Map a menu slot to Horizon's inventory screen slot ids.
pub(super) fn menu_slot(menu: &str, slot: i32) -> Option<usize> {
let s = usize::try_from(slot).ok()?;
@@ -3707,7 +3680,7 @@ impl Game<'_> {
self.move_remote_interaction(p.id, p.position);
}
C::RemoveEntities(p) => {
for id in p.ids {
for &id in &p.ids {
if let Some(r) = self.remote.as_mut() {
if let Some(uuid) = r.level_players.remove(&id) {
r.attachments.remove(&Holder::Player(uuid));
@@ -3716,8 +3689,8 @@ impl Game<'_> {
r.level_player_textures.remove(&id);
r.attachments.remove(&Holder::Entity(id as u32));
}
self.remove_entity(id);
}
self.remove_entities(&p.ids);
}
C::MoveEntityPos(play::MoveEntityPos(m))
| C::MoveEntityPosRot(play::MoveEntityPosRot(m))
@@ -6176,7 +6149,7 @@ impl Game<'_> {
player.spectator = self.spectator();
player.velocity = delta;
self.player = Some(player);
self.pad.player_replaced();
self.input_player_replaced();
self.sway = Default::default();
self.eye = 1.62;
self.previous_eye = 1.62;
@@ -6185,7 +6158,7 @@ impl Game<'_> {
self.apply_remote_abilities();
}
}
if self.studio_replay.is_none() {
if !self.replay.playing() {
self.previous_position = position;
}
self.yaw = yaw;
@@ -6441,7 +6414,7 @@ impl Game<'_> {
}
return Ok(());
}
let stick = self.pad_move();
let stick = self.input_move();
if self.player.is_none() {
return Ok(());
}
@@ -6521,7 +6494,7 @@ impl Game<'_> {
return Ok(());
};
player.fluids.boat = boat;
if !riding && self.studio_replay.is_none() {
if !riding && !self.replay.playing() {
self.previous_position = player.position;
}
let before = (
@@ -6728,10 +6701,10 @@ impl Game<'_> {
self.snap_player_model_pitch();
}
// The player's own model (third person, the inventory's preview).
if self.studio_replay.is_none() {
if !self.replay.playing() {
self.tick_player_model();
}
self.tick_emotes();
self.client_hooks_tick();
if !dead {
self.remote_tick_using();
}
@@ -9525,7 +9498,7 @@ impl Game<'_> {
KeyCode::KeyQ if !self.spectator() => {
// Minecraft.hasControlDown: the physical keys, so a toggled
// sprint on Left Control does not make Q drop the stack.
let all = self.has_control_down() || std::mem::take(&mut self.pad.drop_stack);
let all = self.has_control_down() || self.input_takes_stack_drop();
self.remote_drop(all);
}
KeyCode::KeyE
@@ -10528,7 +10501,7 @@ impl Game<'_> {
/// A connection's screen is over the world: connecting, reconfiguring, or
/// the level loading screen, a portal's included (`remote_overlay`).
pub(super) fn remote_screen_up(&self) -> bool {
self.studio_replay.is_none()
!self.replay.playing()
&& self
.remote
.as_ref()
@@ -10612,7 +10585,7 @@ impl Game<'_> {
/// Connected or connecting with no player yet: `ConnectScreen`, then
/// `LevelLoadingScreen`, over the panorama with its blur.
pub(super) fn remote_loading(&self) -> bool {
if self.studio_replay.is_some() {
if self.replay.playing() {
return false;
}
use horizon_client::level_loading::Reason;
@@ -10714,17 +10687,11 @@ impl Game<'_> {
/// The profile name of another player the server sent, by entity id.
/// The UUID of another player's entity in the level.
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
let r = self.remote.as_ref()?;
let uuid = *r.level_players.get(&(id as i32))?;
(uuid != r.profile).then_some(uuid)
self.remote.as_ref()?.player_uuid(id)
}
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
let r = self.remote.as_ref()?;
let uuid = r.level_players.get(&(id as i32))?;
if *uuid == r.profile {
return None;
}
r.level_player_name(id as i32, uuid)
r.level_player_name(id as i32, &r.player_uuid(id)?)
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (their
@@ -11505,6 +11472,11 @@ impl Remote {
pub(super) fn profile(&self) -> u128 {
self.profile
}
/// Another player's profile UUID, by their entity id (None for the local player).
pub(super) fn player_uuid(&self, id: u32) -> Option<u128> {
let uuid = *self.level_players.get(&(id as i32))?;
(uuid != self.profile).then_some(uuid)
}
/// The entity id of a player in the level, by profile UUID.
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
self.level_players
@@ -11642,6 +11614,9 @@ fn in_storage_range(view_distance: i32, center: (i32, i32), x: i32, z: i32) -> b
x.abs_diff(center.0) <= range && z.abs_diff(center.1) <= range
}
// A packet's removals (`RemoveEntities`), apart: this file may not grow.
#[path = "remote_removal.rs"]
mod removal;
#[cfg(test)]
mod tests {
#[test]
@@ -793,13 +793,17 @@ impl Game<'_> {
}
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
self.fangs.list.retain(|f| f.id != id);
self.tnts.retain(|t| t.id != id);
self.falling.retain(|f| f.id != id);
self.crystals.retain(|c| c.id != id);
self.remove_remote_small_projectiles(id);
}
pub(super) fn remove_remote_small_projectiles(&mut self, id: u32) {
self.fangs.list.retain(|f| f.id != id);
self.creatures.projectiles.retain(|p| p.id != id);
self.tridents.retain(|t| t.id != id);
self.rockets.retain(|r| r.id != id);
self.crystals.retain(|c| c.id != id);
self.potions.remove_remote_thrown(id);
self.potions.clouds.retain(|c| c.id != id);
self.end.remove_remote_eye(id);
@@ -0,0 +1,142 @@
//! Removing entities a server dropped (`RemoveEntities`), out of remote.rs,
//! which may not grow.
use super::super::Game;
impl Game<'_> {
pub(super) fn remove_entity(&mut self, id: i32) {
self.remove_entities(&[id]);
}
/// `ClientboundRemoveEntitiesPacket`: a packet's ids go together, the big
/// entity lists filtered once (#13007), each id's own cleanup in order.
pub(super) fn remove_entities(&mut self, ids: &[i32]) {
for &id in ids {
self.detach_remote_tracking_entity(id);
self.audio.remove_sound_entity(id as u32);
// Entity.setRemoved detaches every passenger, even when no empty
// SetPassengers packet preceded the vehicle's removal. Otherwise
// mobs and other players keep their passenger pose after it is gone.
self.remote_set_passengers(id, &[]);
// Before the crystals go: the dragon's beam index follows them.
self.remove_remote_dragon(id);
self.crystals.retain(|c| c.id != id as u32);
self.remove_remote_object(id as u32);
self.remove_remote_small_projectiles(id as u32);
self.persist.uuids.remove(&(id as u32));
if let Some(r) = self.remote.as_mut() {
r.glowing.remove(&id);
r.entities.remove(&id);
r.passenger_vehicles.remove(&(id as u32));
r.lerps.remove(&id);
r.cart_lerps.remove(&id);
r.stepped.remove(&id);
r.head_lerps.remove(&id);
r.pending_items.remove(&id);
}
}
let removed: horizon_client::fast_hash::FxHashSet<u32> = if ids.len() > 1 {
ids.iter().map(|&id| id as u32).collect()
} else {
Default::default()
};
let keep = |id: u32| {
if ids.len() > 1 {
!removed.contains(&id)
} else {
ids.first().is_none_or(|&removed| id != removed as u32)
}
};
self.mobs.retain(|m| keep(m.id));
self.orbs.retain(|o| keep(o.id));
self.dropped.retain(|d| keep(d.id));
self.falling.retain(|f| keep(f.id));
self.tnts.retain(|t| keep(t.id));
if let Some(r) = self.remote.as_mut() {
r.passenger_vehicles.retain(|_, v| keep(*v));
}
}
}
#[cfg(test)]
mod tests {
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn remove_entities_filters_mixed_entity_lists() {
use crate::game::{
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
};
use horizon_protocol::packets::play::{self, Clientbound as C};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = ExecutionRole::Client;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let (client, _server) = horizon_protocol::connection::Pipe::pair();
game.connect_local(client, "RemovalTest").unwrap();
for (id, kind) in [
(40, "minecraft:falling_block"),
(41, "minecraft:falling_block"),
(42, "minecraft:falling_block"),
(43, "minecraft:tnt"),
(44, "minecraft:tnt"),
(45, "minecraft:experience_orb"),
(46, "minecraft:experience_orb"),
(47, "minecraft:zombie"),
(48, "minecraft:zombie"),
] {
if matches!(kind, "minecraft:falling_block" | "minecraft:tnt") {
assert!(game.spawn_remote_projectile(
id,
kind,
[0.0, 64.0, 0.0],
(0.0, 0.0),
[0.0; 3],
0
));
} else {
game.spawn_entity(id, kind, [0.0, 64.0, 0.0], [0.0; 3]);
}
}
for id in [49, 50] {
game.dropped.push(horizon_client::entities::ItemEntity::new(
id,
horizon_client::items::ItemStack::new("minecraft:sand", 1),
horizon_core::geometry::Vec3::new(0.0, 64.0, 0.0),
horizon_core::geometry::Vec3::ZERO,
0.0,
));
}
game.handle_packet(C::RemoveEntities(play::RemoveEntities {
ids: vec![40, 42, 43, 45, 47, 49],
}))
.unwrap();
assert_eq!(game.falling.iter().map(|e| e.id).collect::<Vec<_>>(), [41]);
assert_eq!(game.tnts.iter().map(|e| e.id).collect::<Vec<_>>(), [44]);
assert_eq!(game.orbs.iter().map(|e| e.id).collect::<Vec<_>>(), [46]);
assert_eq!(game.mobs.iter().map(|e| e.id).collect::<Vec<_>>(), [48]);
assert_eq!(game.dropped.iter().map(|e| e.id).collect::<Vec<_>>(), [50]);
}
}
@@ -168,6 +168,7 @@ impl Game<'_> {
full_water_vision: self.settings.extras.pvp.tweaks.clear_water,
night_vision: self.fog_night_vision(),
environment: self.pack.polytone_colors.env,
fluid_fog: self.pack.polytone_colors.fluid_fog,
};
let mut fog = horizon_client::fog::setup_fog(tables, &self.fog, &input);
if !self.settings.horizon.fog {
+41
View File
@@ -0,0 +1,41 @@
//! Replay playback's state in a `Game`. Recording a session's packets as `.mcpr` and playing them
//! back is platform (docs/layers.md, "Replays"); Studio's editor (`studio.rs`) builds on it.
use super::Game;
use horizon_client::studio::ReplayDriver;
/// The packet replay a `Game` plays, if any, and its playback clock.
#[derive(Default)]
pub(super) struct Replay {
/// The packet replay playing, if any.
pub(super) driver: Option<ReplayDriver>,
/// Camera perspective to restore after leaving replay playback.
pub(super) previous_camera_type: Option<super::perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
pub(super) standalone: bool,
pub(super) last_tick: Option<std::time::Instant>,
pub(super) virtual_tick_ms: f32,
}
impl Replay {
/// A replay plays: the world shown is the recording's.
pub(super) fn playing(&self) -> bool {
self.driver.is_some()
}
/// How far the replay's player is between its last two ticks, while a replay plays: it
/// moves on the replay's clock, not the game's.
pub(super) fn player_alpha(&self) -> Option<f64> {
self.playing()
.then(|| (f64::from(self.virtual_tick_ms) / 50.0).clamp(0.0, 1.0))
}
}
impl Game<'_> {
/// How far the window's own player model is between two ticks: on the replay's clock while a
/// replay plays, else the game's.
pub(super) fn own_player_alpha(&self) -> f64 {
self.replay
.player_alpha()
.unwrap_or_else(|| self.clock.camera_fraction())
}
}
@@ -487,7 +487,7 @@ pub(super) fn load_resources(
};
let resources = Arc::new(resources);
if menu {
horizon_client::play_session::core_ready();
horizon_client::telemetry::mark_core_ready();
log::info!(
"Horizon: core-ready after {} ms",
started.elapsed().as_millis()
@@ -526,7 +526,7 @@ pub(super) fn load_resources(
.set(rest)
.map_err(|_| "world resources already loaded".to_owned())?;
progress.set(9);
horizon_client::play_session::rest_ready();
horizon_client::telemetry::mark_rest_ready();
log::info!(
"Horizon: rest-ready after {} ms",
started.elapsed().as_millis()
@@ -29,12 +29,13 @@ pub(super) struct RespawnConfig {
pub dim: Dim,
pub pos: [i32; 3],
pub yaw: f32,
pub pitch: f32,
pub forced: bool,
}
/// A respawn point as a player's save keeps it (`respawn`): dimension,
/// block, yaw and whether it is forced.
pub(super) type SavedRespawn = (String, [i32; 3], f32, bool);
/// block, yaw, pitch and whether it is forced.
pub(super) type SavedRespawn = (String, [i32; 3], f32, f32, bool);
impl RespawnConfig {
/// `RespawnData.of`: the yaw wrapped to [-180, 180).
@@ -43,19 +44,37 @@ impl RespawnConfig {
dim,
pos,
yaw: wrap_degrees(f64::from(yaw)) as f32,
pitch: 0.0,
forced,
}
}
pub(super) fn from_saved((dim, pos, yaw, forced): &SavedRespawn) -> Self {
/// `SetSpawnCommand`: the forced literal, with its already parsed angles.
pub(super) fn forced(dim: Dim, pos: [i32; 3], yaw: f32, pitch: f32) -> Self {
Self {
dim,
pos,
yaw,
pitch,
forced: true,
}
}
pub(super) fn from_saved((dim, pos, yaw, pitch, forced): &SavedRespawn) -> Self {
Self {
dim: Dim::from_id(dim),
pos: *pos,
yaw: *yaw,
pitch: *pitch,
forced: *forced,
}
}
pub(super) fn saved(&self) -> SavedRespawn {
(self.dim.id().to_owned(), self.pos, self.yaw, self.forced)
(
self.dim.id().to_owned(),
self.pos,
self.yaw,
self.pitch,
self.forced,
)
}
}
@@ -180,6 +199,7 @@ impl Game<'_> {
dim: self.dim,
pos,
yaw: 0.0,
pitch: 0.0,
forced: false,
};
// RespawnConfig.isSamePosition: dimension and block.
@@ -478,14 +498,14 @@ impl Game<'_> {
rule.can_set_spawn.test(self.dark_outside())
}
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
/// dimension: the position and yaw, using an anchor's charge; a bed's
/// dimension: position and angles, using an anchor's charge; a bed's
/// stand-up spot. A forced point (`spawnpoint`) that is no anchor or bed
/// holds if a player fits in it; anything else is gone.
pub(super) fn find_respawn_and_use(
&mut self,
c: RespawnConfig,
consume: bool,
) -> Result<Option<(Vec3, f32)>> {
) -> Result<Option<(Vec3, f32, f32)>> {
let state = self.world.block(c.pos[0], c.pos[1], c.pos[2]);
if !(self.is_anchor(state)
&& (c.forced || self.anchor_charges(state) > 0)
@@ -495,7 +515,7 @@ impl Game<'_> {
if self.is_bed(state) && self.bed_sets_spawn(state) {
let facing = self.bed_forward(state);
let stand = self.bed_stand_up(c.pos, facing, c.yaw);
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))));
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)));
}
let [x, y, z] = c.pos;
let free = c.forced
@@ -503,7 +523,7 @@ impl Game<'_> {
&& self.possible_to_respawn_in(state)
&& self.possible_to_respawn_in(self.world.block(x, y + 1, z));
let at = Vec3::new(f64::from(x) + 0.5, f64::from(y) + 0.1, f64::from(z) + 0.5);
return Ok(free.then_some((at, c.yaw)));
return Ok(free.then_some((at, c.yaw, c.pitch)));
}
let stand = self.anchor_stand_up(c.pos);
if !c.forced
@@ -513,7 +533,7 @@ impl Game<'_> {
{
self.level_set(c.pos, next)?;
}
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))))
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)))
}
}
@@ -560,3 +580,354 @@ mod tests {
assert!((look_at_yaw(east, head) - 90.0).abs() < 1.0e-4);
}
}
#[cfg(test)]
mod connected_spawnpoint_rotation_tests {
use crate::game::{Game, lan::LanSettings};
use horizon_client::{
chunk::Chunk,
lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig, playerdata},
};
use horizon_protocol::packets::{
configuration::ClientInformation,
play::{self, Clientbound as C, Serverbound as S},
};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn command(game: &mut Game<'_>, text: &str) {
game.chat_capture = Some(Vec::new());
game.ui.registries.permission = 4;
let dispatcher = horizon_client::command::vanilla::build_for(false, true);
let parsed = dispatcher.parse(text, &game.ui.registries);
let (ctx, _) = parsed
.executable()
.expect("actual registered dedicated command");
let (root, ctx) = (parsed.context.clone(), ctx.clone());
assert_eq!(game.run_chain(&root, &ctx).unwrap(), 1, "{text}");
assert!(
!game.chat_capture.take().unwrap().is_empty(),
"actual command feedback {text}"
);
}
#[test]
#[ignore = "prepared resources and isolated scratch homes/cwd"]
fn command_forced_free_point_survives_real_guest_death_and_respawn_packet() {
let mut game = crate::game::native_machines::tests::server_game();
game.set_allow_commands(true);
let scratch = std::env::temp_dir().join(format!(
"horizon-forced-spawn-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&scratch).unwrap();
game.world_root = scratch.clone();
// Fixture setup through the existing column insertion API; admit every lit column below.
for x in -2..=2 {
for z in -2..=2 {
game.streaming
.insert_remote(&mut game.world, Chunk::empty(x, z));
assert!(game.streaming.is_lit(x, z));
}
}
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
let air = game
.pack
.assets
.registry
.default_state("minecraft:air")
.unwrap();
game.world.set(12, 99, 12, stone).unwrap();
for y in [100, 101] {
game.world.set(12, y, 12, air).unwrap();
}
assert_eq!(game.world.block(12, 99, 12), stone);
assert!(game.world.loaded(12, 12));
game.level.world_spawn = Some([4, 100, 4]);
game.level.world_spawn_angle = [-30.0, -5.0];
game.lan_host(
LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
listen: false,
announce: 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: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 7,
uuid: 77,
name: "Alice".into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:7".parse().unwrap(),
expected: false,
configured: Default::default(),
},
7,
[4.5, 100.0, 4.5],
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
guest.health = 20.0;
guest.game_mode = 0;
guest.yaw = 0.0;
guest.pitch = 0.0;
game.lan.as_mut().unwrap().hub.guests.push(guest);
let clock = game.world.game_time;
command(&mut game, "spawnpoint Alice 12 100 12 45 10");
let host = game.lan.as_ref().unwrap();
assert!(host.owner.is_none());
let guest = &host.hub.guests[0];
assert_eq!(
(guest.uuid, guest.health, guest.game_mode, guest.position),
(77, 20.0, 0, [4.5, 100.0, 4.5])
);
let tag = playerdata::player_tag(guest);
let respawn = tag
.get("respawn")
.expect("actual serialized personal configuration")
.clone();
assert_eq!(
respawn.get("yaw"),
Some(&horizon_world::nbt::Tag::Float(45f32.to_bits()))
);
assert_eq!(
respawn.get("pos"),
Some(&horizon_world::nbt::Tag::IntArray(vec![12, 100, 12]))
);
command(&mut game, "kill Alice");
game.tick_lan(false);
let mut host = game.lan.take().unwrap();
assert_eq!(host.hub.guests[0].uuid, 77);
assert_eq!(host.hub.guests[0].health, 0.0);
assert!(
host.hub.guests[0].dead,
"actual command death before request"
);
for _ in rx.try_iter() {}
game.lan_guest_packet(
&mut host,
7,
S::ClientCommand(play::ClientCommand { action: 0 }),
);
game.tick_server_travel(&mut host);
assert!(
!game.on_server_trip(7),
"admitted lit columns permit actual arrival"
);
let guest = &host.hub.guests[0];
assert_eq!(guest.uuid, 77);
assert_eq!(guest.health, 20.0);
assert!(!guest.dead);
assert_eq!(guest.position, [12.5, 100.1, 12.5]);
assert_eq!(game.level.world_spawn, Some([4, 100, 4]));
assert_eq!(game.level.world_spawn_angle, [-30.0, -5.0]);
assert_eq!(game.world.game_time, clock);
let physical = [guest.yaw.to_bits(), guest.pitch.to_bits()];
let awaiting_id = guest
.awaiting_teleport
.expect("actual final arrival awaits its acknowledgement")
.0;
let mut packets = Vec::new();
for out in rx.try_iter() {
match out {
Out::Packet(p) => packets.push(p),
Out::Packets(p) => packets.extend(p),
Out::Disconnect(_) => panic!("unexpected disconnect"),
}
}
let mut positions = Vec::new();
let mut respawns = 0;
for p in packets {
println!("complete actual Hub frame {p:?}");
let mut r = horizon_protocol::buf::Reader::new(&p);
let id = r.varint().unwrap();
match C::decode(id, r.rest()).unwrap() {
C::PlayerPosition(p) => positions.push(p),
C::Respawn(_) => respawns += 1,
_ => {}
}
}
assert_eq!(respawns, 1);
assert!(!positions.is_empty());
println!("all actual arrival positions {positions:?}");
let selected: Vec<_> = positions
.iter()
.filter(|p| p.teleport == awaiting_id)
.collect();
assert_eq!(
selected.len(),
1,
"select by actual authoritative pending id, never expected position/pitch"
);
let p = selected[0];
assert_eq!(p.position, [12.5, 100.1, 12.5]);
// All successful command/death/arrival/identity/world controls precede the intended regression.
assert_eq!(
(
respawn.get("pitch"),
physical,
[p.yaw.to_bits(), p.pitch.to_bits()]
),
(
Some(&horizon_world::nbt::Tag::Float(10f32.to_bits())),
[45f32.to_bits(), 10f32.to_bits()],
[45f32.to_bits(), 10f32.to_bits()]
)
);
drop(host);
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
}
#[cfg(test)]
mod pitch_preservation_controls {
use super::*;
#[test]
fn wrapped_existing_callers_default_to_positive_zero_pitch() {
let c = RespawnConfig::wrapped(Dim::Overworld, [5, 100, 5], 45.0, false);
assert_eq!(c.yaw.to_bits(), 45f32.to_bits());
assert_eq!(c.pitch.to_bits(), 0.0f32.to_bits());
assert!(!c.forced);
}
/// Added source/model controls; these do not add a connected bed/anchor runtime claim.
#[test]
#[ignore = "prepared resources and isolated scratch homes/cwd"]
fn bed_anchor_facing_and_invalid_personal_fallback_ignore_stored_pitch() {
let mut game = crate::game::native_machines::tests::server_game();
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
for x in 0..16 {
for z in 0..16 {
game.world.set(x, 99, z, stone).unwrap();
}
}
let state = |game: &Game<'_>, name: &str, properties: &[(&str, &str)]| {
let registry = game.pack.assets.registry;
let id = registry.default_state(name).unwrap();
let mut value = registry.state(id).unwrap().clone();
for (key, value_) in properties {
value.properties.insert((*key).into(), (*value_).into());
}
registry.resolve(&value).unwrap()
};
let head = state(
&game,
"minecraft:red_bed",
&[("facing", "north"), ("part", "head")],
);
let foot = state(
&game,
"minecraft:red_bed",
&[("facing", "north"), ("part", "foot")],
);
game.world.set(5, 100, 5, head).unwrap();
game.world.set(5, 100, 6, foot).unwrap();
assert!(game.is_bed(head) && game.bed_sets_spawn(head));
let bed = RespawnConfig {
dim: Dim::Overworld,
pos: [5, 100, 5],
yaw: 45.0,
pitch: 0.0,
forced: true,
};
let zero = game
.find_respawn_and_use(bed, false)
.unwrap()
.expect("valid bed geometry");
let stored = game
.find_respawn_and_use(RespawnConfig { pitch: 75.0, ..bed }, false)
.unwrap()
.unwrap();
assert_eq!(stored, zero);
assert_eq!(stored.2.to_bits(), 0.0f32.to_bits());
assert_eq!(stored.1.to_bits(), look_at_yaw(stored.0, bed.pos).to_bits());
// Existing built-in dimension facts select the anchor rule; no new world or actor API.
game.dim = Dim::Nether;
let anchor = state(&game, "minecraft:respawn_anchor", &[("charges", "2")]);
game.world.set(10, 100, 10, anchor).unwrap();
assert!(game.anchor_works() && game.is_anchor(anchor));
let c = RespawnConfig {
dim: Dim::Nether,
pos: [10, 100, 10],
yaw: 45.0,
pitch: 0.0,
forced: true,
};
let zero = game
.find_respawn_and_use(c, false)
.unwrap()
.expect("valid anchor geometry");
let stored = game
.find_respawn_and_use(RespawnConfig { pitch: 75.0, ..c }, true)
.unwrap()
.unwrap();
assert_eq!(stored, zero);
assert_eq!(stored.2.to_bits(), 0.0f32.to_bits());
assert_eq!(stored.1.to_bits(), look_at_yaw(stored.0, c.pos).to_bits());
assert_eq!(
game.world.block(10, 100, 10),
anchor,
"existing forced-charge behavior"
);
game.dim = Dim::Overworld;
game.level.world_spawn = Some([4, 100, 4]);
game.level.world_spawn_angle = [-30.0, -5.0];
game.world.set(12, 100, 12, stone).unwrap();
let invalid = RespawnConfig {
dim: Dim::Overworld,
pos: [12, 100, 12],
yaw: 45.0,
pitch: 10.0,
forced: true,
};
game.respawn_config = Some(invalid);
assert!(game.resolve_server_respawn(invalid).is_none());
assert!(game.respawn_config.is_none());
assert_eq!(game.level.world_spawn, Some([4, 100, 4]));
assert_eq!(game.level.world_spawn_angle, [-30.0, -5.0]);
}
}
+1
View File
@@ -362,6 +362,7 @@ impl Game<'_> {
path,
size: Some([64, 64]),
scaled: false,
background: true,
});
}
}
+7 -5
View File
@@ -823,7 +823,7 @@ impl Game<'_> {
for (location, rgba) in ready {
self.store_skin(location, rgba);
}
self.cosmetics_frame();
self.client_hooks_frame();
}
/// `Hud.extractTabList`: the player list while the player-list key is
/// held. A local world shows it with a joined LAN guest or a `list`
@@ -900,10 +900,12 @@ impl Game<'_> {
let board = &self.score.board;
let objective = board.display_objective(sb::SLOT_LIST);
let up = |id: u128| upside_down.contains(&id);
// Horizon's mark before the names of Horizon players (the cosmetics service's cache).
let horizon = self.horizon_players(players.iter().map(|p| p.uuid));
let horizon_mark = super::cosmetics::MARK;
let marked = |id: u128| horizon.contains(&id).then_some(horizon_mark);
// A hook's mark before a name (Horizon's: they play Horizon).
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let marked = horizon_hooks::engine::player_mark;
let inputs = TabInputs {
players: &players,
board,
@@ -138,10 +138,7 @@ impl Game<'_> {
return Err("a screenshot sequence is already running".into());
}
let renderer = self.renderer.as_ref().ok_or("no renderer yet")?;
if self.pending_screenshot.is_some()
|| self.ui.screenshot_message.is_some()
|| renderer.screenshot.is_some()
{
if self.pending_screenshot.is_some() || renderer.screenshot.is_some() {
return Err("a screenshot is already pending".into());
}
if let Some(refusal) = self.sequence_refusal() {
@@ -233,6 +230,7 @@ impl Game<'_> {
path,
size: None,
scaled: false,
background: false,
});
renderer.screenshot_result = None;
}
@@ -644,11 +644,14 @@ impl Game<'_> {
/// in the level in place: where it stands up, facing the anchor (with
/// the deplete sound) or the bed; else the point is forgotten, and the
/// caller tells the player (`Hub::missing_respawn_block`).
pub(super) fn resolve_server_respawn(&mut self, config: RespawnConfig) -> Option<(Vec3, f32)> {
pub(super) fn resolve_server_respawn(
&mut self,
config: RespawnConfig,
) -> Option<(Vec3, f32, f32)> {
let [x, y, z] = config.pos;
let anchor = self.is_anchor(self.world.block(x, y, z));
match self.find_respawn_and_use(config, true) {
Ok(Some((at, yaw))) => {
Ok(Some((at, yaw, pitch))) => {
if !config.forced && anchor {
let [x, y, z] = config.pos.map(f64::from);
self.audio.play(
@@ -658,7 +661,7 @@ impl Game<'_> {
1.0,
);
}
Some((at, yaw))
Some((at, yaw, pitch))
}
Ok(None) | Err(_) => {
let mut cancel = false;
@@ -796,7 +799,10 @@ impl Game<'_> {
let yaw = host.hub.guests[i].yaw;
let (position, yaw) = if let Some(config) = trip.respawn {
match self.as_guest(host, trip.conn, |g| g.resolve_server_respawn(config)) {
Some(Some(found)) => found,
Some(Some((at, yaw, pitch))) => {
host.hub.guests[i].pitch = pitch;
(at, yaw)
}
_ => {
host.hub.missing_respawn_block(trip.conn);
self.respawn_at_world_spawn(host, trip.conn);
+2
View File
@@ -261,6 +261,8 @@ impl<'a> Split<'a> {
.get(i.wrapping_sub(1))
.map(|g| g.options.clone()),
);
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
horizon_hooks::engine::set_seat(seat - 1);
self.turn = i;
}
@@ -126,6 +126,7 @@ impl Split<'_> {
// Until it has a profile, a scratch options file: never P1's.
let options = Self::folder()?.join(".joining/options.txt");
OPTIONS.set(Some(options.clone()));
horizon_hooks::engine::set_seat(seat - 1);
let mut settings = self.host.settings.clone();
if pad.is_some() {
// The Controller setting is P1's own: a player who pressed a pad to join plays
@@ -135,6 +136,7 @@ impl Split<'_> {
}
let game = self.start_guest(seat, &options, settings);
OPTIONS.set(None);
horizon_hooks::engine::set_seat(0);
let mut game = game?;
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
@@ -299,16 +301,31 @@ impl Split<'_> {
}
let profile = profiles.join(&name);
let options = profile.join("options.txt");
let mut settings = if options.exists() {
horizon_client::settings::Settings::load(&options)
let made = if options.exists() {
None
} else {
std::fs::create_dir_all(&profile)?;
let mut new = self.host.settings.clone();
// The Controller setting is P1's, as on the profile page (`join`).
new.controller.enabled |= self.guests[i].pad.is_some();
// On P1's seat, so the crates' settings stores write P1's values too.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(0);
new.save(&options);
new
horizon_hooks::engine::set_seat(turn);
Some(new)
};
// The crates' stores load into the guest's seat, before its turn starts below.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
let mut settings = match made {
Some(new) => {
horizon_client::settings::Settings::load_stores(&options);
new
}
None => horizon_client::settings::Settings::load(&options),
};
horizon_hooks::engine::set_seat(turn);
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
settings.controller.enabled |= self.guests[i].pad.is_some();
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
@@ -53,7 +53,7 @@ pub(super) struct StartupMenu {
pub reload_packs: bool,
/// Browse mods' worker (`mod_browser`).
mod_jobs: super::mod_browser::Jobs,
pub perf: horizon_client::play_session::Session,
pub perf: horizon_client::telemetry::Session,
}
impl StartupMenu {
@@ -1268,7 +1268,7 @@ impl Loading {
let output = self
.renderer
.as_ref()
.map_or(Default::default(), |r| *r.output());
.map_or(Default::default(), |r| r.output());
self.menu
.options
.draw(&self.settings, output, batch, &gui.atlas, self.menu.cursor);
@@ -15,6 +15,8 @@ pub(super) fn rename_box() -> horizon_client::ui::EditBox {
edit.text_color = u32::MAX;
// AnvilScreen also sets `setTextColorUneditable(-1)`: white either way.
edit.text_color_uneditable = u32::MAX;
// AnvilScreen sets `setInvertHighlightedTextColor(false)`.
edit.invert_highlighted_text_color = false;
edit.focused = true;
edit.editable = false;
edit
@@ -40,6 +42,7 @@ mod tests {
// AnvilScreen.init: no input yet, so the box is read-only but focused.
let station_box = rename_box();
assert!(station_box.focused && !station_box.editable);
assert!(!station_box.invert_highlighted_text_color);
// slotChanged with an item: editable, and it shows the item's name.
let mut station = Station::new_kind(Kind::Anvil, [0; 3]);
+362 -128
View File
@@ -6,7 +6,7 @@
use super::{Game, PendingWorldEntry, Screen, packs};
use horizon_client::{
Result,
studio::{Metadata, ReplayDriver},
studio::{Metadata, ReplayDriver, TimelineAction},
text::Text,
};
use serde_json::{Value, json};
@@ -15,6 +15,25 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
/// Studio's state in a `Game`: the replay editor's free camera, timeline and top bar. Studio is a
/// Horizon extra (docs/layers.md: the replay editor) on top of the platform's replay
/// (`replay.rs`); the engine only asks it whether the free camera flies.
#[derive(Default)]
pub(super) struct Studio {
pub(super) freecam: horizon_client::studio::StudioFreecam,
pub(super) timeline: horizon_client::studio::TimelineUi,
pub(super) topbar: horizon_client::studio::TopBarUi,
pub(super) rmb_down: bool,
pub(super) camera_track_dirty: bool,
}
impl Studio {
/// The free camera flies, apart from the player.
pub(super) fn flying(&self) -> bool {
self.freecam.active
}
}
pub const HELP: &[&str] = &[
"studio record start [NAME] start recording clientbound packets into .mcpr archive",
"studio record stop stop recording and finalize .mcpr archive",
@@ -48,6 +67,11 @@ pub(super) fn studio_screen_opens(screen: Screen, blocked: bool) -> Option<bool>
}
impl Game<'_> {
/// Studio is up (its bars, its camera path, its mouse): a replay plays or the free camera flies.
pub(super) fn studio_up(&self) -> bool {
self.replay.playing() || self.studio.flying()
}
/// Opens the Studio script screen (`Screen::Studio`) over the world.
///
/// Horizon Studio script windows (`ui.open`) use this slot instead of the
@@ -109,13 +133,13 @@ impl Game<'_> {
match words[0] {
"freecam" => match words.get(1).copied() {
Some("on") => {
if self.studio_replay.is_none() {
if self.replay.driver.is_none() {
self.system_message(Text::translate(
"horizon.studio.freecam.replay_required",
&[],
));
} else if let Some((position, yaw, pitch)) = self.camera_view(1.0) {
self.studio_freecam.activate(
self.studio.freecam.activate(
position,
yaw,
pitch,
@@ -131,24 +155,24 @@ impl Game<'_> {
}
}
Some("off") => {
self.studio_freecam.deactivate();
self.studio.freecam.deactivate();
self.capture(false);
self.system_message(Text::translate("horizon.studio.freecam.disabled", &[]));
}
_ => self.system_message(Text::translate("horizon.studio.freecam.usage", &[])),
},
"keyframe" => {
let Some(driver) = self.studio_replay.as_ref() else {
let Some(driver) = self.replay.driver.as_ref() else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
return true;
};
let time_ms = driver.position_ms();
match words.get(1).copied() {
Some("add") => {
if !self.studio_freecam.active
if !self.studio.freecam.active
&& let Some((position, yaw, pitch)) = self.camera_view(1.0)
{
self.studio_freecam.activate(
self.studio.freecam.activate(
position,
yaw,
pitch,
@@ -156,16 +180,16 @@ impl Game<'_> {
);
}
self.capture(true);
self.studio_freecam.add_keyframe_at_current(time_ms);
self.studio_camera_track_dirty = true;
self.studio.freecam.add_keyframe_at_current(time_ms);
self.studio.camera_track_dirty = true;
self.system_message(Text::tr(
"horizon.studio.keyframe.added",
&[&time_ms.to_string()],
));
}
Some("clear") => {
self.studio_freecam.track.clear();
self.studio_camera_track_dirty = true;
self.studio.freecam.track.clear();
self.studio.camera_track_dirty = true;
self.system_message(Text::translate(
"horizon.studio.keyframe.cleared",
&[],
@@ -181,15 +205,15 @@ impl Game<'_> {
));
return true;
};
if index >= self.studio_freecam.track.keyframes.len() {
if index >= self.studio.freecam.track.keyframes.len() {
self.system_message(Text::translate(
"horizon.studio.keyframe.index_invalid",
&[],
));
return true;
}
self.studio_freecam.track.keyframes.remove(index);
self.studio_camera_track_dirty = true;
self.studio.freecam.track.keyframes.remove(index);
self.studio.camera_track_dirty = true;
self.system_message(Text::tr(
"horizon.studio.keyframe.removed",
&[&index.to_string()],
@@ -199,14 +223,14 @@ impl Game<'_> {
}
}
"path" => match words.get(1).copied() {
Some("lock") if !self.studio_freecam.track.is_empty() => {
self.studio_freecam.active = true;
self.studio_freecam.locked_to_path = true;
Some("lock") if !self.studio.freecam.track.is_empty() => {
self.studio.freecam.active = true;
self.studio.freecam.locked_to_path = true;
self.capture(true);
self.system_message(Text::translate("horizon.studio.path.locked", &[]));
}
Some("unlock") => {
self.studio_freecam.locked_to_path = false;
self.studio.freecam.locked_to_path = false;
self.system_message(Text::translate("horizon.studio.path.unlocked", &[]));
}
_ => self.system_message(Text::translate("horizon.studio.path.usage", &[])),
@@ -299,7 +323,7 @@ impl Game<'_> {
}
}
Some("pause") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(true);
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
} else {
@@ -307,7 +331,7 @@ impl Game<'_> {
}
}
Some("resume") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(false);
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
} else {
@@ -323,7 +347,7 @@ impl Game<'_> {
return true;
};
if let Ok(spd) = spd_str.parse::<f64>() {
let new_speed = if let Some(r) = self.studio_replay.as_mut() {
let new_speed = if let Some(r) = self.replay.driver.as_mut() {
r.set_speed(spd);
Some(r.speed())
} else {
@@ -357,11 +381,12 @@ impl Game<'_> {
return true;
};
if let Ok(ms) = ms_str.parse::<u32>() {
if self.studio_replay.is_some() {
if self.replay.driver.is_some() {
match self.seek_studio_replay(ms) {
Ok(()) => {
let actual_ms = self
.studio_replay
.replay
.driver
.as_ref()
.map_or(ms, |replay| ms.min(replay.duration_ms()));
let ms_text = actual_ms.to_string();
@@ -400,7 +425,7 @@ impl Game<'_> {
}
}
Some("status") => {
if let Some(r) = self.studio_replay.as_ref() {
if let Some(r) = self.replay.driver.as_ref() {
let position = r.position_ms().to_string();
let duration = r.duration_ms().to_string();
let progress = format!("{:.1}", r.progress() * 100.0);
@@ -435,7 +460,7 @@ impl Game<'_> {
/// Reset playback tick accumulator and align positions on seek.
pub(super) fn reset_studio_playback_ticks(&mut self) {
self.studio_virtual_tick_ms = 0.0;
self.replay.virtual_tick_ms = 0.0;
if let Some(player) = &self.player {
self.previous_position = player.position;
if let Some(m) = self.player_model.as_mut() {
@@ -452,12 +477,14 @@ impl Game<'_> {
/// forward because its pre-replay world must remain intact.
pub(super) fn seek_studio_replay(&mut self, target_ms: u32) -> std::io::Result<()> {
let current_ms = self
.studio_replay
.replay
.driver
.as_ref()
.map_or(0, ReplayDriver::position_ms);
let packets = if self.studio_replay_standalone {
let packets = if self.replay.standalone {
let packets = self
.studio_replay
.replay
.driver
.as_mut()
.expect("replay checked by caller")
.seek_and_collect(target_ms)?;
@@ -471,7 +498,8 @@ impl Game<'_> {
"backward seek requires a standalone replay",
));
}
self.studio_replay
self.replay
.driver
.as_mut()
.expect("replay checked by caller")
.seek_forward(target_ms)?
@@ -479,7 +507,7 @@ impl Game<'_> {
self.dispatch_studio_replay_packets(packets);
self.reset_studio_playback_ticks();
self.last_replay_tick = Some(std::time::Instant::now());
self.replay.last_tick = Some(std::time::Instant::now());
Ok(())
}
@@ -510,19 +538,19 @@ impl Game<'_> {
/// Advance active replay playback by elapsed frame time and dispatch ready packets.
pub(super) fn advance_studio_replay(&mut self) -> Result<()> {
let Some(driver) = self.studio_replay.as_mut() else {
self.last_replay_tick = None;
let Some(driver) = self.replay.driver.as_mut() else {
self.replay.last_tick = None;
return Ok(());
};
let now = std::time::Instant::now();
let delta = match self.last_replay_tick {
let delta = match self.replay.last_tick {
Some(last) => now
.saturating_duration_since(last)
.min(Duration::from_millis(250)),
None => Duration::from_millis(16),
};
self.last_replay_tick = Some(now);
self.replay.last_tick = Some(now);
let ready_packets = match driver.advance(delta) {
Ok(pkts) => pkts,
@@ -533,16 +561,18 @@ impl Game<'_> {
};
self.dispatch_studio_replay_packets(ready_packets);
if self.studio_freecam.locked_to_path
if self.studio.freecam.locked_to_path
&& let Some(position) = self
.studio_replay
.replay
.driver
.as_ref()
.map(|replay| replay.position_ms())
{
self.studio_freecam.update_spline_playback(position);
self.studio.freecam.update_spline_playback(position);
}
let paused = self
.studio_replay
.replay
.driver
.as_ref()
.is_some_and(|replay| replay.is_paused());
if self.player_model.is_none() {
@@ -550,13 +580,14 @@ impl Game<'_> {
}
if !paused {
let speed = self
.studio_replay
.replay
.driver
.as_ref()
.map_or(1.0, |r| r.speed() as f32)
.max(0.1);
self.studio_virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
while self.studio_virtual_tick_ms >= 50.0 {
self.studio_virtual_tick_ms -= 50.0;
self.replay.virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
while self.replay.virtual_tick_ms >= 50.0 {
self.replay.virtual_tick_ms -= 50.0;
if let Some(player) = &self.player {
let current_pos = player.position;
let (dx, dz) = (
@@ -607,7 +638,7 @@ impl Game<'_> {
m.previous_limb_speed = m.limb_speed;
}
if let Some((position, duration, paused, markers)) =
self.studio_replay.as_ref().map(|replay| {
self.replay.driver.as_ref().map(|replay| {
(
replay.position_ms(),
replay.duration_ms(),
@@ -616,10 +647,10 @@ impl Game<'_> {
)
})
{
self.studio_timeline.current_time_ms = u64::from(position);
self.studio_timeline.duration_ms = u64::from(duration);
self.studio_timeline.playing = !paused;
self.studio_timeline.markers = markers;
self.studio.timeline.current_time_ms = u64::from(position);
self.studio.timeline.duration_ms = u64::from(duration);
self.studio.timeline.playing = !paused;
self.studio.timeline.markers = markers;
}
Ok(())
}
@@ -639,10 +670,10 @@ impl Game<'_> {
self.capture(false);
}
TopBarAction::ExitStudio => {
if self.studio_replay.is_some() {
if self.replay.driver.is_some() {
self.close_studio_replay();
} else {
self.studio_freecam.deactivate();
self.studio.freecam.deactivate();
self.capture(false);
}
}
@@ -668,42 +699,46 @@ impl Game<'_> {
} else {
(glam::DVec3::ZERO, 0.0, 0.0)
};
self.studio_freecam
self.studio
.freecam
.activate(position, yaw, pitch, self.settings.fov);
}
TopBarAction::ToggleLockToPath => {
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
}
TopBarAction::ToggleFreecam => {
if self.studio_freecam.active {
self.studio_freecam.deactivate();
if self.studio.freecam.active {
self.studio.freecam.deactivate();
self.capture(false);
} else {
let (position, yaw, pitch) =
self.camera_view(0.0)
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
self.studio_freecam
self.studio
.freecam
.activate(position, yaw, pitch, self.settings.fov);
self.capture(true);
}
}
TopBarAction::AddKeyframe => {
self.studio_freecam
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
self.studio_camera_track_dirty = true;
self.studio
.freecam
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
self.studio.camera_track_dirty = true;
}
TopBarAction::AddMarker => self.add_studio_marker(),
TopBarAction::ClearKeyframes => {
self.studio_freecam.track.keyframes.clear();
self.studio_timeline.selected_keyframe = None;
self.studio_camera_track_dirty = true;
self.studio.freecam.track.keyframes.clear();
self.studio.timeline.selected_keyframe = None;
self.studio.camera_track_dirty = true;
}
TopBarAction::ToggleTimeline => {
self.studio_timeline.visible = !self.studio_timeline.visible;
self.studio.timeline.visible = !self.studio.timeline.visible;
}
TopBarAction::SplitClip => {
self.studio_timeline
.split_at(self.studio_timeline.current_time_ms);
self.studio
.timeline
.split_at(self.studio.timeline.current_time_ms);
}
}
}
@@ -716,93 +751,97 @@ impl Game<'_> {
use horizon_client::studio::TimelineAction;
match action {
TimelineAction::PauseForSeek => {
self.studio_timeline.playing = false;
if let Some(replay) = self.studio_replay.as_mut() {
self.studio.timeline.playing = false;
if let Some(replay) = self.replay.driver.as_mut() {
replay.set_paused(true);
}
}
TimelineAction::Seek(time_ms) => {
let time_ms = if self.studio_replay_standalone {
let time_ms = if self.replay.standalone {
time_ms
} else {
let current =
self.studio_replay
self.replay
.driver
.as_ref()
.map_or(0, ReplayDriver::position_ms) as u64;
time_ms.max(current)
};
self.studio_timeline.current_time_ms = time_ms;
self.studio.timeline.current_time_ms = time_ms;
if let Err(error) = self.seek_studio_replay(time_ms.min(u32::MAX as u64) as u32) {
log::warn!("Studio timeline seek failed: {error}");
}
}
TimelineAction::TogglePlay => {
self.studio_timeline.playing = !self.studio_timeline.playing;
if let Some(replay) = self.studio_replay.as_mut() {
replay.set_paused(!self.studio_timeline.playing);
self.studio.timeline.playing = !self.studio.timeline.playing;
if let Some(replay) = self.replay.driver.as_mut() {
replay.set_paused(!self.studio.timeline.playing);
}
}
TimelineAction::StepBack => {
let time_ms = self.studio_timeline.current_time_ms.saturating_sub(1000);
let time_ms = self.studio.timeline.current_time_ms.saturating_sub(1000);
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
}
TimelineAction::StepForward => {
let time_ms = self
.studio_timeline
.studio
.timeline
.current_time_ms
.saturating_add(1000)
.min(self.studio_timeline.duration_ms);
.min(self.studio.timeline.duration_ms);
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
}
TimelineAction::SeekStart => {
self.handle_studio_timeline_action(TimelineAction::Seek(0))
}
TimelineAction::SeekEnd => self.handle_studio_timeline_action(TimelineAction::Seek(
self.studio_timeline.duration_ms,
self.studio.timeline.duration_ms,
)),
TimelineAction::SetSpeed(speed) => {
self.studio_timeline.speed = speed;
if let Some(replay) = self.studio_replay.as_mut() {
self.studio.timeline.speed = speed;
if let Some(replay) = self.replay.driver.as_mut() {
replay.set_speed(speed);
}
}
TimelineAction::ToggleFreecam => {
if self.studio_freecam.active {
self.studio_freecam.deactivate();
if self.studio.freecam.active {
self.studio.freecam.deactivate();
} else {
let (position, yaw, pitch) =
self.camera_view(0.0)
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
self.studio_freecam
self.studio
.freecam
.activate(position, yaw, pitch, self.settings.fov);
}
}
TimelineAction::AddCameraKeyframe => {
self.studio_freecam
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
self.studio_camera_track_dirty = true;
self.studio
.freecam
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
self.studio.camera_track_dirty = true;
}
TimelineAction::RemoveCameraKeyframe(index) => {
if index < self.studio_freecam.track.keyframes.len() {
self.studio_freecam.track.keyframes.remove(index);
self.studio_camera_track_dirty = true;
if index < self.studio.freecam.track.keyframes.len() {
self.studio.freecam.track.keyframes.remove(index);
self.studio.camera_track_dirty = true;
}
self.studio_timeline.selected_keyframe = None;
self.studio.timeline.selected_keyframe = None;
}
TimelineAction::SelectKeyframe(index) => {
self.studio_timeline.selected_keyframe = Some(index);
self.studio.timeline.selected_keyframe = Some(index);
}
TimelineAction::KeyframeMoved => {
self.studio_camera_track_dirty = true;
self.studio.camera_track_dirty = true;
}
TimelineAction::SplitClip(time_ms) => self.studio_timeline.split_at(time_ms),
TimelineAction::SplitClip(time_ms) => self.studio.timeline.split_at(time_ms),
TimelineAction::None => {}
}
}
/// Add a bookmark at the replay playhead and save it in the `.mcpr` archive.
pub(super) fn add_studio_marker(&mut self) {
let Some(replay) = self.studio_replay.as_mut() else {
let Some(replay) = self.replay.driver.as_mut() else {
return;
};
let label = Text::translate(
@@ -811,7 +850,7 @@ impl Game<'_> {
)
.plain();
match replay.add_marker(label) {
Ok(_) => self.studio_timeline.markers = replay.markers().to_vec(),
Ok(_) => self.studio.timeline.markers = replay.markers().to_vec(),
Err(error) => log::warn!("Studio: could not save replay marker: {error}"),
}
}
@@ -845,7 +884,7 @@ impl Game<'_> {
Ok(Some(res))
}
Some("pause") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(true);
Ok(Some(json!({ "ok": true, "paused": true })))
} else {
@@ -853,7 +892,7 @@ impl Game<'_> {
}
}
Some("resume") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(false);
Ok(Some(json!({ "ok": true, "paused": false })))
} else {
@@ -863,7 +902,7 @@ impl Game<'_> {
Some("speed") => {
let spd_str = words.get(3).ok_or("missing speed value")?;
let spd: f64 = spd_str.parse().map_err(|_| "invalid speed")?;
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.replay.driver.as_mut() {
r.set_speed(spd);
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
} else {
@@ -873,7 +912,7 @@ impl Game<'_> {
Some("seek") => {
let ms_str = words.get(3).ok_or("missing seek timestamp")?;
let ms: u32 = ms_str.parse().map_err(|_| "invalid timestamp")?;
let pos = if let Some(r) = self.studio_replay.as_mut() {
let pos = if let Some(r) = self.replay.driver.as_mut() {
r.seek(ms).map_err(|e| format!("{e}"))?;
Some(r.position_ms())
} else {
@@ -887,7 +926,7 @@ impl Game<'_> {
}
}
Some("status") => {
if let Some(r) = self.studio_replay.as_ref() {
if let Some(r) = self.replay.driver.as_ref() {
Ok(Some(json!({
"ok": true,
"active": true,
@@ -999,13 +1038,13 @@ impl Game<'_> {
}
pub(super) fn open_studio_replay(&mut self, path: &str) -> Result<Value> {
if self.studio_camera_track_dirty
&& let Some(active) = self.studio_replay.as_mut()
if self.studio.camera_track_dirty
&& let Some(active) = self.replay.driver.as_mut()
{
if let Err(error) = active.save_camera_track(&self.studio_freecam.track) {
if let Err(error) = active.save_camera_track(&self.studio.freecam.track) {
return Ok(json!({ "ok": false, "error": error.to_string() }));
}
self.studio_camera_track_dirty = false;
self.studio.camera_track_dirty = false;
}
// The recordings browser can be opened while the title screen is
// available but the slower world tables are still loading. Replay
@@ -1028,7 +1067,7 @@ impl Game<'_> {
}
};
let standalone = self.studio_replay.is_none() && self.remote.is_none();
let standalone = self.replay.driver.is_none() && self.remote.is_none();
if self.remote.is_none()
&& let Err(error) = self.init_remote_for_replay()
{
@@ -1038,17 +1077,17 @@ impl Game<'_> {
let duration_ms = driver.duration_ms();
let packets = driver.packet_count();
self.studio_timeline.duration_ms = u64::from(duration_ms);
self.studio_timeline.current_time_ms = 0;
self.studio_timeline.playing = !driver.is_paused();
self.studio_timeline.markers = driver.markers().to_vec();
self.studio_freecam.track = driver.camera_track().cloned().unwrap_or_default();
self.studio_camera_track_dirty = false;
if self.studio_replay.is_none() {
self.studio_replay_previous_camera_type = Some(self.camera_type);
self.studio_replay_standalone = standalone;
self.studio.timeline.duration_ms = u64::from(duration_ms);
self.studio.timeline.current_time_ms = 0;
self.studio.timeline.playing = !driver.is_paused();
self.studio.timeline.markers = driver.markers().to_vec();
self.studio.freecam.track = driver.camera_track().cloned().unwrap_or_default();
self.studio.camera_track_dirty = false;
if self.replay.driver.is_none() {
self.replay.previous_camera_type = Some(self.camera_type);
self.replay.standalone = standalone;
}
self.studio_replay = Some(driver);
self.replay.driver = Some(driver);
self.camera_type = super::perspective::CameraType::FirstPerson;
self.title = None;
self.screen = Screen::Playing;
@@ -1066,14 +1105,14 @@ impl Game<'_> {
}))
}
pub(super) fn close_studio_replay(&mut self) -> bool {
if self.studio_camera_track_dirty
&& let Some(driver) = self.studio_replay.as_mut()
&& let Err(error) = driver.save_camera_track(&self.studio_freecam.track)
if self.studio.camera_track_dirty
&& let Some(driver) = self.replay.driver.as_mut()
&& let Err(error) = driver.save_camera_track(&self.studio.freecam.track)
{
log::warn!("Studio: could not save camera path: {error}");
}
self.studio_camera_track_dirty = false;
let closed = self.studio_replay.take().is_some();
self.studio.camera_track_dirty = false;
let closed = self.replay.driver.take().is_some();
if !closed {
return false;
}
@@ -1081,23 +1120,23 @@ impl Game<'_> {
// A standalone replay owns a synthetic Remote connection. Treat that
// connection as replay state even if the saved origin flag was lost;
// sending it through leave_server() opens the Multiplayer screen.
let standalone = self.studio_replay_standalone
let standalone = self.replay.standalone
|| self
.remote
.as_ref()
.is_some_and(|remote| remote.brand == "horizon-studio");
self.studio_replay_standalone = false;
if let Some(camera_type) = self.studio_replay_previous_camera_type.take() {
self.replay.standalone = false;
if let Some(camera_type) = self.replay.previous_camera_type.take() {
self.camera_type = camera_type;
}
self.last_replay_tick = None;
self.studio_timeline.current_time_ms = 0;
self.studio_timeline.playing = false;
self.studio_timeline.markers.clear();
self.studio_timeline.split_points.clear();
self.studio_timeline.selected_keyframe = None;
self.studio_freecam.deactivate();
self.studio_virtual_tick_ms = 0.0;
self.replay.last_tick = None;
self.studio.timeline.current_time_ms = 0;
self.studio.timeline.playing = false;
self.studio.timeline.markers.clear();
self.studio.timeline.split_points.clear();
self.studio.timeline.selected_keyframe = None;
self.studio.freecam.deactivate();
self.replay.virtual_tick_ms = 0.0;
self.capture(false);
if standalone {
if let Some(mut remote) = self.remote.take() {
@@ -1243,3 +1282,198 @@ impl Game<'_> {
);
}
}
/// What the engine's frame, render and window code hand to Studio.
impl Game<'_> {
/// The top bar and the timeline over the HUD, while Studio is up in play.
pub(super) fn draw_studio_bars(&mut self, batch: &mut super::HudBatch) {
if self.ui.hide_gui || self.screen != Screen::Playing || !self.studio_up() {
return;
}
self.studio.topbar.render(
batch,
&self.gui.atlas,
self.cursor,
self.studio.freecam.locked_to_path,
);
self.studio
.timeline
.render(batch, &self.gui.atlas, self.cursor, &self.studio.freecam);
}
/// The camera path among the debug lines, while Studio is up.
pub(super) fn studio_camera_path(&self, gizmos: &mut horizon_client::gizmos::Gizmos) {
if self.studio_up() {
horizon_client::studio::render_camera_path(
&self.studio.freecam.track,
gizmos,
None,
100,
);
}
}
/// The pointer moved while the timeline's playhead or a keyframe is dragged: Studio took it.
pub(super) fn studio_mouse_dragged(&mut self, width: f32) -> bool {
if self.screen != Screen::Playing
|| !(self.studio.timeline.dragging_playhead
|| self.studio.timeline.dragging_keyframe.is_some())
{
return false;
}
let action =
self.studio
.timeline
.handle_mouse_drag(self.cursor, width, &mut self.studio.freecam);
if matches!(action, TimelineAction::KeyframeMoved) {
self.studio.camera_track_dirty = true;
}
true
}
/// A mouse button while Studio is up: true when Studio took it (its bars, the right button's
/// mouse look, every click while a replay plays).
pub(super) fn studio_mouse_input(
&mut self,
pressed: bool,
button: winit::event::MouseButton,
) -> bool {
if !pressed && button == winit::event::MouseButton::Left {
let action = self.studio.timeline.handle_mouse_up();
if !matches!(action, TimelineAction::None) {
self.handle_studio_timeline_action(action);
}
}
if (self.studio.freecam.active || self.replay.driver.is_some())
&& button == winit::event::MouseButton::Right
{
if let Some(renderer) = &self.renderer {
let batch = super::HudBatch::new([renderer.config.width, renderer.config.height]);
if self.studio.timeline.visible
&& self.cursor.1 >= batch.height - self.studio.timeline.height()
{
if pressed
&& let Some(index) = self.studio.timeline.keyframe_at_cursor(
self.cursor,
batch.width,
batch.height,
&self.studio.freecam,
)
{
if index < self.studio.freecam.track.keyframes.len() {
self.studio.freecam.track.keyframes.remove(index);
self.studio.camera_track_dirty = true;
}
self.studio.timeline.selected_keyframe = None;
}
return true;
}
}
if pressed {
self.studio.topbar.close_dropdown();
self.studio.rmb_down = true;
self.capture(true);
return true;
} else if self.studio.rmb_down {
self.studio.rmb_down = false;
self.capture(false);
return true;
}
}
if pressed
&& button == winit::event::MouseButton::Left
&& self.screen == Screen::Playing
&& (self.replay.driver.is_some() || self.studio.freecam.active)
{
let size = self
.renderer
.as_ref()
.map(|renderer| [renderer.config.width, renderer.config.height])
.unwrap_or([1, 1]);
let batch = super::HudBatch::new(size);
let had_open_menu = self.studio.topbar.open_menu.is_some();
let topbar_action =
self.studio
.topbar
.handle_mouse_click(self.cursor, batch.width, batch.height);
if !matches!(topbar_action, horizon_client::studio::TopBarAction::None) {
self.handle_studio_topbar_action(topbar_action);
return true;
}
if had_open_menu || self.cursor.1 <= self.studio.topbar.height {
return true;
}
if self.studio.timeline.visible {
let action = self.studio.timeline.handle_mouse_down(
self.cursor,
batch.width,
batch.height,
&self.studio.freecam,
);
if !matches!(action, TimelineAction::None) {
self.handle_studio_timeline_action(action);
return true;
}
if self.cursor.1 >= batch.height - self.studio.timeline.height() {
return true;
}
}
}
if self.screen == Screen::Playing && self.replay.driver.is_some() {
return true;
}
false
}
/// The free camera flies by the movement keys, once per frame.
pub(super) fn studio_fly(&mut self, elapsed: Duration) {
if !self.studio.flying() || self.screen != Screen::Playing || self.dialog_open() {
return;
}
use winit::keyboard::KeyCode;
let held = |key| f64::from(u8::from(self.keys.raw(&key)));
let forward = held(KeyCode::KeyW) - held(KeyCode::KeyS);
let strafe = held(KeyCode::KeyD) - held(KeyCode::KeyA);
let vertical = held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
let fast = self.keys.raw(&KeyCode::ControlRight);
self.studio.freecam.update_flight_motion(
forward,
strafe,
vertical,
fast,
elapsed.as_secs_f64().min(0.1),
);
}
/// A key pressed while a replay plays: Studio's shortcuts, and nothing else reaches play.
pub(super) fn studio_key(&mut self, key: winit::keyboard::KeyCode) {
use winit::keyboard::KeyCode;
match key {
KeyCode::Escape => {
self.close_studio_replay();
}
KeyCode::KeyP => {
self.pause_focus = None;
self.screen = Screen::Paused;
self.pause_menu = true;
self.capture(false);
}
KeyCode::KeyF => self.handle_studio_timeline_action(TimelineAction::ToggleFreecam),
KeyCode::KeyT => self.studio.timeline.visible = !self.studio.timeline.visible,
KeyCode::KeyM => self.add_studio_marker(),
KeyCode::Home => self.handle_studio_topbar_action(
horizon_client::studio::TopBarAction::ResetCameraToPlayer,
),
KeyCode::KeyL => {
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
}
KeyCode::KeyK => self.handle_studio_timeline_action(TimelineAction::AddCameraKeyframe),
KeyCode::Delete => {
if let Some(index) = self.studio.timeline.selected_keyframe {
self.handle_studio_timeline_action(TimelineAction::RemoveCameraKeyframe(index));
}
}
_ => {}
}
}
}
+1 -1
View File
@@ -149,7 +149,7 @@ impl Game<'_> {
self.audio
.play_unshared("minecraft:item.totem.use", Some(body.bounds.feet), 1.0, 1.0);
if target == TrackingTarget::LocalPlayer {
self.pad_rumble(horizon_client::gamepad::Rumble::Totem);
self.input_feedback(horizon_client::input_source::Feedback::Totem);
// findTotem, then LocalPlayer.displayItemActivation. The offsets
// come from the player's own random in vanilla, not the level's:
// the client-only particle random stands in for it. The server
Loaded 100 of 278 files, more files were not shown because too many files have changed in this diff. Show more