Compare commits

..
Author SHA1 Message Date
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
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
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 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 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 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-agent b26a050b2b Merge remote-tracking branch 'origin/main' into pr-13795 2026-11-09 19:18:26 +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
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
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
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 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 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
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
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 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
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 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
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
215 changed files with 3382 additions and 5276 deletions

No files matched your search

@@ -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;
+4
View File
@@ -76,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
}
}
+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!(
@@ -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 {
+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,
+5 -5
View File
@@ -7041,8 +7041,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`).
@@ -8319,9 +8321,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;
}
+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());
}
}
@@ -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.
@@ -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,
+3 -2
View File
@@ -119,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.",
+1 -1
View File
@@ -110,7 +110,6 @@ 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;
@@ -172,6 +171,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;
+2 -7
View File
@@ -20,18 +20,13 @@ 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());
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);
// Opt-in telemetry's play session plugs in through the hooks until it moves to a Horizon
// crate in horizon-official's parts (layer plan M5). horizon-extras: telemetry is Horizon's
// (docs/layers.md); without it no session is kept.
#[cfg(feature = "horizon-extras")]
let _ = horizon_hooks::register(&horizon_client::telemetry::HOOKS);
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_hooks::engine::seal();
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");
+60 -80
View File
@@ -2801,7 +2801,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 +2882,8 @@ 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,
/// Horizon Studio: replay playback, its free camera, timeline and top bar (game/studio.rs).
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 +5015,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 +5834,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,7 +5931,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 in_studio = self.studio.replay.is_some();
let show_hud = !self.ui.hide_gui && !in_studio;
// Hud.extractRenderState asks `Screen.isInGameUi` twice: such a screen
// defers the subtitles over itself, and keeps them while F1 hides the
@@ -6284,19 +6273,19 @@ impl Game<'_> {
}
if !self.ui.hide_gui
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
&& (self.studio.replay.is_some() || self.studio.freecam.active)
{
self.studio_topbar.render(
self.studio.topbar.render(
&mut batch,
&self.gui.atlas,
self.cursor,
self.studio_freecam.locked_to_path,
self.studio.freecam.locked_to_path,
);
self.studio_timeline.render(
self.studio.timeline.render(
&mut batch,
&self.gui.atlas,
self.cursor,
&self.studio_freecam,
&self.studio.freecam,
);
}
if show_hud {
@@ -6521,12 +6510,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.freecam.active {
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,8 +6722,8 @@ 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)
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()
};
@@ -9236,7 +9225,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.freecam.active {
view
} else {
self.native_camera_pose_now(camera_alpha as f32, view)
@@ -9414,9 +9403,9 @@ 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() {
if self.studio.freecam.active || self.studio.replay.is_some() {
horizon_client::studio::render_camera_path(
&self.studio_freecam.track,
&self.studio.freecam.track,
&mut g,
None,
100,
@@ -9443,7 +9432,7 @@ impl Game<'_> {
Vec::new()
};
pr.lap("pr_native_visuals");
let view = if self.studio_freecam.active {
let view = if self.studio.freecam.active {
glam::Mat4::from_quat(self.camera_orientation().conjugate())
} else {
horizon_client::camera::view_matrix(view_yaw, view_pitch)
@@ -9569,7 +9558,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.freecam.active);
let bob = if !camera_effects {
glam::Mat4::IDENTITY
} else {
@@ -10074,7 +10063,7 @@ impl Game<'_> {
)
)
{
self.pad.keyboard_took_over = true;
self.input_keyboard_used();
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Pressed
@@ -10463,19 +10452,19 @@ impl Game<'_> {
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())
&& (self.studio.timeline.dragging_playhead
|| self.studio.timeline.dragging_keyframe.is_some())
{
let action = self.studio_timeline.handle_mouse_drag(
let action = self.studio.timeline.handle_mouse_drag(
self.cursor,
b.width,
&mut self.studio_freecam,
&mut self.studio.freecam,
);
if matches!(
action,
horizon_client::studio::TimelineAction::KeyframeMoved
) {
self.studio_camera_track_dirty = true;
self.studio.camera_track_dirty = true;
}
return Ok(());
}
@@ -10527,7 +10516,7 @@ impl Game<'_> {
}
}
WindowEvent::MouseWheel { .. }
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
if self.screen == Screen::Playing && self.studio.replay.is_some() => {}
WindowEvent::MouseWheel { delta, .. } => {
let (raw_x, raw_y) = match delta {
MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
@@ -10805,44 +10794,44 @@ impl Game<'_> {
WindowEvent::MouseInput { state, button, .. } => {
let pressed = state == ElementState::Pressed;
if !pressed && button == MouseButton::Left {
let action = self.studio_timeline.handle_mouse_up();
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())
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 self.studio.timeline.visible
&& self.cursor.1 >= batch.height - self.studio.timeline.height()
{
if pressed
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
&& let Some(index) = self.studio.timeline.keyframe_at_cursor(
self.cursor,
batch.width,
batch.height,
&self.studio_freecam,
&self.studio.freecam,
)
{
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;
}
return Ok(());
}
}
if pressed {
self.studio_topbar.close_dropdown();
self.studio_rmb_down = true;
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;
} else if self.studio.rmb_down {
self.studio.rmb_down = false;
self.capture(false);
return Ok(());
}
@@ -10850,7 +10839,7 @@ impl Game<'_> {
if pressed
&& button == MouseButton::Left
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
&& (self.studio.replay.is_some() || self.studio.freecam.active)
{
let size = self
.renderer
@@ -10858,8 +10847,8 @@ impl Game<'_> {
.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(
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,
@@ -10868,26 +10857,26 @@ impl Game<'_> {
self.handle_studio_topbar_action(topbar_action);
return Ok(());
}
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
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(
if self.studio.timeline.visible {
let action = self.studio.timeline.handle_mouse_down(
self.cursor,
batch.width,
batch.height,
&self.studio_freecam,
&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() {
if self.cursor.1 >= batch.height - self.studio.timeline.height() {
return Ok(());
}
}
}
if self.screen == Screen::Playing && self.studio_replay.is_some() {
if self.screen == Screen::Playing && self.studio.replay.is_some() {
return Ok(());
}
if matches!(self.screen, Screen::Inventory | Screen::Creative)
@@ -11357,7 +11346,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
if self.studio.freecam.active
&& self.screen == Screen::Playing
&& !self.dialog_open()
{
@@ -11367,7 +11356,7 @@ impl Game<'_> {
let vertical =
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
let fast = self.keys.raw(&KeyCode::ControlRight);
self.studio_freecam.update_flight_motion(
self.studio.freecam.update_flight_motion(
forward,
strafe,
vertical,
@@ -11447,7 +11436,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();
@@ -12157,16 +12146,7 @@ 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,
studio: Default::default(),
lan: None,
integrated: None,
level_shapes: Default::default(),
@@ -12635,11 +12615,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);
}
@@ -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) {
+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());
+2 -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,
@@ -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;
@@ -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.freecam.active {
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.freecam.active,
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() {
+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);
@@ -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;
@@ -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.freecam.active {
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
@@ -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.freecam.active {
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.freecam.active
}
/// 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.studio.replay.is_some() {
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.freecam.active {
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};
+7 -8
View File
@@ -10,7 +10,7 @@ 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() {
if self.screen == Screen::Playing && self.studio.replay.is_some() {
match key {
KeyCode::Escape => {
self.close_studio_replay();
@@ -25,19 +25,19 @@ impl Game<'_> {
KeyCode::KeyF => self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::ToggleFreecam,
),
KeyCode::KeyT => self.studio_timeline.visible = !self.studio_timeline.visible,
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;
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 {
if let Some(index) = self.studio.timeline.selected_keyframe {
self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::RemoveCameraKeyframe(index),
);
@@ -78,7 +78,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.studio.replay.is_some() && self.captured {
self.capture(false);
return Ok(());
}
@@ -213,7 +213,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.studio.replay.is_none() => {
self.cycle_camera()
}
KeyCode::KeyE
@@ -279,8 +279,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
@@ -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.freecam.active);
let active = self.focused || self.agent_pointer;
if let Some((xo, yo)) = self
.mouse
@@ -76,8 +76,8 @@ impl Game<'_> {
&& self.control.is_none()
&& self.screen == Screen::Playing
&& !studio_suppresses_focus_pause(
self.studio_replay.is_some(),
self.studio_freecam.active,
self.studio.replay.is_some(),
self.studio.freecam.active,
)
&& self.player.is_some()
&& !self.options.no_pause
@@ -149,7 +149,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 +353,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.replay.is_some() || self.studio.freecam.active {
self.regrab_on_refocus = false;
self.studio_rmb_down = false;
self.studio.rmb_down = false;
self.capture(false);
return;
}
+14 -38
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.studio.replay.is_some() {
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.studio.replay.is_none() {
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.studio.replay.is_none() {
self.previous_position = player.position;
}
let before = (
@@ -6728,7 +6701,7 @@ 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.studio.replay.is_none() {
self.tick_player_model();
}
self.tick_emotes();
@@ -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.studio.replay.is_none()
&& 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.studio.replay.is_some() {
return false;
}
use horizon_client::level_loading::Reason;
@@ -11642,6 +11615,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 {
@@ -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]);
}
}
@@ -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]);
+162 -127
View File
@@ -15,6 +15,26 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
/// Studio's state in a `Game`: the replay playing, its free camera, timeline and top bar. Studio is
/// a Horizon extra (docs/layers.md: the replay editor); the engine only asks it whether a replay
/// plays or the free camera flies.
#[derive(Default)]
pub(super) struct Studio {
/// The packet replay playing, if any.
pub(super) replay: Option<ReplayDriver>,
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,
/// Camera perspective to restore after leaving replay playback.
pub(super) replay_previous_camera_type: Option<super::perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
pub(super) replay_standalone: bool,
pub(super) last_replay_tick: Option<std::time::Instant>,
pub(super) virtual_tick_ms: f32,
}
pub const HELP: &[&str] = &[
"studio record start [NAME] start recording clientbound packets into .mcpr archive",
"studio record stop stop recording and finalize .mcpr archive",
@@ -109,13 +129,13 @@ impl Game<'_> {
match words[0] {
"freecam" => match words.get(1).copied() {
Some("on") => {
if self.studio_replay.is_none() {
if self.studio.replay.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 +151,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.studio.replay.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 +176,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 +201,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 +219,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 +319,7 @@ impl Game<'_> {
}
}
Some("pause") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.studio.replay.as_mut() {
r.set_paused(true);
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
} else {
@@ -307,7 +327,7 @@ impl Game<'_> {
}
}
Some("resume") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.studio.replay.as_mut() {
r.set_paused(false);
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
} else {
@@ -323,7 +343,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.studio.replay.as_mut() {
r.set_speed(spd);
Some(r.speed())
} else {
@@ -357,11 +377,12 @@ impl Game<'_> {
return true;
};
if let Ok(ms) = ms_str.parse::<u32>() {
if self.studio_replay.is_some() {
if self.studio.replay.is_some() {
match self.seek_studio_replay(ms) {
Ok(()) => {
let actual_ms = self
.studio_replay
.studio
.replay
.as_ref()
.map_or(ms, |replay| ms.min(replay.duration_ms()));
let ms_text = actual_ms.to_string();
@@ -400,7 +421,7 @@ impl Game<'_> {
}
}
Some("status") => {
if let Some(r) = self.studio_replay.as_ref() {
if let Some(r) = self.studio.replay.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 +456,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.studio.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 +473,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
.studio
.replay
.as_ref()
.map_or(0, ReplayDriver::position_ms);
let packets = if self.studio_replay_standalone {
let packets = if self.studio.replay_standalone {
let packets = self
.studio_replay
.studio
.replay
.as_mut()
.expect("replay checked by caller")
.seek_and_collect(target_ms)?;
@@ -471,7 +494,8 @@ impl Game<'_> {
"backward seek requires a standalone replay",
));
}
self.studio_replay
self.studio
.replay
.as_mut()
.expect("replay checked by caller")
.seek_forward(target_ms)?
@@ -479,7 +503,7 @@ impl Game<'_> {
self.dispatch_studio_replay_packets(packets);
self.reset_studio_playback_ticks();
self.last_replay_tick = Some(std::time::Instant::now());
self.studio.last_replay_tick = Some(std::time::Instant::now());
Ok(())
}
@@ -510,19 +534,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.studio.replay.as_mut() else {
self.studio.last_replay_tick = None;
return Ok(());
};
let now = std::time::Instant::now();
let delta = match self.last_replay_tick {
let delta = match self.studio.last_replay_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.studio.last_replay_tick = Some(now);
let ready_packets = match driver.advance(delta) {
Ok(pkts) => pkts,
@@ -533,16 +557,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
.studio
.replay
.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
.studio
.replay
.as_ref()
.is_some_and(|replay| replay.is_paused());
if self.player_model.is_none() {
@@ -550,13 +576,14 @@ impl Game<'_> {
}
if !paused {
let speed = self
.studio_replay
.studio
.replay
.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.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;
if let Some(player) = &self.player {
let current_pos = player.position;
let (dx, dz) = (
@@ -607,7 +634,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.studio.replay.as_ref().map(|replay| {
(
replay.position_ms(),
replay.duration_ms(),
@@ -616,10 +643,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 +666,10 @@ impl Game<'_> {
self.capture(false);
}
TopBarAction::ExitStudio => {
if self.studio_replay.is_some() {
if self.studio.replay.is_some() {
self.close_studio_replay();
} else {
self.studio_freecam.deactivate();
self.studio.freecam.deactivate();
self.capture(false);
}
}
@@ -668,42 +695,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 +747,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.studio.replay.as_mut() {
replay.set_paused(true);
}
}
TimelineAction::Seek(time_ms) => {
let time_ms = if self.studio_replay_standalone {
let time_ms = if self.studio.replay_standalone {
time_ms
} else {
let current =
self.studio_replay
self.studio
.replay
.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.studio.replay.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.studio.replay.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.studio.replay.as_mut() else {
return;
};
let label = Text::translate(
@@ -811,7 +846,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 +880,7 @@ impl Game<'_> {
Ok(Some(res))
}
Some("pause") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.studio.replay.as_mut() {
r.set_paused(true);
Ok(Some(json!({ "ok": true, "paused": true })))
} else {
@@ -853,7 +888,7 @@ impl Game<'_> {
}
}
Some("resume") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.studio.replay.as_mut() {
r.set_paused(false);
Ok(Some(json!({ "ok": true, "paused": false })))
} else {
@@ -863,7 +898,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.studio.replay.as_mut() {
r.set_speed(spd);
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
} else {
@@ -873,7 +908,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.studio.replay.as_mut() {
r.seek(ms).map_err(|e| format!("{e}"))?;
Some(r.position_ms())
} else {
@@ -887,7 +922,7 @@ impl Game<'_> {
}
}
Some("status") => {
if let Some(r) = self.studio_replay.as_ref() {
if let Some(r) = self.studio.replay.as_ref() {
Ok(Some(json!({
"ok": true,
"active": true,
@@ -999,13 +1034,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.studio.replay.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 +1063,7 @@ impl Game<'_> {
}
};
let standalone = self.studio_replay.is_none() && self.remote.is_none();
let standalone = self.studio.replay.is_none() && self.remote.is_none();
if self.remote.is_none()
&& let Err(error) = self.init_remote_for_replay()
{
@@ -1038,17 +1073,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.studio.replay.is_none() {
self.studio.replay_previous_camera_type = Some(self.camera_type);
self.studio.replay_standalone = standalone;
}
self.studio_replay = Some(driver);
self.studio.replay = Some(driver);
self.camera_type = super::perspective::CameraType::FirstPerson;
self.title = None;
self.screen = Screen::Playing;
@@ -1066,14 +1101,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.studio.replay.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.studio.replay.take().is_some();
if !closed {
return false;
}
@@ -1081,23 +1116,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.studio.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.studio.replay_standalone = false;
if let Some(camera_type) = self.studio.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.studio.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.capture(false);
if standalone {
if let Some(mut remote) = self.remote.take() {
+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
+1 -1
View File
@@ -298,7 +298,7 @@ impl super::Game<'_> {
.into_iter()
.any(|action| self.keys.held(action))
|| self
.pad_move()
.input_move()
.is_some_and(|[left, forward]| left.abs() > 0.3 || forward.abs() > 0.3))
{
self.tutorial.input();
@@ -107,9 +107,9 @@ impl Game<'_> {
state: ElementState::Pressed,
..
}
) && !self.pad.menu.synthetic
) && !self.input_made_pointer_event()
{
self.pad.keyboard_took_over = true;
self.input_keyboard_used();
}
if let WindowEvent::MouseInput { state, button, .. } = &event {
if *state == ElementState::Pressed {
+19 -15
View File
@@ -169,29 +169,33 @@ impl Game<'_> {
/// Leaving the game with a world open (window closed, Quit, `quit`):
/// menu items go back (Player.closeContainer), then the world is saved,
/// once.
/// Ends this play session for the hooks (`horizon_client::play_session`): opt-in telemetry
/// keeps its performance summary for the launcher to send, if the player allows it.
/// Keeps this play session's performance summary for the launcher to send, if the player
/// allows performance statistics (`horizon_client::telemetry`), and starts the next one.
pub(super) fn finish_perf(&mut self) {
// One session per computer: P1's (a splitscreen player's would count it again).
if self.options.split_guest {
return;
}
let s = &self.settings;
let settings = horizon_telemetry::payload::Settings {
render_distance: s.render_distance.clamp(0, 255) as u8,
simulation_distance: s.simulation_distance.clamp(0, 255) as u8,
// 260 is the slider's "unlimited".
max_fps: if s.max_fps >= 260 {
0
} else {
s.max_fps.min(u32::from(u16::MAX)) as u16
},
vsync: s.vsync,
fullscreen: s.fullscreen,
graphics: s.graphics_preset.clone(),
};
// Again now: on Wayland the window is on a screen only once it has been shown.
if let Some(window) = &self.window {
horizon_client::telemetry::note_display(window);
}
let s = &self.settings;
let home = horizon_client::logging::game_home();
self.perf.finish(&horizon_hooks::WorldLeft {
home: &home,
render_distance: s.render_distance,
simulation_distance: s.simulation_distance,
// 260 is the slider's "unlimited".
max_fps: (s.max_fps < 260).then_some(s.max_fps),
vsync: s.vsync,
fullscreen: s.fullscreen,
graphics: &s.graphics_preset,
});
self.perf
.finish(&horizon_client::logging::game_home(), settings);
}
pub(super) fn save_for_exit(&mut self) -> Result<()> {
@@ -241,7 +245,7 @@ impl Game<'_> {
// Pause-menu and death-screen exits use the normal world-close action.
// A standalone replay owns a synthetic remote session, so it must go
// through replay cleanup instead of being treated like a server.
if self.studio_replay.is_some() {
if self.studio.replay.is_some() {
self.close_studio_replay();
return Ok(());
}
+43
View File
@@ -165,6 +165,22 @@ impl<T: Joined> JoinList<T> {
self.push(entity);
}
}
/// An entity that never left its level keeps its place in
/// `EntityTickList`: one a pass took out and puts back (an arrow bouncing
/// off) goes back before the first entity that joined after it. One that
/// has not joined yet is appended and numbered, as by `push`.
pub fn put_back(&mut self, entity: T) {
let seq = entity.join_seq();
let at = if seq.is_joined() {
self.list
.iter()
.position(|q| q.join_seq() > seq)
.unwrap_or(self.list.len())
} else {
self.list.len()
};
self.insert(at, entity);
}
}
impl<'a, T> IntoIterator for &'a JoinList<T> {
@@ -193,6 +209,33 @@ impl<T> IntoIterator for JoinList<T> {
mod tests {
use super::*;
#[derive(Debug)]
struct E {
join: JoinSeq,
}
crate::impl_joined!(E);
/// A numbered entity put back lands by its join number; a new one goes last.
#[test]
fn put_back_keeps_the_join_order() {
let mut list = JoinList::default();
for _ in 0..3 {
list.push(E {
join: JoinSeq::UNJOINED,
});
}
let first = list.remove(0);
let first_seq = first.join;
list.put_back(first);
assert_eq!(list[0].join, first_seq, "back before the later joins");
list.put_back(E {
join: JoinSeq::UNJOINED,
});
let seqs: Vec<_> = list.iter().map(|e| e.join).collect();
assert!(seqs.windows(2).all(|w| w[0] < w[1]), "{seqs:?}");
assert_eq!(seqs.len(), 4);
}
/// Whatever can change a list changes its stamp; only reading leaves it.
#[test]
fn a_stamp_holds_until_the_list_can_have_changed() {
+4 -4
View File
@@ -173,10 +173,10 @@ pub struct Guest {
/// Where the player respawns (a bed's spot; `ServerPlayer.respawnConfig`),
/// None for the world spawn.
pub spawn: Option<[f64; 3]>,
/// A respawn point outside the Overworld (a respawn anchor, or one
/// `spawnpoint` set there): its dimension, block, yaw and whether it is
/// forced, as the player's save keeps it (`respawn`).
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
/// A block-resolved personal respawn point: its dimension, block, yaw,
/// pitch and whether it is forced, as the player's save keeps it
/// (`respawn`).
pub respawn_elsewhere: Option<(String, [i32; 3], f32, f32, bool)>,
/// Pending ClientboundPlayerPositionPacket id; movement is ignored until
/// the client accepts it (ServerGamePacketListenerImpl.awaitingPositionFromClient).
pub awaiting_teleport: Option<(i32, [f64; 3])>,
+1 -1
View File
@@ -343,7 +343,7 @@ struct Kept {
effects: Option<crate::effects::PlayerEffects>,
game_mode: Option<(i32, bool, f32)>,
position: Option<([f64; 3], [f64; 3], f32, f32, f32, bool, Option<[i32; 4]>)>,
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, bool)>)>,
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, f32, bool)>)>,
recipes: Option<crate::recipe_book::Settings>,
/// The body's values: its fall goes with the position, its air and fire with the
/// health.
+111 -9
View File
@@ -246,7 +246,9 @@ impl Goal for DefendVillage {
// wider, as the box test is strict).
let near = b.inflate(Vec3::new(1.0, 1.0, 1.0));
// Villager.getPlayerReputation of the player this golem sees: the
// villager's gossips about that player's own UUID.
// villager's gossips about that player's own UUID. The villagers come
// through the same `attackTargeting` (`forCombat().range(64)`), so the
// golem must see them too.
let bad = cx.w.others_near(near).any(|s| {
s.species == Species::Villager
&& s.alive
@@ -255,6 +257,7 @@ impl Goal for DefendVillage {
.is_some_and(|v| v.gossips.reputation(p.uuid) <= -100)
&& b.contains(s.position)
&& s.position.distance_squared(m.position) <= 64.0 * 64.0
&& m.sees(cx.w, Who::Mob(s.id))
});
bad && m.sees(cx.w, Who::Player)
}
@@ -322,6 +325,39 @@ impl Goal for MoveTowardsTarget {
m.nav_stop();
}
}
impl Mob {
/// `IronGolem.mobInteract`: the repair sound's pitch comes from the
/// golem's own random, not the level's.
pub fn golem_repair_pitch(&mut self) -> f32 {
1.0 + (self.random.next_float() - self.random.next_float()) * 0.2
}
}
/// `Level.isBrightOutside`: a fixed-time dimension (the End) is never bright.
fn bright_outside(w: &MobWorld) -> bool {
w.daylight && !w.dimension_type.has_fixed_time
}
/// `OfferFlowerGoal.getNearestEntity`: the nearest of
/// #candidate_for_iron_golem_gift (villagers and copper golems) in the golem's
/// box inflated by (6, 2, 6) that passes `forNonCombat().range(6.0)`: within
/// six blocks and in sight.
fn gift_candidate(m: &mut Mob, w: &MobWorld) -> Option<u32> {
let area = m.bounds().inflate(Vec3::new(6.0, 2.0, 6.0));
let mut best: Option<(f64, u32)> = None;
for s in w.others_near(area) {
if !matches!(s.species, Species::Villager | Species::CopperGolem)
|| !s.alive
|| !area.intersects(s.bounds)
{
continue;
}
let d = s.position.distance_squared(m.position);
if d > 36.0 || best.is_some_and(|(b, _)| d >= b) || !m.sees(w, Who::Mob(s.id)) {
continue;
}
best = Some((d, s.id));
}
best.map(|(_, id)| id)
}
/// `OfferFlowerGoal`: by day, now and then, hold out a poppy to a villager.
#[derive(Clone, Debug)]
struct OfferFlower {
@@ -334,16 +370,10 @@ impl Goal for OfferFlower {
MOVE | LOOK
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if !cx.w.daylight || m.random.next_int_bound(8000).unwrap_or(1) != 0 {
if !bright_outside(cx.w) || m.random.next_int_bound(8000).unwrap_or(1) != 0 {
return false;
}
// The nearest of #candidate_for_iron_golem_gift: villagers and copper golems.
self.villager =
cx.w.nearby(m.position, 6.0, |s| {
matches!(s.species, Species::Villager | Species::CopperGolem) && s.alive
})
.map(|s| s.id)
.next();
self.villager = gift_candidate(m, cx.w);
self.villager.is_some()
}
fn can_continue(&mut self, _: &mut Mob, _: &mut Cx) -> bool {
@@ -612,6 +642,78 @@ mod tests {
Mob::new(id, Arc::new(kind), at, 0.0)
}
fn villager_at(id: u32, at: Vec3) -> crate::mobs::Seen {
mob(id, Species::Villager, at).seen(None)
}
fn pick(w: &MobWorld) -> Option<u32> {
let mut golem = mob(1, Species::IronGolem, Vec3::new(0.5, 64.0, 0.5));
gift_candidate(&mut golem, w)
}
fn with_chunks(world: &mut crate::world::World, _: &horizon_world::registry::BlockRegistry) {
for cx in -2..2 {
for cz in -2..2 {
world.chunks.insert(
(cx, cz),
std::sync::Arc::new(crate::chunk::Chunk::empty(cx, cz)),
);
}
}
}
/// `OfferFlowerGoal`: the search box is the golem's box inflated by
/// (6, 2, 6) and the candidate must be within six blocks (Euclidean).
#[test]
fn the_poppy_goes_to_a_villager_in_the_box_within_six_blocks() {
let cases = [
(Vec3::new(4.5, 64.0, 0.5), true),
// 6.5 blocks on one axis: outside the range.
(Vec3::new(7.0, 64.0, 0.5), false),
// About 8.5 blocks away, inside the per-axis box of six.
(Vec3::new(6.0, 64.0, 6.0), false),
// Five blocks above: outside the box's two-block rise.
(Vec3::new(0.5, 69.0, 0.5), false),
];
for (at, offered) in cases {
let villager = villager_at(2, at);
crate::mobs::world::with_built_test_world(&[villager], with_chunks, |w, _| {
assert_eq!(pick(w).is_some(), offered, "villager at {at:?}");
});
}
}
/// The candidate must also be in sight, and a fixed-time dimension is
/// never bright outside.
#[test]
fn the_poppy_needs_sight_and_a_dimension_with_a_day() {
let villager = villager_at(2, Vec3::new(4.5, 64.0, 0.5));
let wall = |world: &mut crate::world::World, r: &horizon_world::registry::BlockRegistry| {
with_chunks(world, r);
let stone = r.default_state("minecraft:stone").unwrap();
for y in 62..70 {
for z in -6..8 {
world.set(2, y, z, stone).unwrap();
}
}
};
crate::mobs::world::with_built_test_world(std::slice::from_ref(&villager), wall, |w, _| {
assert_eq!(pick(w), None, "a villager behind a wall");
});
crate::mobs::world::with_built_test_world(&[villager], with_chunks, |w, _| {
assert!(bright_outside(w));
assert_eq!(pick(w), Some(2));
let mut end = w.clone();
end.dimension_type =
horizon_world::dimension_type::DimensionFacts::vanilla("minecraft:the_end")
.unwrap();
assert!(!bright_outside(&end));
let mut night = w.clone();
night.daylight = false;
assert!(!bright_outside(&night));
});
}
#[test]
fn snow_trail_posts_block_place_with_golem_and_snow_state() {
let ran = crate::mobs::world::with_built_test_world(
@@ -21,7 +21,12 @@ use horizon_core::{
random::RandomSource,
};
use serde_json::Value;
use std::{borrow::Cow, collections::HashMap, fs, path::Path};
use std::{
borrow::{Borrow, Cow},
collections::HashMap,
fs,
path::Path,
};
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
@@ -879,13 +884,17 @@ impl SpawnState {
/// `NaturalSpawner.createState`: every non-persistent entity of a
/// spawning category in a loaded chunk adds its biome's spawn charge, its
/// category count and (for mobs) the local caps of the players near it.
pub fn new<L: NaturalLevel>(
pub fn new<'a, L: NaturalLevel, I>(
l: &L,
spawnable_chunks: i32,
players: Vec<SpawnPlayer>,
entities: &[Counted],
entities: I,
chunk_loaded: impl Fn(i32, i32) -> bool,
) -> Self {
) -> Self
where
I: IntoIterator,
I::Item: std::borrow::Borrow<Counted<'a>>,
{
let mut state = Self {
spawnable_chunks,
counts: [0; 8],
@@ -898,6 +907,7 @@ impl SpawnState {
last_checked: None,
};
for e in entities {
let e = e.borrow();
if e.is_mob && e.persistent || e.category == Category::Misc {
continue;
}
@@ -915,6 +925,10 @@ impl SpawnState {
}
state
}
/// Reclaims the player buffer for reuse across passes.
pub fn into_players(self) -> Vec<SpawnPlayer> {
self.players
}
fn add_charge(&mut self, pos: [i32; 3], charge: f64) {
if charge != 0.0 {
self.charges.push((pos, charge));
-69
View File
@@ -1,69 +0,0 @@
//! A play session as the hooks above the engine hear it (`horizon_hooks`): the title screen's
//! first frame, a world opening and its first frame, the frames played, and leaving the world.
//! It also marks those moments in the startup trace (`spans::startup`). Opt-in telemetry's
//! performance summary is built from these hooks (docs/telemetry.md).
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use horizon_hooks::{Frame, Milestone, WorldLeft, engine};
use crate::spans::{self, startup};
/// One window's session: moved from the startup menu to the game with the window.
#[derive(Default)]
pub struct Session {
/// When the world being opened started to, until its first frame.
opening: Option<Instant>,
}
impl Session {
/// The title screen drew a frame: the first time, how long the start took.
pub fn title_shown(&mut self) {
static SHOWN: AtomicBool = AtomicBool::new(false);
if !SHOWN.swap(true, Ordering::Relaxed) {
startup::mark("title.shown");
// The frame itself: the menu bundle can be ready seconds before the renderer that
// draws it (pipelines compiling on a slow driver).
engine::startup(Milestone::TitleShown, startup::since_start());
}
}
/// A world starts loading.
pub fn world_opening(&mut self) {
if self.opening.is_none() {
startup::arm("world.opening");
}
self.opening.get_or_insert_with(Instant::now);
}
/// An entry queued during startup was cancelled before opening a world.
pub fn cancel_world_opening(&mut self) {
self.opening = None;
}
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
/// recorded in it ([`spans::take_raw`]): the step it spent most of its time in is
/// [`spans::culprit`].
pub fn frame(&mut self, ms: f32, frame_spans: &[spans::Span]) {
if let Some(opening) = self.opening.take() {
startup::mark("world.first_frame");
engine::world_shown(opening.elapsed());
}
engine::frame(&Frame::new(ms, &|| spans::culprit(frame_spans)));
}
/// The player left the world: the session ends.
pub fn finish(&mut self, left: &WorldLeft<'_>) {
engine::world_left(left);
}
}
/// The resources the menus need are loaded.
pub fn core_ready() {
engine::startup(Milestone::CoreReady, startup::since_start());
}
/// What a world needs is loaded too.
pub fn rest_ready() {
engine::startup(Milestone::RestReady, startup::since_start());
}
+10 -29
View File
@@ -13,6 +13,8 @@
//! game rules) is kept from the loaded files and written back unchanged.
//! Worlds saved by older Horizon builds (`region/`, `DIM-1/`, `Player` in
//! `level.dat`) are migrated on open.
mod respawn;
use crate::{
Result,
beehive::BeehiveExt,
@@ -3945,8 +3947,8 @@ pub struct Level {
pub spawn: Option<[f64; 3]>,
/// A respawn point resolved through its block when the player respawns
/// (a bed, a respawn anchor, a forced point in any dimension):
/// dimension, block, yaw and `forced`.
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
/// dimension, block, yaw, pitch and `forced`.
pub respawn_elsewhere: Option<(String, [i32; 3], f32, f32, bool)>,
pub position: Option<[f64; 3]>,
pub rotation: [f32; 2],
pub health: f32,
@@ -4292,20 +4294,8 @@ impl Level {
if let Some(d) = text(p.get("Dimension")) {
self.dimension = d;
}
if let Some(respawn) = p.get("respawn")
&& let Some(Tag::IntArray(pos)) = respawn.get("pos")
&& pos.len() == 3
{
let dimension =
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
// Forced points, including Overworld ones, are validated through
// their blocks on death, just like beds and respawn anchors.
let yaw = match respawn.get("yaw") {
Some(Tag::Float(b)) => f32::from_bits(*b),
_ => 0.0,
};
self.respawn_elsewhere = Some((dimension, [pos[0], pos[1], pos[2]], yaw, forced));
if let Some(respawn) = respawn::read(p) {
self.respawn_elsewhere = Some(respawn);
}
let mut inventory = Vec::new();
let mut inventory_components = HashMap::new();
@@ -4536,17 +4526,8 @@ impl Level {
));
}
// A respawn point other than the world spawn: an anchor elsewhere, or a bed.
if let Some((dimension, block, yaw, forced)) = &self.respawn_elsewhere {
let mut respawn = vec![
("dimension", string(dimension)),
("pos", Tag::IntArray(block.to_vec())),
("yaw", Tag::Float(yaw.to_bits())),
("pitch", Tag::Float(0f32.to_bits())),
];
if *forced {
respawn.push(("forced", Tag::Byte(1)));
}
entries.push(("respawn", Tag::compound(respawn)));
if let Some(saved) = &self.respawn_elsewhere {
entries.push(("respawn", respawn::write(saved)));
} else if let Some(s) = self.spawn {
let block = [
s[0].floor() as i32,
@@ -7698,7 +7679,7 @@ mod tests {
assert_eq!(back.spawn, Some([8.0, 64.0, 8.0]));
assert_eq!(
back.respawn_elsewhere,
Some(("minecraft:overworld".into(), [20, 70, 20], 0.0, true))
Some(("minecraft:overworld".into(), [20, 70, 20], 0.0, 0.0, true))
);
assert!(back.difficulty_locked);
assert!(back.weather.raining);
@@ -7969,7 +7950,7 @@ mod tests {
// A personal point coinciding with the world spawn must still keep
// its forced flag and yaw, rather than turning into a world fallback.
for pos in [[5, 64, 5], [0, 80, 0]] {
let point = ("minecraft:overworld".to_owned(), pos, 37.0, true);
let point = ("minecraft:overworld".to_owned(), pos, 37.0, 0.0, true);
let level = Level {
respawn_elsewhere: Some(point.clone()),
..Level::default()
+104
View File
@@ -0,0 +1,104 @@
//! Personal respawn record leaves from Level, using its existing typed NBT rules.
use super::{Tag, number, string, text};
pub(super) fn read(p: &Tag) -> Option<(String, [i32; 3], f32, f32, bool)> {
let mut value = None;
if let Some(respawn) = p.get("respawn")
&& let Some(Tag::IntArray(pos)) = respawn.get("pos")
&& pos.len() == 3
{
let dimension =
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
// Forced points, including Overworld ones, are validated through
// their blocks on death, just like beds and respawn anchors.
let yaw = match respawn.get("yaw") {
Some(Tag::Float(b)) => f32::from_bits(*b),
_ => 0.0,
};
let pitch = match respawn.get("pitch") {
Some(Tag::Float(b)) => f32::from_bits(*b),
_ => 0.0,
};
value = Some((dimension, [pos[0], pos[1], pos[2]], yaw, pitch, forced));
}
value
}
pub(super) fn write(
(dimension, block, yaw, pitch, forced): &(String, [i32; 3], f32, f32, bool),
) -> Tag {
let mut respawn = vec![
("dimension", string(dimension)),
("pos", Tag::IntArray(block.to_vec())),
("yaw", Tag::Float(yaw.to_bits())),
("pitch", Tag::Float(pitch.to_bits())),
];
if *forced {
respawn.push(("forced", Tag::Byte(1)));
}
Tag::compound(respawn)
}
#[cfg(test)]
mod tests {
use super::super::{Level, read_dat, write_dat};
use super::*;
/// Added model control: old records without pitch retain the existing positive-zero default.
#[test]
fn personal_respawn_missing_pitch_loads_positive_zero() {
let tag = Tag::compound(vec![(
"respawn",
Tag::compound(vec![
("dimension", string("minecraft:overworld")),
("pos", Tag::IntArray(vec![12, 100, 12])),
("yaw", Tag::Float(45f32.to_bits())),
("forced", Tag::Byte(1)),
]),
)]);
let mut level = Level::default();
level.read_player(&tag);
let (_, pos, yaw, pitch, forced) = level.respawn_elsewhere.unwrap();
assert_eq!(pos, [12, 100, 12]);
assert_eq!(yaw.to_bits(), 45f32.to_bits());
assert_eq!(pitch.to_bits(), 0.0f32.to_bits());
assert!(forced);
}
/// Added model/I/O control, separate from the connected runtime proof.
#[test]
fn personal_respawn_nonzero_pitch_survives_actual_player_file_write_and_read() {
let root = std::env::temp_dir().join(format!(
"horizon-personal-pitch-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&root).unwrap();
let file = root.join("player.dat");
let original = Level {
respawn_elsewhere: Some((
"minecraft:overworld".into(),
[12, 100, 12],
45.0,
10.0,
true,
)),
..Level::default()
};
write_dat(&file, original.player_tag()).unwrap();
let tag = read_dat(&file).expect("actual written NBT player file");
assert_eq!(
tag.get("respawn").unwrap().get("pitch"),
Some(&Tag::Float(10f32.to_bits()))
);
let mut loaded = Level::default();
loaded.read_player(&tag);
assert_eq!(loaded.respawn_elsewhere, original.respawn_elsewhere);
println!("actual saved personal respawn {:?}", tag.get("respawn"));
std::fs::remove_dir_all(root).unwrap();
}
}
+36 -5
View File
@@ -852,14 +852,24 @@ fn parse_recipe_sources<'a>(
"minecraft:stonecutting" => {
// A result count outside 1..=99 fails vanilla's codec: no recipe.
if let Some(count) = crate::crafting::template_count(&v["result"]) {
stonecutting.push(Stonecutting {
id,
input: ingredient(&v["ingredient"], items),
result: ItemStack::new(
let result = if v["result"].get("components").is_some() {
match SmithingResult::parse(&v["result"], defaults)
.and_then(|result| result.create())
{
Some(result) => result,
None => continue,
}
} else {
ItemStack::new(
horizon_world::loot::recipe_result_id(&v["result"])
.unwrap_or("minecraft:air"),
count,
),
)
};
stonecutting.push(Stonecutting {
id,
input: ingredient(&v["ingredient"], items),
result,
});
}
}
@@ -2216,6 +2226,27 @@ mod tests {
vec!["minecraft:diamond_sword", "minecraft:diamond_axe"]
);
}
#[test]
fn stonecutter_result_keeps_its_components() {
let root = std::env::temp_dir().join(format!(
"horizon-stonecutter-components-{}",
std::process::id()
));
let dir = root.join("data/minecraft/recipe");
fs::create_dir_all(&dir).unwrap();
fs::write(root.join("items.json"), r#"{"minecraft:stone":{}}"#).unwrap();
fs::write(
dir.join("cut.json"),
r#"{"type":"minecraft:stonecutting","ingredient":"minecraft:stone","result":{"id":"minecraft:stone","components":{"minecraft:custom_name":"Cut Stone"}}}"#,
)
.unwrap();
let recipes = StationRecipes::load(&root, &sample_items(), &PackOverlay::NONE);
let _ = fs::remove_dir_all(&root);
let listed = recipes.stonecutter(&ItemStack::new("minecraft:stone", 1));
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].result.custom_name().as_deref(), Some("Cut Stone"));
}
/// A world pack's stonecutting recipe takes its place in registration
/// order (path, then namespace), not a place after the game's own.
#[test]
+63 -99
View File
@@ -3,16 +3,10 @@
//! summary, in the outbox. It never sends those itself: the launcher does, at its next start (the
//! game's only sending is the anonymous "I'm playing" ping, `heartbeat.rs`).
//! Without the player's yes for that kind nothing is kept, and crash-reports/ stays local.
//!
//! The play session's summary is built from the game's hooks ([`HOOKS`], `horizon_hooks`), as a
//! Horizon crate does: it leaves the engine for the Horizon layer when the official executable
//! ships (layer plan M5, docs/layers.md).
use std::path::Path;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use horizon_hooks::{Frame, Hooks, Milestone, WorldLeft};
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
use horizon_telemetry::payload::{Crash, Display, Perf, Settings, System, Timings};
use horizon_telemetry::scrub::Scrubber;
@@ -25,68 +19,30 @@ const MIN_SECONDS: f32 = 60.0;
/// world, the server or the resource packs.
const CRASH_DETAILS: [&str; 3] = ["Horizon Version", "Graphics Adapter", "Render Settings"];
static STARTED: OnceLock<Instant> = OnceLock::new();
static CORE_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
/// The screen the window opened on.
static DISPLAY: Mutex<Option<Display>> = Mutex::new(None);
/// The game's hooks, for `horizon_hooks::register`.
pub static HOOKS: Telemetry = Telemetry {
session: LazyLock::new(|| Mutex::new(Session::default())),
};
/// The play session's performance, as the game's hooks tell it.
pub struct Telemetry {
session: LazyLock<Mutex<Session>>,
/// When the game started, for the time to the title screen. Call first thing in `main`.
pub fn mark_start() {
STARTED.get_or_init(Instant::now);
}
impl Telemetry {
fn session(&self) -> std::sync::MutexGuard<'_, Session> {
self.session.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn mark_core_ready() {
mark_ready(&CORE_READY_MS);
}
impl Hooks for Telemetry {
fn startup(&self, milestone: Milestone, since_start: Duration) {
match milestone {
Milestone::CoreReady => mark_ready(&CORE_READY_MS, since_start),
Milestone::RestReady => mark_ready(&REST_READY_MS, since_start),
Milestone::TitleShown => self.session().title_shown(since_start),
}
}
/// A player who allows performance statistics also says what their slow frames spent their
/// time in.
fn wants_frame_steps(&self, home: &Path) -> bool {
horizon_telemetry::Consent::load(home).allows(horizon_telemetry::Kind::Perf)
}
fn world_shown(&self, since_opening: Duration) {
self.session().world_shown(since_opening);
}
fn frame(&self, frame: &Frame<'_>) {
self.session().frame(frame.ms, || frame.slowest_step());
}
fn world_left(&self, left: &WorldLeft<'_>) {
let settings = Settings {
render_distance: left.render_distance.clamp(0, 255) as u8,
simulation_distance: left.simulation_distance.clamp(0, 255) as u8,
max_fps: left
.max_fps
.map_or(0, |f| f.min(u32::from(u16::MAX)) as u16),
vsync: left.vsync,
fullscreen: left.fullscreen,
graphics: left.graphics.to_owned(),
};
self.session().finish(left.home, settings);
}
pub fn mark_rest_ready() {
mark_ready(&REST_READY_MS);
}
fn mark_ready(mark: &AtomicU32, since_start: Duration) {
let ms = since_start.as_millis().min(u128::from(u32::MAX - 1)) as u32;
let _ = mark.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
fn mark_ready(mark: &AtomicU32) {
if let Some(started) = STARTED.get() {
let ms = started.elapsed().as_millis().min(u128::from(u32::MAX - 1)) as u32;
let _ = mark.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
}
}
fn readiness_ms(mark: &AtomicU32) -> Option<u32> {
@@ -220,33 +176,42 @@ pub struct Session {
/// The start was measured: only the first session carries it.
title_measured: bool,
to_world_ms: Option<u32>,
opening: Option<Instant>,
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
}
impl Session {
/// The title screen drew its first frame, `since_start` after the game started.
pub fn title_shown(&mut self, since_start: Duration) {
/// The title screen drew a frame: the first time, how long the start took.
pub fn title_shown(&mut self) {
if !self.title_measured {
self.title_measured = true;
self.to_title_ms = Some(since_start.as_millis().min(u128::from(u32::MAX)) as u32);
crate::spans::startup::mark("title.shown");
// The frame itself: the menu bundle can be ready seconds before
// the renderer that draws it (pipelines compiling on a slow driver).
self.to_title_ms = STARTED.get().map(|s| s.elapsed().as_millis() as u32);
}
}
/// A world's first frame, `since_opening` after it started opening: the session's first
/// world counts.
pub fn world_shown(&mut self, since_opening: Duration) {
if self.to_world_ms.is_none() {
self.to_world_ms = Some(since_opening.as_millis().min(u128::from(u32::MAX)) as u32);
/// A world starts loading.
pub fn world_opening(&mut self) {
if self.opening.is_none() {
crate::spans::startup::arm("world.opening");
}
self.opening.get_or_insert_with(Instant::now);
}
/// A frame drawn while playing (not in menus or loading): a frame of `SLOW_FRAME_MS` or
/// more counts under the step it spent most of its time in (`slowest_step`, the game's
/// `spans::culprit`).
pub fn frame(&mut self, ms: f32, slowest_step: impl FnOnce() -> Option<(String, f32)>) {
/// An entry queued during startup was cancelled before opening a world.
pub fn cancel_world_opening(&mut self) {
self.opening = None;
}
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
/// recorded in it ([`crate::spans::take_raw`]): a frame of `SLOW_FRAME_MS` or more counts
/// under the step it spent most of its time in ([`crate::spans::culprit`]).
pub fn frame(&mut self, ms: f32, spans: &[crate::spans::Span]) {
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
if ms >= SLOW_FRAME_MS {
let (mut path, span_ms) = slowest_step().unwrap_or_default();
let (mut path, span_ms) = crate::spans::culprit(spans).unwrap_or_default();
// A path too long for the item keeps its last steps.
while path.len() > SlowFrames::MAX_NAME
&& let Some((_, rest)) = path.split_once('/')
@@ -255,6 +220,12 @@ impl Session {
}
SlowFrames::record(&mut self.slow_frames, ms, &path, span_ms);
}
if let Some(opening) = self.opening.take() {
crate::spans::startup::mark("world.first_frame");
if self.to_world_ms.is_none() {
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
}
}
self.frames.record(ms);
self.played_ms += ms;
}
@@ -482,30 +453,25 @@ mod tests {
}
#[test]
fn the_time_to_a_world_is_the_sessions_first() {
fn cancelled_startup_entry_does_not_time_menu_browsing() {
let mut session = Session::default();
session.world_shown(Duration::from_millis(4200));
session.world_shown(Duration::from_millis(900));
assert_eq!(session.to_world_ms, Some(4200));
}
#[test]
fn the_time_to_the_title_goes_with_the_first_session_only() {
let home = home("title");
let mut session = Session::default();
session.title_shown(Duration::from_millis(3100));
assert_eq!(session.to_title_ms, Some(3100));
session.finish(&home, Settings::default());
session.title_shown(Duration::from_millis(9000));
assert_eq!(session.to_title_ms, None);
session.world_opening();
assert!(session.opening.is_some());
session.cancel_world_opening();
assert!(session.opening.is_none());
session.frame(16.0, &[]);
assert!(session.to_world_ms.is_none());
session.world_opening();
session.frame(16.0, &[]);
assert!(session.to_world_ms.is_some());
}
#[test]
fn a_session_is_kept_only_with_consent_and_once_long_enough() {
let home = home("session");
let mut session = Session::default();
session.world_shown(Duration::from_millis(1500));
(0..2000).for_each(|_| session.frame(16.6, || None));
session.world_opening();
(0..2000).for_each(|_| session.frame(16.6, &[]));
session.finish(&home, Settings::default());
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
allow(
@@ -515,11 +481,11 @@ mod tests {
..Choices::NONE
},
);
(0..2000).for_each(|_| session.frame(16.6, || None));
(0..2000).for_each(|_| session.frame(16.6, &[]));
session.finish(&home, Settings::default());
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
session.world_shown(Duration::from_millis(1500));
(0..4000).for_each(|_| session.frame(16.6, || None));
session.world_opening();
(0..4000).for_each(|_| session.frame(16.6, &[]));
(0..1200).for_each(|_| session.tick(4.0));
session.finish(
&home,
@@ -552,15 +518,13 @@ mod tests {
cpu_ms: ms,
depth,
};
let culprit = |spans: &[Span]| crate::spans::culprit(spans);
let mut session = Session::default();
let ticking = [span("tick", 10.0, 0)];
(0..4000).for_each(|_| session.frame(16.6, || culprit(&ticking)));
(0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)]));
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
session.frame(320.0, || culprit(&frozen));
session.frame(900.0, || culprit(&frozen));
session.frame(260.0, || culprit(&[]));
session.frame(249.0, || culprit(&frozen));
session.frame(320.0, &frozen);
session.frame(900.0, &frozen);
session.frame(260.0, &[]);
session.frame(249.0, &frozen);
let perf = session.summary(Settings::default()).unwrap();
let seen: Vec<_> = perf
.slow_frames
+9
View File
@@ -47,6 +47,15 @@ pub fn open_vulkan_device(
Some(unsafe { adapter.create_device_from_hal(open, desc) })
}
/// wgpu's opt-in to its `EXPERIMENTAL_*` features, which the crates that forbid unsafe code
/// can't give: hardware ray queries, asked for only when the player turns ray tracing on
/// (`gfx/ray_query.rs` in horizon-client-render).
pub fn experimental_features() -> wgpu::ExperimentalFeatures {
// Safety: wgpu warns that its experimental features may have bugs that reach undefined
// behaviour through safe calls. Horizon accepts that only on request, for ray queries.
unsafe { wgpu::ExperimentalFeatures::enabled() }
}
/// Tells the compositor, through `VK_EXT_hdr_metadata`, that the swapchain's
/// content is BT.709, peaks at `peak` nits and is no darker than `black`. Needs a
/// device from `open_vulkan_device` and a configured surface; does nothing otherwise.
+20 -150
View File
@@ -24,9 +24,8 @@ pub mod settings;
pub mod tunnel;
use registry::Registry;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::time::Duration;
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
/// What a crate above the engine hears from the game. Every method has an empty default:
/// implement only those you need. Hooks run on the game's thread (the render thread) unless a
@@ -44,27 +43,6 @@ pub trait Hooks: Sync {
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
fn shutdown(&self) {}
/// A moment of the game's start, once each, with the time since the process started.
fn startup(&self, _milestone: Milestone, _since_start: Duration) {}
/// Whether this hook wants the render thread to time its steps in every frame, for
/// [`Frame::slowest_step`]. Asked once as the game starts (not on a dedicated server);
/// `home` is the game's home folder. Timing the steps costs microseconds a frame.
fn wants_frame_steps(&self, _home: &Path) -> bool {
false
}
/// A world's first frame was drawn: the time since it started opening (single player, a
/// server, LAN). Every time a world is entered.
fn world_shown(&self, _since_opening: Duration) {}
/// A frame drawn while playing: not in the menus or while a world loads. Every frame.
fn frame(&self, _frame: &Frame<'_>) {}
/// The player left a world (to the title screen, or by quitting): the end of a play
/// session, with the settings it was played with.
fn world_left(&self, _left: &WorldLeft<'_>) {}
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
/// offers one is used. Asked on the network thread when joining and on the game thread when
/// an address is shown, never per frame.
@@ -73,61 +51,6 @@ pub trait Hooks: Sync {
}
}
/// A moment of the game's start ([`Hooks::startup`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Milestone {
/// The resources the menus need are loaded.
CoreReady,
/// The rest (what a world needs) too.
RestReady,
/// The title screen drew its first frame.
TitleShown,
}
impl Milestone {
fn bit(self) -> u8 {
match self {
Self::CoreReady => 1,
Self::RestReady => 2,
Self::TitleShown => 4,
}
}
}
/// What [`Hooks::frame`] gets.
pub struct Frame<'a> {
/// How long the frame took.
pub ms: f32,
slowest_step: &'a dyn Fn() -> Option<(String, f32)>,
}
impl<'a> Frame<'a> {
/// `slowest_step` gives [`Frame::slowest_step`], only when asked.
pub fn new(ms: f32, slowest_step: &'a dyn Fn() -> Option<(String, f32)>) -> Self {
Self { ms, slowest_step }
}
/// The step the frame spent the most time in (`render/terrain`) and its milliseconds, when
/// the steps were timed ([`Hooks::wants_frame_steps`]). Worked out on each call: ask only
/// for a slow frame.
pub fn slowest_step(&self) -> Option<(String, f32)> {
(self.slowest_step)()
}
}
/// What [`Hooks::world_left`] gets: the game's home folder and the settings played with.
pub struct WorldLeft<'a> {
pub home: &'a Path,
pub render_distance: i32,
pub simulation_distance: i32,
/// `None`: unlimited.
pub max_fps: Option<u32>,
pub vsync: bool,
pub fullscreen: bool,
/// The graphics preset's name.
pub graphics: &'a str,
}
/// What [`Hooks::started`] gets.
#[non_exhaustive]
pub struct Started<'a> {
@@ -167,10 +90,27 @@ fn all() -> &'static [&'static dyn Hooks] {
HOOKS.all()
}
thread_local! {
static SEAT: Cell<usize> = const { Cell::new(0) };
}
/// The splitscreen player whose turn it is: 0 for P1 (and without splitscreen), 1 for P2...
/// Splitscreen players are one process, each `Game` in turn on the game's thread, so a crate's
/// statics are shared by them: a crate that keeps per-player state (settings, pages, HUD) indexes
/// it by this. A player keeps its seat while it plays. Other threads always see 0.
pub fn seat() -> usize {
SEAT.get()
}
/// The engine's side: where it calls the hooks. Only the engine calls these.
pub mod engine {
use super::*;
/// The player whose turn starts on this thread ([`seat`]).
pub fn set_seat(seat: usize) {
SEAT.set(seat);
}
/// Seals every list (hooks and the registries); their first use does it anyway. Call where
/// registration ends.
pub fn seal() {
@@ -208,44 +148,6 @@ pub mod engine {
}
}
/// The milestones already said.
static MILESTONES: AtomicU8 = AtomicU8::new(0);
/// [`Hooks::startup`], the first time `milestone` is reached; later calls do nothing.
pub fn startup(milestone: Milestone, since_start: Duration) {
if MILESTONES.fetch_or(milestone.bit(), Ordering::Relaxed) & milestone.bit() == 0 {
for hooks in all() {
hooks.startup(milestone, since_start);
}
}
}
/// [`Hooks::wants_frame_steps`]: whether any hook wants them.
pub fn wants_frame_steps(home: &Path) -> bool {
all().iter().any(|h| h.wants_frame_steps(home))
}
/// [`Hooks::world_shown`].
pub fn world_shown(since_opening: Duration) {
for hooks in all() {
hooks.world_shown(since_opening);
}
}
/// [`Hooks::frame`].
pub fn frame(frame: &Frame<'_>) {
for hooks in all() {
hooks.frame(frame);
}
}
/// [`Hooks::world_left`].
pub fn world_left(left: &WorldLeft<'_>) {
for hooks in all() {
hooks.world_left(left);
}
}
/// [`Hooks::tunnel`]: the first registered hook's resolver.
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
all().iter().find_map(|hooks| hooks.tunnel())
@@ -271,24 +173,6 @@ mod tests {
fn published(&self, open: bool) {
self.0.lock().unwrap().push(format!("published {open}"));
}
fn startup(&self, milestone: Milestone, since_start: Duration) {
self.0
.lock()
.unwrap()
.push(format!("{milestone:?} {}", since_start.as_millis()));
}
fn wants_frame_steps(&self, _home: &Path) -> bool {
true
}
fn frame(&self, frame: &Frame<'_>) {
let step = frame
.slowest_step()
.map(|(name, ms)| format!(" {name} {ms}"));
self.0
.lock()
.unwrap()
.push(format!("frame {}{}", frame.ms, step.unwrap_or_default()));
}
}
/// Implements nothing: the defaults do nothing.
@@ -306,24 +190,10 @@ mod tests {
for open in [false, true, true, true, false, false] {
engine::published(open);
}
// Each milestone once: the first time it is reached.
engine::startup(Milestone::CoreReady, Duration::from_millis(900));
engine::startup(Milestone::TitleShown, Duration::from_millis(1500));
engine::startup(Milestone::CoreReady, Duration::from_millis(4000));
// Any hook that wants the steps timed has them.
assert!(engine::wants_frame_steps(Path::new("home")));
engine::frame(&Frame::new(70.0, &|| Some(("render/terrain".into(), 50.0))));
engine::shutdown();
assert_eq!(
*RECORDER.0.lock().unwrap(),
[
"started 0.2.31",
"published true",
"published false",
"CoreReady 900",
"TitleShown 1500",
"frame 70 render/terrain 50",
]
["started 0.2.31", "published true", "published false"]
);
assert_eq!(register(&Silent), Err(TooLate));
}
+1 -3
View File
@@ -46,9 +46,7 @@ pub use horizon_server_files as server_files;
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
/// in"). The engine's dispatch side is not part of the API.
pub mod hooks {
pub use horizon_hooks::{
Frame, Hooks, Milestone, Started, TooLate, WorldLeft, register, tunnel,
};
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
}
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
+125
View File
@@ -50,12 +50,111 @@ pub enum BiomeColor {
Colormap(String),
}
/// A fluid modifier's fog (`fluid_modifiers`): `fog_colormap` as a flat colour (RGB), and the
/// `fog_radius` and `fog_fade` multipliers (1 is vanilla).
#[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, read with the colours.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct FluidFogs {
pub water: Option<FluidFog>,
pub lava: Option<FluidFog>,
}
impl FluidFogs {
/// Every `assets/<namespace>/polytone/fluid_modifiers/**.json` under a pack overlay root that
/// sets a fog field and targets `minecraft:water` or `minecraft:lava` (still or flowing). A
/// colormap id or an expression isn't read (a warning names it).
pub fn read(root: &Path) -> Self {
let mut fogs = Self::default();
for namespace in std::fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten()
{
let mut stack = vec![namespace.path().join("polytone/fluid_modifiers")];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "json") {
fogs.add(root, &path);
}
}
}
}
fogs
}
fn add(&mut self, root: &Path, path: &Path) {
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
let Ok(json) = serde_json::from_str::<Json>(&text) else {
return;
};
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
let number = |key: &str| match json.get(key) {
None => Some(1.0),
Some(Json::Number(n)) => n.as_f64().map(|v| v as f32),
Some(Json::String(s)) => s.trim().parse().ok().or_else(|| {
log::warn!("{relative}: {key} expressions aren't read yet");
None
}),
_ => None,
};
let color = match json.get("fog_colormap") {
None => None,
Some(value) => match parse_color(value) {
Some(c) => Some(c & 0xFF_FFFF),
None => {
log::warn!("{relative}: only flat fog colours are read");
None
}
},
};
let fog = FluidFog {
color,
radius: number("fog_radius").unwrap_or(1.0),
fade: number("fog_fade").unwrap_or(1.0),
};
if fog
== (FluidFog {
color: None,
radius: 1.0,
fade: 1.0,
})
{
return;
}
let implicit = crate::implicit_id(&relative, "fluid_modifiers");
let Ok(targets) = crate::Targets::parse(json.get("targets"), implicit.as_deref()) else {
return;
};
let aims = |id: &str| targets.matches(id, |_| false);
if aims("minecraft:water") || aims("minecraft:flowing_water") {
self.water = Some(fog);
}
if aims("minecraft:lava") || aims("minecraft:flowing_lava") {
self.lava = Some(fog);
}
}
}
/// Every pack's custom colours, by dotted key.
#[derive(Clone, Debug, Default)]
pub struct PackColors {
entries: BTreeMap<String, Json>,
/// [`Self::environment`], read once with the packs: the fog reads it every frame.
pub env: Environment,
/// The packs' fluid modifiers' fog.
pub fluid_fog: FluidFogs,
}
impl PackColors {
@@ -83,6 +182,7 @@ impl PackColors {
}
}
colors.env = colors.environment();
colors.fluid_fog = FluidFogs::read(root);
colors
}
@@ -376,6 +476,31 @@ mod tests {
assert_eq!(c.biome_color("dry_foliage"), None);
}
#[test]
fn fluid_modifiers_give_water_and_lava_their_fog() {
let root = std::env::temp_dir().join(format!("fluid-fog-{}", std::process::id()));
let dir = root.join("assets/minecraft/polytone/fluid_modifiers");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("water.json"),
r#"{"targets": ["minecraft:water", "minecraft:flowing_water"], "fog_colormap": "ff0000", "fog_radius": 0.3}"#,
)
.unwrap();
std::fs::write(dir.join("lava.json"), r#"{"fog_fade": "0"}"#).unwrap();
std::fs::write(dir.join("honey.json"), r#"{"colormap": "foliage_color"}"#).unwrap();
let fogs = FluidFogs::read(&root);
let fog = |color, radius, fade| {
Some(FluidFog {
color,
radius,
fade,
})
};
assert_eq!(fogs.water, fog(Some(0xff0000), 0.3, 1.0));
assert_eq!(fogs.lava, fog(None, 1.0, 0.0));
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn dyes_name_their_colour_kind() {
let mut c = PackColors::default();
+267
View File
@@ -0,0 +1,267 @@
//! The numeric fast path: a program of one expression whose every part is a number (constants,
//! arithmetic, comparisons of numbers, math functions, ternaries and `root.method(...)` calls) is
//! compiled again into a tree of `f64`s, which runs without the interpreter's `Value`s and
//! `Result`s. It answers exactly what the interpreter would: anything that could tell them apart
//! (strings, objects, `==` on a method's value, locals, game functions) keeps the interpreter.
use super::parse::{BinOp, Expr, Stmt};
use super::{Field, Host, MathFn, Root, Value};
use std::sync::Arc;
/// What a node gives: always a number, always a boolean, or whatever a host method answers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Num,
Bool,
Unknown,
}
/// A method argument, handed to the host as the interpreter would: a constant of any kind
/// (`o.custom('glow')`), another method's own value, or a number or boolean.
#[derive(Debug)]
pub(super) enum Arg {
Value(Value),
Method(Num),
Num(Num, bool),
}
#[derive(Debug)]
pub(super) enum Num {
Const(f64),
Neg(Box<Num>),
Not(Box<Num>),
Bin(BinOp, Box<Num>, Box<Num>),
And(Box<Num>, Box<Num>),
Or(Box<Num>, Box<Num>),
Cond(Box<Num>, Box<Num>, Box<Num>),
Call(MathFn, Vec<Num>),
Method(Root, Arc<str>, Vec<Arg>),
/// A method without arguments the host may answer by [`Field`] (its name otherwise).
Field(Root, Field, Arc<str>),
}
/// The fast form of a whole program, with the kind of value it gives.
#[derive(Debug)]
pub(super) struct Fast {
root: Num,
kind: Kind,
}
impl Fast {
/// The fast form of a program, when it is one expression the fast path covers.
pub(super) fn of(body: &[Stmt], locals: usize) -> Option<Self> {
let [Stmt::Expr(e)] = body else {
return None;
};
if locals != 0 {
return None;
}
let root = Num::of(e)?;
let kind = root.kind();
// A method at the top hands back its own value; elsewhere the kind must be known.
(kind != Kind::Unknown || matches!(root, Num::Method(..) | Num::Field(..)))
.then_some(Self { root, kind })
}
pub(super) fn eval(&self, host: &mut dyn Host) -> Result<Value, String> {
match &self.root {
Num::Method(root, name, args) => return call(host, *root, name, args),
Num::Field(root, field, name) => return field_value(host, *root, *field, name),
_ => {}
}
let n = self.root.eval(host)?;
Ok(match self.kind {
Kind::Bool => Value::Bool(n != 0.0),
_ => Value::Num(n),
})
}
}
fn field_value(host: &mut dyn Host, root: Root, field: Field, name: &str) -> Result<Value, String> {
match host.field(root, field) {
Some(value) => Ok(value),
None => call(host, root, name, &[]),
}
}
fn call(host: &mut dyn Host, root: Root, name: &str, args: &[Arg]) -> Result<Value, String> {
const FEW: usize = 4;
let target = host.root(root);
if args.is_empty() {
return host.method(&target, name, &[]);
}
let mut values: [Value; FEW] = std::array::from_fn(|_| Value::Null);
if args.len() > FEW {
let all = args
.iter()
.map(|a| a.value(host))
.collect::<Result<Vec<_>, _>>()?;
return host.method(&target, name, &all);
}
for (slot, arg) in values.iter_mut().zip(args) {
*slot = arg.value(host)?;
}
host.method(&target, name, &values[..args.len()])
}
impl Arg {
fn value(&self, host: &mut dyn Host) -> Result<Value, String> {
Ok(match self {
Self::Value(v) => v.clone(),
Self::Method(Num::Method(root, name, args)) => call(host, *root, name, args)?,
Self::Method(Num::Field(root, field, name)) => field_value(host, *root, *field, name)?,
Self::Method(n) | Self::Num(n, false) => Value::Num(n.eval(host)?),
Self::Num(n, true) => Value::Bool(n.eval(host)? != 0.0),
})
}
}
/// Whether a subtree calls the host, whose answer may be no number.
fn has_method(e: &Expr) -> bool {
match e {
Expr::Method(..) => true,
Expr::Neg(a) | Expr::Not(a) => has_method(a),
Expr::Binary(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => has_method(a) || has_method(b),
Expr::Cond(c, a, b) => has_method(c) || has_method(a) || has_method(b),
Expr::Call(_, args) | Expr::Host(_, args) => args.iter().any(has_method),
_ => false,
}
}
impl Num {
fn of(e: &Expr) -> Option<Self> {
let boxed = |e: &Expr| Self::of(e).map(Box::new);
Some(match e {
Expr::Const(Value::Num(n)) => Self::Const(*n),
Expr::Const(_) | Expr::Local(_) | Expr::Root(_) | Expr::Host(..) => return None,
Expr::Neg(a) => Self::Neg(boxed(a)?),
Expr::Not(a) => Self::Not(boxed(a)?),
Expr::Binary(op, a, b) => {
// `==` and `!=` also compare strings and objects: only between plain numbers.
if matches!(op, BinOp::Eq | BinOp::Ne) && (has_method(a) || has_method(b)) {
return None;
}
Self::Bin(*op, boxed(a)?, boxed(b)?)
}
Expr::And(a, b) => Self::And(boxed(a)?, boxed(b)?),
Expr::Or(a, b) => Self::Or(boxed(a)?, boxed(b)?),
Expr::Cond(c, a, b) => Self::Cond(boxed(c)?, boxed(a)?, boxed(b)?),
Expr::Call(f, args) => {
Self::Call(*f, args.iter().map(Self::of).collect::<Option<_>>()?)
}
Expr::Method(target, name, args) => {
let Expr::Root(root) = **target else {
return None;
};
if let (true, Some(field)) = (args.is_empty(), Field::of(name)) {
return Some(Self::Field(root, field, name.clone()));
}
let args = args
.iter()
.map(|a| match a {
Expr::Const(v) => Some(Arg::Value(v.clone())),
other => {
let n = Self::of(other)?;
match n.kind() {
Kind::Unknown
if matches!(n, Self::Method(..) | Self::Field(..)) =>
{
Some(Arg::Method(n))
}
// A ternary between method values: the interpreter's own value.
Kind::Unknown => None,
kind => Some(Arg::Num(n, kind == Kind::Bool)),
}
}
})
.collect::<Option<_>>()?;
Self::Method(root, name.clone(), args)
}
})
}
fn kind(&self) -> Kind {
match self {
Self::Const(_) | Self::Neg(_) | Self::Call(..) => Kind::Num,
Self::Bin(op, ..) => match op {
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => Kind::Bool,
_ => Kind::Num,
},
Self::Not(_) | Self::And(..) | Self::Or(..) => Kind::Bool,
Self::Cond(_, a, b) => match (a.kind(), b.kind()) {
(x, y) if x == y => x,
_ => Kind::Unknown,
},
Self::Method(..) | Self::Field(..) => Kind::Unknown,
}
}
/// The value as a number, as the interpreter's `Value::num` reads it.
fn eval(&self, host: &mut dyn Host) -> Result<f64, String> {
Ok(match self {
Self::Const(n) => *n,
Self::Neg(a) => -a.eval(host)?,
Self::Not(a) => f64::from(u8::from(!a.truthy(host)?)),
Self::Bin(op, a, b) => {
let (x, y) = (a.eval(host)?, b.eval(host)?);
let t = |b: bool| f64::from(u8::from(b));
match op {
BinOp::Add => x + y,
BinOp::Sub => x - y,
BinOp::Mul => x * y,
BinOp::Div => x / y,
BinOp::Rem => x % y,
BinOp::Pow => x.powf(y),
BinOp::Eq => t(x == y),
BinOp::Ne => t(x != y),
BinOp::Lt => t(x < y),
BinOp::Le => t(x <= y),
BinOp::Gt => t(x > y),
BinOp::Ge => t(x >= y),
}
}
Self::And(a, b) => f64::from(u8::from(a.truthy(host)? && b.truthy(host)?)),
Self::Or(a, b) => f64::from(u8::from(a.truthy(host)? || b.truthy(host)?)),
Self::Cond(c, a, b) => {
if c.truthy(host)? {
a.eval(host)?
} else {
b.eval(host)?
}
}
Self::Call(f, args) if args.len() <= 4 => {
let mut nums = [0.0; 4];
for (slot, arg) in nums.iter_mut().zip(args) {
*slot = arg.eval(host)?;
}
f(&nums[..args.len()])
}
Self::Call(f, args) => {
let nums = args
.iter()
.map(|a| a.eval(host))
.collect::<Result<Vec<_>, _>>()?;
f(&nums)
}
Self::Method(root, name, args) => call(host, *root, name, args)?.num(),
Self::Field(root, field, name) => field_value(host, *root, *field, name)?.num(),
})
}
/// The value's truth, as the interpreter's `Value::truthy` reads it: a method's own answer
/// (a string or an object is true), else whether the number isn't 0.
fn truthy(&self, host: &mut dyn Host) -> Result<bool, String> {
match self {
Self::Method(root, name, args) => Ok(call(host, *root, name, args)?.truthy()),
Self::Field(root, field, name) => Ok(field_value(host, *root, *field, name)?.truthy()),
Self::Cond(c, a, b) => {
if c.truthy(host)? {
a.truthy(host)
} else {
b.truthy(host)
}
}
other => Ok(other.eval(host)? != 0.0),
}
}
}
+50 -1
View File
@@ -3,6 +3,7 @@
//! loads, with every name resolved, and then evaluated against a [`Host`], the game side that
//! answers context objects (`o`, `p`, `g`...) and their methods.
mod colors;
mod fast;
mod legacy;
mod lex;
mod parse;
@@ -68,6 +69,36 @@ impl Root {
}
}
/// A method without arguments that expressions read most, resolved when the program compiles so
/// a host can answer it without comparing names ([`Host::field`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Field {
Age,
Life,
Custom,
X,
Y,
Z,
Temperature,
Downfall,
}
impl Field {
pub(crate) fn of(name: &str) -> Option<Self> {
Some(match name {
"age" => Self::Age,
"life" => Self::Life,
"custom" => Self::Custom,
"x" => Self::X,
"y" => Self::Y,
"z" => Self::Z,
"temperature" => Self::Temperature,
"downfall" => Self::Downfall,
_ => return None,
})
}
}
/// What the game answers while an expression runs.
pub trait Host {
fn root(&mut self, root: Root) -> Value;
@@ -76,6 +107,12 @@ pub trait Host {
fn method(&mut self, target: &Value, name: &str, args: &[Value]) -> Result<Value, String>;
/// A global function of the game's (`config`, `colormap`, `modOn`, `date*`).
fn function(&mut self, name: &str, args: &[Value]) -> Result<Value, String>;
/// `root.field()` answered without its name, or `None` to answer it through [`Self::method`]:
/// a host that answers must give what `method` would.
fn field(&mut self, root: Root, field: Field) -> Option<Value> {
let _ = (root, field);
None
}
}
type MathFn = fn(&[f64]) -> f64;
@@ -139,13 +176,16 @@ pub(crate) const MATH: &[(&str, usize, usize, MathFn)] = &[
pub struct Program {
body: Vec<Stmt>,
locals: usize,
/// The same program on the numeric fast path, when it is one (`fast.rs`).
fast: Option<fast::Fast>,
}
impl Program {
/// Parses `src`; a syntax error or an unknown name fails here.
pub fn compile(src: &str) -> Result<Self, String> {
let (body, locals) = parse::parse(src)?;
Ok(Self { body, locals })
let fast = fast::Fast::of(&body, locals);
Ok(Self { body, locals, fast })
}
/// Whether the program reads nothing of its context but the `root.name()` methods `allowed`
@@ -185,6 +225,15 @@ impl Program {
/// Runs the statements; the result is the last statement's value (an assignment's is the
/// value it stored).
pub fn eval(&self, host: &mut dyn Host) -> Result<Value, String> {
if let Some(fast) = &self.fast {
return fast.eval(host);
}
self.interpret(host)
}
/// Runs the statements through the interpreter (what [`Self::eval`] does for a program the
/// fast path doesn't cover).
fn interpret(&self, host: &mut dyn Host) -> Result<Value, String> {
let mut run = Run {
host,
locals: vec![Value::Null; self.locals],
+111
View File
@@ -231,3 +231,114 @@ fn reads_only_names_what_a_program_may_read() {
assert!(!ok("r.noise(o.y, 0)"));
assert!(!ok("config('pack:option')"));
}
/// A host whose methods answer every kind of value, and record the arguments they get.
struct Kinds {
age: f64,
seen: Vec<String>,
/// Answers `o.age` and `o.life` by field too, as the particles' host does.
fields: bool,
}
impl Host for Kinds {
fn root(&mut self, root: Root) -> Value {
match root {
Root::Object => Value::Object(1),
Root::Random => Value::Object(2),
_ => Value::Object(3),
}
}
fn method(&mut self, _: &Value, name: &str, args: &[Value]) -> Result<Value, String> {
self.seen.push(format!("{name}{args:?}"));
Ok(match name {
"age" => Value::Num(self.age),
"life" => Value::Num(140.0),
"custom" => Value::Num(if args.is_empty() { 0.3 } else { 2.0 }),
"inWater" => Value::Bool(self.age > 50.0),
"biome" => Value::Str("minecraft:plains".into()),
"empty" => Value::Str("".into()),
"me" => Value::Object(7),
"noise" => Value::Num(args.iter().map(Value::num).sum::<f64>().sin()),
"fail" => return Err("no such thing".into()),
_ => Value::Null,
})
}
fn function(&mut self, _: &str, _: &[Value]) -> Result<Value, String> {
Ok(Value::Null)
}
fn field(&mut self, root: Root, field: Field) -> Option<Value> {
if !self.fields || root != Root::Object {
return None;
}
match field {
Field::Age => Some(Value::Num(self.age)),
Field::Life => Some(Value::Num(140.0)),
_ => None,
}
}
}
#[test]
fn the_fast_path_answers_what_the_interpreter_does() {
let corpus = [
"(0.7 + 0.3*sin(o.age*0.18 + o.custom*13000)) * min(o.age/(o.life*0.15),1)",
"0.5+(r.noise(o.age/60,5+o.age/60)*0.55)",
"o.age > 40",
"o.age >= 40 && o.age < 90",
"!(o.age > 3)",
"o.age > 10 || o.fail",
"o.age > 70 ? 1 : 0.25",
"o.age > 70 ? o.age : o.life",
"o.age > 70 ? o.biome : o.life",
"o.inWater",
"o.inWater ? 2 : 3",
"!o.inWater",
"o.biome ? 1 : 0",
"o.empty ? 1 : 0",
"o.me ? 1 : 0",
"o.biome + 1",
"-o.inWater",
"o.custom('glow') + 1",
"o.custom(o.age > 3)",
"r.noise(o.inWater, 2)",
"o.fail + 1",
"o.age % 7 - 2 ^ 3",
"clamp(o.age, 10, 20) / 0",
"o.age == 3",
"2 == 2",
"1 != 2 ? o.age : 0",
"max(o.age, o.life) - min(o.age, 1)",
"o.age > 3 ? o.age > 5 : o.inWater",
];
for src in corpus {
let program = Program::compile(src).unwrap();
let cases = [0.0, 3.0, 45.0, 75.0, 139.0]
.into_iter()
.flat_map(|a| [(a, false), (a, true)]);
for (age, fields) in cases {
let mut fast_host = Kinds {
age,
seen: Vec::new(),
fields,
};
let mut slow_host = Kinds {
age,
seen: Vec::new(),
fields,
};
let fast = program.eval(&mut fast_host);
let slow = program.interpret(&mut slow_host);
let same = match (&fast, &slow) {
(Ok(Value::Num(a)), Ok(Value::Num(b))) => a.to_bits() == b.to_bits(),
(a, b) => a == b,
};
assert!(same, "{src} at {age}: fast {fast:?}, interpreter {slow:?}");
// A field the host answers skips its call; the interpreter makes it by name.
if !fields {
assert_eq!(fast_host.seen, slow_host.seen, "{src} at {age}: the calls");
}
}
}
// The fast path covers the particles' and colormaps' plain arithmetic.
assert!(Program::compile(corpus[0]).unwrap().fast.is_some());
assert!(Program::compile(corpus[1]).unwrap().fast.is_some());
}
+1 -1
View File
@@ -49,7 +49,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
With `HORIZON_HDR_FRAME=float`, HDR on and no shader pack or a 0.2 one, devices with independent blending draw the frame in layers ([hdr-linear-light.md](design/hdr-linear-light.md), The HUD and menus). The world's shaders write their own light into the frame instead of a mark (`hdr_written`, `hdr_light.wgsl`), a whole frame at a time, so glass, water, clouds and particles each lay their own light over the light behind them. Improved Transparency's layers write their own light too, before they are weighted and composited. No float frame holds marks: under a 0.2 pack Horizon's own draws keep their colour, alpha 1 where opaque and coverage where blended, HDR output lifts nothing, and the pack's `[tonemap]` takes the tone curve's place below (one or the other runs, never both); without the HUD layer (HDR off, or a device without independent blending) its float frame finishes on the swapchain's 8 bits before the camera overlays, which then draw with the HUD as in SDR, and HDR output stays off. The camera overlays (vignette, pumpkin, powder snow, portal, nausea), post effects and entity outlines draw on light. After the post effects and before the camera overlays, the tone curve eases that light into the peak (`Hdr::tonemap`) in place of HDR lighting's own easing and the light sources' cap at the peak: up to 60% of the peak light stays as it is, and above it ITU-R BT.2390's EETF in PQ rolls it off, on its brightest channel so it keeps its hue. The HUD and menus then draw on an 8-bit target of their own, the world clipped to white, as they found the frame before (each draw clips at white as there), and write how much of the world they hide into an R8 coverage target. The last pass lays the HUD over the world's light by that coverage, in gamma as the HUD blends: light behind see-through HUD shows as vanilla weights the HUD over it, and none shows through what covers it. The menu blur blurs that picture, light above white included, and the menu draws over the result. UI Brightness scales the HUD's own part, not the world under it. A screenshot maps the world's light into SDR, unchanged up to 0.6 of paper white in linear light and smoothly rolled towards white above it, with one scale for all RGB channels to keep their hue (where Horizon lifts nothing, with HDR Lighting off or under a 0.2 pack, the world is clipped at white as SDR draws it); the HUD's own part stays as drawn, whatever UI Brightness is, over the mapped world by its coverage. The world's icon uses the same mapping without the HUD; the `-hdr.png` beside a screenshot keeps the whole HDR picture. `HORIZON_HDR_DEBUG=coverage` tints the coverage magenta for inspection.
HDR also lights the world past paper white: surfaces at block or sky light levels 11 to 15 rise above it, up to 4 times for the strongest light, sky light by how a face meets the sun, easing into the peak from 60% of it so bright textures keep their detail. The frame's alpha between 0.76 and 1 carries it to the last pass, where it amounts to the lightmap going past 1. Darker light stays as in SDR. It's off while a shader pack's post or material stage runs, and `HORIZON_HDR_LIGHTING=0` turns it off to compare.
HDR also lights the world past paper white: surfaces at block or sky light levels 11 to 15 rise above it, up to 4 times for the strongest light, sky light by how a face meets the sun, easing into the peak from 60% of it so bright textures keep their detail. The frame's alpha between 0.76 and 1 carries it to the last pass, where it amounts to the lightmap going past 1. Darker light stays as in SDR. It's off while a shader pack runs. **HDR Lighting** (`hdrLighting`, on by default) is the whole of this lift: light sources, the sun and the lit surfaces above; off, HDR shows SDR's picture. Under a `shaders/` pack or a 0.2 one Horizon lifts nothing, so the row is greyed and says why; under a 0.1 pack it still lifts the light sources and the sun.
HDR's light sources shine by the kind of light rather than its level (`mesher::hdr_tier`): the sun most, then glowstone, campfires and the soul fires, then flames and lava, torch and candle flames among them (particles too), then faint glowers such as a redstone torch or magma; glow lichen stays as in SDR. Each tier has its share of the headroom and the brightness its lift starts and ends at, the texel's own before lighting, so a light source shines alike at any light level and on every face (`gfx/hdr.rs` `TIERS`); `HORIZON_HDR_DEBUG=1` greys what isn't lifted and tints what is magenta.
+1 -1
View File
@@ -20,7 +20,7 @@ plan are in [server.md](server.md).
takes the account's UUID when someone owns that name, as vanilla does, and that player is then refused.
- **An offline server** (`online_mode = false`) never checks accounts: anyone who reaches it can join under
any name. Keep it on your LAN or a tailnet (Tailscale, ZeroTier), or share it only with friends you trust.
A Horizon player under an offline name can join only such a server.
A Horizon game started without a session (no network at Play) can join only such a server.
- **Secure chat** works as on vanilla's server: in online mode, vanilla clients and Horizon's (Nightly
2026-11-01 and newer) sign their chat, and with `enforce-secure-profile=true` (the default) unsigned chat is
refused. On an offline server, or for a player without a chat session, chat stays unsigned and shows as not
+1 -36
View File
@@ -188,7 +188,7 @@ MCP: `account_sign_out`, needs `confirm: true`.
## `horizon-launcher account select`
Play as this account, or as one of your offline names.
Play as this account.
horizon-launcher account select <WHO>
@@ -196,41 +196,6 @@ Play as this account, or as one of your offline names.
MCP: `account_select`.
## `horizon-launcher account offline`
Play offline (LAN, offline-mode servers) beside a signed-in account that owns the game: as NAME (kept to switch back to; 3 to 16 letters, digits or _), or as the name the game made up.
horizon-launcher account offline [NAME]
- `NAME`
MCP: `account_offline`.
## `horizon-launcher account forget-offline`
Forget one of your offline names; its skin and cape go with it.
horizon-launcher account forget-offline <NAME>
- `NAME`
MCP: `account_forget_offline`.
## `horizon-launcher account skin`
Show or set an offline name's skin and cape: a 64x64 (or 64x32) skin PNG, a 64x32 cape PNG, the model. Kept by the launcher and given to the game when that name plays.
horizon-launcher account skin <NAME> [FILE]
- `NAME`
- `FILE`: The skin's PNG
- `--model`: classic or slim arms
- `--cape`: The cape's PNG
- `--no-skin`: Back to the default skin
- `--no-cape`: Take the cape off
MCP: `account_skin`.
## `horizon-launcher settings`
Show the launcher's settings, or set one (the Settings page): selected-version TAG, game-architecture auto|x64|arm64, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, prune-old-versions on|off (delete old versions after an install, keeping the selected one, the ones instances use and the newest few), keep-versions N (how many of the newest to keep; "default" is 2), opening-animation on|off, proxy http://host:port|socks5://host:port ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs), language system|en|fr|ru|de|zh|es|uk|pl (the window's language; system follows the computer's), accent violet|blue|teal|green|amber|red|pink|#rrggbb|DEGREES (a named accent, any colour, or any hue), background PATH (a picture behind Home; "default" shows Horizon's landscape), native-cache-gib N (combined native compilation cache limit in GiB, default 30; 0 disables automatic cleanup), native-profile performance|dev|release (the profile native mods build with: performance is incremental, dev rebuilds a mod quickest for a slow game, release is slowest to build and slightly faster to play).
+73 -5
View File
@@ -288,11 +288,7 @@ build pays nothing.
The hooks today: `started` (once, when someone plays: not a dedicated server, a benchmark or an automated run),
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
world saved), and the play session's: `startup` (core-ready, rest-ready, the title's first frame, once each),
`wants_frame_steps` (asked once: time the render thread's steps), `world_shown` (a world's first frame), `frame`
(every frame played) and `world_left` (the session's end, with its settings); and `tunnel` (the resolver of
`friend:` addresses, Play with Friends'). Opt-in telemetry's performance summary (`telemetry.rs`) is built from
these. **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
world saved) and `tunnel` (the resolver of `friend:` addresses, Play with Friends'). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
@@ -427,6 +423,78 @@ pub trait Layer: Sync {
- Sprites, items and the player's state come with their platform API (M6). They are not in this first
version.
### Look (`horizon_platform::look`)
What the engine draws differently for a crate above, as plain data: PvP & HUD's knobs today (#14217).
```rust
pub struct Look { // #[non_exhaustive]; Default is vanilla's
pub legacy_pose: bool, pub hide_attack_indicator: bool,
pub mute_attack_sounds: bool, pub hide_attack_particles: bool,
pub frame: Frame, // outline, hurt overlay and glint colours
pub shield_size: u8, pub totem_scale: f32, pub fire_lower: f32,
pub clear_lava: bool, pub clear_water: bool, pub chat: Chat,
pub hide_effect_icons: bool, pub time_of_day: Option<i64>, pub weather: Option<(f32, f32)>,
}
pub fn update(change: impl FnOnce(&mut Look)); // the player whose turn it is (`seat()`)
```
- Pushed, not asked: a crate changes its own fields when its settings load or change (several crates share the
look, each keeping to its fields), and the engine reads it where it draws (`game/look.rs`, the only reader).
Nothing is called per frame, and a seat nothing changed sees vanilla's.
- A knob is named by what it changes in the picture or the sound, never by the feature that uses it. A new knob
is a field added with vanilla's value as its default.
### Container screens (`horizon_platform::containers`)
Input and drawing on an open container screen (the inventory, chests, furnaces, crafting tables...), for the
extras that act on one: Mouse Tweaks and sorting (M5).
```rust
/// The open screen's menu, for the length of one call. The engine implements it.
pub trait Container {
fn menu(&self) -> &str; // "inventory", "minecraft:generic_9x3", "minecraft:furnace"...
fn slots(&self) -> Vec<Slot>; // every slot drawn, in the screen's order
fn stack(&mut self, slot: SlotId) -> Option<Stack>;
fn carried(&self) -> Option<Stack>; // the stack on the cursor
fn takes_one(&mut self, slot: SlotId, of: &Stack) -> bool; // the menu's rules: mayPlace, the slot's limit
fn landing(&mut self, slot: SlotId) -> Vec<SlotId>; // where quickMoveStack would put its items
fn click(&mut self, slot: SlotId, click: Click) -> bool; // one ordinary click, as vanilla sends it
fn spread(&self) -> usize; // the slots vanilla's quick craft drag covers now
fn cursor(&self) -> (f32, f32); // GUI pixels, from the panel's corner
fn shift(&self) -> bool;
fn order(&mut self, slot: SlotId) -> (u32, String); // the creative tabs' order and the shown name
}
pub struct SlotId(pub u32); // opaque, stable while the screen is open
pub struct Slot { pub id: SlotId, pub role: Role, pub x: f32, pub y: f32 } // top left, from the panel's corner
pub enum Role { Hotbar, Inventory, Storage, Input, Result, Equipment, Other } // a crafting grid is Input
pub struct Stack { pub item: String, pub count: u32, pub max: u32, pub kind: u64 } // kind: equal for the same item and components
pub enum Click { Pick { right: bool }, QuickMove, Put { one: bool } } // Put: a one-slot QUICK_CRAFT drag
// In `Hooks`, each `false` (or nothing) by default, so vanilla's handling runs:
fn container_press(&self, c: &mut dyn Container, slot: Option<SlotId>, right: bool) -> bool;
fn container_drag(&self, c: &mut dyn Container, slot: Option<SlotId>) -> bool; // the cursor moved, a button held
fn container_release(&self, c: &mut dyn Container);
fn container_wheel(&self, c: &mut dyn Container, slot: Option<SlotId>, steps: f32) -> bool;
fn container_key(&self, c: &mut dyn Container, slot: Option<SlotId>, key: &str) -> bool; // `key.keyboard.r`, `key.mouse.middle`
fn container_draw(&self, c: &dyn Container, canvas: &mut dyn hud::Canvas); // over the panel, from its corner
```
- `true` means the hook took the input: vanilla's press, drag or wheel doesn't run, and a press or drag taken
ends vanilla's quick craft drag, as Mouse Tweaks' right-drag replaces it. Every hook hears `release`. The
first hook that returns `true` wins.
- Each `click` is an ordinary slot click: the menu engine's on the player's own world, a `ContainerClick` on a
server. So an extra built on it works wherever vanilla's clicks do, and anticheats see vanilla's packets.
- The engine offers every menu screen's slots; the crate decides which menus it acts in, by `menu()` (Mouse
Tweaks stays out of those whose slots cost or trigger something: anvils, enchanting, merchants, beacons,
crafters). Creative's tabs are no container screen.
- The reads that ask the menu's rules take `&mut self`, as the engine's do; drawing gets `&dyn Container`,
so it reads the rest only.
- The engine builds a `Container` only when a hook is registered and a container screen gets input or draws.
Without one there is no cost, and vanilla's handling is unchanged.
- `order` is the only text: Sorting ranks by it. The rest is ids and numbers, so the API names no engine type
(`ItemStack` stays in the engine).
## Extras that may leave the game
Horizon's extras are Horizon-layer code. Each one is a candidate to become an optional native mod that players add
+14 -8
View File
@@ -2,9 +2,9 @@
Horizon signs players in with their Microsoft account in the launcher and joins online-mode
(authenticated) servers like vanilla 26.3 does. The launcher plays only with an account that owns
Minecraft: Java Edition ([the owner gate](#the-owner-gate)); offline names, LAN and offline-mode
servers play behind it. This note is the design, the status of each part and what Ludovic has to
register.
Minecraft: Java Edition ([the owner gate](#the-owner-gate)), as that account, on LAN and
offline-mode servers too. There are no offline names. This note is the design, the status of each
part and what Ludovic has to register.
## What Ludovic registers (needs his accounts)
@@ -87,7 +87,7 @@ possible; the launcher just doesn't offer it on its own.
says "Sign in with a Microsoft account that owns Minecraft: Java Edition to play", with Sign in.
- **The window says it once.** A player without an owner (one who played offline until now) sees
the gate's screen once when the launcher opens: why Horizon needs the account ("like other
launchers"), that worlds, offline names and settings stay where they are, and Sign in, which shows
launchers"), and Sign in, which shows
the device code on the same screen. It comes back whenever Play is refused; Not now closes it,
and Home keeps a Sign in row (`launcher/src/owner_page.rs`).
- **The answer lives with the tokens.** Each account's keychain item keeps the last answer
@@ -96,8 +96,13 @@ possible; the launcher just doesn't offer it on its own.
`secret-tool` or Keychain Access. With no network (or a sign-in that can't be renewed) the
last answer stands: being offline never blocks a player who passed before, and the game starts
without a session, offline. An answer of "doesn't own" (or no profile) blocks at once.
- **Offline names play behind an owner.** They can be added and played only while an owning
account is signed in. Signing the last one out keeps them, and they play again once one is back.
- **The account that plays owns the game.** The game plays as the selected account (else the
first), which must own the game itself: another account's ownership doesn't let it play
(`account::launch_for_game`). The project decided on 2026-11-09 that Horizon plays only with a
Microsoft account that owns Minecraft: Java Edition: the offline names, their skins and capes,
and 0.2.58's hidden developer option are gone. Files from older launchers still load; the next
save drops them (`offline`, `offline_selected`, `looks` and `skins/` in accounts.json's folder,
`dev_offline` in settings.json).
- **Who isn't affected:** the game binary itself (`horizon-client`, `horizon-ctl launch`, the
harnesses, CI), the dedicated server and `horizon-server`, the website. There is no bypass flag:
the launcher's tests use accounts in a memory store (`MemoryStore`, and `HORIZON_NO_KEYCHAIN`
@@ -230,7 +235,7 @@ Progress is public metadata and does not expose credentials through the UI or MC
name the expected player. Signing in to a different account is rejected before storing its
credentials or adding/selecting that identity. Retry keeps the same target; Cancel leaves the
saved account alone. Successful re-sign-in restores that account without changing the player
or offline name currently selected for Play. Adding a new account remains a separate action.
currently selected for Play. Adding a new account remains a separate action.
## Handing the session to the game
@@ -287,7 +292,8 @@ Code: `horizon-protocol` (`rsa.rs`, `crypto::server_hash`), `horizon-client` (`a
Horizon's dedicated server verifies players when `online-mode=true`. A LAN or Play with Friends world
verifies its guests when the host is signed in, as vanilla's `IntegratedServer` does
(`OnlineMode::lan`); under an offline name it stays offline-mode. The host's own player joins through
(`OnlineMode::lan`); started without a session (the services couldn't be reached at Play) it
stays offline-mode. The host's own player joins through
memory and is never asked to authenticate.
`HORIZON_API_DISCOVERY` overrides the discovery URL, the counterpart of authlib's
+2 -2
View File
@@ -115,8 +115,8 @@ run at once there, and each must finish within 10 seconds however slowly its byt
Like LAN, and like vanilla's `IntegratedServer`: a host signed in with a Microsoft account runs the world
in online mode, so each guest signs in through Mojang's session servers and arrives with their own
UUID, skin and cape. A host playing under an offline name runs it in offline mode, where a guest joins
with the name their game uses. Either way the host's own profile is reserved. Every tunnelled guest
UUID, skin and cape. A host playing without a session (the services couldn't be reached at Play) runs it in
offline mode, where a guest joins with the name their game uses. Either way the host's own profile is reserved. Every tunnelled guest
reaches the integrated server from `127.0.0.1`.
## Relays (optional)
+7 -19
View File
@@ -160,15 +160,11 @@ The opening, then Play ([clip](docs/opening.webm)):
(`horizon_launcher::shortcut`). **Remove shortcut** moves it to the trash; it is found by the
instance id inside it, so a rename doesn't lose it. `horizon-launcher instance shortcut
[instance] [--remove]`; `HORIZON_LAUNCHER_DESKTOP` points it at another folder.
- **Accounts and quick switch**: the top bar's account chip opens a menu of everyone who can play:
each signed-in Microsoft account, the offline names the player keeps, and the name the game
made up (`horizon-player.txt`). Picking one plays as it from the next Play. Offline names play
only while an account that owns the game is signed in ([Microsoft accounts](#microsoft-accounts));
signing the last one out keeps them, unplayable until one is back. Settings > Account
adds offline names (3 to 16 letters, digits or `_`; the game gets `--name`) and forgets them,
skin and cape files included, and rolls the game's made-up name for a new one; the Console's
Share log takes the names out of the log too. `horizon-launcher account offline
[NAME]`, `account select <name>` (an account or an offline name), `account forget-offline
- **Accounts and quick switch**: the top bar's account chip opens a menu of the signed-in
Microsoft accounts. Picking one plays as it from the next Play; that account must own the game
([Microsoft accounts](#microsoft-accounts)). There are no offline names: the project decided
that Horizon plays only with a Microsoft account that owns Minecraft: Java Edition. The
Console's Share log takes the account names out of the log. `horizon-launcher account select
<name>`.
- **Accent and Home background**: on top of Dark, Light or System, Settings > Launcher picks an
accent (Horizon's violet, blue, teal, green, amber, red or pink, any hue, or any colour typed as
@@ -185,13 +181,6 @@ The opening, then Play ([clip](docs/opening.webm)):
`launcher/lang/<code>.json`, English text to its translation, Horizon's own; a test fails when
the code asks `tr("…")` for a string a table lacks, or a table keeps one the code dropped.
`horizon-launcher settings language system|en|fr|ru|de|zh|es|uk|pl`.
- **Skin and cape**: each offline name's **Skin…** in Settings > Account shows the front of its
skin and cape, takes a skin PNG (64×64, or the older 64×32) and a cape PNG (64×32), and picks
classic or slim arms. The launcher keeps copies in its `skins/` folder and gives the game
`HORIZON_OFFLINE_SKIN`, `HORIZON_OFFLINE_SKIN_MODEL` and `HORIZON_OFFLINE_CAPE` when that name
plays; the game draws the default skins until it reads them. A Microsoft account's skin and
cape: coming soon, with sign-in. `horizon-launcher account skin <name> [FILE] [--model
classic|slim] [--cape FILE] [--no-skin] [--no-cape]`.
- **Console**: Home's Console shows the game's output of this run as it comes: a search (every
word, any case), Follow to keep the newest line in view, Copy, and Open logs folder. Warnings
and errors stand out. **Share log…** uploads the log to [mclo.gs](https://mclo.gs), a paste
@@ -399,7 +388,6 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
horizon-launcher account sign-in sign in in your browser with a code
horizon-launcher account sign-out <name>
horizon-launcher account select <name> play as this account
horizon-launcher account offline play without an account
horizon-launcher instance [list] the instances and their game folders (* = Play starts it)
horizon-launcher instance create <name> [--copy <instance>] empty, or a copy of one's worlds, options and packs
horizon-launcher instance create <name> --minecraft <1.21.4|latest|latest-snapshot> a Minecraft: Java Edition instance
@@ -492,8 +480,8 @@ MCP's `play`) says "Sign in with a Microsoft account that owns Minecraft: Java E
and starts nothing. Settings → Accounts signs in: the launcher shows a code, you enter it at
microsoft.com/link, and the launcher checks that the account owns the game. It asks again at every
Play, with the token renewal; with no network the last answer stands, and a "doesn't own" blocks
at once. The account also plays online-mode servers under your own name and skin; offline names
play LAN and offline-mode servers beside it. The game binary started directly (`horizon-client`,
at once. The account that plays must own the game itself, and it plays online-mode servers under
your own name and skin, LAN and offline-mode servers too. The game binary started directly (`horizon-client`,
`horizon-ctl launch`), the harnesses and the servers don't go through this check. Tokens live in the system keychain (Keychain,
Credential Manager, or the Secret Service on Linux), never in a log or the command line;
`accounts.json` in the data folder keeps only names and UUIDs. On Linux without a keychain they go
-28
View File
@@ -37,7 +37,6 @@
"Building {name}'s post-build step": "Bou van {name} se na-bou-stap",
"In use": "In gebruik",
"Prepare": "Berei voor",
"Developer options": "Ontwikkelaar opsies",
"{online} of {max} players": "{online} van {max} spelers",
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java-uitgawe). Play laai dit die eerste keer van Mojang af vir jou rekening, met die Java wat dit nodig het.",
"Close": "Maak toe",
@@ -98,7 +97,6 @@
"From my install": "Van my installasie",
"the update failed: {error}": "die opdatering het misluk: {error}",
"Mesa's DRI_PRIME=1, so Vulkan picks the other card.": "Mesa se DRI_PRIME=1, so Vulkan kies die ander kaart.",
"New name": "Nuwe naam",
"Dark": "Donker",
"Not now": "Nie nou nie",
"Use an access token…": "Gebruik 'n toegangtoken...",
@@ -115,7 +113,6 @@
"Partly": "Gedeeltelik",
"All": "Almal",
"Instance": "Instansie",
"Classic arms": "Klassieke arms",
"Versions": "Weergawes",
"Release build: slower to build, slightly faster game": "Vrystelling bou: stadiger om te bou, effens vinniger spel",
"Performance build": "Prestasiebou",
@@ -172,7 +169,6 @@
"about a minute left": "omtrent 'n minuut oor",
"Cancel": "Kanselleer",
"Add to {client}": "Voeg by {client}",
"Skin…": "Vel…",
"Environment": "Omgewing",
"They often still work together. If the build fails, install versions made for the same Nightly.": "Hulle werk dikwels nog saam. As die bou misluk, installeer weergawes wat vir dieselfde Nightly gemaak is.",
"Import an instance folder…": "Voer 'n instansievouer in …",
@@ -184,7 +180,6 @@
"newest stable version": "nuutste stabiele weergawe",
"Choose image…": "Kies prent …",
"No worlds yet: they appear here once you play one.": "Nog geen wêrelde nie: hulle verskyn hier sodra jy een speel.",
"Offline names play without a Microsoft account that owns the game. Minecraft still comes from your own install.": "Vanlyn name speel sonder 'n Microsoft-rekening wat die speletjie besit. Minecraft kom steeds van jou eie installasie af.",
"crash reports beside it": "ineenstortingsverslae langsaan",
"Install all": "Installeer alles",
"No group": "Geen groep nie",
@@ -202,7 +197,6 @@
"My instance": "My voorbeeld",
"Snapshot": "Momentopname",
"Gallery": "Galery",
"Offline name": "Vanlyn naam",
"Home": "Tuis",
"Stopping": "Besig om te stop",
"MSPT · tick time": "MSPT · tiktyd",
@@ -304,8 +298,6 @@
"Patch notes": "Patch notas",
"Your Minecraft server, {online}.": "Jou Minecraft-bediener, {online}.",
"Crash reports": "Ineenstortingsverslae",
"Offline name · the game's own · plays": "Vanlyn naam · die speletjie se eie · speel",
"Slim arms": "Skraal arms",
"Asking GitHub about {owner}/{repo}…": "Vra GitHub oor {owner}/{repo}...",
"Downloading Minecraft {version} from Mojang · {done} of {total} files": "Laai tans Minecraft {version} af vanaf Mojang · {done} van {total} lêers",
"Could not save your choice: {error}": "Kon nie jou keuse stoor nie: {error}",
@@ -354,7 +346,6 @@
"Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "Horizon se landskap. Kies 'n eie PNG-, JPEG- of WebP-prent.",
"Not needed": "Nie nodig nie",
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "Hierdie bou van die lanseerder het geen Microsoft-aanmelding nie (HORIZON_MSA_CLIENT_ID), so dit kan nie die speletjie begin nie.",
"Offline name · plays": "Vanlyn naam · speel",
"Run the game through": "Begin die speletjie deur",
"needed by another pack": "benodig deur 'n ander pakkie",
"Older →": "Ouer →",
@@ -365,13 +356,10 @@
"Could not run {program}: {error}": "Kon nie {program}: {error} laat loop nie",
"Restart": "Herbegin",
"Copied to the clipboard.": "Gekopieer na die knipbord.",
"Your worlds, offline names and settings stay where they are.": "Jou wêrelde, vanlyn name en instellings bly waar hulle is.",
"Send nothing": "Stuur niks",
"Move to Trash": "Skuif na asblik",
"Start from": "Begin vanaf",
"Asking Mojang for its versions…": "Vra Mojang vir sy weergawes ...",
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "'n 64×64 (of 64×32) vel PNG en 'n 64×32 cape PNG, gehou deur die lanseerder en aan die speletjie gegee wanneer hierdie naam speel. Die speletjie teken die verstekvelle vir nou; dit gebruik dit sodra dit kan. 'n Microsoft-rekening se vel en mantel: binnekort.",
"Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "Speel vanlyn onder 'n ander naam, en skakel van die boonste balk af. Aanlyn bedieners neem nie vanlyn name nie.",
"Report on Discord": "Verslag oor Discord",
"Export": "Uitvoer",
"Server": "Bediener",
@@ -384,7 +372,6 @@
"No version installed yet.": "Nog geen weergawe geïnstalleer nie.",
"Release": "Vrystelling",
"Verified": "Geverifieer",
"Choose a skin…": "Kies 'n vel...",
"{instance} doesn't play its native mods yet: build the game with them.": "{instance} speel nog nie sy oorspronklike mods nie: bou die speletjie daarmee.",
"Use jars from my instance…": "Gebruik potte uit my instansie...",
"Applications menu": "Toepassings-kieslys",
@@ -408,7 +395,6 @@
"Open backups": "Maak rugsteun oop",
"The game is running: change packs in its own menus, or here once it is closed.": "Die speletjie is aan die gang: verander pakke in sy eie spyskaarte, of hier sodra dit gesluit is.",
"Check now": "Kyk nou",
"Choose a cape…": "Kies 'n kaap...",
"Save": "Stoor",
"Folder": "Vouer",
"Searching…": "Soek tans …",
@@ -422,7 +408,6 @@
"MangoHud": "MangoHud",
"Sign out": "Meld af",
"When it starts": "Wanneer dit begin",
"{name}'s skin": "{name} se vel",
"Start": "Begin",
"No Minecraft install needed: Horizon downloads it from Mojang for your account.": "Geen Minecraft-installasie nodig nie: Horizon laai dit van Mojang af vir jou rekening.",
"Logs": "Logs",
@@ -446,7 +431,6 @@
"The wordmark, while the release list loads.": "Die woordmerk, terwyl die vrystellinglys laai.",
"Playing offline": "Speel tans vanlyn",
"Sign in to Tailscale": "Meld aan by Tailscale",
"Offline names without an account": "Vanlyn name sonder 'n rekening",
"Help make Horizon faster and steadier?": "Help om Horizon vinniger en bestendiger te maak?",
"Sign in to play": "Meld aan om te speel",
"Ported": "Geplaas",
@@ -488,14 +472,12 @@
"Update this launcher with your package manager.": "Dateer hierdie lanseerder op met jou pakketbestuurder.",
"Close this launcher, move it to {folder}, and open it there to enable updates.": "Maak hierdie lanseerder toe, skuif dit na {folder} en maak dit daar oop om opdaterings te aktiveer.",
"Any MCP client": "Enige MCP-kliënt",
"{name}'s cape is kept.": "{name} se kaap word gehou.",
"Not reviewed by the Horizon registry": "Nie hersien deur die Horizon-register nie",
"Proxy": "Volmag",
"Checking": "Kontroleer",
"Updates": "Opdaterings",
"Asking Microsoft…": "Vra Microsoft …",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Voorbereid vir Minecraft {version}, sonder sy klanke: die speletjie sal stil wees. Berei weer voor om dit vanaf jou Minecraft-installasie te kopieer.",
"Forget": "Vergeet",
"Import a modpack…": "Voer 'n modpack in...",
"Search versions": "Soek weergawes",
"failed": "misluk",
@@ -504,7 +486,6 @@
"Left off: {line}.": "Opgehou: {line}.",
"Resource packs": "Hulpbronpakke",
"yesterday": "gister",
"No cape": "Geen mantel nie",
"After each session: frame-time percentiles and a 14-bucket histogram, tick times, peak memory and startup times, with your machine (OS, CPU and its cores and clocks, memory size, GPU, its memory and driver, drive type, screen size and refresh rate, laptop or desktop, on battery or not) and the settings that change performance.": "Na elke sessie: raam-tyd persentiele en 'n 14-emmer histogram, regmerkie tye, piek geheue en opstart tye, met jou masjien (OS, SVE en sy kerns en horlosies, geheue grootte, GPU, sy geheue en drywer, tipe skyf, skerm grootte en herlaaitempo, skootrekenaar of rekenaar, op battery of nie) en die instellings wat werkverrigting verander.",
"Make a token": "Maak 'n teken",
"Added {names}: the game turns it on when the world opens.": "Bygevoeg {names}: die speletjie skakel dit aan wanneer die wêreld oopgaan.",
@@ -557,7 +538,6 @@
"Shared: {url}": "Gedeel: {url}",
"Friends on the same Wi-Fi: Multiplayer, Add Server, then paste this address.": "Vriende op dieselfde Wi-Fi: Multiplayer, Voeg bediener by, plak dan hierdie adres.",
"None yet: drop a .hshader or a shader pack .zip here.": "Nog geen: gooi 'n .hshader- of 'n shader-pak .zip hier.",
"Offline name · the game's own": "Vanlyn naam · die speletjie se eie",
"Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Inheemse mods is nie sandboxed nie: hulle loop met volle toegang tot jou rekenaar, soos enige program. Installeer slegs mods wie se skrywers jy vertrou. Om met een te speel, bou die speletjie op die Mods-bladsy; die eerste bou laai Rust af en neem 20 tot 30 minute.",
"Your selected version only changes if you were playing the newest one.": "Jou gekose weergawe verander net as jy die nuutste een gespeel het.",
"Multiplayer": "Multispeler",
@@ -587,7 +567,6 @@
"the port": "die hawe",
"The source of {source}. Its release ships {file}, a content mod: installing those from GitHub is not supported yet.": "Die bron van {source}. Die vrystelling daarvan stuur {file}, 'n inhoudmodi: installering van dié vanaf GitHub word nog nie ondersteun nie.",
"Build anyway": "Bou in elk geval",
"A name, 3 to 16 letters": "'n Naam, 3 tot 16 letters",
"Installed {mods} from GitHub ({source}). Build it on the Mods page.": "Geïnstalleer {mods} vanaf GitHub ({source}). Bou dit op die Mods-bladsy.",
"Settings, Keys, Generate access token. It's kept in this computer's keychain and only sent to Tailscale.": "Instellings, Sleutels, Genereer toegangtoken. Dit word in hierdie rekenaar se sleutelhouer gehou en net na Tailscale gestuur.",
"{id} is up to date.": "{id} is op datum.",
@@ -602,7 +581,6 @@
"I read it and trust it: install": "Ek lees dit en vertrou dit: installeer",
"Close export": "Maak uitvoer toe",
"Playing needs a prepared Horizon version (Play page).": "Speel benodig 'n voorbereide Horizon-weergawe (Speelbladsy).",
"Offline, the game's name": "Vanlyn, die speletjie se naam",
"From Horizon's registry": "Van Horizon se register",
"Sounds": "Klanke",
"Edit…": "Wysig …",
@@ -635,12 +613,10 @@
"up to date": "op datum",
"← Newer": "← Nuwer",
"Downloading from Mojang · {done} of {total} files": "Laai tans af vanaf Mojang · {done} van {total} lêers",
"{name}'s skin is kept.": "{name} se vel word behou.",
"Edit instance": "Wysig instansie",
"Remove shortcut": "Verwyder kortpad",
"{program} is not installed here (or not on the PATH): copy the config instead.": "{program} is nie hier geïnstalleer nie (of nie op die PATH): kopieer eerder die konfigurasie.",
"No worlds to put in.": "Geen wêrelde om in te plaas nie.",
"Default skin": "Standaard vel",
"A link to a repository (its latest release), a release, a branch or tag, or a commit. The launcher installs one exact release file or commit, never a branch that moves.": "'n Skakel na 'n bewaarplek (die jongste vrystelling daarvan), 'n vrystelling, 'n tak of merker, of 'n commit. Die lanseerder installeer een presiese vrystellinglêer of commit, nooit 'n tak wat beweeg nie.",
"Sign in with GitHub to rate": "Meld aan met GitHub om te gradeer",
"Star on GitHub": "Gee 'n ster op GitHub",
@@ -1053,10 +1029,6 @@
"played by {names}": "gespeel deur {names}",
"{day}, {time} UTC": "{day}, {time} UTC",
"Microsoft account · online servers too": "Microsoft-rekening · ook aanlyn bedieners",
"Offline · single player and LAN worlds": "Vanlyn · enkelspeler- en LAN-wêrelde",
"Offline names": "Vanlyn name",
"Add a name": "Voeg 'n naam by",
"3 to 16 letters, numbers or underscores. Switch names from the top bar too. Online servers don't take offline names.": "3 tot 16 letters, syfers of onderstrepe. Verander ook name vanaf die boonste balk. Aanlyn bedieners neem nie vanlyn name nie.",
"Play online with your Microsoft account": "Speel aanlyn met jou Microsoft-rekening",
"Sign in with the account that owns Minecraft: Java Edition. Your name and skin come along to online servers, like in other launchers.": "Meld aan met die rekening wat Minecraft: Java Edition besit. Jou naam en vel kom saam na aanlyn bedieners, soos in ander lanseerders.",
"Microsoft's own page opens in your browser. Horizon never sees your password.": "Microsoft se eie bladsy maak in jou blaaier oop. Horizon sien nooit jou wagwoord nie.",
-28
View File
@@ -37,7 +37,6 @@
"Building {name}'s post-build step": "خطوة ما بعد البناء لـ {name}",
"In use": "قيد الاستخدام",
"Prepare": "تحضير",
"Developer options": "خيارات المطور",
"{online} of {max} players": "{online} من {max} لاعبين",
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "ماين كرافت {version} (إصدار جافا). يقوم الزر \"تشغيل\" بتنزيلها من موجانج لحسابك في المرة الأولى، مع إصدار جافا الذي تحتاجه.",
"Close": "إغلاق",
@@ -98,7 +97,6 @@
"From my install": "من تثبيتي الخاص",
"the update failed: {error}": "فشل التحديث: {error}",
"Mesa's DRI_PRIME=1, so Vulkan picks the other card.": "خاصية DRI_PRIME=1 في Mesa، لكي يختار Vulkan البطاقة الأخرى.",
"New name": "اسم جديد",
"Dark": "داكن",
"Not now": "ليس الآن",
"Use an access token…": "استخدام رمز وصول…",
@@ -115,7 +113,6 @@
"Partly": "جزئيًا",
"All": "الكل",
"Instance": "نسخة (Instance)",
"Classic arms": "أذرع كلاسيكية",
"Versions": "الإصدارات",
"Release build: slower to build, slightly faster game": "إصدار رسمي: أبطأ في البناء، وأسرع قليلاً في اللعبة",
"Performance build": "إصدار الأداء",
@@ -172,7 +169,6 @@
"about a minute left": "متبقي حوالي دقيقة",
"Cancel": "إلغاء",
"Add to {client}": "إضافة إلى {client}",
"Skin…": "المظهر (Skin)…",
"Environment": "البيئة",
"They often still work together. If the build fails, install versions made for the same Nightly.": "غالبًا ما تستمر في العمل معًا. إذا فشل البناء، قم بتثبيت الإصدارات المصنوعة لنفس نسخة Nightly.",
"Import an instance folder…": "استيراد مجلد نسخة…",
@@ -184,7 +180,6 @@
"newest stable version": "أحدث إصدار مستقر",
"Choose image…": "اختر صورة…",
"No worlds yet: they appear here once you play one.": "لا توجد عوالم حتى الآن: ستظهر هنا بمجرد لعب أحدها.",
"Offline names play without a Microsoft account that owns the game. Minecraft still comes from your own install.": "تتيح الأسماء غير المتصلة اللعب بدون حساب مايكروسوفت يمتلك اللعبة. لا تزال ماين كرافت تأتي من تثبيتك الخاص.",
"crash reports beside it": "تقارير الأعطال بجانبها",
"Install all": "تثبيت الكل",
"No group": "بدون مجموعة",
@@ -202,7 +197,6 @@
"My instance": "نسختي",
"Snapshot": "نسخة تجريبية",
"Gallery": "المعرض",
"Offline name": "اسم غير متصل",
"Home": "الرئيسية",
"Stopping": "جاري الإيقاف",
"MSPT · tick time": "MSPT · وقت التكتكة",
@@ -305,8 +299,6 @@
"Patch notes": "ملاحظات الإصدار",
"Your Minecraft server, {online}.": "خادم ماين كرافت الخاص بك، {online}.",
"Crash reports": "تقارير الأعطال",
"Offline name · the game's own · plays": "اسم غير متصل · الخاص باللعبة · يلعب",
"Slim arms": "أذرع نحيفة",
"Asking GitHub about {owner}/{repo}…": "جاري الاستعلام من جيت هاب عن {owner}/{repo}…",
"Downloading Minecraft {version} from Mojang · {done} of {total} files": "جاري تنزيل ماين كرافت {version} من موجانج · {done} من {total} ملفات",
"Could not save your choice: {error}": "تعذر حفظ اختيارك: {error}",
@@ -355,7 +347,6 @@
"Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "خلفية هورايزون الأفقية. اختر صورة بصيغة PNG أو JPEG أو WebP خاصة بك.",
"Not needed": "غير مطلوب",
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "هذا الإصدار من المشغل لا يحتوي على ميزة تسجيل دخول مايكروسوفت (HORIZON_MSA_CLIENT_ID)، لذا لا يمكنه بدء اللعبة.",
"Offline name · plays": "اسم غير متصل · يلعب",
"Run the game through": "تشغيل اللعبة عبر",
"needed by another pack": "مطلوب بواسطة حزمة أخرى",
"Older →": "الأقدم ←",
@@ -366,13 +357,10 @@
"Could not run {program}: {error}": "تعذر تشغيل {program}: {error}",
"Restart": "إعادة التشغيل",
"Copied to the clipboard.": "تم النسخ إلى الحافظة.",
"Your worlds, offline names and settings stay where they are.": "عوالمك وأسماؤك غير المتصلة وإعداداتك تبقى كما هي.",
"Send nothing": "لا تقم بإرسال أي شيء",
"Move to Trash": "نقل إلى سلة المهملات",
"Start from": "البدء من",
"Asking Mojang for its versions…": "جاري طلب الإصدارات من موجانج…",
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "صورة مظهر (Skin) بصيغة PNG بدقة 64×64 (أو 64×32) وصورة رداء (Cape) بدقة 64×32، يتم الاحتفاظ بهما بواسطة المشغل وتمريرهما للعبة عند اللعب بهذا الاسم. ترسم اللعبة المظاهر الافتراضية في الوقت الحالي؛ وستستخدم هذه المظاهر فور إمكانية ذلك. مظهر ورداء حساب مايكروسوفت: قريباً.",
"Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "العب بلا اتصال تحت اسم آخر، وقم بالتبديل من الشريط العلوي. لا تقبل خوادم الإنترنت الأسماء غير المتصلة.",
"Report on Discord": "الإبلاغ على ديسكورد",
"Export": "تصدير",
"Server": "خادم",
@@ -385,7 +373,6 @@
"No version installed yet.": "لم يتم تثبيت أي إصدار بعد.",
"Release": "إصدار رسمي",
"Verified": "مُتحقق منه",
"Choose a skin…": "اختر مظهراً (Skin)…",
"{instance} doesn't play its native mods yet: build the game with them.": "{instance} لا يشغل تعديلاته الأصلية بعد: قم ببناء اللعبة معها.",
"Use jars from my instance…": "استخدام ملفات Jars من نسختي…",
"Applications menu": "قائمة التطبيقات",
@@ -409,7 +396,6 @@
"Open backups": "فتح النسخ الاحتياطية",
"The game is running: change packs in its own menus, or here once it is closed.": "اللعبة قيد التشغيل: قم بتغيير الحزم من قوائمها الداخلية، أو هنا بمجرد إغلاقها.",
"Check now": "تحقق الآن",
"Choose a cape…": "اختر رداءً (Cape)…",
"Save": "حفظ",
"Folder": "المجلد",
"Searching…": "جاري البحث…",
@@ -423,7 +409,6 @@
"MangoHud": "MangoHud",
"Sign out": "تسجيل الخروج",
"When it starts": "عند البدء",
"{name}'s skin": "مظهر {name}",
"Start": "بدء",
"No Minecraft install needed: Horizon downloads it from Mojang for your account.": "لا حاجة لتثبيت ماين كرافت: يقوم هورايزون بتنزيلها من موجانج لحسابك.",
"Logs": "السجلات",
@@ -447,7 +432,6 @@
"The wordmark, while the release list loads.": "شعار الاسم أثناء تحميل قائمة الإصدارات.",
"Playing offline": "اللعب بدون اتصال",
"Sign in to Tailscale": "تسجيل الدخول إلى Tailscale",
"Offline names without an account": "أسماء غير متصلة بدون حساب",
"Help make Horizon faster and steadier?": "هل تريد المساعدة في جعل هورايزون أسرع وأكثر استقرارًا؟",
"Sign in to play": "تسجيل الدخول للعب",
"Ported": "منقولة",
@@ -490,14 +474,12 @@
"Update this launcher with your package manager.": "حدّث هذا المشغّل باستخدام مدير الحزم.",
"Close this launcher, move it to {folder}, and open it there to enable updates.": "أغلق هذا المشغّل، وانقله إلى {folder}، ثم افتحه من هناك لتمكين التحديثات.",
"Any MCP client": "أي عميل MCP",
"{name}'s cape is kept.": "تم الاحتفاظ برداء {name}.",
"Not reviewed by the Horizon registry": "لم يتم مراجعته من قِبل سجل هورايزون",
"Proxy": "الوكيل (Proxy)",
"Checking": "جاري التحقق",
"Updates": "التحديثات",
"Asking Microsoft…": "جاري السؤال مايكروسوفت…",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "تم التجهيز لماين كرافت {version} بدون أصوات: ستكون اللعبة صامتة. قم بالتجهيز مرة أخرى لنسخها من تثبيت ماين كرافت الخاص بك.",
"Forget": "نسيان",
"Import a modpack…": "استيراد حزمة تعديلات…",
"Search versions": "البحث في الإصدارات",
"failed": "فشل",
@@ -506,7 +488,6 @@
"Left off: {line}.": "التوقف عند السطر: {line}.",
"Resource packs": "حزم الموارد",
"yesterday": "أمس",
"No cape": "بدون رداء",
"After each session: frame-time percentiles and a 14-bucket histogram, tick times, peak memory and startup times, with your machine (OS, CPU and its cores and clocks, memory size, GPU, its memory and driver, drive type, screen size and refresh rate, laptop or desktop, on battery or not) and the settings that change performance.": "بعد كل جلسة: النسب المئوية لأوقات الإطارات ومخطط بياني بـ 14 فئة، وأوقات التكتكة، وذروة الذاكرة وأوقات البدء، مع مواصفات جهازك (نظام التشغيل، المعالج وأنويته وسرعاته، حجم الذاكرة، بطاقة الرسوميات وذاكرتها وتعريفها، نوع القرص، حجم الشاشة ومعدل التحديث، ما إذا كان لابتوب أو جهاز مكتبياً، ويعمل على البطارية أم لا) والإعدادات التي تغير الأداء.",
"Make a token": "إنشاء رمز وصول (Token)",
"Added {names}: the game turns it on when the world opens.": "تمت إضافة {names}: تفعل اللعبة ذلك تلقائيًا عند فتح العالم.",
@@ -559,7 +540,6 @@
"Shared: {url}": "تمت المشاركة: {url}",
"Friends on the same Wi-Fi: Multiplayer, Add Server, then paste this address.": "الأصدقاء على نفس شبكة الواي فاي: اللعب الجماعي > إضافة خادم، ثم الصق هذا العنوان.",
"None yet: drop a .hshader or a shader pack .zip here.": "لا شيء بعد: اسحب ملف .hshader أو حزمة تظليل بصيغة .zip وأفلتها هنا.",
"Offline name · the game's own": "اسم غير متصل · الخاص باللعبة",
"Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "التعديلات الأصلية ليست معزولة في بيئة آمنة (Sandboxed): فهي تعمل بصلاحيات كاملة على جهازك، مثل أي برنامج. ثبت فقط التعديلات التي تثق بمؤلفيها. للعب بها، قم ببناء اللعبة من صفحة التعديلات؛ يتطلب البناء الأول تنزيل لغة Rust ويستغرق من 20 إلى 30 دقيقة.",
"Your selected version only changes if you were playing the newest one.": "يتغير إصدارك المحدد فقط إذا كنت تلعب بالأحدث.",
"Multiplayer": "اللعب الجماعي",
@@ -589,7 +569,6 @@
"the port": "المنفذ",
"The source of {source}. Its release ships {file}, a content mod: installing those from GitHub is not supported yet.": "مصدر {source}. يحتوي إصداره على {file}، وهو تعديل محتوى: تثبيت هذه التعديلات من جيت هاب غير مدعوم بعد.",
"Build anyway": "البناء على أي حال",
"A name, 3 to 16 letters": "اسم، من 3 إلى 16 حرفًا",
"Installed {mods} from GitHub ({source}). Build it on the Mods page.": "تم تثبيت {mods} من جيت هاب ({source}). قم ببنائه في صفحة التعديلات.",
"Settings, Keys, Generate access token. It's kept in this computer's keychain and only sent to Tailscale.": "الإعدادات (Settings) > المفاتيح (Keys) > إنشاء رمز وصول (Generate access token). يتم الاحتفاظ به في سلسلة مفاتيح هذا الكمبيوتر وإرساله إلى Tailscale فقط.",
"{id} is up to date.": "{id} محدث لأحدث نسخة.",
@@ -604,7 +583,6 @@
"I read it and trust it: install": "لقد قرأته وأثق به: تثبيت",
"Close export": "إغلاق التصدير",
"Playing needs a prepared Horizon version (Play page).": "يتطلب اللعب وجود إصدار مجهز من هورايزون (صفحة اللعب).",
"Offline, the game's name": "غير متصل، اسم اللعبة",
"From Horizon's registry": "من سجل هورايزون الخاص",
"Sounds": "الأصوات",
"Edit…": "تعديل…",
@@ -637,12 +615,10 @@
"up to date": "محدث",
"← Newer": "← الأحدث",
"Downloading from Mojang · {done} of {total} files": "جاري التنزيل من موجانج · {done} من {total} ملفات",
"{name}'s skin is kept.": "تم الاحتفاظ بمظهر {name}.",
"Edit instance": "تعديل النسخة",
"Remove shortcut": "إزالة الاختصار",
"{program} is not installed here (or not on the PATH): copy the config instead.": "{program} غير مثبت هنا (أو غير مدرج في المسار PATH): انسخ ملف التكوين بدلاً من ذلك.",
"No worlds to put in.": "لا توجد عوالم لإضافتها.",
"Default skin": "المظهر الافتراضي",
"A link to a repository (its latest release), a release, a branch or tag, or a commit. The launcher installs one exact release file or commit, never a branch that moves.": "رابط إلى مستودع (أحدث إصدار له)، أو إصدار، أو فرع (Branch)، أو علامة (Tag)، أو التزام (Commit). يقوم المشغل بتثبيت ملف إصدار أو التزام محدد تمامًا، ولا يثبت فرعًا متغيرًا.",
"Sign in with GitHub to rate": "سجل الدخول بواسطة جيت هاب للتقييم",
"Star on GitHub": "أضف نجمة على GitHub",
@@ -1055,10 +1031,6 @@
"played by {names}": "يلعبه {names}",
"{day}, {time} UTC": "{day}، {time} UTC",
"Microsoft account · online servers too": "حساب Microsoft · الخوادم عبر الإنترنت أيضًا",
"Offline · single player and LAN worlds": "دون اتصال · اللعب الفردي وعوالم الشبكة المحلية",
"Offline names": "أسماء دون اتصال",
"Add a name": "أضف اسمًا",
"3 to 16 letters, numbers or underscores. Switch names from the top bar too. Online servers don't take offline names.": "من 3 إلى 16 حرفًا أو رقمًا أو شرطة سفلية. يمكنك تبديل الأسماء من الشريط العلوي أيضًا. الخوادم عبر الإنترنت لا تقبل الأسماء دون اتصال.",
"Play online with your Microsoft account": "العب عبر الإنترنت بحساب Microsoft",
"Sign in with the account that owns Minecraft: Java Edition. Your name and skin come along to online servers, like in other launchers.": "سجّل الدخول بالحساب الذي يملك Minecraft: Java Edition. يرافقك اسمك ومظهرك إلى الخوادم عبر الإنترنت، كما في المشغلات الأخرى.",
"Microsoft's own page opens in your browser. Horizon never sees your password.": "تُفتح صفحة Microsoft نفسها في متصفحك. لا يرى Horizon كلمة مرورك أبدًا.",
-28
View File
@@ -37,7 +37,6 @@
"Building {name}'s post-build step": "{name}-nin tikintidən sonrakı addımının qurulması",
"In use": "İstifadədədir",
"Prepare": "Hazırlayın",
"Developer options": "Tərtibatçı seçimləri",
"{online} of {max} players": "{max} oyunçudan {online}",
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). Play ehtiyac duyduğu Java ilə ilk dəfə hesabınız üçün onu Mojang-dan endirir.",
"Close": "Bağlayın",
@@ -98,7 +97,6 @@
"From my install": "Quraşdırdığımdan",
"the update failed: {error}": "yeniləmə uğursuz oldu: {error}",
"Mesa's DRI_PRIME=1, so Vulkan picks the other card.": "Mesanın DRI_PRIME=1, ona görə də Vulkan digər kartı seçir.",
"New name": "Yeni ad",
"Dark": "Qaranlıq",
"Not now": "İndi yox",
"Use an access token…": "Giriş nişanından istifadə edin...",
@@ -115,7 +113,6 @@
"Partly": "Qismən",
"All": "Hamısı",
"Instance": "Nüsxə",
"Classic arms": "Klassik qollar",
"Versions": "Versiyalar",
"Release build: slower to build, slightly faster game": "Buraxılış quruluşu: qurmaq daha yavaş, bir az daha sürətli oyun",
"Performance build": "Performans qurmaq",
@@ -172,7 +169,6 @@
"about a minute left": "təxminən bir dəqiqə qaldı",
"Cancel": "Ləğv et",
"Add to {client}": "Əlavə et {client}",
"Skin…": "Dəri…",
"Environment": "Ətraf mühit",
"They often still work together. If the build fails, install versions made for the same Nightly.": "Çox vaxt hələ də birlikdə işləyirlər. Quraşdırma uğursuz olarsa, eyni Nightly üçün hazırlanmış versiyaları quraşdırın.",
"Import an instance folder…": "Nümunə qovluğunu idxal edin...",
@@ -184,7 +180,6 @@
"newest stable version": "ən yeni stabil versiya",
"Choose image…": "Şəkil seçin...",
"No worlds yet: they appear here once you play one.": "Hələ dünya yoxdur: siz birini oynadığınız zaman onlar burada görünür.",
"Offline names play without a Microsoft account that owns the game. Minecraft still comes from your own install.": "Oflayn adlar oyunun sahibi olan Microsoft hesabı olmadan oynayır. Minecraft hələ də öz quraşdırmanızdan gəlir.",
"crash reports beside it": "yanında çökmə hesabatları",
"Install all": "Hamısını quraşdırın",
"No group": "Qrup yoxdur",
@@ -202,7 +197,6 @@
"My instance": "Mənim nümunəm",
"Snapshot": "Ani görüntü",
"Gallery": "Qalereya",
"Offline name": "Oflayn ad",
"Home": "Ev",
"Stopping": "Dayanır",
"MSPT · tick time": "MSPT · tik vaxtı",
@@ -304,8 +298,6 @@
"Patch notes": "Yamaq qeydləri",
"Your Minecraft server, {online}.": "Minecraft serveriniz, {online}.",
"Crash reports": "Çökmə hesabatları",
"Offline name · the game's own · plays": "Oflayn ad · oyunun öz · oynayır",
"Slim arms": "İncə qollar",
"Asking GitHub about {owner}/{repo}…": "GitHub-dan {owner}/{repo}... haqqında soruşulur",
"Downloading Minecraft {version} from Mojang · {done} of {total} files": "Mojang-dan Minecraft {version} endirilir · {done} fayldan {total} fayl",
"Could not save your choice: {error}": "Seçiminizi saxlamaq mümkün olmadı: {error}",
@@ -354,7 +346,6 @@
"Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "Horizon mənzərəsi. Özünüzə məxsus PNG, JPEG və ya WebP şəklini seçin.",
"Not needed": "Lazım deyil",
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "Başlatıcının bu quruluşunda Microsoft girişi yoxdur (HORIZON_MSA_CLIENT_ID), ona görə də o, oyunu başlata bilmir.",
"Offline name · plays": "Oflayn ad · oynayır",
"Run the game through": "Oyunu keçin",
"needed by another pack": "başqa paketə lazımdır",
"Older →": "Köhnə →",
@@ -365,13 +356,10 @@
"Could not run {program}: {error}": "Çalıştırmaq mümkün olmadı {program}: {error}",
"Restart": "Yenidən başladın",
"Copied to the clipboard.": "Buferə kopyalandı.",
"Your worlds, offline names and settings stay where they are.": "Dünyalarınız, oflayn adlarınız və parametrləriniz olduğu yerdə qalır.",
"Send nothing": "Heç nə göndərmə",
"Move to Trash": "Zibil qutusuna köçürün",
"Start from": "-dən başlayın",
"Asking Mojang for its versions…": "Mojangdan versiyaları soruşulur...",
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "Başlatıcı tərəfindən saxlanılan və bu ad oynanılan zaman oyuna verilən 64×64 (və ya 64×32) dəri PNG və 64×32 baş PNG. Oyun hazırda standart dəriləri çəkir; bir dəfə bunlardan istifadə edir. Microsoft hesabının dərisi və örtüyü: tezliklə.",
"Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "Başqa ad altında oflayn oynayın və yuxarı paneldən keçin. Onlayn serverlər oflayn adlar götürmür.",
"Report on Discord": "Discord haqqında hesabat",
"Export": "İxrac",
"Server": "Server",
@@ -384,7 +372,6 @@
"No version installed yet.": "Hələ heç bir versiya quraşdırılmayıb.",
"Release": "Buraxılış",
"Verified": "Doğrulanıb",
"Choose a skin…": "Dəri seçin...",
"{instance} doesn't play its native mods yet: build the game with them.": "{instance} hələ öz doğma modlarını oynamır: onlarla oyunu qurun.",
"Use jars from my instance…": "Mənim nümunəmdəki bankalardan istifadə edin...",
"Applications menu": "Proqramlar menyusu",
@@ -408,7 +395,6 @@
"Open backups": "Yedəkləri açın",
"The game is running: change packs in its own menus, or here once it is closed.": "Oyun işləyir: paketləri öz menyularında və ya burada bağlandıqdan sonra dəyişdirin.",
"Check now": "İndi yoxlayın",
"Choose a cape…": "Başlıq seçin...",
"Save": "Saxla",
"Folder": "Qovluq",
"Searching…": "Axtarılır...",
@@ -422,7 +408,6 @@
"MangoHud": "MangoHud",
"Sign out": "Çıxın",
"When it starts": "Başlayanda",
"{name}'s skin": "{name} dərisi",
"Start": "Başlayın",
"No Minecraft install needed: Horizon downloads it from Mojang for your account.": "Minecraft quraşdırmasına ehtiyac yoxdur: Horizon onu hesabınız üçün Mojang-dan endirir.",
"Logs": "Qeydlər",
@@ -446,7 +431,6 @@
"The wordmark, while the release list loads.": "Buraxılış siyahısı yüklənərkən söz nişanı.",
"Playing offline": "Oflayn oynayır",
"Sign in to Tailscale": "Tailscale-ə daxil olun",
"Offline names without an account": "Hesab olmadan oflayn adlar",
"Help make Horizon faster and steadier?": "Horizon daha sürətli və sabit olmağa kömək edin?",
"Sign in to play": "Oynamaq üçün daxil olun",
"Ported": "Köçürülüb",
@@ -488,14 +472,12 @@
"Update this launcher with your package manager.": "Bu başlatıcını paket menecerinizlə yeniləyin.",
"Close this launcher, move it to {folder}, and open it there to enable updates.": "Bu başlatıcını bağlayın, onu {folder} ünvanına köçürün və yeniləmələri aktivləşdirmək üçün orada açın.",
"Any MCP client": "İstənilən MCP müştəri",
"{name}'s cape is kept.": "{name} nin papağı saxlanılır.",
"Not reviewed by the Horizon registry": "Horizon reyestr tərəfindən nəzərdən keçirilməyib",
"Proxy": "Proksi",
"Checking": "Yoxlanılır",
"Updates": "Yeniləmələr",
"Asking Microsoft…": "Microsoft soruşulur...",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Minecraft {version} üçün hazırlanmışdır, onun səsləri olmadan: oyun səssiz olacaq. Onları Minecraft quraşdırmanızdan köçürmək üçün yenidən hazırlayın.",
"Forget": "Unut",
"Import a modpack…": "Mod paketini idxal edin...",
"Search versions": "Versiyaları axtarın",
"failed": "uğursuz oldu",
@@ -504,7 +486,6 @@
"Left off: {line}.": "Qaldı: {line}.",
"Resource packs": "Resurs paketləri",
"yesterday": "dünən",
"No cape": "Plaş yoxdur",
"After each session: frame-time percentiles and a 14-bucket histogram, tick times, peak memory and startup times, with your machine (OS, CPU and its cores and clocks, memory size, GPU, its memory and driver, drive type, screen size and refresh rate, laptop or desktop, on battery or not) and the settings that change performance.": "Hər seansdan sonra: kadr vaxtı faizləri və 14 kova histoqramı, işarələmə vaxtları, pik yaddaş və işə başlama vaxtları, maşınınız (ƏS, CPU və onun nüvələri və saatları, yaddaş ölçüsü, GPU, onun yaddaşı və sürücüsü, sürücünün növü, ekran ölçüsü və yeniləmə sürəti, noutbuk və ya iş masası, batareyada olub-olmaması) və performansı dəyişən parametrlər.",
"Make a token": "Bir nişan hazırlayın",
"Added {names}: the game turns it on when the world opens.": "{names} əlavə edildi: dünya açılanda oyun onu işə salır.",
@@ -557,7 +538,6 @@
"Shared: {url}": "Paylaşıldı: {url}",
"Friends on the same Wi-Fi: Multiplayer, Add Server, then paste this address.": "Eyni Wi-Fi-da olan dostlar: Multiplayer, Server əlavə edin, sonra bu ünvanı yapışdırın.",
"None yet: drop a .hshader or a shader pack .zip here.": "Hələ heç biri: bura .hshader və ya shader paketini .zip buraxın.",
"Offline name · the game's own": "Oflayn ad · oyunun özünə məxsusdur",
"Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Native mods sandboxed deyil: onlar hər hansı bir proqram kimi kompüterinizə tam giriş imkanı ilə işləyirlər. Yalnız müəlliflərinə etibar etdiyiniz modları quraşdırın. Biri ilə oynamaq üçün oyunu Modlar səhifəsində qurun; ilk quruluş Rust-u yükləyir və 20-30 dəqiqə çəkir.",
"Your selected version only changes if you were playing the newest one.": "Seçdiyiniz versiya yalnız ən yenisini ifa etsəniz dəyişir.",
"Multiplayer": "Multiplayer",
@@ -587,7 +567,6 @@
"the port": "liman",
"The source of {source}. Its release ships {file}, a content mod: installing those from GitHub is not supported yet.": "Mənbə {source}. Onun buraxılışı məzmun modu olan {file} göndərilir: GitHub-dan bunları quraşdırmaq hələ dəstəklənmir.",
"Build anyway": "Hər halda tikin",
"A name, 3 to 16 letters": "Ad, 3 ilə 16 hərf",
"Installed {mods} from GitHub ({source}). Build it on the Mods page.": "GitHub-dan {mods} quraşdırılıb ({source}). Onu Modlar səhifəsində qurun.",
"Settings, Keys, Generate access token. It's kept in this computer's keychain and only sent to Tailscale.": "Parametrlər, Açarlar, Giriş nişanı yaradın. O, bu kompüterin brelokunda saxlanılır və yalnız Tailscale-ə göndərilir.",
"{id} is up to date.": "{id} güncəldir.",
@@ -602,7 +581,6 @@
"I read it and trust it: install": "Mən onu oxudum və inanıram: quraşdırın",
"Close export": "İxracatı bağlayın",
"Playing needs a prepared Horizon version (Play page).": "Oynamaq üçün hazırlanmış Horizon versiya lazımdır (Oynatma səhifəsi).",
"Offline, the game's name": "Oflayn, oyunun adı",
"From Horizon's registry": "Horizon reyestrindən",
"Sounds": "Səslər",
"Edit…": "Redaktə et...",
@@ -635,12 +613,10 @@
"up to date": "bu günə qədər",
"← Newer": "← Daha yeni",
"Downloading from Mojang · {done} of {total} files": "Mojang-dan endirilir · {total} fayldan {done} fayl",
"{name}'s skin is kept.": "{name} dərisi saxlanılır.",
"Edit instance": "Nümunəni redaktə edin",
"Remove shortcut": "Qısayolu silin",
"{program} is not installed here (or not on the PATH): copy the config instead.": "{program} burada quraşdırılmayıb (və ya PATH-də deyil): yerinə konfiqurasiyanı kopyalayın.",
"No worlds to put in.": "Yerləşdirməyə dünyalar yoxdur.",
"Default skin": "Defolt dəri",
"A link to a repository (its latest release), a release, a branch or tag, or a commit. The launcher installs one exact release file or commit, never a branch that moves.": "Repozitoriyaya keçid (onun ən son buraxılışı), buraxılış, filial və ya etiket və ya öhdəlik. Başlatıcı bir dəqiq buraxılış faylı və ya öhdəliyi quraşdırır, heç vaxt hərəkət edən bir filial yoxdur.",
"Sign in with GitHub to rate": "Qiymətləndirmək üçün GitHub ilə daxil olun",
"Star on GitHub": "GitHub-da ulduz ver",
@@ -1053,10 +1029,6 @@
"played by {names}": "ifa etdi {names}",
"{day}, {time} UTC": "{day}, {time} UTC",
"Microsoft account · online servers too": "Microsoft hesabı · onlayn serverlər də",
"Offline · single player and LAN worlds": "Oflayn · tək oyunçu və LAN dünyaları",
"Offline names": "Offline adlar",
"Add a name": "Ad əlavə edin",
"3 to 16 letters, numbers or underscores. Switch names from the top bar too. Online servers don't take offline names.": "3-dən 16-a qədər hərf, rəqəm və ya alt xətt. Üst paneldən də adları dəyişin. Onlayn serverlər oflayn adlar götürmür.",
"Play online with your Microsoft account": "Microsoft hesabınızla onlayn oynayın",
"Sign in with the account that owns Minecraft: Java Edition. Your name and skin come along to online servers, like in other launchers.": "Minecraft: Java Edition-a sahib olan hesabla daxil olun. Adınız və dəriniz digər işəsalanlar kimi onlayn serverlərə gəlir.",
"Microsoft's own page opens in your browser. Horizon never sees your password.": "Brauzerinizdə Microsoftun öz səhifəsi açılır. Horizon parolunuzu heç vaxt görmür.",
-28
View File
@@ -37,7 +37,6 @@
"Building {name}'s post-build step": "Бина {name} төҙөүҙән һуңғы аҙым",
"In use": "Ҡулланыуҙа",
"Prepare": "Әҙерләү .",
"Developer options": "Төҙөүсе варианттары",
"{online} of {max} players": "{max} уйынсыларҙан {online}",
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Баҫма). Play скачивает уны Mojang һеҙҙең иҫәп яҙмаһы өсөн беренсе тапҡыр, Java менән ул кәрәк.",
"Close": "Ябыҡ",
@@ -98,7 +97,6 @@
"From my install": "Минең ҡуйыуҙан",
"the update failed: {error}": "Яңыртыу уңышһыҙлыҡҡа осраны: {error}",
"Mesa's DRI_PRIME=1, so Vulkan picks the other card.": "Месаның DRI_PRIME=1, шуға күрә Vulkan икенсе картаны һайлай.",
"New name": "Яңы исем .",
"Dark": "Ҡараңғы",
"Not now": "Хәҙер түгел .",
"Use an access token…": "Ҡулланыу маркеры рөхсәт...",
@@ -115,7 +113,6 @@
"Partly": "Өлөшләтә",
"All": "Барыһы ла",
"Instance": "Инстанция",
"Classic arms": "Классик ҡоралдар",
"Versions": "Версиялары",
"Release build: slower to build, slightly faster game": "Релиз төҙөү: яйыраҡ төҙөү, бер аҙ тиҙерәк уйын",
"Performance build": "Һөҙөмтәлелек төҙөү",
@@ -172,7 +169,6 @@
"about a minute left": "бер минут самаһы ҡалды .",
"Cancel": "Юҡҡа сығарыу",
"Add to {client}": "{client} тиклем өҫтәргә",
"Skin…": "Скин…",
"Environment": "Тирә-яҡ мөхит",
"They often still work together. If the build fails, install versions made for the same Nightly.": "Йыш ҡына әле лә бергә эшләйҙәр. Әгәр ҙә төҙөү уңышһыҙлыҡҡа осраһа, шул уҡ Nightly өсөн эшләнгән версияларҙы ҡуйығыҙ.",
"Import an instance folder…": "Импортлау экземпляр папкаһы...",
@@ -184,7 +180,6 @@
"newest stable version": "иң яңы тотороҡло версия",
"Choose image…": "Һүрәтте һайлағыҙ...",
"No worlds yet: they appear here once you play one.": "Әлегә донъялар юҡ: улар бында бер тапҡыр уйнаһаң күренә.",
"Offline names play without a Microsoft account that owns the game. Minecraft still comes from your own install.": "Офлайн исемдәр уйнай Microsoft иҫәп яҙмаһы булмаған, тип уйын хужаһы. Minecraft һаман да үҙ ҡуйыуҙан килә.",
"crash reports beside it": "авария тураһында хәбәрҙәр уның эргәһендә",
"Install all": "Барыһын да ҡуйырға",
"No group": "Төркөм юҡ",
@@ -202,7 +197,6 @@
"My instance": "Минең миҫалым",
"Snapshot": "Снапшот",
"Gallery": "Галерея",
"Offline name": "Офлайн исем",
"Home": "Төп Төп",
"Stopping": "Туҡтау",
"MSPT · tick time": "MSPT · тик ваҡыты",
@@ -304,8 +298,6 @@
"Patch notes": "Патч иҫкәрмәләре",
"Your Minecraft server, {online}.": "Һеҙҙең Minecraft серверығыҙ, {online}.",
"Crash reports": "Авария тураһында хәбәрҙәр",
"Offline name · the game's own · plays": "Офлайн исем · уйындың үҙ · уйнай",
"Slim arms": "Нәҙек ҡулдар .",
"Asking GitHub about {owner}/{repo}…": "Һорау GitHub тураһында {owner}/{repo}...",
"Downloading Minecraft {version} from Mojang · {done} of {total} files": "Minecraft {version} Mojang · {done} {total} файлдарынан",
"Could not save your choice: {error}": "Һеҙҙең һайлауҙы һаҡлай алманы: {error}",
@@ -354,7 +346,6 @@
"Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "Horizon-ның пейзажы. Һайлап алыу PNG, JPEG йәки WebP һүрәте үҙ.",
"Not needed": "Кәрәкмәй",
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "Был ҡоролма стартер Microsoft логин юҡ (HORIZON_MSA_CLIENT_ID), шуға күрә ул уйынды башлай алмай.",
"Offline name · plays": "Офлайн исем · уйнай",
"Run the game through": "Уйынды үткәреү",
"needed by another pack": "икенсе пакетҡа кәрәк",
"Older →": "Өлкәнерәк →",
@@ -365,13 +356,10 @@
"Could not run {program}: {error}": "Йүгерә алманы {program}: {error}",
"Restart": "Перестарт",
"Copied to the clipboard.": "Буферға күсерелгән.",
"Your worlds, offline names and settings stay where they are.": "Һеҙҙең донъялар, офлайн исемдәр һәм параметрҙар улар ҡайҙа ҡала.",
"Send nothing": "Бер нәмә лә ебәрмәгеҙ",
"Move to Trash": "Сүплеккә күсерергә",
"Start from": "2012 йылдан башлай.",
"Asking Mojang for its versions…": "Һорау Mojang уның версиялары өсөн...",
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "64×64 (йәки 64×32) тире PNG һәм 64×32 плащ PNG, пускатель һаҡлай һәм был исем уйнағанда уйынға бирелә. Уйын әлегә тиреләрҙе ғәҙәттәгесә тарта; ул был ҡуллана бер тапҡыр мөмкин. А Microsoft иҫәп яҙмаһының тиреһе һәм плащ: тиҙҙән.",
"Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "Икенсе исем аҫтында офлайн уйнау, һәм өҫкө панелдән күсеү. Онлайн серверҙар офлайн исемдәрҙе алмай.",
"Report on Discord": "Discord буйынса отчет",
"Export": "Экспорт",
"Server": "Сервер",
@@ -384,7 +372,6 @@
"No version installed yet.": "Әлегә бер ниндәй ҙә версия ҡуйылмаған.",
"Release": "Релиз",
"Verified": "Тикшерелгән",
"Choose a skin…": "Тире һайлағыҙ...",
"{instance} doesn't play its native mods yet: build the game with them.": "{instance} әлегә үҙенең туған модтарын уйнамай: улар менән уйын төҙөгөҙ.",
"Use jars from my instance…": "Минең миҫалдан банкалар ҡулланығыҙ...",
"Applications menu": "Ҡушымталар менюһы",
@@ -408,7 +395,6 @@
"Open backups": "Резерв копияларын асыу",
"The game is running: change packs in its own menus, or here once it is closed.": "Уйын эшләй: үҙ менюларында пакеттарҙы алмаштырыу, йәки бында бер тапҡыр ул ябыла.",
"Check now": "Хәҙер тикшерегеҙ",
"Choose a cape…": "Һайлағыҙ плащ...",
"Save": "Һаҡлау",
"Folder": "Папка",
"Searching…": "Эҙләү...",
@@ -422,7 +408,6 @@
"MangoHud": "МангоХуд",
"Sign out": "Сығыу",
"When it starts": "Ҡасан башлана .",
"{name}'s skin": "{name} тиреһе",
"Start": "Башланғыс",
"No Minecraft install needed: Horizon downloads it from Mojang for your account.": "Minecraft ҡуйыу кәрәкмәй: Horizon уны Mojang-нан һеҙҙең иҫәп яҙмаһына скачивает.",
"Logs": "Журналдар",
@@ -446,7 +431,6 @@
"The wordmark, while the release list loads.": "Һүҙ билдәһе, шул уҡ ваҡытта релиз исемлеге йөкләнә.",
"Playing offline": "Офлайн уйнау",
"Sign in to Tailscale": "Tailscale сайтына инергә",
"Offline names without an account": "Иҫәп яҙмаһы булмаған офлайн исемдәр",
"Help make Horizon faster and steadier?": "Horizon тиҙерәк һәм тотороҡлораҡ эшләргә ярҙам итегеҙме?",
"Sign in to play": "Уйнар өсөн инергә",
"Ported": "Портланған",
@@ -488,14 +472,12 @@
"Update this launcher with your package manager.": "Был лаунчерҙы пакет менеджерығыҙ менән яңыртығыҙ.",
"Close this launcher, move it to {folder}, and open it there to enable updates.": "Был лаунчерҙы ябығыҙ, уны {folder} папкаһына күсерегеҙ һәм яңыртыуҙарҙы ҡабыҙыу өсөн шунда асығыҙ.",
"Any MCP client": "Теләһә ниндәй MCP клиенты",
"{name}'s cape is kept.": "{name}-ның пласы һаҡлана.",
"Not reviewed by the Horizon registry": "Horizon реестры тарафынан тикшерелмәгән",
"Proxy": "Прокси",
"Checking": "Тикшереү",
"Updates": "Яңыртыуҙар",
"Asking Microsoft…": "Һорау Microsoft...",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Minecraft {version} өсөн әҙерләнгән, уның тауыштары булмаған: уйын тын буласаҡ. Яңынан әҙерләнергә, уларҙы күсерергә һеҙҙең Minecraft ҡуйыу.",
"Forget": "Онот .",
"Import a modpack…": "Модпакты импортлағыҙ...",
"Search versions": "Эҙләү версиялары",
"failed": "уңышһыҙлыҡҡа осраны",
@@ -504,7 +486,6 @@
"Left off: {line}.": "Ҡалды: {line}.",
"Resource packs": "Ресурс пакеттары",
"yesterday": "кисә",
"No cape": "Плащ юҡ",
"After each session: frame-time percentiles and a 14-bucket histogram, tick times, peak memory and startup times, with your machine (OS, CPU and its cores and clocks, memory size, GPU, its memory and driver, drive type, screen size and refresh rate, laptop or desktop, on battery or not) and the settings that change performance.": "Һәр сессиянан һуң: кадр-ваҡыт процентилдәр һәм 14-биҙрә гистограмма, галочкой ваҡыт, пик хәтер һәм старт ваҡыт, һеҙҙең машина менән (ОС, процессор һәм уның ядролары һәм сәғәттәр, хәтер күләме, GPU, уның хәтере һәм драйверы, диск тибы, экран күләме һәм яңыртыу тиҙлеге, батарея йәки өҫтәл notttting, тип етештереүсәнлеге.",
"Make a token": "Жетон эшләү .",
"Added {names}: the game turns it on when the world opens.": "Өҫтәлде {names}: уйын донъя асылғанда уны ҡабыҙа.",
@@ -557,7 +538,6 @@
"Shared: {url}": "{url}",
"Friends on the same Wi-Fi: Multiplayer, Add Server, then paste this address.": "Дуҫтар бер үк Wi-Fi: Мультиплеер, Сервер өҫтәү, һуңынан был адресты йәбештереү.",
"None yet: drop a .hshader or a shader pack .zip here.": "Әлегә бер ниндәй ҙә: бында .hshader йәки .zip шейдер пакетын ташлағыҙ.",
"Offline name · the game's own": "Офлайн исем · уйындың үҙ",
"Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Туған модтар ҡом йәшниктәре түгел: улар һеҙҙең компьютерға тулы рөхсәт менән эшләй, теләһә ниндәй программа кеүек. Авторҙарына ышанған модтарҙы ғына ҡуйығыҙ. Бер уйнау өсөн, уйын төҙөү Mods битендә; беренсе төҙөү скачивает Rust һәм 20 30 минут ала.",
"Your selected version only changes if you were playing the newest one.": "Һеҙ һайлаған версия ғына үҙгәрә, әгәр һеҙ уйнаған иң яңы.",
"Multiplayer": "Күп уйынсылар",
@@ -587,7 +567,6 @@
"the port": "порт",
"The source of {source}. Its release ships {file}, a content mod: installing those from GitHub is not supported yet.": "{source} сығанағы. Уның релиз суднолар {file}, контент мод: ҡуйыу, уларҙы GitHub әлегә ярҙам итмәй.",
"Build anyway": "Барыбер төҙөү",
"A name, 3 to 16 letters": "Исем, 3-тән 16 хәрефкә тиклем",
"Installed {mods} from GitHub ({source}). Build it on the Mods page.": "GitHub-нан {mods} ҡуйылған ({source}). Уны Модтар битендә төҙөгөҙ.",
"Settings, Keys, Generate access token. It's kept in this computer's keychain and only sent to Tailscale.": "Көйләүҙәр, Асҡыстар, Генерациялау инеү маркеры. Ул был компьютерҙағы брелокта һаҡлана һәм Tailscale һанына ғына ебәрелә.",
"{id} is up to date.": "{id} заманса.",
@@ -602,7 +581,6 @@
"I read it and trust it: install": "Мин уны уҡыным һәм ышанам: ҡуйырға",
"Close export": "Ябыҡ экспорт",
"Playing needs a prepared Horizon version (Play page).": "Уйнау өсөн әҙерләнгән Horizon версияһы кәрәк (Уйнау бите).",
"Offline, the game's name": "Офлайн, уйындың исеме",
"From Horizon's registry": "Horizon реестрынан",
"Sounds": "Тауыштар .",
"Edit…": "Мөхәррирләргә...",
@@ -635,12 +613,10 @@
"up to date": "заманса",
"← Newer": "← Яңыраҡ",
"Downloading from Mojang · {done} of {total} files": "Mojang · {done} {total} файлдарынан күсереп алыу",
"{name}'s skin is kept.": "{name} тиреһе һаҡлана.",
"Edit instance": "Данаһын мөхәррирләү",
"Remove shortcut": "Ярлыҡты алып ташлау",
"{program} is not installed here (or not on the PATH): copy the config instead.": "{program} бында ҡуйылмаған (йәки PATH-та юҡ): уның урынына конфигты күсерергә.",
"No worlds to put in.": "Ҡуйырға донъялар юҡ.",
"Default skin": "Ғәҙәттәгесә тире",
"A link to a repository (its latest release), a release, a branch or tag, or a commit. The launcher installs one exact release file or commit, never a branch that moves.": "Һылтанма һаҡлағысҡа (уның һуңғы релизы), релизға, тармаҡҡа йәки тегҡа йәки коммитҡа. Запускчик бер теүәл релиз файлы йәки коммит ҡуя, бер ҡасан да тармаҡ, тип күсә.",
"Sign in with GitHub to rate": "Баһа ҡуйыу өсөн GitHub менән инергә",
"Star on GitHub": "GitHub-та йондоҙ ҡуй",
@@ -1053,10 +1029,6 @@
"played by {names}": "{names} уйнай",
"{day}, {time} UTC": "{day}, {time} UTC",
"Microsoft account · online servers too": "Microsoft иҫәп яҙмаһы · онлайн-серверҙар ҙа",
"Offline · single player and LAN worlds": "Офлайн · бер уйынсы һәм LAN донъялары",
"Offline names": "Офлайн исемдәр",
"Add a name": "Исем өҫтәү .",
"3 to 16 letters, numbers or underscores. Switch names from the top bar too. Online servers don't take offline names.": "3-тән 16-ға тиклем хәреф, һан йәки аҫтына һыҙыҡ. Өҫкө панелдән дә исемдәрҙе алмаштырығыҙ. Онлайн серверҙар офлайн исемдәрҙе алмай.",
"Play online with your Microsoft account": "Microsoft иҫәбегеҙ менән онлайн уйнағыҙ",
"Sign in with the account that owns Minecraft: Java Edition. Your name and skin come along to online servers, like in other launchers.": "Minecraft: Java Баҫмаға эйә булған иҫәп яҙмаһы менән инергә. Һеҙҙең исем һәм тире менән бергә килә онлайн-серверҙар, башҡа стартерҙар кеүек.",
"Microsoft's own page opens in your browser. Horizon never sees your password.": "Браузерығыҙҙа Microsoft үҙ бите асыла. Horizon бер ҡасан да һеҙҙең паролде күрмәй.",
-28
View File
@@ -37,7 +37,6 @@
"Building {name}'s post-build step": "Выкананне этапу пасля зборкі для {name}",
"In use": "Ва ўжытку",
"Prepare": "Падрыхтаваць",
"Developer options": "Параметры распрацоўніка",
"{online} of {max} players": "{online} з {max} гульцоў",
"Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (выданне Java). Play спампоўвае яго з Mojang для вашага ўліковага запісу ў першы раз, з патрэбнай Java.",
"Close": "Закрыць",
@@ -98,7 +97,6 @@
"From my install": "З маёй устаноўкі",
"the update failed: {error}": "абнаўленне не атрымалася: {error}",
"Mesa's DRI_PRIME=1, so Vulkan picks the other card.": "DRI_PRIME=1 у Mesa, таму Vulkan выбірае іншую відэакарту.",
"New name": "Новая назва",
"Dark": "Цёмны",
"Not now": "Не цяпер",
"Use an access token…": "Выкарыстоўвайце маркер доступу…",
@@ -115,7 +113,6 @@
"Partly": "Часткова",
"All": "Усе",
"Instance": "Экземпляр",
"Classic arms": "Класічныя рукі",
"Versions": "Версіі",
"Release build: slower to build, slightly faster game": "Рэлізная зборка: збіраецца даўжэй, гульня працуе крыху хутчэй",
"Performance build": "Зборка для прадукцыйнасці",
@@ -172,7 +169,6 @@
"about a minute left": "засталося каля хвіліны",
"Cancel": "Адмяніць",
"Add to {client}": "Дадаць у {client}",
"Skin…": "Скін…",
"Environment": "Асяроддзе",
"They often still work together. If the build fails, install versions made for the same Nightly.": "Яны часта ўсё яшчэ працуюць разам. Калі зборка не атрымаецца, усталюйце версіі, зробленыя для таго ж Nightly.",
"Import an instance folder…": "Імпартаваць тэчку экземпляра…",
@@ -184,7 +180,6 @@
"newest stable version": "апошняя стабільная версія",
"Choose image…": "Выберыце малюнак…",
"No worlds yet: they appear here once you play one.": "Светаў пакуль няма: яны з'яўляюцца тут, як толькі вы згуляеце ў адзін.",
"Offline names play without a Microsoft account that owns the game. Minecraft still comes from your own install.": "З афлайн-імёнамі можна гуляць без уліковага запісу Microsoft, якому належыць гульня. Minecraft усё роўна бярэцца з вашай уласнай усталёўкі.",
"crash reports beside it": "справаздачы аб збоях побач",
"Install all": "Усталяваць усе",
"No group": "Няма групы",
@@ -202,7 +197,6 @@
"My instance": "Мой экземпляр",
"Snapshot": "Здымак",
"Gallery": "Галерэя",
"Offline name": "Пазасеткавае імя",
"Home": "Галоўная",
"Stopping": "Спыняецца",
"MSPT · tick time": "MSPT · час ціку",
@@ -304,8 +298,6 @@
"Patch notes": "Нататкі аб выпраўленнях",
"Your Minecraft server, {online}.": "Ваш сервер Minecraft, {online}.",
"Crash reports": "Справаздачы аб збоях",
"Offline name · the game's own · plays": "Афлайн-імя · уласнае імя ў гульні · гуляе",
"Slim arms": "Тонкія рукі",
"Asking GitHub about {owner}/{repo}…": "Запыт да GitHub пра {owner}/{repo}…",
"Downloading Minecraft {version} from Mojang · {done} of {total} files": "Спампоўка Minecraft {version} з Mojang · {done} з {total} файлаў",
"Could not save your choice: {error}": "Немагчыма захаваць ваш выбар: {error}",
@@ -354,7 +346,6 @@
"Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "Пейзаж Horizon. Выберыце ўласную выяву ў фармаце PNG, JPEG або WebP.",
"Not needed": "Не трэба",
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "У гэтай зборцы праграмы запуску няма ўваходу ў Microsoft (HORIZON_MSA_CLIENT_ID), таму яна не можа запусціць гульню.",
"Offline name · plays": "Пазасеткавае імя · гуляе",
"Run the game through": "Запускаць гульню праз",
"needed by another pack": "патрэбны іншы пакет",
"Older →": "Старэйшыя →",
@@ -365,13 +356,10 @@
"Could not run {program}: {error}": "Не атрымалася запусціць {program}: {error}",
"Restart": "Перазапуск",
"Copied to the clipboard.": "Скапіравана ў буфер абмену.",
"Your worlds, offline names and settings stay where they are.": "Вашы светы, імёны ў аўтаномным рэжыме і налады застаюцца на месцы.",
"Send nothing": "Нічога не адпраўляць",
"Move to Trash": "Перамясціць у сметніцу",
"Start from": "Пачаць з",
"Asking Mojang for its versions…": "Запыт у Mojang аб яго версіях...",
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "PNG скіна 64×64 (або 64×32) і PNG плашча 64×32, якія захоўваюцца ў праграме запуску і перадаюцца гульні, калі пад гэтым імем гуляюць. На дадзены момант гульня малюе скіны па змаўчанні; яна будзе выкарыстоўваць іх, як толькі зможа. Скін і плашч уліковага запісу Microsoft: хутка.",
"Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "Гуляйце ў аўтаномным рэжыме пад іншым імем і пераключайцеся з верхняй панэлі. Інтэрнэт-серверы не прымаюць афлайн-імёны.",
"Report on Discord": "Паведаміць у Discord",
"Export": "Экспарт",
"Server": "Сервер",
@@ -384,7 +372,6 @@
"No version installed yet.": "Версія яшчэ не ўсталявана.",
"Release": "Рэліз",
"Verified": "Праверана",
"Choose a skin…": "Выберыце скін…",
"{instance} doesn't play its native mods yet: build the game with them.": "Экземпляр {instance} яшчэ не выкарыстоўвае свае натыўныя моды: збярыце гульню разам з імі.",
"Use jars from my instance…": "Выкарыстаць jar-файлы з майго экземпляра…",
"Applications menu": "Меню прыкладанняў",
@@ -408,7 +395,6 @@
"Open backups": "Адкрыць рэзервовыя копіі",
"The game is running: change packs in its own menus, or here once it is closed.": "Гульня працуе: мяняйце пакеты ва ўласным меню або тут, калі яна закрыта.",
"Check now": "Праверце зараз",
"Choose a cape…": "Выберыце плашч…",
"Save": "Захаваць",
"Folder": "Тэчка",
"Searching…": "Пошук...",
@@ -422,7 +408,6 @@
"MangoHud": "MangoHud",
"Sign out": "Выйсці",
"When it starts": "Калі пачнецца",
"{name}'s skin": "Скін {name}",
"Start": "Пачаць",
"No Minecraft install needed: Horizon downloads it from Mojang for your account.": "Minecraft не патрабуецца ўсталёўваць: Horizon спампоўвае яго з Mojang для вашага ўліковага запісу.",
"Logs": "Лагі",
@@ -446,7 +431,6 @@
"The wordmark, while the release list loads.": "Лагатып, пакуль загружаецца спіс рэлізаў.",
"Playing offline": "Гульня ў аўтаномным рэжыме",
"Sign in to Tailscale": "Увайдзіце ў Tailscale",
"Offline names without an account": "Аўтаномныя імёны без уліковага запісу",
"Help make Horizon faster and steadier?": "Дапамажыце зрабіць Horizon больш хуткім і стабільным?",
"Sign in to play": "Увайдзіце, каб гуляць",
"Ported": "Партаваны",
@@ -488,14 +472,12 @@
"Update this launcher with your package manager.": "Абнавіце гэты лаўнчар праз свой пакетны менеджар.",
"Close this launcher, move it to {folder}, and open it there to enable updates.": "Закрыйце гэты лаўнчар, перамясціце яго ў {folder} і запусціце адтуль, каб уключыць абнаўленні.",
"Any MCP client": "Любы кліент MCP",
"{name}'s cape is kept.": "Захоўваецца плашч {name}.",
"Not reviewed by the Horizon registry": "Не разглядаецца рэестрам Horizon",
"Proxy": "Проксі",
"Checking": "Праверка",
"Updates": "Абнаўленні",
"Asking Microsoft…": "Запыт да Microsoft…",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Падрыхтавана для Minecraft {version}, без яго гукаў: гульня будзе бясшумнай. Падрыхтуйцеся зноў скапіяваць іх з усталяванай Minecraft.",
"Forget": "Забыць",
"Import a modpack…": "Імпартаваць пакет модаў…",
"Search versions": "Пошук версій",
"failed": "не атрымалася",
@@ -504,7 +486,6 @@
"Left off: {line}.": "Пакінуты: {line}.",
"Resource packs": "Пакі рэсурсаў",
"yesterday": "учора",
"No cape": "Без плашча",
"After each session: frame-time percentiles and a 14-bucket histogram, tick times, peak memory and startup times, with your machine (OS, CPU and its cores and clocks, memory size, GPU, its memory and driver, drive type, screen size and refresh rate, laptop or desktop, on battery or not) and the settings that change performance.": "Пасля кожнага сеансу: працэнты часу кадраў і гістаграма з 14 інтэрваламі, час цікаў, пік памяці і час запуску з вашай машынай (АС, працэсар і яе ядра і тактавыя частоты, памер памяці, графічны працэсар, яго памяць і драйвер, тып дыска, памер экрана і частата абнаўлення, ноўтбук або настольны камп'ютар, працуе ад батарэі ці не) і налады, якія змяняюць прадукцыйнасць.",
"Make a token": "Стварыць токен",
"Added {names}: the game turns it on when the world opens.": "Дададзена {names}: гульня падключыць гэта пры адкрыцці свету.",
@@ -557,7 +538,6 @@
"Shared: {url}": "Абагулены: {url}",
"Friends on the same Wi-Fi: Multiplayer, Add Server, then paste this address.": "Сябры ў адной Wi-Fi: шматкарыстальніцкая гульня, дадаць сервер, затым устаўце гэты адрас.",
"None yet: drop a .hshader or a shader pack .zip here.": "Пакуль няма: перанясіце .hshader або пакет шэйдараў .zip сюды.",
"Offline name · the game's own": "Афлайн-імя · уласнае ў гульні",
"Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Натыўныя моды не знаходзяцца ў «пясочніцы»: яны маюць поўны доступ да вашага камп'ютара, як і любая праграма. Усталёўвайце моды толькі ад правераных аўтараў. Каб гуляць з імі, збярыце гульню на старонцы «Моды»; першая зборка спампуе Rust і зойме ад 20 да 30 хвілін.",
"Your selected version only changes if you were playing the newest one.": "Выбраная вамі версія змяняецца толькі ў тым выпадку, калі вы гулялі ў самую новую.",
"Multiplayer": "Мультыплэер",
@@ -587,7 +567,6 @@
"the port": "порт",
"The source of {source}. Its release ships {file}, a content mod: installing those from GitHub is not supported yet.": "Крыніца {source}. Яго рэліз пастаўляецца {file}, мод кантэнту: усталяванне з GitHub пакуль не падтрымліваецца.",
"Build anyway": "Усё роўна сабраць",
"A name, 3 to 16 letters": "Імя, ад 3 да 16 літар",
"Installed {mods} from GitHub ({source}). Build it on the Mods page.": "Усталяваны {mods} з GitHub ({source}). Стварыце яго на старонцы Mods.",
"Settings, Keys, Generate access token. It's kept in this computer's keychain and only sent to Tailscale.": "Налады, ключы, стварэнне маркера доступу. Ён захоўваецца ў бірульцы гэтага камп'ютара і адпраўляецца толькі ў Tailscale.",
"{id} is up to date.": "{id} актуальны.",
@@ -602,7 +581,6 @@
"I read it and trust it: install": "Я прачытаў і давяраю: усталяваць",
"Close export": "Закрыць экспарт",
"Playing needs a prepared Horizon version (Play page).": "Для гульні патрэбна падрыхтаваная версія Horizon (старонка прайгравання).",
"Offline, the game's name": "Offline, назва гульні",
"From Horizon's registry": "З рэестра Horizon",
"Sounds": "Гукі",
"Edit…": "Рэдагаваць...",
@@ -635,12 +613,10 @@
"up to date": "у актуальным стане",
"← Newer": "← Навей",
"Downloading from Mojang · {done} of {total} files": "Спампоўка з Mojang · {done} з {total} файлаў",
"{name}'s skin is kept.": "Скін {name} захоўваецца.",
"Edit instance": "Рэдагаваць экземпляр",
"Remove shortcut": "Выдаліць ярлык",
"{program} is not installed here (or not on the PATH): copy the config instead.": "{program} не ўсталяваны тут (або не ў PATH): замест гэтага скапіруйце канфігурацыю.",
"No worlds to put in.": "Няма светаў, каб змясціць.",
"Default skin": "Скін па змаўчанні",
"A link to a repository (its latest release), a release, a branch or tag, or a commit. The launcher installs one exact release file or commit, never a branch that moves.": "Спасылка на рэпазітар (яго апошнюю версію), рэліз, галінку або тэг, або фіксацыю. Праграма запуску ўсталёўвае адзін дакладны файл выпуску або фіксацыю, а не галінку, якая рухаецца.",
"Sign in with GitHub to rate": "Увайдзіце праз GitHub, каб ацаніць",
"Star on GitHub": "Пазначыць зоркай на GitHub",
@@ -1053,10 +1029,6 @@
"played by {names}": "гуляў {names}",
"{day}, {time} UTC": "{day}, {time} UTC",
"Microsoft account · online servers too": "Уліковы запіс Microsoft · Інтэрнэт-серверы таксама",
"Offline · single player and LAN worlds": "Пазасеткавы · светы для аднаго гульца і лакальнай сеткі",
"Offline names": "Афлайн імёны",
"Add a name": "Дадайце імя",
"3 to 16 letters, numbers or underscores. Switch names from the top bar too. Online servers don't take offline names.": "Ад 3 да 16 літар, лічбаў або знакаў падкрэслівання. Таксама пераключайце імёны з верхняй панэлі. Інтэрнэт-серверы не прымаюць афлайн-імёны.",
"Play online with your Microsoft account": "Гуляйце ў інтэрнэце са сваім уліковым запісам Microsoft",
"Sign in with the account that owns Minecraft: Java Edition. Your name and skin come along to online servers, like in other launchers.": "Увайдзіце з уліковым запісам, які валодае Minecraft: Java Edition. Ваша імя і скін прыходзяць на анлайн-серверы, як і ў іншых праграмах запуску.",
"Microsoft's own page opens in your browser. Horizon never sees your password.": "У вашым браўзеры адкрыецца ўласная старонка Microsoft. Horizon ніколі не бачыць ваш пароль.",
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff. Show more