Compare commits

...
Author SHA1 Message Date
Ludovic Marie f24ee1accf Merge pull request #15361 from UMCEKO/perf-platform
🤖 Agent: queued for merge at Ludovic's request.
2026-11-12 12:12:30 +01:00
umcekoandClaude Opus 5.5 327daa3899 Merge origin/main into perf-platform
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 13:12:23 +03:00
Kacper ba91f75cb2 Merge pull request #13731 from TheApple05/fix/13666-chest-boat-loot
Fix chest boat loot for hopper transfers and destruction
2026-11-12 11:08:09 +01:00
Kacper 5164ada2a6 Merge branch 'main' into fix/13666-chest-boat-loot 2026-11-12 10:36:41 +01:00
Kacper a21f8a3947 Merge pull request #14047 from MrCloudy2/fix/wind-charge-no-deflect-ticks
A player's wind charge can't be turned in its first five ticks
2026-11-12 10:36:19 +01:00
Kacper f0811d3363 Merge pull request #14598 from ilguerro/perf/faster-client-lib-tests
Load the prepared test resources once per test process
2026-11-12 09:50:51 +01:00
Kacper c543cb8689 Merge pull request #14596 from Ragbie/fix/orb-fall-distance
Experience orbs keep their fall distance and make a vibration when they land
2026-11-12 09:50:34 +01:00
Kacper 4b65ec8a0d Merge pull request #13578 from NikitaMGrimm/hdr-one-peak
HDR float frame: one peak, the world mastered for 1000 nits
2026-11-12 09:49:46 +01:00
Kacper bb8a15db1e Merge pull request #15359 from Akirabane/fix/guest-invalid-entity-attack-kick
A guest who attacks an item, an orb, themselves or an arrow is disconnected, as handleAttack does
2026-11-12 09:39:18 +01:00
Kacper 555b885429 Merge pull request #14860 from vermingov/fix/cloud-fall-distance-14853
An area effect cloud keeps the fall_distance it was loaded with (#14853)
2026-11-12 09:38:57 +01:00
Kacper 722e63500a Merge pull request #14848 from vermingov/fix/trident-fall-distance-14846
A trident keeps the fall_distance it was loaded with (#14846)
2026-11-12 09:38:40 +01:00
Kacper f1708966d5 Merge pull request #14612 from Solenopsisbot/t3/nameplate-distance-attributes
Client: honor synced nameplate distances
2026-11-12 09:38:23 +01:00
Kacper 7b45eb9614 Merge pull request #13962 from horizon-rust/split/telemetry-probes
horizon-system: the process probes leave horizon-bench (layer plan M5)
2026-11-12 09:33:41 +01:00
Kacper 911aa6ddc6 Merge pull request #14904 from vermingov/fix/collar-colour-14897
A wolf's or cat's CollarColor outside 0..15 reads white (#14897)
2026-11-12 09:27:07 +01:00
Kacper 5816e84e56 Merge pull request #14822 from DUzzL/fix/14800-arrow-air
An arrow loads and keeps its saved Air
2026-11-12 09:26:50 +01:00
Kacper 1cd6c70899 Merge pull request #14771 from zenith600/launcher/version-file-without-game
Launcher: a folder with 26.3's version file but not its game says to play 26.3 once
2026-11-12 09:26:32 +01:00
Kacper eb084c5152 Merge pull request #15058 from ilguerro/fix/fox-village-shade-defend-trusted
Keep foxes out of the shade in villages and defending the players they trust
2026-11-12 09:20:15 +01:00
Kacper aa3483bf5a Merge branch 'main' into perf/faster-client-lib-tests 2026-11-12 09:15:37 +01:00
Kacper 23678db4f7 Merge branch 'main' into fix/orb-fall-distance 2026-11-12 09:15:28 +01:00
Kacper a97725d63c Merge branch 'main' into fix/wind-charge-no-deflect-ticks 2026-11-12 09:15:09 +01:00
Kacper 0ce3cf8624 Merge branch 'main' into hdr-one-peak 2026-11-12 09:14:54 +01:00
Kacper aa548b0a41 Merge branch 'main' into fix/guest-invalid-entity-attack-kick 2026-11-12 09:04:15 +01:00
Kacper cd4e73e270 Merge branch 'main' into fix/collar-colour-14897 2026-11-12 09:04:06 +01:00
Kacper d946b838fe Merge branch 'main' into fix/cloud-fall-distance-14853 2026-11-12 09:03:57 +01:00
Kacper b2d30693b8 Merge branch 'main' into fix/trident-fall-distance-14846 2026-11-12 09:03:48 +01:00
Kacper 8b13f108ef Merge branch 'main' into fix/14800-arrow-air 2026-11-12 09:03:38 +01:00
Kacper a15a3ea50c Merge branch 'main' into launcher/version-file-without-game 2026-11-12 09:03:29 +01:00
Kacper 5ddbaaab62 Merge branch 'main' into t3/nameplate-distance-attributes 2026-11-12 09:03:19 +01:00
Kacper 6c34352b0f Merge pull request #14931 from Detimes/perf-equipment-cache
Perf: convert a guest's equipment only when a stack changed
2026-11-12 08:52:49 +01:00
Kacper 52540d4ff2 Merge pull request #13830 from RINNECODER/up13/camera-armor-stand
Spectator camera: a server's camera on an armor stand holds the view
2026-11-12 08:52:32 +01:00
Kacper f671c935c2 Merge pull request #11190 from Ragbie/fix/tnt-minecart-explosion-power
TNT minecarts keep explosion_power and explosion_speed_factor, and blow with them
2026-11-12 08:52:14 +01:00
Kacper d0c091ae1d Merge branch 'main' into fix/13666-chest-boat-loot 2026-11-12 08:52:13 +01:00
Kacper 11e85c0411 Merge pull request #14784 from MSedgeMC/fix/port-error-tooltip
The LAN port box says why its number is wrong
2026-11-12 08:51:57 +01:00
Kacpherek 2dabcba7dc Merge main into fix/fox-village-shade-defend-trusted 2026-11-12 08:44:32 +01:00
Kacper c94cdb0c76 Merge pull request #14131 from MSedgeMC/fix/component-can_eat
Food and consumable components come from the stack
2026-11-12 08:41:33 +01:00
Kacper 8182a33000 Merge pull request #14943 from vermingov/fix/shulker-attach-face-14940
A shulker's AttachFace is narrowed to a byte before the face is picked (#14940)
2026-11-12 08:30:45 +01:00
Kacper dd90805814 Merge branch 'main' into fix/tnt-minecart-explosion-power 2026-11-12 08:30:16 +01:00
Kacper 0c1a839a02 Merge pull request #15192 from MSedgeMC/fix/ibeam
A hovered text field shows the I-beam cursor
2026-11-12 06:49:20 +01:00
Kacper 00f230d640 Merge pull request #13843 from v0ntr3n/fix/thunder-trap-random-streams
Use Level.randValue for thunder strike columns and trap horse RNG for scatter
2026-11-12 06:48:52 +01:00
Kacper 535211eb7e Merge pull request #13966 from horizon-rust/fix/warden-silent-unmount-wither-easy
Wardens: silent ones make no sound, forced riders get off to dig; withers' idle volleys wait for Normal
2026-11-12 06:47:41 +01:00
Kacper d4c59f8c7a Merge pull request #15021 from DUzzL/fix/15013-launcher-ipv4-fallback
launcher: fall back to IPv4 when IPv6 to Microsoft sign-in is dead
2026-11-12 06:34:08 +01:00
Kacper 82f042de20 Merge branch 'main' into split/telemetry-probes 2026-11-12 06:27:32 +01:00
Kacper c351efbab6 Merge branch 'main' into fix/warden-silent-unmount-wither-easy 2026-11-12 06:27:20 +01:00
Kacper 3054963dec Merge pull request #15289 from DUzzL/fix/15276-set-custom-data
Remove empty custom_data after set_custom_data item modifier
2026-11-12 06:27:02 +01:00
Kacper 25a8fab320 Merge branch 'main' into fix/ibeam 2026-11-12 06:26:03 +01:00
Kacper 194c5c2c6c Merge pull request #15217 from GIGABAIT93/docs/index-updated
docs: the index's `updated`, and the fields a reader may rely on (#10562)
2026-11-12 06:25:38 +01:00
Kacper bab03f04db Merge pull request #15170 from MSedgeMC/fix/loot-set-enchantments
Set Enchantments replaces the levels
2026-11-12 06:25:24 +01:00
Kacper 9a71098242 Merge pull request #15061 from DUzzL/fix/15057-egg-breed
Thrown eggs hatch the chicken variant of their item
2026-11-12 06:25:11 +01:00
Kacper 6d76e89de2 Merge pull request #15053 from ilguerro/fix/fox-eat-effects-panda-pickup
Give a fox that eats its food its effects, and credit a panda's pickup
2026-11-12 06:24:20 +01:00
Kacper 609981c352 Merge pull request #14740 from TiggerTakkyTTT/fix/wind-burst-ender-dragon
Fix Wind Burst knockback for the Ender Dragon
2026-11-12 06:24:05 +01:00
Kacper 3aeb206813 Merge pull request #14587 from MSedgeMC/fix/skeleton-never-freezes
A skeleton and a loaded sulfur cube never freeze
2026-11-12 06:23:52 +01:00
Kacper 9bd670d3d3 Merge pull request #14574 from voidsipp/split/11535-sulfur-portal-compile-fix-20261109
Preserve portal blocks in sulfur cube explosions
2026-11-12 06:23:39 +01:00
Kacper 63bd6c914d Merge pull request #14561 from ilguerro/fix/lectern-book-state
Lecterns take a placed book into has_book, gate the comparator on it and post block_change
2026-11-12 06:23:26 +01:00
Kacper ea787a658c Merge pull request #14553 from ilguerro/fix/adventure-sign-place-on
Place a can_place_on block against a sign in adventure mode
2026-11-12 06:23:14 +01:00
Kacper 14e27946ca Merge pull request #15410 from Web-mc1/fix/item-set-count-overflow
Guard item count arithmetic against i32 overflow
2026-11-12 06:20:31 +01:00
Kacper 9639526d6b Merge pull request #15360 from Web-mc1/fix/pvp-held-count-overflow
Guard held item count against u32 overflow
2026-11-12 06:20:18 +01:00
Kacper d9b9996a4d Merge pull request #14850 from DUzzL/fix/14806-fire-reset
A mob's negative Fire is reset to 0 on its first tick
2026-11-12 04:54:06 +01:00
Kacper a71f9d3bd3 Merge pull request #11507 from voidsipp/fix/shield-blocking-11338
Fix built-in shield piercing, use statistics and durability
2026-11-12 04:53:39 +01:00
Kacpherek 91e8f91fee Skeleton trap test: the horse's random is an EntityRandom 2026-11-12 04:52:33 +01:00
Kacpherek c6544a2311 Foxes do not flee wolves and polar bears while defending
Vanilla gates both avoidance goals on !isDefending(); a defending fox now goes on to the melee attack.
2026-11-12 04:49:41 +01:00
Kacpherek 63ad77a1da Send the drink game event for a fox's drinkable food
A consumable with the DRINK animation posts minecraft:drink instead of minecraft:eat, and the mob game-event forwarding now passes minecraft:drink on.
2026-11-12 04:48:53 +01:00
rdbtCVSandClaude Sonnet 5.5 440a7f0144 Format the held-count fold with rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 03:47:32 +00:00
rdbtCVSandClaude Sonnet 5.5 96bb424a7f Format saturating item count arithmetic
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 03:47:18 +00:00
Kacpherek 5cdcbf9812 Accept libtest's singular banner for a one-test parity run 2026-11-12 04:47:15 +01:00
Kacpherek 3cc15c8ba0 Keep enchantments that set_enchantments does not name
Only the supplied enchantments are set, as vanilla does; the others on the stack stay.
2026-11-12 04:44:31 +01:00
Kacpherek e983ba70a5 Limit the never-freeze override to Skeleton; the bogged, parched and wither skeleton freeze 2026-11-12 04:39:52 +01:00
Kacpherek 1a5bea076d Set a placed lectern's has_book before the item's block_state is applied 2026-11-12 04:35:28 +01:00
Kacpherek 672b1cae9a Test that an empty set_custom_data merge adds no custom_data component 2026-11-12 04:33:33 +01:00
Kacpherek 9a2135d7b2 Decide whether a sign consumes the click before its commands run 2026-11-12 04:32:31 +01:00
Kacpherek beb77a48e0 Wrap the invalid-port tooltip to 170 pixels like vanilla 2026-11-12 04:31:33 +01:00
Kacper 2abe71ca92 Merge pull request #14589 from MSedgeMC/fix/drowned-fluid-push
A swimming drowned is not pushed by the current
2026-11-12 04:29:17 +01:00
Kacpherek d7a25b8aec Merge commit '571ef142c5' into unstick/14574
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/explosion.rs
#	crates/horizon-client/src/game/nether_portal.rs
2026-11-12 04:28:26 +01:00
Detimes c6683ed9a2 Merge branch 'perf-equipment-cache' of https://github.com/Detimes/horizon into perf-equipment-cache 2026-11-12 03:21:14 +00:00
DetimesandClaude Opus 5.5 0a484606c9 Merge upstream/main into perf-equipment-cache
Keeps both new tests at the end of hub_tests.rs (main's teleport accept test and the equipment cache test).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 03:21:12 +00:00
Kacpherek 98961ccd14 Merge commit '571ef142c5' into unstick/15061
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/projectiles.rs
2026-11-12 04:16:58 +01:00
Kacper f787306fce Merge pull request #15457 from svetch/fix/26.3-seventh-republish
Prepare: accept Mojang's seventh 26.3 republish (9f67e10e, index e99f9973)
2026-11-12 04:16:20 +01:00
Kacper d8bf54d1e6 Merge pull request #14494 from vnsmoreira/fix/explosion-creaking-heart-xp
A player's blast awards a natural creaking heart's experience
2026-11-12 04:16:11 +01:00
Kacper f165e7fa29 Merge pull request #15419 from yahya-nusseibeh/fix/launcher-sign-in-bounds
Launcher: bound the sign-in services' interval and lifetimes
2026-11-12 04:16:01 +01:00
Kacper 9d73f6a26a Merge pull request #15151 from MSedgeMC/fix/chiseled-shelf-can-take
fix: a chiseled bookshelf only gives a book to a hopper with room
2026-11-12 04:15:48 +01:00
Kacpherek c2bead90c4 Workflow files as on main: this pull request changes the item count only 2026-11-12 04:14:31 +01:00
Kacpherek e470dac100 Merge remote-tracking branch 'origin/main' into takeover/15410 2026-11-12 04:14:20 +01:00
Kacper 4a60f12112 Merge pull request #13630 from MSedgeMC/fix/goat-long-jump
A goat keeps its long-jump cooldown in its chunk save
2026-11-12 04:09:39 +01:00
Kacper 192be7194f Merge pull request #15120 from MSedgeMC/port_map/farmland-is-near-water
port_map: FarmlandBlock.isNearWater is the water fluid search
2026-11-12 04:01:45 +01:00
Kacper 5f3caf9be4 Merge pull request #13881 from Skr1zzzz/split-renderer
Move building and adopting the renderer out of game.rs into game/render_settings.rs
2026-11-12 04:00:55 +01:00
Kacper 571ef142c5 Merge pull request #15477 from Ragbie/fix/stack-dump-windows-unused
horizon-threads: stack_dump's Unix-only code no longer fails clippy on Windows
2026-11-12 03:48:06 +01:00
Kacper 21845f7949 Merge pull request #14376 from Joansitoh/fix/14371-rod-neighbours
Lightning rod: a strike updates the front block's neighbours without redstone experiments
2026-11-12 03:46:31 +01:00
Kacper 27a4dc65c9 Merge pull request #14365 from oloion/perf/range-dispatch-binary-search
assets: avoid heap allocation in item model range dispatch binary search
2026-11-12 03:46:19 +01:00
Kacper deaab60ba9 Merge pull request #15418 from yahya-nusseibeh/fix/protocol-inflate-growth
Protocol: grow the inflate buffer instead of reserving the declared size
2026-11-12 03:38:58 +01:00
Kacper c2c3cea8e3 Merge pull request #15478 from Bl4ckspell7/refactor/gfx-census-module
Renderer: residency and memory figures move out of gfx.rs into gfx/census.rs
2026-11-12 03:33:14 +01:00
umcekoandClaude Opus 5.5 7798130a18 Merge origin/main into perf-platform
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 05:24:24 +03:00
Kacpherek fcf7c70c83 Merge commit '24b778c3b4' into unstick/14131
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-12 02:58:34 +01:00
Kacpherek be34bdfd3c Merge commit '24b778c3b4' into unstick/14589
# Conflicts:
#	crates/horizon-client/src/mobs/living_tests.rs
2026-11-12 02:57:18 +01:00
Kacper 09d748f19c Merge branch 'main' into fix/shulker-attach-face-14940 2026-11-12 02:55:48 +01:00
Kacpherek e72b69caca Merge commit '24b778c3b4' into unstick/14494
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-12 02:54:54 +01:00
Kacpherek 166e09f55e Merge commit '24b778c3b4' into unstick/14587
# Conflicts:
#	tools/port_map/game-006.tsv
2026-11-12 02:52:49 +01:00
Kacpherek 61c43e5109 Merge commit 'aab654d4e8' into unstick/11507
# Conflicts:
#	crates/horizon-client/src/game/dispense_fixture.rs
#	crates/horizon-client/src/game/shield.rs
2026-11-12 02:19:15 +01:00
Kacpherek 10721b288e Merge commit 'aab654d4e8' into unstick/14848
# Conflicts:
#	crates/horizon-client/src/entity_nbt/trident.rs
#	crates/horizon-client/src/game/trident.rs
2026-11-12 02:17:22 +01:00
Kacpherek be9a3c000f Merge commit 'aab654d4e8' into unstick/14822
# Conflicts:
#	crates/horizon-client/src/entity_nbt/projectile_fields.rs
2026-11-12 02:16:25 +01:00
Kacpherek bb9d4c2467 Merge commit 'aab654d4e8' into unstick/14860
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-12 02:13:03 +01:00
Kacpherek d9d89df9d8 Merge commit 'aab654d4e8' into unstick/14494
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-12 02:12:20 +01:00
Kacpherek 5a5d9f780c Merge commit 'aab654d4e8' into unstick/14131
# Conflicts:
#	crates/horizon-client/src/game/using_item.rs
2026-11-12 02:10:09 +01:00
Kacpherek 344bcdec9a Merge commit 'aab654d4e8' into unstick/15021
# Conflicts:
#	launcher/src/account/tests.rs
2026-11-12 02:07:33 +01:00
Kacpherek 18415b2fbd Merge commit 'aab654d4e8' into unstick/11190
# Conflicts:
#	crates/horizon-client/src/game/minecarts/nbt.rs
2026-11-12 02:05:06 +01:00
umcekoandClaude Opus 5.5 474e9eb4c8 Perf: the entity context lines live in the per-mob and per-projectile helpers
creatures.rs may not grow after main moved the mob loop out; end_mob clears the mob's context, Projectile::tick sets its own, and the fangs start with none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 03:59:29 +03:00
umcekoandClaude Opus 5.5 9fd3efbf81 Merge origin/main into perf-platform-clean
The mob loop moved into creatures/mob_group.rs; its per-mob sample takes start_mob/end_mob there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 03:53:38 +03:00
Kacpherek 45f36d84e1 Keep reset_unlit_fire before control.rs's test module 2026-11-12 01:36:18 +01:00
Solenopsisbot 8368def6fa Merge remote-tracking branch 'origin/main' into t3/nameplate-distance-attributes
# Conflicts:
#	crates/horizon-client/src/entities.rs
2026-11-12 11:35:51 +11:00
Kacpherek 10004fcd77 Merge remote-tracking branch 'origin/main' into unstick/14850 2026-11-12 01:35:00 +01:00
Joansitoh f8e59c3e3b Merge upstream main into fix/14371-rod-neighbours 2026-11-12 01:31:16 +01:00
Kacper 94077f9e66 Merge branch 'main' into launcher/version-file-without-game 2026-11-12 01:15:59 +01:00
Kacper 1dcb48e1ec Merge branch 'main' into fix/26.3-seventh-republish 2026-11-12 01:12:39 +01:00
Kacper 80570541d0 Merge branch 'main' into fix/goat-long-jump 2026-11-12 01:11:14 +01:00
Kacper 475ea2f6a7 Merge branch 'main' into fix/thunder-trap-random-streams 2026-11-12 01:11:06 +01:00
Kacper b0918fb06c Merge branch 'main' into fix/loot-set-enchantments 2026-11-12 01:07:18 +01:00
Kacper 702b9e5a6e Merge branch 'main' into docs/index-updated 2026-11-12 01:07:03 +01:00
Kacper 0bdbffccf7 Merge branch 'main' into fix/fox-village-shade-defend-trusted 2026-11-12 01:06:24 +01:00
Kacper e3d11137f6 Merge branch 'main' into fix/fox-eat-effects-panda-pickup 2026-11-12 01:06:15 +01:00
Kacper 783c053601 Merge branch 'main' into fix/15276-set-custom-data 2026-11-12 01:05:54 +01:00
Kacper 1416eb94b9 Merge branch 'main' into fix/pvp-held-count-overflow 2026-11-12 01:05:46 +01:00
Kacper 76a0ded489 Merge branch 'main' into fix/item-set-count-overflow 2026-11-12 01:05:37 +01:00
Kacper 44699b4c51 Merge branch 'main' into fix/protocol-inflate-growth 2026-11-12 01:05:19 +01:00
Kacper 22b2093962 Merge branch 'main' into fix/14806-fire-reset 2026-11-12 01:05:11 +01:00
Kacper 7a67cce201 Merge branch 'main' into t3/nameplate-distance-attributes 2026-11-12 01:04:46 +01:00
Kacper 11bda3ecc3 Merge branch 'main' into fix/port-error-tooltip 2026-11-12 01:04:43 +01:00
Kacper c26677ed47 Merge branch 'main' into fix/13666-chest-boat-loot 2026-11-12 01:04:14 +01:00
Kacper 7a725b44e8 Merge branch 'main' into fix/wind-burst-ender-dragon 2026-11-12 01:04:06 +01:00
Kacper c9fbcbc9c0 Merge branch 'main' into fix/orb-fall-distance 2026-11-12 01:03:41 +01:00
Kacper e3d38ae5bd Merge branch 'main' into fix/lectern-book-state 2026-11-12 01:03:08 +01:00
Kacper 58cdf1b551 Merge branch 'main' into fix/adventure-sign-place-on 2026-11-12 01:02:51 +01:00
Kacper 0c03fe844b Merge branch 'main' into perf-equipment-cache 2026-11-12 01:02:19 +01:00
Kacper ede16abf28 Merge branch 'main' into fix/guest-invalid-entity-attack-kick 2026-11-12 01:01:56 +01:00
Kacper 02b5729038 Merge branch 'main' into fix/wind-charge-no-deflect-ticks 2026-11-12 01:00:51 +01:00
Kacper 370854a657 Merge branch 'main' into fix/explosion-creaking-heart-xp 2026-11-12 01:00:16 +01:00
Kacper 2e23fd1dd1 Merge branch 'main' into perf/faster-client-lib-tests 2026-11-12 00:59:58 +01:00
Kacper 90827741ae Merge branch 'main' into up13/camera-armor-stand 2026-11-12 00:58:39 +01:00
Bl4ckspellandClaude Opus 5.5 83703c28b2 refactor(gfx): move residency and memory figures into gfx/census.rs
gfx.rs is over the 3000-line limit and may not grow, and the 4x terrain MSAA work adds to it. The impl block from resident_sections to cpu_census moves unchanged.

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

Fixes #15476.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 02:45:58 +03:00
Your Name 59f63a251a Merge remote-tracking branch 'origin/main' into fix/pvp-held-count-overflow 2026-11-12 12:40:48 +13:00
Your Name e745db772e Fix held item count overflow: use saturating_add; drop unrelated CI changes 2026-11-12 12:40:14 +13:00
Evrim Cicek 7573ae6a3e Merge remote-tracking branch 'upstream/main' into fix/orb-fall-distance
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
#	crates/horizon-client/src/game/commands.rs
#	crates/horizon-client/src/game/loaded_arrow_shot_tests.rs
#	crates/horizon-client/src/game/remote.rs
#	tools/command_exec_corpus.py
2026-11-12 02:28:46 +03:00
umcekoandClaude Opus 5.5 47b40ecd35 Merge remote-tracking branch 'origin/main' into perf-platform-clean
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 02:03:21 +03:00
Kacper 014462436f Merge branch 'main' into docs/index-updated 2026-11-11 23:59:38 +01:00
Kacper 7dffe8983b Merge branch 'main' into fix/15276-set-custom-data 2026-11-11 23:58:31 +01:00
Kacper 789be0131d Merge branch 'main' into fix/item-set-count-overflow 2026-11-11 23:58:07 +01:00
Kacper 54ea398de4 Merge branch 'main' into fix/protocol-inflate-growth 2026-11-11 23:57:50 +01:00
Kacper cccf79f63a Merge branch 'main' into fix/13666-chest-boat-loot 2026-11-11 23:56:45 +01:00
Kacper 0c07b18c76 Merge branch 'main' into perf-equipment-cache 2026-11-11 23:54:41 +01:00
Kacpherek 71d150b028 Read the redstone experiments flag from the enabled features in the lightning rod test 2026-11-11 23:54:20 +01:00
Kacper f8cf08849e Merge branch 'main' into fix/guest-invalid-entity-attack-kick 2026-11-11 23:54:18 +01:00
Kacper d78f353156 Merge branch 'main' into fix/wind-charge-no-deflect-ticks 2026-11-11 23:53:15 +01:00
Kacper 6c58b77fed Merge branch 'main' into fix/explosion-creaking-heart-xp 2026-11-11 23:52:41 +01:00
Kacper 0bd65042f2 Merge branch 'main' into perf/faster-client-lib-tests 2026-11-11 23:52:23 +01:00
Kacpherek 245d97a312 Close the drowned current test in living_tests 2026-11-11 23:51:47 +01:00
Kacpherek eb1fbb151d Merge commit '3298a6dcd5' into unstick/14943
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 23:50:58 +01:00
Kacpherek 9aae4a6671 Merge commit '3298a6dcd5' into unstick/14376 2026-11-11 23:50:18 +01:00
Kacpherek 2497c21f48 Merge commit '3298a6dcd5' into unstick/14589 2026-11-11 23:50:17 +01:00
Kacpherek 5cbded2bd9 Merge commit '3298a6dcd5' into unstick/14904
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 23:50:15 +01:00
Kacper d07604cd8e Merge branch 'main' into up13/camera-armor-stand 2026-11-11 23:48:15 +01:00
Kacper 195dceeb94 Merge branch 'main' into fix/chiseled-shelf-can-take 2026-11-11 23:48:07 +01:00
Kacper 8dc176f4e6 Merge branch 'main' into fix/launcher-sign-in-bounds 2026-11-11 23:47:50 +01:00
Kacper 13fe0905ba Merge branch 'main' into split-renderer 2026-11-11 23:47:32 +01:00
Kacper 7d7fa98a44 Merge branch 'main' into perf/range-dispatch-binary-search 2026-11-11 23:46:17 +01:00
Kacper fd2f881a32 Merge branch 'main' into port_map/farmland-is-near-water 2026-11-11 23:46:08 +01:00
SolenopsisbotandCodex c3325b01f3 Client: honor synced nameplate distances
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-12 09:45:10 +11:00
umcekoandClaude Opus 5.5 77b12a46b1 server_profile_e2e: the disconnect step starts once the timed capture's file has arrived
The client refuses a server start while the last file is still coming in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:40:12 +03:00
Kacpherek 59dbb4703d Merge commit '8d44c49cd3' into unstick/14561
# Conflicts:
#	crates/horizon-client/src/game/books.rs
#	crates/horizon-client/src/game/use_hand.rs
2026-11-11 23:31:04 +01:00
Kacpherek fe78b81e88 Merge commit '8d44c49cd3' into unstick/14740
# Conflicts:
#	crates/horizon-client/src/game/dragon.rs
2026-11-11 23:27:19 +01:00
Kacper 4e86da4a7a Merge branch 'main' into fix/loot-set-enchantments 2026-11-11 23:23:18 +01:00
Kacper 98df3549d2 Merge branch 'main' into fix/fox-village-shade-defend-trusted 2026-11-11 23:22:23 +01:00
Kacper 7d256e1766 Merge branch 'main' into fix/fox-eat-effects-panda-pickup 2026-11-11 23:22:14 +01:00
Kacper 012f70508a Merge branch 'main' into fix/14806-fire-reset 2026-11-11 23:21:02 +01:00
Kacper 40a5a99ca8 Merge branch 'main' into fix/port-error-tooltip 2026-11-11 23:20:26 +01:00
Kacper 4a90452d73 Merge branch 'main' into fix/drowned-fluid-push 2026-11-11 23:19:17 +01:00
Kacper b1fb8a4048 Merge branch 'main' into fix/adventure-sign-place-on 2026-11-11 23:18:25 +01:00
Kacper b9abf293aa Merge branch 'main' into fix/14371-rod-neighbours 2026-11-11 23:17:47 +01:00
Akirabane 54909bf033 Merge remote-tracking branch 'origin/main' into local/W3-02-invalid-attack-kick 2026-11-11 23:13:48 +01:00
Román BenjáminandClaude Opus 5.5 f5a3990fbf Prepare: accept Mojang's seventh 26.3 republish (9f67e10e, index e99f9973)
Fixes #15456.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 23:12:02 +01:00
umcekoandClaude Opus 5.5 3da49d7a02 Perf: the inflation-cap test checks the bomb against the announced cap
Its gzip size depends on the flate2 backend that feature unification picks
(3.7 MB alone, 5.2 MB in a workspace test run); what matters is that the
announced-size cap lets it through, so the inflated cap is what stops it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:58:35 +03:00
umcekoandClaude Opus 5.5 eae9be3b4f Perf: rustfmt the sampler's ring attribute
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:51:16 +03:00
umcekoandClaude Opus 5.5 f21daf7263 Perf: horizon-server report prints a short summary again
On main `horizon-server report` printed the report's text summary; with the
capture it printed only the file's path, and the text form moved to
horizon-perf, which no release ships, so a host on a VPS could not read the
result there. horizon-perf-format, already shipped through the platform,
gets server_brief: the kind and length, TPS and MSPT, CPU, RSS, players,
chunks and entities, and the five heaviest tick phases. The report prints
it before the path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:47:34 +03:00
umcekoandClaude Opus 5.5 21a8b3bff1 Perf: the one-shot Open server retries a failed accept until its deadline
Any accept() error other than WouldBlock ended the server that offers a
capture to the viewer, before the viewer had fetched it: an aborted
connection (ECONNABORTED, common on BSDs and macOS, WSAECONNRESET on
Windows) or a full file table (EMFILE) left Open broken for that click.
Errors now wait 10 ms and try again until the offer's deadline or a cancel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:45:29 +03:00
umcekoandClaude Opus 5.5 1979dc6dc2 Perf: a thread's sampler ring grows when a session needs more
A thread's ring was sized at its first session and kept, but the size a
session needs depends on its Hz, --wall (twice the entries) and the unwind
mode (about 16 KiB per stack copy), so a later capture with --wall or a
higher --hz lost most of its samples to `dropped` (95 of 99 in the new
test). A session now gets a new, larger ring when the thread's is smaller
than it needs. A replaced ring stays allocated for the thread's life, since
a late signal's handler may still hold it; rings only ever grow, so this is
at most a few per thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:44:51 +03:00
umcekoandClaude Opus 5.5 b2df8cee34 Perf: a server profile stops when the guest who asked for it leaves
The requester was keyed by connection id and never cleared on disconnect,
so after the admin's client dropped during an open-ended `profile server
start` everyone got AlreadyRunning until the 600 s window ended, and the
same admin, reconnected under a new id, got NotRunning when stopping. Each
tick's requests now include a stop (reason DISCONNECTED) once the
requester's connection is gone; the file stays in the server's reports/ and
its path is logged, and the slot frees as soon as it is saved, so anyone
(the same admin included) can start again. server_profile_e2e covers the
disconnect and the start after reconnecting.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:43:06 +03:00
umcekoandClaude Opus 5.5 ec1fb9a182 Perf: Share uploads again when the receipt's upload is gone
share_saved always reused an existing .hzperf.share.json and only re-pinned
it, so once the upload expired (a temporary one after an hour, a shared one
after 30 days) or was deleted, Share failed for that capture until the
receipt was removed by hand. A 404 or 410 on the pin of a reused receipt now
uploads the capture again, replaces the receipt (still 0600, written beside
and renamed over) and pins the new upload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:40:09 +03:00
umcekoandClaude Opus 5.5 cec503f33e Perf: a typed /profiler goes where its completion came from
with_profiler keeps a server's or mod's own `profiler` node, so the chat
completed the server's command while handle_chat_input still answered
/profiler locally, and the server's command could not be reached. Horizon's
nodes now carry their own command id; a typed /profiler runs locally only
when the chat tree's `profiler` is Horizon's (or there is no server), and
otherwise goes to the server or mod that owns the name. The client's own
buttons keep running locally.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:36:22 +03:00
Kacpherek 68b16245aa Merge commit '6cc36c6f3a' into unstick/14904
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:27:17 +01:00
umcekoandClaude Opus 5.5 5ccb6cd15b Perf: a server's click can no longer run /profiler on the client
click_command answered any clicked /profiler line itself, so a server's
chat component, book or dialog with run_command "/profiler share" uploaded
the player's last capture after one click. The client's own [Open],
[Share], [Start], [Stop] and retry buttons now carry a client-only click
action that no server text decodes to (as [Folder]'s open_file already
was); a clicked run_command is a plain command again, sent to the server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:27:09 +03:00
Kacpherek 32a43ad647 Merge commit '6cc36c6f3a' into unstick/14943
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:25:32 +01:00
Kacpherek 46179fd42e Pack screen: no I-beam on the filter while the Add-pack browser covers it 2026-11-11 22:23:34 +01:00
umcekoandClaude Opus 5.5 91491459d5 Perf: a server part whose last chunk fails to finish still gets its Stop
The merger was taken out before finish(), so when finish() failed the
abandoned part was closed with a fresh merger that had yielded nothing,
and no Stop{DISCONNECTED} was written for a part that began. finish() now
borrows the merger, which keeps what it yielded for cut_short.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:14:36 +03:00
umcekoandClaude Opus 5.5 4cce8f8484 Perf: cap the server's part and take only its own side's fields
A client took whatever size a server announced and inflated every chunk
without limit, and appended fields of any side into the player's capture.
The announced size is now capped at 64 MiB (the share endpoint's own cap),
the inflated part at 512 MiB (the timeline's 256 MiB cap plus the rest of
a 600 s capture; real captures inflate about 3x from 130-230 KB a minute),
and one field at 64 MiB, which also bounds what waits half-arrived. A field
whose message is not side = SERVER, or has no side (an attachment), refuses
the part; what arrived stays with the incomplete warning, as for any part
that fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:12:29 +03:00
umceko c7227eb3f7 Merge remote-tracking branch 'origin/main' into perf-platform-clean
# Conflicts:
#	crates/horizon-client-render/src/gfx/pack_timing.rs
#	crates/horizon-platform/src/lib.rs
2026-11-12 00:03:27 +03:00
Vinícius Moreira 190aa00a63 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/explosion-creaking-heart-xp
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:10 -03:00
Kacper b2258c35e7 Merge branch 'main' into fix/loot-set-enchantments 2026-11-11 21:51:09 +01:00
Kacper 90fed51ab1 Merge branch 'main' into fix/fox-village-shade-defend-trusted 2026-11-11 21:50:47 +01:00
Kacper 5de4e1ee74 Merge branch 'main' into fix/fox-eat-effects-panda-pickup 2026-11-11 21:50:43 +01:00
Kacper 6b1f808b13 Merge branch 'main' into fix/14806-fire-reset 2026-11-11 21:50:12 +01:00
Kacper 81336ce7a9 Merge branch 'main' into fix/wind-burst-ender-dragon 2026-11-11 21:50:02 +01:00
Kacper 47e22facbb Merge branch 'main' into fix/drowned-fluid-push 2026-11-11 21:49:56 +01:00
Kacper f2a97a7329 Merge branch 'main' into fix/skeleton-never-freezes 2026-11-11 21:49:52 +01:00
Kacper 1d2b14b01d Merge branch 'main' into fix/14371-rod-neighbours 2026-11-11 21:49:49 +01:00
Kacpherek 4c2306d61d Merge commit 'a5df8273bb' into unstick/14561
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/books.rs
2026-11-11 21:39:53 +01:00
Kacpherek ff08319e96 Merge commit 'a5df8273bb' into unstick/15192
# Conflicts:
#	crates/horizon-client-render/src/widgets.rs
2026-11-11 21:39:52 +01:00
Kacpherek 194ddaa041 Merge commit 'a5df8273bb' into unstick/14553
# Conflicts:
#	crates/horizon-client/src/game/use_hand.rs
2026-11-11 21:39:07 +01:00
Kacpherek 289674508b Merge commit 'a5df8273bb' into unstick/14784
# Conflicts:
#	tools/port_map/client2-015.tsv
2026-11-11 21:36:54 +01:00
Akirabane f2ea985b8d Merge remote-tracking branch 'origin/main' into local/W3-02-invalid-attack-kick 2026-11-11 21:33:47 +01:00
rdbtCVSandGPT 6.1 Sol 1bcc5ec3f9 assets: preserve vanilla range-dispatch binary-search hits
Address kacpherek's request on PR #14365: remove the post-hit tie scan while keeping slice-based search and the short-list upper bound. Extend the existing regression test with 16-entry duplicate and signed-zero lists; expected indices were observed on the JVM and the pinned vanilla method was checked with javap.

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

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:31:36 +00:00
rdbtCVSandGPT 6.1 Sol 1c5f7c2d3c Merge main into fix/thunder-trap-random-streams
Address the reviewer request by retaining main's invulnerable_time field rename alongside the PR's RNG changes.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:23:21 +00:00
AkirabaneandClaude Opus 5.5 e7922700f0 Check that the guest's attack on the pig hurts it
The attack-checks test only asserted that attacking a pig does not
disconnect the guest. It now also asserts that the pig lost health,
so a normal attack on a mob is shown to still land.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:21:23 +01:00
DetimesandClaude Opus 5.5 7ad2c3b0bc Merge upstream/main into perf-equipment-cache
Brings the branch up to date with main for review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:11:47 +00:00
Kacpherek 983bd56fb5 Merge main into pr-14598
# Conflicts:
#	crates/horizon-client/src/lan/prediction.rs
2026-11-11 20:54:52 +01:00
Kacpherek b61cff5625 Merge main into fix/chiseled-shelf-can-take 2026-11-11 20:44:41 +01:00
Kacpherek 37f6d169d7 Merge main into port_map/farmland-is-near-water 2026-11-11 20:44:26 +01:00
Kacpherek 7b98f70c85 Merge main into fix/15013-launcher-ipv4-fallback 2026-11-11 20:43:13 +01:00
Kacpherek ac4204f395 Merge main into split-renderer 2026-11-11 20:42:09 +01:00
Kacpherek 7714b1d37a Merge main into fix/goat-long-jump 2026-11-11 20:41:46 +01:00
Kacpherek b045dddfa1 Merge main into fix/tnt-minecart-explosion-power 2026-11-11 20:41:30 +01:00
yahya-nusseibehandClaude Opus 5.5 5f61926401 Launcher: bound the sign-in services' interval and lifetimes
The device-code interval, its expiry and the Minecraft token lifetime came from the services
unchecked; Instant + a huge interval panicked the sign-in thread and left the window on the
code. The interval is clamped to 1-60 s (slow-down too), the device code's expiry to an hour
and the token lifetime to a week, all added with saturating_add.

Split out of #15247 at review.

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

Split out of #15249 at review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:16:47 +02:00
Web-mc1 c93c23248a Guard item count arithmetic against i32 overflow 2026-11-12 07:45:27 +13:00
Web-mc1 fbfa055eff Guard item count arithmetic against i32 overflow 2026-11-12 07:40:58 +13:00
umceko fb85eb77e0 Merge remote-tracking branch 'origin/main' into perf-platform-clean
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-11 21:18:57 +03:00
umcekoandClaude Opus 5.5 a3efbe017d profile_client_e2e: the stop line reads "Profile saved" since the chat redesign
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:18:38 +03:00
Web-mc1 b610bc7618 Guard held item count against u32 overflow 2026-11-12 07:15:10 +13:00
Web-mc1 b082e400c6 Guard held item count against u32 overflow 2026-11-12 07:09:38 +13:00
AkirabaneandClaude Opus 5.5 9a267cf4bf Disconnect a player who attacks an item, an orb, themselves or an arrow
ServerGamePacketListenerImpl.handleAttack disconnects a player whose
attack names an ItemEntity, an ExperienceOrb, the player itself or an
AbstractArrow that is not isAttackable, with
multiplayer.disconnect.invalid_entity_attacked, after the world border,
attack range and PIERCING_WEAPON checks. A hosted or dedicated Horizon
server let the attack end without a word.

handleAttack's checks before Player.attack move to
game/lan_actions/attack_checks.rs: the border and range checks
guest_attack already did, then that test. An arrow's isAttackable reads
#minecraft:redirectable_projectile from the tags the players were sent,
so a data pack that adds arrows to it keeps them attackable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 18:56:15 +01:00
umcekoandClaude Opus 5.5 239b96621a Performance platform: files over 3000 lines don't grow
The pass timing switches and readings and the world pass helpers move
from gfx.rs to gfx/pass_timing_api.rs, the streamer's queue census and
job_stats to streaming/, Horizon's mark sprite to mark_sprite.rs, and
`horizon-ctl lan op` to game/lan_op.rs. A guest's plugin channel
routing moves beside horizon_channel, the client takes the profile
channel's replies before handle_packet, and tick_profile starts and
ends a mob's tick for both the sampler and the mob costs. The server
bench test reads the saved line the server prints now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:53:39 +03:00
umcekoandClaude Opus 5.5 08b55819b1 Performance platform: leave out what is not the profiler
Removes the fork's review and claim tooling and its docs, and the scene
corpus, which the design keeps separate (an input fixture, not a
measurement). The design doc describes the viewer and upload API in
horizon-rust/horizon-site and drops the planning sections; tests and
doc comments point at playhorizon.dev and horizon-site. The debug keys'
comment is upstream's again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 18:21:33 +03:00
umcekoandClaude Opus 5.5 d27e19390d Merge upstream main into the performance platform
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 18:18:28 +03:00
Umut Cevdet Koçak ad491915a5 Merge pull request #280 from sunrise-org/perf-idle-cost
Profiler docs: idle hooks' cost within the margin of error
2026-11-11 18:16:50 +03:00
umcekoandClaude Opus 5.5 f356ceda41 Profiler docs: the idle hooks' cost is within the margin of error
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 18:14:56 +03:00
Riccardo Guerriero 09b8b5b94b Merge origin/main into perf/faster-client-lib-tests 2026-11-11 15:57:05 +01:00
GIGABAIT93andClaude Opus 5.5 dc064e4bb1 docs: broken_on picks a version, it isn't only shown
A reader skips a version broken on the player's Horizon commit for an
older one that builds; only downloads, stars and updated are for show and
sort. None of the four is checked against a downloaded file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 16:21:50 +02:00
DUzzL c741fd4d39 Remove empty custom_data after set_custom_data item modifier 2026-11-11 15:04:13 +01:00
DUzzL 64fc971ebc Honor an egg's explicit chicken variant removal before the item default 2026-11-11 14:39:38 +01:00
GIGABAIT93andClaude Opus 5.5 c6e83d5f09 docs: the index's updated, and the fields a reader may rely on
`projects[].updated` is the date of a project's newest version, set by the
registry's publish job (horizon-rust/horizon-mods#683). While the index is
preview, a reader may rely on every field but the publish job's four
(`downloads`, `stars`, `updated`, `broken_on`), which may be absent at any
time. Point (3) of #10562.

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

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 11:59:51 +00:00
MSedgeMC 08e01dc3da port_map: EditBox.handleCursor's I-beam 2026-11-11 10:37:18 +00:00
MSedgeMC c289d208e6 test: a hovered text field asks for the I-beam cursor
Not hovered asks nothing; hovered asks Ibeam; a read-only one asks NotAllowed.
2026-11-11 10:37:18 +00:00
MSedgeMC 84d6e3d97a fix: a hovered text field shows the I-beam cursor
EditBox.handleCursor requests CursorTypes.IBEAM while hovered and editable, and
NOT_ALLOWED when it takes no text. Horizon's cursor set had no I-beam, and the
pack screen's search boxes and the settings shell's search never asked for one.
widgets::text_cursor adds it, the two screens call it where their fields are
drawn, and the client maps Cursor::Ibeam to CursorIcon::Text.
2026-11-11 10:36:57 +00:00
MSedgeMC 7f0f9337f3 port_map: Set Enchantments replaces the levels 2026-11-11 09:52:12 +00:00
MSedgeMC 7d011d64aa test: Set Enchantments replaces the levels 2026-11-11 09:52:12 +00:00
MSedgeMC 4ea481cb2f fix: Set Enchantments replaces the levels 2026-11-11 09:52:12 +00:00
MSedgeMC 30e4e488b2 port_map: ChiseledBookShelfBlockEntity.canTakeItem asks the target's room
The hopper's path applies the `hasAnyMatching` rule through
`can_take_into`.
2026-11-11 08:49:01 +00:00
rdbtCVSandGPT 6.1 Sol b2095cc1ca Merge current main into TNT minecart explosion fix
Preserve the contributor's TNT power change and the minecart extraction already on main. Git resolved both touched files without manual conflict edits. The net change against main is three files (+82/-2).

Validation: rustfmt, public-tree, layer/file-size checks and three command-fixture Python tests pass. The focused Rust test attempt failed in sccache before compiling Horizon; vanilla resources and the port ledger are unavailable, so gameplay proof remains outstanding.

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

Horizon's `Containers::can_take` had no shelf rule, so a full hopper
still pulled a book out (and put it back). The hopper's path now asks
`can_take_into`, which applies the shelf rule before the existing checks.
2026-11-11 08:40:13 +00:00
TiggerTakkyTTTandCodex 866057db34 Merge upstream/main into fix/wind-burst-ender-dragon
Co-Authored-By: Codex <[email protected]>
2026-11-11 15:30:28 +07:00
MSedgeMC fb05c1acfc port_map: FarmlandBlock.isNearWater is the water fluid search
Vanilla's predicate is `state.getFluidState().is(FluidTags.WATER)`, which
the inline 9x2x9 search already matches; the row's `canBeHydrated` gap
was wrong.
2026-11-11 05:02:45 +00:00
MSedgeMC a2cdad2a10 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:17 +00:00
MSedgeMC 53e8715d83 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:14 +00:00
Vinícius Moreira 306dca0a21 Merge remote-tracking branch 'upstream/main' into fix/explosion-creaking-heart-xp 2026-11-11 00:03:46 -03:00
DUzzL 6352b7468c Thrown eggs hatch the chicken variant of their item
ThrownEgg.onHit sets the chicken's variant from the item's CHICKEN_VARIANT component (egg temperate, blue_egg cold, brown_egg warm); a thrown or dispensed egg now carries it.
2026-11-11 03:37:51 +01:00
Vinícius MoreiraandClaude Opus 5.5 26d32147ea Wrap the zombie owner's summon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:53 -03:00
Vinícius MoreiraandClaude Opus 5.5 0b2ed50a68 A nested blast drops the TNT owner; the zombie owner stays and leaves no drops
A native explosion inside a TNT blast is Level.explode(null, ...): it
now takes blast_owner, blast_guest and blast_owner_player aside with
the rest of the blast's state. The group keeps its zombie persistent
and clears the zombie's drops.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:32:56 -03:00
f90e164b4b Give a fox that eats its food its effects, and credit a panda's pickup
`Fox.aiStep` finishes the item in the mouth with `ItemStack.finishUsingItem`
(`Consumable.onConsume`), which ran only the chorus fruit's teleport here: run
the whole consume in vanilla's order — `emitParticlesAndSounds` (the
consumable's sound, and the four draws a crumb of `spawnItemParticles` costs,
though `Level.addParticle` is empty on a server), `FoodProperties.onConsume`'s
own sound, the `on_consume_effects` through `LivingEntity.addEffect` (so a
golden apple's regeneration and absorption land) and `GameEvent.EAT` at the
fox's feet with the fox as its source.

`Panda.pickUpItem` calls `onItemPickup` before it takes the item, so
`CriteriaTriggers.THROWN_ITEM_PICKED_UP_BY_ENTITY` fires for whoever threw it,
as it already did for the fox (`LivingEntity.onItemPickup`).

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

Part of #11330

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:34 -03:00
Umut Cevdet Koçak add317d016 Merge pull request #277 from sunrise-org/perf-m1-heap
Sampled heap profiling: allocations and live memory by call stack
2026-11-11 03:39:28 +03:00
umcekoandClaude Opus 5.5 b944c970c3 Heap samples leave out the profiler's own threads
The e2e capture's live heap was all on horizon-profile: the CPU
sampler's drain thread copies the binary's unwind sections (~9 MB,
framehop's Module::new from StackUnwinder::refresh) at the start of the
capture and holds them to the end, so the heap sampler profiled the
profiler. Samples were already tagged with the allocating thread.

heap_sample::ignore_this_thread() stops sampling a thread's allocations
(its frees still leave the live set). The sampler's drain thread, the
capture writer (WriterThread::spawn_with), the finishing thread and
the gauges thread call it.

server_perf_e2e checks live bytes over every thread, none on a profiler
thread, and that the top live function is not the profiler's; it
samples every 64 KiB so its 4 s capture holds some. It now passes: 1.0
MiB live on tick, workers and chunk threads, 19,453 samples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 03:39:05 +03:00
umceko 3030aa3652 Merge remote-tracking branch 'fork/perf-platform-upstream' into perf-m1-heap
# Conflicts:
#	tools/server_perf_e2e.py
2026-11-11 03:20:39 +03:00
Umut Cevdet Koçak f65b2574e1 Merge pull request #279 from sunrise-org/perf-chat-calm
Profiler chat: calm lines, action bar progress, and the mark sprite fixed
2026-11-11 03:03:02 +03:00
umcekoandClaude Sonnet 5.5 f80e0cfbf0 Profiler chat: one calm line per outcome, progress on the action bar, and the mark sprite drawn
Chat keeps the lines with a next step (started, saved, shared, failures); saving,
uploading, opened and marked go to the action bar; the saved line carries the
capture's length from when stop was asked; [Retry] names its profile.

Text::styled dropped the style's inline object (Style::inherit never passes one
on), so the profiler tag drew the font's [OBJ] box instead of horizon:gui/mark.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 03:02:11 +03:00
TiggerTakkyTTTandCodex 15e02ce312 fix(cem): preserve layer ownership map
Avoid shadowing the ownership map with the layer-key list so the latest main branch compiles after the CEM armour-layer merge.

Co-Authored-By: Codex <[email protected]>
2026-11-11 07:00:19 +07:00
TiggerTakkyTTT 1c15680e9a Merge remote-tracking branch 'upstream/main' into fix/wind-burst-ender-dragon 2026-11-11 06:57:15 +07:00
rdbtCVSandGPT 6.1 Sol 7cdd21f24d Merge repaired main into PR #15021 to refresh CI
By rdbtCVS's AI agent (OpenCode, GPT 6.1 Sol). Preserve the contributor and maintainer history while including main's merged #15027 repair for the inherited studio_replay and rocket RNG compilation failures. No PR-specific code changes.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:52:38 +00:00
TiggerTakkyTTTandCodex 61d8dea2a7 fix(combat): scale dragon wind burst knockback
Apply the burst damage-calculator multiplier to the Ender Dragon impulse before native knockback hooks, and cover the behavior with a regression test.

Co-Authored-By: Codex <[email protected]>
2026-11-11 06:39:34 +07:00
rdbtCVSandGPT 6.1 Sol 0afcabe0ec test(launcher): exercise sign-in builder in IPv4 fallback test
Use the production timeout builder rather than an unrelated test agent, and
cover IPv4-first multi-address and IPv6-only resolver ordering.

Fixup by rdbtCVS's AI agent (OpenCode, GPT 6.1 Sol), not by rdbtCVS personally.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:07:38 +00:00
Umut Cevdet Koçak b97a7d0dec Merge pull request #278 from sunrise-org/perf-m2-reader-memory
Perf platform M2: finer sample blocks, memory_labels/counters ops, samples in tick, complete server memory report
2026-11-11 01:48:42 +03:00
Umut Cevdet Koçak b6832dc940 Merge pull request #276 from sunrise-org/perf-l1-layers
Profiler on upstream's layers: Open and Share through a hook, no built-in address
2026-11-11 01:48:38 +03:00
umcekoandClaude Sonnet 5.5 7942b6d4c3 Memory report: a typed, complete server report with entities, players, chunk send caches, queues and unattributed_mb
`horizon-ctl memory` and a capture's memory snapshots are built from `#[derive(Serialize)]` structs instead
of `json!`; existing keys keep their shape. A dedicated server (no window) names its columns `columns`;
single player keeps `client_columns` and `server_columns`.

New, all estimated from capacities:
- `entities`: count and bytes by entity type (top 30), across every level
- `players`: count and, per player by name, the send queue (packets and bytes), chunks waiting, chunks sent
  and tracked entities
- `chunk_send`: the encoded section and heightmap caches and the shared compressed frames
- `light_queue`, `generation_queue`: lengths and the bytes of their tables
- `allocator`: `live_heap_mb` (mimalloc's statistics), `commit_mb`, `peak_commit_mb`, `overhead_mb`
- `named_mb` and `unattributed_mb` (resident minus the named parts) on every side

The server e2e asserts a stop snapshot has `players`, `entities` and `unattributed_mb`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:47:31 +03:00
umcekoandClaude Sonnet 5.5 c7a26889ff horizon-perf: tick carries the top functions of the samples around the tick
`hot` is the top 30 functions (the `hot` op's row) of the main thread's samples in the time blocks
overlapping the tick, only the slow samples when the tick is slow, and `samples_from`/`samples_to` say the
window those blocks cover. With 1 s blocks that is the second around the tick, not the tick's own samples.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:47:31 +03:00
umcekoandClaude Sonnet 5.5 46b588e72a horizon-perf: memory_labels lists a capture's memory snapshots and counters its counters
`memory_labels` (CLI `query memory-labels`, MCP and wasm through the shared op list) gives one row per
snapshot, `{label, t, side, rows}`, in time order. `counters` gives one row per counter definition,
`{id, name, unit, kind, labels, side, samples}`. Each has a golden reply. `memory` also ranks a negative
megabyte row by its size, so `unattributed_mb` can show.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:47:31 +03:00
umcekoandClaude Sonnet 5.5 2843049551 Profiler: CPU sample time blocks are 1 s, up to 1800, and a time range selects the blocks it overlaps
`block_s` defaults to 1.0 and `MAX_TIME_BLOCKS` is 1800 (30 minutes, the block index a `u16`); a longer
session keeps the rest in the last block, as before. The reader selects every block a `--from`/`--to`
range overlaps, a range inside one block picks that block, and the reply's `selection.from`/`to` name the
blocks the weights cover, so a 2 s range no longer leaves Functions and the flame graph empty.

Capture size before and after on the 200-bot benchmark and a client capture is not measured yet: pending a
quiet machine. If growth is over 2x, blocks older than 5 minutes get merged at write time.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:47:31 +03:00
umcekoandClaude Opus 5.5 9e7925e2e9 Sampled heap profiling: allocations and live memory by call stack
horizon-threads' heap_sample wraps mimalloc and is the game's allocator
on Linux (alloc-count and alloc-census still replace it). While a
capture runs it samples about one allocation per 512 KiB
(HORIZON_HEAP_SAMPLE_KIB; 0 turns it off) with an exponential countdown
per thread, records the stack, thread and span context, and weighs
each sample interval / (1 - exp(-size / interval)) bytes. Frees look a
pointer up in a 1 MiB table of per-bucket counts and only take the lock
on a hit. One session per process: the first capture to start owns it,
so the integrated server's joined side does not start another.

Captures write two more kinds, alloc and live (bytes, period 1), into
the same tables as the CPU samples, without the allocator's own frames
at the leaf end.

Measured (fast profile, Linux x86_64, 24 threads, machine at load ~50
from other builds):
- Off: alloc_count_bench 64 B alloc+free pairs, +0.4-0.6 ns a pair
  (+9-11% of mimalloc's 5 ns on 1 thread; +1-14% on 8, noisy): one
  relaxed load and branch on each side, as designed.
- On, 200 walking bots with a capture: the sampler is 1.2% of the
  tick thread's CPU samples (1.24 and 0.77 ms a tick), about 70% of it
  .eh_frame unwinding, at ~40-70 us a sample; 63k and 100k samples in
  ~2 min. MSPT avg on 89/148/224/97 vs off 108/69/217/65 ms: the
  overloaded server (TPS 5-14) and the machine load swamp the A/B.
- Capture size: 209 and 231 KB on vs 171 and 171 KB off (+22-35%).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:40:00 +03:00
umcekoandClaude Opus 5.5 81b07fb2d6 Profiler format and reader: live heap samples
SampleSource gains LIVE (sampled heap bytes still allocated when the
capture stopped, at their allocation's time block), and Overhead gains
heap_samples and heap_sample_ns.

horizon-perf reads them: --select live works in query hot/tree/callers
and in the folded and pprof exports, in bytes (a total, not a rate per
tick); the summary prints a heap line (live and allocated MiB, the
functions holding the most) and the heap sampler's cost.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:40:00 +03:00
DUzzL d13e35bb8f launcher: fall back to IPv4 when IPv6 to Microsoft is dead
Interleave the address families of resolved sign-in hosts and bound the
connect time, so a broken IPv6 route no longer fails sign-in with
'Could not reach Microsoft'. Fixes #15013.
2026-11-10 23:27:51 +01:00
e24554895d Keep foxes out of the shade in villages and defending the players they trust
Fox.SeekShelterGoal.canUse no longer looks for shade when the fox stands in a
village (ServerLevel.isVillage: within one section of a village POI), so a
village fox keeps strolling in the sun as vanilla's does.

Fox.DefendTrustedTargetGoal is now registered at target priority 3: the fox
takes whoever last hurt a trusted entity as its target, plays FOX_AGGRO at its
feet, sets DEFENDING (the growl and the avoid/stroll guards were dead code) and
drops the flag through Fox.setTarget(null) when the goal stops.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:13:12 +01:00
umcekoandClaude Sonnet 5.5 bd94713fdd /profiler: stop only saves; [Open] and [Share] are the player's to click
`/profiler stop` prints the saved file and never uploads. Where a captures part is
registered (the official build) the line carries [Open] and [Share] buttons that run
`/profiler open <id>` and `/profiler share <id>` (no id: the last capture saved);
a build without the part answers both "not available in this build". horizon-ctl
gains `profile open FILE` and `profile share FILE`. The profiler harnesses read the
new saved line, and `profile_client_e2e.py --official` checks the buttons and open_at.
Docs: saving, Open and Share are independent of telemetry; Share is the only upload.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 00:28:07 +03:00
umcekoandClaude Sonnet 5.5 a86ab9d4bc Layers: the profiler crates have layers, and benchmarks sit on horizon-system
The benchmark report format reads its hardware through `horizon-system`, not
`horizon-telemetry`, so `horizon-bench` is an engine crate and the two `-> horizon-bench`
exceptions go. `horizon-perf-format` is engine, `horizon-perf` a tool, `horizon-perf-share`
Horizon. The launcher and `horizon-server` reach the report format and the capture files
through `horizon_platform::{bench, perf_format}`. `horizon-threads` joins the portable
crates, which `horizon-world` already used.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 00:28:07 +03:00
umcekoandClaude Sonnet 5.5 ccf6b2c913 Perf captures: Open and Share go through a hook, not a built-in address
Saving a capture is local. A registered `Hooks::captures` offers the viewer page,
Open (serve the file once on 127.0.0.1 and open the viewer on it) and Share (the
upload). `horizon-perf-share`, a Horizon crate in `horizon-official`'s parts, holds
both, with the addresses in `horizon-addresses` and their env overrides; the upload
code moves there from `horizon-perf`, which gains `horizon-perf open`.

The saved line and the `profile stop` reply carry no address: `open_at` is present
only when a build has the hook. `horizon-client` keeps `horizon-perf` for tests only.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 00:28:07 +03:00
Umut Cevdet Koçak e301d92133 Merge branch 'horizon-rust:main' into perf-platform-upstream 2026-11-10 22:13:29 +03:00
Verm 7d37a291c2 A shulker's AttachFace is narrowed to a byte before the face is picked (#14940)
Shulker reads AttachFace with Direction.LEGACY_ID_CODEC: a byte through
from3DDataValue (abs(data % 6)). The loader took the remainder of the
whole number, so an int 258 was face 0 where vanilla's byte 2 is face 2.
The shulker bullet's Dir already narrowed first.
2026-11-10 18:02:13 +00:00
DetimesandClaude Haiku 5.5 d2bc41cd4e Test: equipment conversion repeats only for changed stacks
Compares the cached equipment with a direct conversion of every equipped
slot over static ticks, after a count change and after a slot is cleared.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:42:31 +00:00
DetimesandClaude Haiku 5.5 61ffa23382 Perf: cache converted equipment per guest
Held, offhand and armor stacks were converted to network stacks for every
guest on every tick. Each guest now keeps the converted stack of each
equipment slot together with the ItemStack it came from, and converts
again only when that stack differs by full equality. The /reload path
clears the cache, since conversions depend on the registries.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:42:28 +00:00
Verm c44aa01ebb A wolf's or cat's CollarColor outside 0..15 reads white (#14897)
Wolf and Cat read CollarColor with DyeColor.LEGACY_ID_CODEC: a byte
through DyeColor.byId, whose out-of-range answer is the first colour
(OutOfBoundsStrategy.ZERO). The loader masked the id with & 15, so 17b
was orange and -1b black where vanilla's collar is white.
2026-11-10 17:26:10 +00:00
Verm 935faca944 An area effect cloud keeps the fall_distance it was loaded with (#14853)
AreaEffectCloud extends Entity and never moves, so Entity.saveWithoutId
writes the fall_distance Entity.load read. entity_base_tag, which the
cloud's save uses, took OnGround from the loaded compound but wrote
fall_distance 0.0; it now takes that from the compound too.
2026-11-10 16:18:38 +00:00
Verm 70ee24e9ef A trident keeps the fall_distance it was loaded with (#14846)
Entity.saveWithoutId writes fall_distance from fallDistance and
Entity.load reads it back. AbstractArrow.tick never calls Entity.move,
so a trident keeps the loaded value until water resets it. The trident's
save wrote 0.0 and its load ignored the key; it now reads, keeps and
saves it as the arrow does, and water resets it in the ticks that reach
Entity.baseTick. Lava's halving is not modelled for a trident.
2026-11-10 15:58:46 +00:00
DUzzL 267c3838b6 Reset a negative mob Fire to 0 on its first tick 2026-11-10 16:06:42 +01:00
DUzzL dfbdf94740 An arrow loads and keeps its saved Air 2026-11-10 15:10:23 +01:00
Oloion afb34764ec assets: eliminate heap allocations in RangeSelectItemModel binary search 2026-11-10 17:00:17 +03:00
umcekoandClaude Opus 5.5 fc9eb64042 Captures name players: the privacy section and its harness say so
A server owner has to know which connection floods or lags, so net rows carry each player's name, client
brand and view distance. server_perf_e2e still checks that the world, seed, MOTD, mob and sign names and the
IP stay out, and now checks that net names both bots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 16:22:22 +03:00
umcekoandClaude Opus 5.5 fc9acc4a74 Profiler: a client capture records its connection, and a server-thread start waits for the last file
The window now hands its server connection's byte counters to the capture each frame, so a client capture's
network tab has data. A server-thread capture asked for while the previous one is still being written waits
for that file instead of refusing, which the window had already reported as started.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 16:22:22 +03:00
umcekoandClaude Sonnet 5.5 79b1df4051 Golden net replies carry each connection's name, brand and view distance
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:58:46 +03:00
umcekoandClaude Sonnet 5.5 31ae839a81 Format the ported profiler work
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:18:40 +03:00
umcekoandClaude Sonnet 5.5 226fb118f8 Captures name each connection: player, client brand and view distance; a client records its own server traffic
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:17:58 +03:00
umcekoandClaude Sonnet 5.5 4390baae27 Profiler chat: Horizon's mark as the line's tag
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:15:50 +03:00
umcekoandClaude Sonnet 5.5 aa3df3b4f7 Read a single-player capture's GPU frames without naming the side
The profile e2e checks the GPU summary and frame series too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:01:15 +03:00
umcekoandClaude Sonnet 5.5 51fa349fe4 Record every GPU-timed frame in captures and split the world pass into draw groups
A capture made with --gpu now writes each timed frame (gpu_frames = 24: its
start on the capture clock, first-to-last timestamp total, every pass and part)
in blocks about once a second, with the CPU ms of every drawn frame. GpuPasses
ms becomes the median over the whole capture instead of the last 15 frames.

Where the device stamps inside passes, every timed frame splits the world
passes into their draw groups (sky, solid terrain, occlusion boxes, entities,
translucent terrain, clouds, weather, hand...) and compute passes into their
dispatches, so GPU culling's parts show without detail mode; Metal keeps pass
boundaries, recorded as GpuDevice.pass_split.

query gpu gains per-pass and per-part samples, p50/p95/p99/max and share of the
GPU frame p50, plus a summary row with GPU and CPU frame percentiles and a
gpu/cpu/mixed bound verdict; query gpu_frames gives the series with slow frames
flagged. Older captures read with empty statistics.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 14:44:54 +03:00
MSedgeMC 479276f66d port_map: WorldOptionsScreen.setPortError is ported 2026-11-10 11:44:22 +00:00
MSedgeMC 17cdf49055 test: a rejected port says why in its tooltip
Above 65535, below 1024, not a number and negative all read the invalid-port
reason; 1024, 25565 and 65535 keep the Port Number label, and an empty box
takes the suggestion.
2026-11-10 11:44:22 +00:00
MSedgeMC b49f625199 fix: the LAN port box says why its number is wrong
WorldOptionsScreen.setPortError swaps the port box's tooltip from
PORT_INFO_TEXT to INVALID_PORT when tryParsePort rejects the value. Horizon
always showed Port Number, so a red box never said what was wrong.
2026-11-10 11:44:22 +00:00
umcekoandClaude Sonnet 5.5 034ffdbfef Parse /profiler through its command tree, with gpu literals and spark-style chat lines
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 14:35:00 +03:00
MSedgeMC c1fad2fb18 port_map: the loaded cube's freeze immunity
SulfurCube.canFreeze had no row; it is the cube's arm of freezing.rs::can_freeze.
2026-11-10 11:28:57 +00:00
MSedgeMC 9b087a4560 A sulfur cube holding a block does not freeze
SulfurCube.canFreeze answers `!hasBodyItem() && super`, so a cube carrying a
block in its body takes no frozen ticks from powder snow and no freeze damage,
while one without a body freezes like the cube mobs do. can_freeze answered
the tag and the skeleton's refusal and nothing else, so a loaded cube shook and
took the 1 a fully frozen mob takes.

The test puts a sulfur cube with a stone body at 0 and at 140 frozen ticks: it
stays at 0 with no damage and melts to 138, where the cube without a body gains
a tick (1) and takes the 1 at 140. Without the arm the loaded cube reads (1,
0.0) like the empty one.
2026-11-10 11:17:11 +00:00
umcekoandClaude Sonnet 5.5 28fa20f13d Share in-game profiler captures through direct viewer links
Upload completed chat-started captures on a background worker and show a clickable capture URL. Preserve the local file and an owner-only receipt before pinning, so /profiler share can retry without uploading twice. Use an application user agent accepted by the deployed API. Keep control-socket and environment captures local.

Validated timed and manual in-game completion against the live API with byte-identical download, plus retry/ownership regression, command completion and Clippy. Test uploads removed after validation.

Fixes sunrise-org/horizon#267

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 14:11:21 +03:00
umcekoandClaude Sonnet 5.5 ac784c6832 Record complete 1% and 0.1% timing lows for performance captures
Keep bounded raw unit timings during recording and expose exact slow-tail means through meta. Tick rates express work capacity capped at 20 TPS; old or incomplete captures omit lows. Related: sunrise-org/horizon#264.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 14:08:24 +03:00
zenith600andClaude Opus 5.5 4d3d579464 launcher: say when a folder has the version file but not the game (#14286)
A folder where the official launcher wrote versions/26.3/26.3.json but not
26.3.jar (a version picked but never played, or a download that stopped)
said "no Minecraft 26.3 client jar", the same as a folder that never had
26.3. It now says the version file is there and to play 26.3 once in that
launcher. A wrong jar and a folder without the version file keep their
messages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 16:29:39 +05:30
umcekoandClaude Sonnet 5.5 9cddcfb071 Carry the remaining profiler and benchmark work onto upstream's layout
Benchmark A/B runs and --benchmark-set/--profile, the memory section, span
laps through streaming and the world update, the server report and the
/profiler command tree, ported from the original patches onto the files
upstream split out of game.rs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 13:48:22 +03:00
umcekoandClaude Sonnet 5.5 4039f0eeb6 Merge origin/main into perf-platform-upstream
Conflicts kept both sides: the platform's pass timing feature moves into the
device search's new open_adapter, its Options fields and work-attribution
calls follow the code upstream moved out of game.rs, and the perf channel
joins upstream's list of Horizon channels.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 13:37:36 +03:00
TiggerTakkyTTTandCodex 01731db683 fix(combat): make Wind Burst affect the Ender Dragon
Fixes #14724.

Co-Authored-By: Codex <[email protected]>
2026-11-10 17:03:22 +07:00
MSedgeMC 7b9fca2a49 Move the freezing helpers into mobs/ai/freezing.rs
ai.rs is over the 3000-line limit and may not grow, so in_powder_snow and can_freeze move out; the skeleton rule stays
the same.
2026-11-10 09:51:34 +00:00
Riccardo GuerrieroandClaude Fable 5.1 1258ec339a Load the prepared test resources once per test process
horizon-client-lib's unit tests spent 71% of their time in the 708 mobs::
tests and 7% in lan::prediction, and nearly all of that was each test
reading the whole prepared root again in a debug build: Assets (block
registry, tags), BlockFacts, MotionResources, MobData, MobTypes, Biomes,
Items and the collision registry, about 7 s a call under load. A test
calling the fixture 16 times paid 16 times (157 s).

The tests only read those tables and build their own World over them, so
mobs/world.rs keeps the vanilla root's bundle (TestResources) in a OnceLock
and run_test_world builds the per-test World on it as before. A mod root
in a test's scratch folder is still loaded fresh each call, since the same
path can hold different content between tests. test_fixtures::vanilla()
does the same for Assets, RenderAssets, collisions and MotionResources;
lan::prediction's steps_in uses it, and motion.rs and entities.rs can
follow.

Same tests, same cases, same assertions; nothing new is #[ignore]d.
cargo test -p horizon-client-lib --lib on an 8-core laptop at 8 threads:
388 s -> 99 s wall (three interleaved runs each), 3061 s -> 770 s summed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 01:58:51 +01:00
MSedgeMC 7cbe3d49b0 port_map: the drowned's fluid push names its arm
Drowned.isPushedByFluid had no row; it is the drowned arm of
living.rs::Mob::pushed_by_fluid now.
2026-11-10 00:50:48 +00:00
MSedgeMC 83122825e5 Test the drowned that a current leaves alone
Mob::pushed_by_fluid, the predicate both push sites of
LivingEntity.updateFluidInteraction read: a drowned with its swimming flag set
answers false, the same drowned with the flag cleared answers true, the water
mobs that never take a push (a cod, a squid, a turtle, a nautilus) answer
false either way, and a zombie answers true. Without the drowned arm the
swimming one answers true too.
2026-11-10 00:50:42 +00:00
MSedgeMC c3af4fadeb A swimming drowned is not pushed by the current
Drowned.isPushedByFluid answers `!isSwimming()`: a drowned swimming after its
target holds its line in a stream, while one walking the bottom is carried
along like any other mob. Horizon answered the plain LivingEntity rule for
every drowned, so a drowned chasing a player through flowing water was swept
off its path, and the current was applied even while its own move control was
driving it.

pushed_by_fluid now answers the drowned with its swimming flag, which
Drowned.updateSwimming keeps on the mob (`extra.flag`) and the clients read
from the shared flags, and the rest of the table stays as it was.
2026-11-10 00:50:42 +00:00
MSedgeMC fbf4ddd296 port_map: the freeze immunity names its port
canFreeze had no row in either place: LivingEntity's tag-and-wearables half
and the Skeleton override are both ai.rs::can_freeze.
2026-11-10 00:47:14 +00:00
MSedgeMC a8c16eb5db Test the skeleton that powder snow leaves alone
LivingEntity.aiStep's freezing, through freeze_tick, in a world with a powder
snow block at the mob's feet: a skeleton, a stray, a bogged, a parched and a
wither skeleton gain no frozen tick (0) where a zombie gains one (1), and one
of them put at 140 frozen ticks melts to 138 with no damage where the zombie
takes its 1. Without the skeleton arm the skeleton freezes like the zombie.
2026-11-10 00:47:14 +00:00
MSedgeMC ce66fd40bd A skeleton never freezes
Skeleton.canFreeze answers false, so powder snow builds no frozen ticks on a
skeleton and never deals it the 1 a fully frozen mob takes, and a skeleton
already frozen melts out of it at two ticks a tick. The stray, the bogged, the
parched and the wither skeleton inherit the same false. Horizon only asked the
freeze_immune_entity_types tag — which lists the stray, the polar bear, the
snow golem and the wither — and the worn freeze_immune_wearables, so a plain
skeleton standing in powder snow froze like a zombie.

can_freeze now answers the five skeletons before the tag, which is where
vanilla puts the class override.
2026-11-10 00:47:13 +00:00
Evrim CicekandClaude Opus 5.5 22e92450e4 ExperienceOrb::new builds a fresh orb
The summon and remote-spawn sites build their orb with it, so the new
fall_distance field doesn't grow commands.rs and remote.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:37:25 +03:00
Evrim CicekandClaude Opus 5.5 ee1dfabaa6 An orb's first tick doesn't halve its fall in lava
Entity.isInLava is false while firstTick holds, so Entity.baseTick halves
the fall only from the second tick. Review finding on #14440; the
`orb fall distance` group gains an orb summoned inside lava.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:22:21 +03:00
Evrim CicekandClaude Opus 5.5 5e1eab0e05 Experience orbs keep their fall distance and post hit_ground on landing
Part of #14440: an orb's Entity.fallDistance builds up as it falls and is
saved and loaded (and taken by /data merge). Entity.baseTick's fluid step
resets it in water and halves it in lava; Entity.move's long-move ray
resets it past a fall-resetting block or water, and checkFallDamage adds
the drop and, landing after a fall, posts HIT_GROUND. Adds the
`orb fall distance` command-exec group.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:07:01 +03:00
voidsip 7f8e095514 Keep ordinary explosion reference tests on the default calculator 2026-11-09 18:04:34 -06:00
793565a33f Lecterns take a placed book into has_book, gate the comparator on it and post block_change
`LecternBlock.getStateForPlacement` sets HAS_BOOK from the item's
`block_entity_data.Book` when the placer may use game-master blocks, so a
lectern placed from a book-carrying item draws its book.

`LecternBlock.getAnalogOutputSignal` reads the state's HAS_BOOK before asking
`LecternBlockEntity.getRedstoneSignal`, so `/setblock ... lectern[has_book=true]`
with no book now reads 14 instead of 0, and `has_book=false` reads 0.

`LecternBlock.resetBookState` sets POWERED and HAS_BOOK in one write, then posts
`minecraft:block_change` (the player who put the book on, none when
`LecternBlockEntity.onBookItemRemove` takes it off) and updates below, so a
sculk sensor hears both.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 23:49:04 +01:00
voidsip 4bed23dc4c Leave portal placement helpers in their existing module 2026-11-09 16:29:08 -06:00
b820331574 Place a can_place_on block against a sign in adventure mode
SignBlock.useWithoutItem answers PASS when the player may not build and
the sign is not waxed and has no click action to run (SignBlock.java:139-146),
so ServerPlayerGameMode.useItemOn goes on to the item phase
(ServerPlayerGameMode.java:411-427) and ItemStack.useOn's canPlaceOn test
lets a `can_place_on` block place against the sign (ItemStack.java:358-362).
Horizon's sim's sign arm answered SUCCESS, which `use_hand` consumed before
the item was used. `sign_consumes_click` now asks the sign's block entity
the question vanilla asks it, and use_hand only ends the click when it says
a waxed sign, a click-command side or a player who may build takes it.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 23:20:28 +01:00
Umut Cevdet Koçak 18c2782391 Merge pull request #259 from sunrise-org/fix/perf-upload-public-check
Fix the capture upload E2E driver for the public CDN
2026-11-10 00:47:14 +03:00
umcekoandCodex gpt-6.1-sol eb150b0297 Make the capture upload check work through the public CDN
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 00:29:46 +03:00
Umut Cevdet Koçak 88cc456f65 Merge pull request #258 from sunrise-org/feat/perf-sharing
Move the perf viewer to the site and implement capture sharing in the CLI
2026-11-10 00:25:02 +03:00
umcekoandCodex gpt-6.1-sol b2d4f65e77 Move the perf viewer to the site and add capture uploads and share links
Keep Rust reader artifacts in the game repository, move the viewer source into sunrise-org/horizon-site, and add CLI uploads and remote capture reads. Update fork automation for the organization transfer.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 00:03:48 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 9f1fb2c77c HDR tone curve test: the scene's peak itself may come back one half-float step off
S's gamma rounds up in half float, to light just above S that the curve holds at S. Whether that comes back to the same half float depends on the GPU's rounding: on an Apple M4 Pro (Metal) it lands one step lower, so the exact check at S failed there. Every stored value below S still comes back bit-identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:45:09 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 46fe2c7f3d Merge main into #13578; graphics.md's HDR paragraph is main's plus this PR's sentences
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:28:40 +01:00
JoansitohandClaude Sonnet 5.5 f23a03f6c7 Lightning rod: update the front block's neighbours without redstone experiments
Fixes #14371

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:23:34 +01:00
voidsip fa7424ca31 Protect sulfur cube blasts while portal cooldown is positive 2026-11-09 14:15:30 -06:00
Ludovic Marie 4cea8c2ee6 Merge remote-tracking branch 'origin/main' into split/telemetry-probes 2026-11-09 21:00:24 +01:00
Ludovic Marie 0694db7dcb Merge pull request #14152 from horizon-rust/split/telemetry-scrub
🤖 Agent: scrub.rs moved unchanged to horizon-system; horizon-telemetry re-exports it, so the launcher and the ingest server keep their paths.
2026-11-09 20:00:36 +01:00
Horizon agentandClaude Opus 5.5 b21058f361 deflect tests: the turned wind charge is past its no-deflect ticks
The two redirect tests built a fresh wind charge, which this PR now (as
WindCharge.deflect) refuses for its first five ticks; their fixture clears
no_deflect_ticks, and a new test checks a fresh one keeps its course and owner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:51:31 +01:00
Horizon agent 58f80819d9 Merge remote-tracking branch 'rv3n/14131' into rv3-comp 2026-11-09 19:21:19 +01:00
MSedgeMC f606efbf36 Merge remote-tracking branch 'upstream/main' into fix/component-can_eat 2026-11-09 18:03:15 +00:00
MSedgeMC e8397cd8eb Merge remote-tracking branch 'upstream/main' into fix/component-can_eat 2026-11-09 17:55:10 +00:00
Evrim Cicek dd2d560901 Merge branch 'refactor/minecarts-split' into fix/tnt-minecart-explosion-power 2026-11-09 20:51:09 +03:00
Horizon agentandClaude Opus 5.5 a03902b64d Use the stack's whole consumable component, finish the merged series
The eight component branches (#14131-#14138) each edited the same spots and
three of them left an unclosed block in finish_consuming. Merged here:

- item_sync::stack_consumable builds the component a use reads once: the
  stack's own when it has one, with Consumable.CODEC's defaults for the
  fields it leaves out (1.6 s, eat, entity.generic.eat, particles), else the
  item's. finish_consuming, the per-tick particles and sounds and the
  instant-consume check use it.
- Consumable.canConsume's food check asks the stack's food component.
- A direct sound reads SoundEvent.DIRECT_CODEC's `sound_id`, and every
  ItemUseAnimation name maps (trident, crossbow and spyglass were eat).
- The glider test's doc comment is back on its test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:42:09 +01:00
Horizon agent 30637c0820 Merge remote-tracking branch 'rv3/14136' into comp
# Conflicts:
#	crates/horizon-client/src/game/consume.rs
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:24 +01:00
Horizon agent b63d7491d2 Merge remote-tracking branch 'rv3/14135' into comp
# Conflicts:
#	crates/horizon-client/src/game/consume.rs
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:21 +01:00
Horizon agent e64e477126 Merge remote-tracking branch 'rv3/14133' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:14 +01:00
Horizon agent dc94adc898 Merge remote-tracking branch 'rv3/14134' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:05 +01:00
Horizon agent f914bcd4a9 Merge remote-tracking branch 'rv3/14138' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:36:53 +01:00
Horizon agent 0a6d0dd257 Merge remote-tracking branch 'rv3/14137' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:36:51 +01:00
Horizon agent c719e4ac0e Merge remote-tracking branch 'rv3/14132' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:36:50 +01:00
Nikita Maximilian Grimm 7049d0e811 Merge remote-tracking branch 'origin/main' into hdr-one-peak
# Conflicts:
#	docs/graphics.md
2026-11-09 18:34:01 +01:00
rdbtCVSandClaude Sonnet 5.5 78e094e614 Merge main into fix/tnt-minecart-explosion-power
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:29:33 +00:00
rdbtCVSandClaude Sonnet 5.5 f81f6de46b cargo fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:23:28 +00:00
MSedgeMC ff42a99d98 port_map: Dispensable comes from the stack 2026-11-09 17:20:59 +00:00
MSedgeMC aced4bb6b9 test: Dispensable comes from the stack 2026-11-09 17:20:59 +00:00
MSedgeMC 97a323b499 fix: Dispensable comes from the stack 2026-11-09 17:20:59 +00:00
MSedgeMC 227333eacd port_map: Use remainder comes from the stack 2026-11-09 17:20:53 +00:00
MSedgeMC 9367ed7710 test: Use remainder comes from the stack 2026-11-09 17:20:53 +00:00
MSedgeMC e15589bbbb fix: Use remainder comes from the stack 2026-11-09 17:20:53 +00:00
MSedgeMC 84fe5b4f12 port_map: Consumable particles come from the stack 2026-11-09 17:20:47 +00:00
MSedgeMC 7ee566f6b7 test: Consumable particles come from the stack 2026-11-09 17:20:47 +00:00
MSedgeMC 3774ca6edd fix: Consumable particles come from the stack 2026-11-09 17:20:47 +00:00
MSedgeMC c73321876c port_map: Consumable sound comes from the stack 2026-11-09 17:20:41 +00:00
MSedgeMC 19d083fa26 test: Consumable sound comes from the stack 2026-11-09 17:20:41 +00:00
MSedgeMC 8d35e7fc0a fix: Consumable sound comes from the stack 2026-11-09 17:20:41 +00:00
MSedgeMC 46f0546de6 port_map: Consumable animation comes from the stack 2026-11-09 17:20:13 +00:00
MSedgeMC 7c173c30b1 test: Consumable animation comes from the stack 2026-11-09 17:20:13 +00:00
MSedgeMC 715a969071 fix: Consumable animation comes from the stack 2026-11-09 17:20:13 +00:00
MSedgeMC 5097c53509 port_map: Consumable seconds come from the stack 2026-11-09 17:20:08 +00:00
MSedgeMC a89477a4b9 test: Consumable seconds come from the stack 2026-11-09 17:20:08 +00:00
MSedgeMC 430b6dc4c5 fix: Consumable seconds come from the stack 2026-11-09 17:20:07 +00:00
MSedgeMC e591b36a8d port_map: Food can_always_eat comes from the stack 2026-11-09 17:20:01 +00:00
MSedgeMC 701eaa6f9c test: Food can_always_eat comes from the stack 2026-11-09 17:20:01 +00:00
MSedgeMC 018a51c27f fix: Food can_always_eat comes from the stack 2026-11-09 17:20:01 +00:00
MSedgeMC 4db980e5de port_map: Food nutrition and saturation come from the stack 2026-11-09 17:19:53 +00:00
MSedgeMC d40ab7df1a test: Food nutrition and saturation come from the stack 2026-11-09 17:19:53 +00:00
MSedgeMC 54578041bc fix: Food nutrition and saturation come from the stack 2026-11-09 17:19:52 +00:00
Horizon agentandClaude Opus 5.5 bdec76846e Merge main: keep main's blocking_item_max_damage
main's 3b34446f84 added the same helper with the fuller ItemStack.isDamageableItem rule (an
explicit DAMAGE component on a non-damageable prototype); this branch keeps that one and its own
ItemDamageFunction.apply.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:08:04 +01:00
Horizon agentandClaude Opus 5.5 93ebb80b92 Carry blocking_item_used through the staged equipment hurt
main's health/equipment.rs (3cdf31772f) runs a hurt in stages and copies the block results
between them; it now copies blocking_item_used too, so a staged hit reports the shield use as
the one-shot hurt does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:58:51 +01:00
Evrim Cicek 345041401e Merge branch 'refactor/minecarts-split' into fix/tnt-minecart-explosion-power
# Conflicts:
#	crates/horizon-client/src/game/minecarts.rs
2026-11-09 19:36:49 +03:00
Nikita Maximilian Grimm 18c03d23d8 Merge remote-tracking branch 'fork/hdr-one-peak' into hdr-one-peak 2026-11-09 17:14:50 +01:00
Horizon agent dceee849cb Merge remote-tracking branch 'origin/main' into r-13578
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
2026-11-09 17:08:54 +01:00
Horizon agentandClaude Opus 5.5 10b318cd6b Merge main into split-renderer
main moved more of game.rs out (#13877, #13878, #13880...): the conflict is only the
neighbours of the moved functions; game.rs takes main and drops the four functions
this branch moves to game/render_settings.rs, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:08:14 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 79cfecbdb9 HDR float frame: main's encoding-switch test tunes a tier's gain, as one peak has no share
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:29 +01:00
MrCloudy2andClaude Opus 5.5 298d3f12e9 A player's wind charge can't be turned in its first five ticks
WindCharge.deflect refuses while noDeflectTicks (5, one less after each tick,
not saved) lasts: a projectile hitting a young wind charge doesn't redirect it
(Projectile.onRedirectProjectile), and a player's blow goes on as an attack
that hurts nothing. A breeze's wind charge has no such wait.
Harness: tools/test_wind_charge_no_deflect_ticks_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:02:39 +01:00
Horizon agentandClaude Opus 5.5 73a745339e Carry an arrow's piercing in a combat flag so creatures.rs doesn't grow
creatures.rs is over 3000 lines (docs/layers.md#file-size). The arrow's hit on the player goes
through shield.rs's arrow_hits_player, which sets combat.piercing_hit; hurt_player_from takes it
as it takes rocket_hit and hands it to player_blocking. Same behaviour as hurt_player_with_pierce.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:35:03 +01:00
Nikita Maximilian Grimm 8e46f3971d Merge remote-tracking branch 'origin/main' into hdr-one-peak
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
2026-11-09 16:30:19 +01:00
Horizon agentandClaude Opus 5.5 ebe1a5d52b Wardens: silent ones make no sound, forced riders get off to dig; withers' idle volleys wait for Normal
- A Silent:1b warden played its emerge, dig, roar, sniff, tendril, attack and sonic boom sounds:
  both warden sound helpers now check `silent`, as Entity.playSound does (#8835).
- ForceUnmount was stubbed out: a warden put on a vehicle with /ride or a summon's Passengers
  never got off, so it could never dig. It now runs vanilla's unRide (passengers ejected, the warden
  set down where its vehicle lets it off) and stops in the tick it starts (#8833).
- WitherBoss.customServerAiStep counts the side heads' idle updates only on Normal and Hard: on Easy
  a wither fired its random blue skulls all the same (#8840).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:17:45 +01:00
Horizon agent 61a0362859 Merge remote-tracking branch 'origin/main' into fix-11507 2026-11-09 16:14:13 +01:00
MSedgeMC 166073d4bc Re-run checks: the previous run failed in an unrelated fixture / harness 2026-11-09 14:52:23 +00:00
Horizon agentandClaude Opus 5.5 92742bef1b horizon-system: the scrubber leaves horizon-telemetry (layer plan M5)
scrub.rs moves unchanged from horizon-telemetry to horizon-system (engine);
horizon-telemetry re-exports it at horizon_telemetry::scrub for the launcher.
The repro export (agent control) reads it there, so gameplay no longer uses
horizon-telemetry for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:36:00 +01:00
Horizon agentandClaude Opus 5.5 bb29b70b32 Cargo.lock: horizon-system for horizon-client and horizon-optifine
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:23:16 +01:00
Horizon agentandClaude Opus 5.5 3ad8a18a7b horizon-system: the process probes leave horizon-bench (layer plan M5)
rss_bytes, cpu_seconds and load_average move to horizon-system (engine);
horizon-bench re-exports them for the launcher. horizon-system's cpu() and
ram_bytes() become public and replace horizon-bench's identical copies.
The dedicated server's performance report and cem_expr_bench read them
there, so gameplay no longer uses horizon-bench for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:14:20 +01:00
Evrim Cicek 16237bb2b7 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 16:47:50 +03:00
Skr1zzzzandClaude Opus 5.5 71e2401edc Move building and adopting the renderer out of game.rs into game/render_settings.rs
Part of #11857. new_renderer, adopt_renderer, forget_atlas_slots and all_sections_changed join game/render_settings.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:29:00 +03:00
RINNECODERandClaude Opus 5.5 6c5f62af35 Spectator camera: a server's camera on an armor stand holds the view
ClientPacketListener.handleSetCamera takes any entity the level holds, but
remote_set_camera only looked among the mobs, so a camera on an armor stand
(the usual target of a server's cutscene or menu scene) was dropped: the
view stayed on the player, who kept moving and turning. The stand now holds
the camera like a mob does: the view looks out of its eyes along its yaw,
the player sends no moves, the crosshair skips the stand and first person
does not draw it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:25:24 -06:00
Tran Anh Van d029364bd4 Use Level.randValue for thunder strike columns and trap horse RNG for scatter
ServerLevel.tickThunder picks its strike column via Level.getBlockRandomPos(minX, 0, minZ, 15), which advances Level.randValue (Ticks::block_random_pos) and extracts bits 2..=5 for X and 10..=13 for Z instead of drawing from Level.random.

SkeletonTrapGoal.tick draws each additional trap horse's horizontal push (two triangle(0.0, 1.1485) draws) from the original trap horse's own random after creating and mounting its skeleton rider, rather than drawing from Level.random before rider creation.
2026-11-09 18:19:33 +07:00
AppaandCodex aac293db9b Keep the boat identity across loot advancement rewards
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 00:25:43 -08:00
AppaandCodex a328e9c5db Keep chest boat loot handling in the boat module
Respect the repository size limit on commands and LAN action modules.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 00:24:50 -08:00
AppaandCodex 4272268cbc Guard chest boat loot fixtures with a fresh hopper cooldown
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 00:21:18 -08:00
AppaandCodex 3ef0ebf52f Fix chest boat loot unpacking for hopper access and destruction
Roll pending loot before automation accesses chest-boat slots or destroying
removals spill them. Keep occupied slots, synchronize open-menu copies, and
award container loot only for the player opening a pending table.

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

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

Horizon kept the cooldown in the goat's `extra.memories` (the shared
`frog::jump_cooldown` the long jump goal asks) and wrote no `Brain`, so a goat
that had just long jumped unloaded with its chunk and came back ready to jump
again. The save now writes `minecraft:long_jump_cooling_down` as the integer
memory it is and the load puts it back in the goat's memories.
2026-11-09 05:18:42 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 dbcbe4fe87 HDR one peak docs: the identity holds where Peak reaches 1000/203 Paper White
The design note and graphics.md said a display at 1000 nits or more shows
the scene as written. That holds at a paper white of 203; in general the
curve is the identity where Peak Brightness is at least 1000/203 times
Paper White, so say that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:05:05 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 8423eabc57 HDR float frame: pin one peak's identity at the settings' extremes
Paper White now runs from 10 to 480 nits and Peak up to 10 000 (#12881): the tone curve
test checks that the world shows exactly as written wherever the display's peak reaches
the scene's, at 480/10 000 and 10/200 nits too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:30:47 +01:00
Nikita Maximilian Grimm fd46aa03c5 Merge remote-tracking branch 'origin/main' into hdr-one-peak 2026-11-09 04:29:46 +01:00
Nikita Maximilian Grimm 87f2c1314e Merge branch 'hdr-step5-gates' into hdr-one-peak 2026-11-09 04:12:22 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4c2a2c42d2 HDR float frame: one peak, the world mastered for 1000 nits
On the float frame the world's light no longer depends on the display. Light sources
multiply their brightest pixels by fixed gains in multiples of paper white (1.6, 2.4,
2.8 and 4 from faint glowers to the sun), and HDR lighting's strongest light by 3. The
tone curve alone fits that scene to the display: light above the scene's peak (1000/203
of paper white, a 1000-nit display at BT.2408's 203-nit white) is held at it, keeping
its hue, then BT.2390's EETF maps the scene's peak onto the display's.

For players: on a 1000-nit display the world shows exactly as written; a 1500-nit or
brighter one shows the same picture, the sun included, instead of a brighter sun and
lava; a dimmer one keeps its midtones and rolls the highlights off below its peak.
Before, the gains scaled with the display's headroom and the curve then compressed
that light again from a peak it derived from the same headroom, so the look changed
with the Peak setting.

Light Horizon doesn't lift (HDR Lighting off, the title, a 0.2 pack's own) has no
known peak, so there the curve keeps the source that follows the display, as the 0.2
contract describes. HDR off, HDR Lighting off, the HUD, the black lift, the SDR
screenshot's shoulder and the 16-bit or 8-bit compatibility frame (HDR's default until
step 5, which changes the look once) are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:26:04 +01:00
umcekoandClaude Opus 5.5 3d11f0b6c6 Merge upstream main into perf-platform
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:15:53 +03:00
umcekoandClaude Opus 5.5 db571535c2 Merge perf-platform web work into the upstream sync
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:07:08 +03:00
Umut Cevdet Koçak 7d9d35c729 Merge pull request #257 from UMCEKO/plat/web-finish
Perf web: call tree, flame graph, info points, copy id, bench and compare in every view
2026-11-08 21:03:37 +03:00
umcekoandClaude Opus 5.5 6354608451 Merge perf-platform into the finished web viewer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:47:45 +03:00
umcekoandClaude Sonnet 5.5 1fc4ccd439 Perf web: UI review fixes (numbers from the wasm, no file name, no duplicated views, units and labels)
- Summary tiles read the wasm's own series stats (new optional `stats` on series rows, asked for with filters.stats); the old browser-side statistics disagreed with the CLI summary (MSPT 8.9 against 10.7, CPU 8.47 against 6.6).
- Queries no longer carry the real file name, so the Query tab never shows it.
- Slow ticks: every kept tick opens into its full span tree; the meaningless min/median/p95 of the slowest 16, the histogram and the sparkline are gone. The summary no longer repeats the worst ticks and lists only the top steps of the tick.
- Metadata: tabs that only repeated Memory, Network and World are gone, rows without data are left out, empty tabs are hidden, enum values read as words, and the mod crate column reads the `crate` field the wasm sends.
- Units on every number, frame figures say frames, warnings are worded from their codes, compare rows have readable names and drop an all-"not enough data" verdict column, network packet columns hide when nothing was counted, TPS windows longer than the capture are not shown.
- Keyboard focus is no longer hidden under the sticky tab bar, and rows that open show a chevron.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 20:42:38 +03:00
umcekoandClaude Sonnet 5.5 ce9fdee6d4 Perf web: offer only the exports the capture can fill, group by thread ignores the picked thread, one verdict colour scheme, strings through t()
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 20:33:14 +03:00
umcekoandClaude Sonnet 5.5 8d1b4c8cca Perf web: every row view shows the change and verdict against a second capture
The diff gains opt-in function and call-path rows (--calls, calls in hp_diff), matched by stable id. One comparison state feeds Summary, Spans, Functions and the Call tree; rows only the second capture has are listed apart.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 20:22:47 +03:00
umcekoandClaude Sonnet 5.5 fee41659bd Perf web: hide the sample picker when there is one choice, drop an unsafe cast in export
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 20:04:27 +03:00
Evrim Cicek 4c246e24f2 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	crates/horizon-client/src/game/minecarts.rs
2026-11-08 20:02:17 +03:00
umcekoandClaude Sonnet 5.5 b0154fc77d Merge plat/web-bench
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:57:30 +03:00
umceko 2dbff9aefe Merge plat/web-points 2026-11-08 19:57:16 +03:00
umceko dee8aecd7a Merge plat/web-flame 2026-11-08 19:57:15 +03:00
umceko 3a0a7390bf Merge plat/web-tree 2026-11-08 19:57:13 +03:00
umcekoandClaude Sonnet 5.5 e434d37311 Perf web: info points on well-known spans and functions, and a copy-id control on every row
Spans and functions the profiler meets most (tick_blocks, tick_lan, streaming,
tick_mobs, lan_chunks, random_ticks, allocator and memory calls...) get a short
explanation keyed by stable id in the translation file, shown as an accessible
hover and focus tooltip. Every row of the Summary, Spans, Functions, Call tree
and Slow ticks views copies its stable id from a control that appears on row
hover or keyboard focus, so rows stay clean at rest.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:56:27 +03:00
umcekoandClaude Sonnet 5.5 544600c10a Perf web: flame graph view for the call tree
A List / Flame graph toggle in the Call tree tab draws the current side, thread,
selection, grouping, merge mode and focus as a canvas flame graph: widths are time,
colours are the tree's crate groups, hover shows name, time and share, a click zooms
in with a trail back, and arrow keys, Enter and Escape do the same from the keyboard.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:49:44 +03:00
umcekoandClaude Sonnet 5.5 75e0f7387b Perf web: call tree with colour by crate, group by, selections, detail panel and search
The tree rows carry their crate, and a request can ask for each row's own time as a percent.
The tree tab is split into focused components; chains of single-child functions share a line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:41:38 +03:00
Umut Cevdet Koçak 285846c5ee Merge pull request #256 from UMCEKO/perf/w1-web-ui
Web UI for horizon-perf
2026-11-08 19:33:32 +03:00
umcekoandClaude Sonnet 5.5 9df9c5bde2 Perf web: Bench tab with frame times per phase, repeat statistics and A/B verdicts
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:29:59 +03:00
umcekoandClaude Opus 5.5 28561fd634 Launcher: a native mod's generated main uses the game's current allocator
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:19:52 +03:00
umcekoandClaude Opus 5.5 f3613a539d Perf web: landing page, tabs only where there is data, and no duplicated views
The no-capture screen is now a landing page: what the viewer reads and how to get a capture. A tab appears only when its query answers with rows for the side on show, so GPU is gone from a server and empty tabs are gone everywhere; Metadata's sub-tabs follow the same rule.

Health, and Summary's network and dimension cards, repeated other views and are gone. So are the file name and size, the report box, New profile, the sort direction and row limit, the placeholder flame graph, and id chips.

Fixes: slow ticks say they cover only the slowest kept, in time order; query errors show their real message; Compare is a table of real rows for the side on show; table headers line up with their columns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:18:46 +03:00
umcekoandClaude Opus 5.5 04a9f56511 Perf web: review fixes, design-system polish and no upload path
Removes sharing entirely: the Share button, share links, the upload API, its S3 storage and the CLI's `share` and `upload`. A capture stays on the machine.

Fixes:
- ureq and the snapshot files are gone, so the wasm builds and the tree is 7,600 lines lighter
- merge mode hides wrappers and merges a function across its callers
- `group --by mod` backs the mods view, charging a sample to its innermost mod frame
- a failed compare shows its error, and the compared capture is closed when replaced
- Hot shows milliseconds rather than calling them percent; Tree bars share one scale
- tick details line up; wasm replies are typed structs; launcher lock is current

Polish follows docs/design-system.md: Geist, list rows with aligned number columns, one accent, one sticky tab row, a real opening screen, focus states and reduced motion. Panels share one query hook, and dead icons, strings and exports are removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:00:20 +03:00
Claude 5ea2d3c0fb Perf web UI: host-machine tab, new profile button, drop filename/top drop title
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-11-08 16:20:42 +01:00
Claude be3db9da27 Perf platform web UI: complete UI1-UI7 client and U1/U2 upload API
Fan out implementation of the remaining perf-platform web UI pieces and upload API:
health/metadata, call tree/group-by/select/detail/search, flat/mods/merge/info,
flame graph and ticks tab, compare/GPU/export/copy/share/open links, and
upload route handlers, S3 storage, validation, share pin, sweep, rate limit.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-11-08 16:12:05 +01:00
Claude 0d6d1f1f66 Perf platform wasm + web UI updates
Co-Authored-By: Claude Code <[email protected]>
2026-11-08 15:43:10 +01:00
Claude bc4559b024 Improve web/perf UI: tabs, paste, panels, theme, typography
Co-Authored-By: Claude Code <[email protected]>
2026-11-08 15:42:48 +01:00
umcekoandClaude Opus 5.5 993525d333 Merge upstream main into perf-platform
Upstream moved Game::shut_down and idle to game/window_handler.rs and loading_frame and start_game to game/loading_window.rs; finish_profile and the benchmark set and profile fields move with them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:14:49 +03:00
Umut Cevdet Koçak 3bd70dbc06 Merge pull request #255 from UMCEKO/plat/L1
Perf platform L1: retire the slow-tick span log lines; docs point to captures
2026-11-08 07:59:40 +03:00
umcekoandClaude Sonnet 5.5 0b4c261d0c Perf platform L1: keep per-event attribution as a log line, pacing_bench drops pre-capture frames
The slow ring no longer shows up as span lists in the log, but whose packets or events took a tick
stay in the server owner's log as a separate line. pacing_bench keeps only frames over the threshold
recorded during the capture.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 07:53:56 +03:00
umcekoandClaude Sonnet 5.5 caac772100 Perf platform L1: slow-tick and slow-frame span lists leave the log; pacing_bench reads captures; docs point to captures
The slow ring and the captures keep the steps of slow ticks and frames; the notes channel that only fed the log line (event timings, packet notes, spawn counters) goes, and the spawn counters move to the counter registry.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 07:36:33 +03:00
Umut Cevdet Koçak 1d7e15c969 Merge pull request #254 from UMCEKO/plat/R1b
Perf platform R1b: rate-limited transfer of the server's capture into the client's file
2026-11-08 07:12:22 +03:00
umcekoandClaude Sonnet 5.5 68685775d9 Perf platform R1b: rate-limited transfer of the server's capture into the client's file
The server sends its finished file in 32 KiB chunks over horizon:perf, at most 256 KiB/s and not while the guest has a backlog. The client merges it field by field into its own stream (PartMerger, no decode), or saves it unchanged for profile server alone; a cut transfer ends the file with SERVER_PART_INCOMPLETE.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 07:07:33 +03:00
Umut Cevdet Koçak e87894a81d Merge pull request #253 from UMCEKO/plat/R1a
Perf platform R1a: profile a remote Horizon server from a client (channel, level 4, server copy)
2026-11-08 06:42:23 +03:00
umcekoandClaude Sonnet 5.5 35d8ee793f Perf platform R1a: profile a remote Horizon server from a client over horizon:perf
A client asks a Horizon server (LAN host, dedicated server) to capture its side: operator level 4, one capture at a time, the server logs who profiled and keeps its copy in reports/. profile server start|stop work on a joined server; a bare profile also asks and a refusal is a warning in the client file.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 06:35:26 +03:00
Umut Cevdet Koçak 78caa5b352 Merge pull request #252 from UMCEKO/plat/C2
Perf platform C2: single player captures both sides; horizon-ctl profile server
2026-11-08 06:05:14 +03:00
umcekoandClaude Sonnet 5.5 3597945405 Perf platform C2: the gpu e2e checks read the client side's options
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 06:05:00 +03:00
umcekoandClaude Sonnet 5.5 87623d9714 Perf platform C2: fit the server capture options and tests to the gpu and timeline pieces after the rebase
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 06:00:17 +03:00
umcekoandClaude Sonnet 5.5 71a09a89c0 Perf platform C2: every mark reply is on the game clock; start lists the sides asked for
A server-only mark replied capture-relative and a joined one on the game clock; both now use the game clock.
The e2e checks the server mark against the client mark's game-clock t.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:54:54 +03:00
umcekoandClaude Sonnet 5.5 18c344ff3c Fix profile mark and status routing for joined and finished server captures
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:54:54 +03:00
umcekoandClaude Sonnet 5.5 1b28cc6feb Perf platform C2: e2e reads the client side by name, docs say what stop and mark do per side
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:54:41 +03:00
umcekoandClaude Sonnet 5.5 af837708aa Perf platform C2: single player captures both sides; profile server
The integrated server thread writes its side (kind INTEGRATED) into the window's capture file, on the same clock, and a bare profile means both. profile server writes the server thread's own file.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:54:41 +03:00
Umut Cevdet Koçak ba04e6991d Merge pull request #251 from UMCEKO/plat/E1
Perf platform E1: horizon-perf export to pprof, folded and Perfetto
2026-11-08 05:47:20 +03:00
umcekoandClaude Sonnet 5.5 56cf9c3301 Perf platform E1: typed pprof labels and sample states, finer pprof period, two-side export tests
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:46:48 +03:00
umcekoandClaude Sonnet 5.5 05925da3cd Perf platform E1: horizon-perf export to pprof, folded stacks and a Perfetto trace
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:46:48 +03:00
Umut Cevdet Koçak a072c2275f Merge pull request #250 from UMCEKO/plat/B1
Perf platform B1: benchmark reports in the capture, horizon-perf attach, server_bench --profile
2026-11-08 05:42:43 +03:00
umcekoandClaude Sonnet 5.5 4e27118c3b Perf platform B1: fit the bench op and stop_with to the gpu piece after the rebase
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:41:55 +03:00
umcekoandClaude Sonnet 5.5 415797f30a perf: keep local paths, label and bot errors out of captures
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:37:05 +03:00
umcekoandClaude Sonnet 5.5 c283f0ce85 Perf platform B1: benchmark reports in the capture, horizon-perf attach, --benchmark --profile, server_bench --profile
Adds BenchResult (field 21) and Attachment (field 22) to the capture contract. horizon-perf reads bare benchmarks/*.json as one-sided captures, answers query bench and prints bench lines in the summary, and attach appends an attachment on the wire bytes so unknown fields survive. The game's --benchmark --profile records a capture and embeds the report through profile stop --bench; server_bench.py --profile records Horizon's measure window with horizon-report and attaches the run record.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:37:03 +03:00
Umut Cevdet Koçak 5bc42b54cf Merge pull request #249 from UMCEKO/plat/T1
Perf platform T1: timeline in the capture; HORIZON_TRACE and trace write .hzperf
2026-11-08 05:35:15 +03:00
umcekoandClaude Sonnet 5.5 980844073d Merge fix: drop the needless options update after rebasing T1 onto G1
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:35:00 +03:00
umcekoandClaude Sonnet 5.5 780a965ae2 Fix T1 review findings: trace seconds range, TLS-safe span, docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:29:27 +03:00
umcekoandClaude Sonnet 5.5 7b7afe9a8f Perf platform T1: timeline chunks in the capture; HORIZON_TRACE, trace and horizon-trace write .hzperf
The Chrome-trace writer in spans.rs is replaced by per-thread buffers drained once a second into
TimelineChunk (field 15), capped at 60 s or 256 MiB with timeline_stopped_t and a warning.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:29:18 +03:00
Umut Cevdet Koçak dc191e58df Merge pull request #248 from UMCEKO/plat/G1
Perf platform G1: GPU pass timings and counters in the capture
2026-11-08 05:09:48 +03:00
umcekoandClaude Sonnet 5.5 9f08382773 Perf platform G1: GPU pass timings and counters in the capture
Adds the gpu field (GpuPasses per timer) to the contract, written from the
renderer's PassTiming readings at stop; profile start --gpu[=detail], the
HORIZON_SHADER_PACK_TIMING and HORIZON_GPU_PASS_DETAIL aliases, horizon-perf
query gpu with fragments_per_pixel, a summary line and a meta count.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:01:54 +03:00
Umut Cevdet Koçak b787473097 Merge pull request #247 from UMCEKO/plat/C1
Perf platform C1: client captures: horizon-ctl profile client, F3+O, HORIZON_PROFILE
2026-11-08 04:31:55 +03:00
umcekoandClaude Sonnet 5.5 89feab9f8f Perf platform C1: adapt the client capture to the span, world and net recorders
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 04:31:42 +03:00
umcekoandClaude Sonnet 5.5 dd5fd7e80d Perf platform C1: fix review findings
One-line docstring and debug-key comment, F3+O feedback decided by who pressed stop, hardware described off the render thread, and the gauge probe spawned on the first frame with this second's counts.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 04:25:38 +03:00
umcekoandClaude Sonnet 5.5 e715b5b743 Perf platform C1: client captures with horizon-ctl profile, F3+O and HORIZON_PROFILE
The window's frames feed an always-on client health history and slow ring.
profile start|stop|status|mark (and F3+O, HORIZON_PROFILE=DIR) write a .hzperf
into reports/ in the game home, with the render thread's samples, retention and
the translated saved message. The capture session is shared with the dedicated
server's through a Role; the gauge probes run off the frame thread.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 04:25:38 +03:00
Umut Cevdet Koçak 4dbc406616 Merge pull request #246 from UMCEKO/plat/S1b
Perf platform S1b: spans, workers, net and world move from report.rs into the capture
2026-11-08 03:56:40 +03:00
umcekoandClaude Sonnet 5.5 c5a342f764 Fix S1b review: rustfmt the rule asserts, check only the report's span bit in the drop test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:52:19 +03:00
umcekoandClaude Sonnet 5.5 6efe76a432 Fix S1b review: spans and job stats off when a recording drops, rule and option tests, far ring 0 when off
A Recording now turns spans and job stats off when it is dropped, so a capture that failed
to start or a shutdown no longer leaves them on. A leaver's unread bytes are lost (noted on
TrafficRecorder). The e2e script's docstring is one line and its build command is in
docs/perf-platform.md, which also says the privacy checks' split and the dropped between-tick spans.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:52:18 +03:00
umcekoandClaude Sonnet 5.5 958af813dd Perf platform S1b: spans, workers, net and world move from report.rs into the capture
The dedicated server's capture now carries span_blocks (5 s blocks with parents), the spans
summary (totals, histogram, 16 worst ticks with their trees), workers, per-player net bytes by
join order and the world at start and stop with sampled mob costs. perf-*.json and .txt are no
longer written. capture.proto gains the game_rules and horizon_options allowlists and the mob
timing overhead fields. Spans recorded between ticks no longer count in a tick.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:52:18 +03:00
Umut Cevdet Koçak da474503d8 Merge pull request #245 from UMCEKO/plat/M1
Perf platform M1: horizon-perf mcp server
2026-11-08 03:38:50 +03:00
umcekoandClaude Sonnet 5.5 50386e55df horizon-perf: mcp server with perf_summary, perf_query, perf_diff, perf_list and perf_follow (M1)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:36:48 +03:00
Umut Cevdet Koçak fc1972ffe7 Merge pull request #244 from UMCEKO/plat/Q2
Perf platform Q2: sample queries hot, tree, callers, callees, split, group, frames
2026-11-08 03:28:49 +03:00
umcekoandClaude Sonnet 5.5 05b2a7afee perf Q2: split t:<thread> uses that thread's units; frames warns on mixed threads; real-writer smoke test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:24:19 +03:00
umcekoandClaude Sonnet 5.5 4e71e99f98 Perf platform Q2: sample queries hot, tree, callers, callees, split, group, frames; hot functions in the summary
Reads the samples section: functions merged by normalized name, ids over the
normalized name and call path, filters (thread, select, slow, where, window),
several captures for frames, meta counts samples, summary gains hot functions
and the entity/block/chunk splits.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:15:17 +03:00
Umut Cevdet Koçak 8fc4c27855 Merge pull request #243 from UMCEKO/plat/S2
Perf platform S2: sampler call trees in the capture; horizon-report samples by default
2026-11-08 02:58:17 +03:00
Evrim Cicek 3ff75a8ecb Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power 2026-11-08 02:38:26 +03:00
umcekoandClaude Sonnet 5.5 35ddec5e9e Fix S2 review findings: one sample node per (parent, function), one-line doc, sampler share test, design doc notes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 02:30:00 +03:00
umcekoandClaude Sonnet 5.5 9102edfb56 Perf platform S2: sampler call trees in the capture; horizon-report samples by default
The samples field (SampleProfile) is built from horizon_threads::Profile in
horizon-client-render: a deduplicated function table, a call-node chain per
stack, a context table and weights per thread, state, slow flag and time block.
The sampler's aggregate gains time blocks. horizon-report samples the tick
thread on Linux unless --no-samples, and finishes the file off the tick thread.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 02:04:12 +03:00
Umut Cevdet Koçak be4be0ce6b Merge pull request #242 from UMCEKO/plat/S1a
Perf platform S1a: dedicated servers: horizon-report writes .hzperf
2026-11-08 01:00:06 +03:00
umcekoandClaude Sonnet 5.5 d7b9e267e0 Fix S1a review: PERF_UI_URL points at /perf, note report output in hosting docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 00:53:36 +03:00
umcekoandClaude Sonnet 5.5 769cdcd232 Fix S1a review findings: one-line comments, unknown slow-tick cpu is 0, empty capture is incomplete, report checks completion only after the window, record files exception in the design doc
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 00:53:35 +03:00
umcekoandClaude Sonnet 5.5 3d9ef21f68 Perf platform S1a: horizon-report writes a .hzperf on the dedicated server
The always-on server health history and slow ring feed a capture file
(Start, history, slow_history, health, counters, slow, Stop) in reports/,
with retention of 30 files or 1 GiB that never touches renamed files.
horizon-report accepts --no-samples and --profile; horizon-server report
prints the saved path and the web UI address.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 00:53:35 +03:00
Umut Cevdet Koçak 6634e4ba98 Merge pull request #241 from UMCEKO/plat/typed
horizon-perf: every request, reply and vocabulary is typed
2026-11-08 00:33:32 +03:00
umceko ad01e692c9 horizon-perf: every request, reply and vocabulary is typed
Replies are serde structs (one Envelope<R>, a row struct per op, typed meta, watch events, diff and bench diff) and
closed vocabularies are enums (Op, Select, ContextKey, GroupBy, Unit, Verdict, HealthField, Flag, the proto name enums).
The CLI parses strings into them at the edge; no serde_json::Value, json! or string-keyed match is left in the library.

The JSON is the same as before as parsed JSON (tests/golden holds 49 cases captured from the old code). Intentional
differences, each visible in the golden diff:
- --where dimension=X is now the key dim, so dim and dimension merge in selection.where
- follow --json spells a stop reason window_cap (it said "window cap"), like query meta
- an unknown --by is a usage error at the edge instead of a world-only query error
- JSON object keys now come out in declaration order, not alphabetical
2026-11-08 00:32:47 +03:00
umceko 1547313299 horizon-perf: the golden follow case includes a window-cap stop 2026-11-08 00:28:45 +03:00
umceko 9874954fd0 horizon-perf: golden outputs of every query, diff and follow --json case, from before the typing refactor 2026-11-08 00:18:35 +03:00
Umut Cevdet Koçak 2e68167379 Merge pull request #240 from UMCEKO/plat/D1
Perf platform D1: horizon-perf diff with block statistics, and summary --baseline
2026-11-07 23:57:34 +03:00
umcekoandClaude Sonnet 5.5 14f833917a horizon-perf: diff with two-sample block statistics, drift warning, summary --baseline, benchmark report ab (D1)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:54:36 +03:00
Umut Cevdet Koçak 9ca66bdb02 Merge pull request #239 from UMCEKO/plat/Q1
Perf platform Q1: horizon-perf queries without samples, stable ids and filters
2026-11-07 23:44:15 +03:00
umcekoandClaude Sonnet 5.5 0da566c2ed horizon-perf: summary sections, filters and tests for the span queries (Q1)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:43:16 +03:00
umcekoandClaude Sonnet 5.5 8883d77504 horizon-perf: span, tick, series, world, net, meta, memory, mark and warning queries with stable ids (Q1)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:43:16 +03:00
Umut Cevdet Koçak 838d140cbc Merge pull request #238 from UMCEKO/plat/F2
Perf platform F2: health recorder, slow ring and counter registry
2026-11-07 23:37:30 +03:00
umcekoandClaude Sonnet 5.5 d2825d34f8 Perf platform F2: health recorder tiers, slow ring and counter registry
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:35:06 +03:00
Umut Cevdet Koçak e6f80a4ee4 Merge pull request #237 from UMCEKO/plat/D
Perf platform D: alloc-count, per-thread allocation counts in the frame stats
2026-11-07 23:30:32 +03:00
umcekoandClaude Sonnet 5.5 1d4d4feaf5 alloc-count: ON is false with system-allocator; rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:25:49 +03:00
umcekoandClaude Sonnet 5.5 ff458a358d alloc-count: one-line comments, rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:25:49 +03:00
umcekoandClaude Sonnet 5.5 c61ca75ca4 Add alloc-count: per-thread allocation counts, and alloc in the frame stats
horizon-threads gets a feature alloc-count (alloc-census implies it) with
CountingAllocator: each thread owns a cache-line slot of four counters, so a
block costs two plain load/store bumps. GameAllocator picks the census, the
counting mimalloc or plain mimalloc, and main.rs and the scene_replay example
declare it; without the feature the allocator is unchanged. The census counts
the same way.

In a counting build stats, bench and fly reply with alloc (window-thread
allocations and KiB per frame, all-thread rates, net growth), and stats raw and
fly raw add alloc_count and alloc_bytes beside frame_ms. Other builds reply
null. docs/memory.md gets the section with the measured cost.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:25:49 +03:00
umcekoandClaude Sonnet 5.5 cc52c95887 Docs: memory.md says what holds the game's memory and what the heap census costs
Describes only what exists: horizon-ctl memory and what each number means, the
alloc-census build (how tags are chosen, what it costs: +1.8% resident, frame
CPU time within the run-to-run spread, measured with --benchmark standard),
spans/trace/stats, resident memory in benchmark reports, scene_replay --memory
and the system-allocator feature for heaptrack on small probes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:25:40 +03:00
Umut Cevdet Koçak e0f6c4f5e8 Merge pull request #236 from UMCEKO/plat/H
Perf platform H: in-process sampling profiler with symbolized function trees
2026-11-07 23:19:38 +03:00
umcekoandClaude Sonnet 5.5 91813c5ae0 horizon-threads: in-process symbolization and merged function trees for the sampler's aggregates
symbols.rs resolves addresses against this process's own binaries (DWARF through addr2line, the ELF symbol table for full paths), keeping base file names only. tree.rs names the aggregate's trie, merges it into a function tree and ranks functions by own and total samples. The pprof, folded and HTML writers stay out; the capture's samples field replaces them. Also fixes the spans test's work:: path and a clippy as_chunks lint.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:16:34 +03:00
umcekoandClaude Sonnet 5.5 a8a0d2cc25 horizon-threads: a profile session drains the sampler into bounded aggregates, with wall-clock samples
`profile::start` runs a writer thread that drains the sampler's rings every 100 ms, unwinds the stack copies there and folds each record
into a call-stack trie (131k nodes at most) plus counters per stack and context (span, entity type, chunk, block, dimension, slow tick).
A tick marker decides afterwards whether the samples of that tick count as slow, so the report can keep only the ticks over a threshold.
Tick durations go to a fixed log-bucket histogram per thread with the ten worst ticks. `Session::status` reads progress while it runs.

`Config::wall` arms a second timer per thread on the monotonic clock, signalling SIGRTMIN so it queues instead of merging with the
CPU-time SIGPROF; each handler blocks the other, so they never nest on one ring. A wall sample is marked running when the thread's CPU
clock advanced by more than half the period since its previous wall sample (the handlers' own microseconds don't count).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:16:33 +03:00
umcekoandClaude Sonnet 5.5 8e88e778a6 Profiler: the game loops store what they are doing in the sampler's context
The innermost span (spans::guard), the entity type before each mob, item,
cart, projectile and block entity tick, the chunk and block of random and
scheduled block ticks, the chunk in natural spawning, the level's dimension,
and a tick marker with its duration at the end of each server tick and each
client frame. Plain stores into the sampler's thread-local; the new
horizon_threads::profile module makes them no-ops where the sampler does not
run and interns the names behind the ids.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:16:33 +03:00
umcekoandClaude Sonnet 5.5 7b5cf13614 horizon-threads: the sampler unwinds copies of the stack with .eh_frame when frame pointers are off
Frame pointers cost 29-34% world load in release builds, so the release build keeps them off and the SIGPROF handler now copies
the interrupted registers and the top 16 KiB of the thread's stack (clamped to its bounds) into a byte ring, memcpy only.
`Session::drain`, on the caller's thread, unwinds each copy with the loaded objects' `.eh_frame` tables. The frame-pointer walk
stays in the handler as the fast path when this build keeps frame pointers (`Unwind::Auto`, decided at start from a probe).
Ring entries are variable length; a ring holds two seconds of samples by default, and a full one drops and counts as before.

New dependencies (Linux only): `framehop` 0.16 (MIT/Apache-2.0, samply's unwinder; default features off, so no Mach-O/PE
parsers) unwinds from `.eh_frame`, and `object` 0.39 locates the sections in each loaded ELF. There is no in-tree unwinder, and
writing a CFI interpreter is the alternative. Both share one `gimli` 0.33, which was not in the lock before.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:16:33 +03:00
umcekoandClaude Sonnet 5.5 1514d3d16a horizon-threads: a frame-pointer unwinder and a Linux CPU-time stack sampler
`unwind` walks frame pointers within a thread's stack bounds (shared with the allocation profiler). `sampler` arms a per-thread
CPU-time POSIX timer that signals SIGPROF to its own thread; the async-signal-safe handler copies the thread's context and
stack into a preallocated ring, and `tick_end` writes tick-duration markers so a report can keep only slow ticks.
Nothing is installed or armed until `sampler::start`. Frame pointers stay off in `.cargo/config.toml`: the benchmark
shows world load +29% (7 of 8 pairs) with `-C force-frame-pointers=yes`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:16:32 +03:00
Umut Cevdet Koçak 3e093c2c6f Merge pull request #235 from UMCEKO/plat/C
Perf platform C: GPU pass timing
2026-11-07 23:16:19 +03:00
umcekoandClaude Sonnet 5.5 e9db26a19a Shorten the detail test doc to one line
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
umcekoandClaude Sonnet 5.5 267d39c511 GPU pass timing: fix review findings (docs on tables, uploads entry, phase 2 test)
step moves above tables' doc comment. The docs now say GPU culling uploads is a pass entry of its
own, only in detail frames on devices with encoder timestamps, not a part of GPU culling. A new
GPU test times both phases and the uploads and checks detail leaves the counts and draws as an
untimed frame's; the test device now asks for SHADER_EARLY_DEPTH_TEST so phase 2 can run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
umcekoandClaude Sonnet 5.5 3b3de54fa3 GPU pass timing: where detail shows (gpu-passes detail, F3, scene_replay)
`horizon-ctl gpu-passes detail` turns on dispatch times and, on Vulkan, per-pass counters;
stats and bench add `gpu_pass_detail`, `gpu_pass_parts` and `gpu_counters`, F3 lists the parts
and the six passes with the most fragments, and `scene_replay --gpu-detail on` reports both per
view after the timed frames. RADV counts fragments per 2x2 quad, so a calibration draw at setup
finds each device's `fragment_unit` and `fragments_per_pixel` is computed from it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
umcekoandClaude Sonnet 5.5 a1cf28d100 GPU pass timing: pipeline statistics on the world passes
`gpu-passes detail` frames now alternate between a parts frame (times) and a counters frame
(vertex, clipper and fragment shader invocations per world pass, split into segments such as
before translucent, occlusion boxes and translucent terrain). A counters frame throws its times
away, and CountedPass ends its query on drop, so none stays open at the end of a pass. Vulkan
only; a device without pipeline statistics keeps parts frames.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
umcekoandClaude Sonnet 5.5 517546d1af GPU pass timing: dispatch parts inside a pass, and GPU culling's dispatches and uploads
`gpu-passes detail` (the renderer's `set_pass_detail`, or HORIZON_GPU_PASS_DETAIL) times each
dispatch of GPU culling (apply, cull, scan, scatter; count2, scan2, scatter2) and its uploads
plus clear_buffer, so a slow pass can be split. A timestamp pair brackets each part inside one
compute pass where the device has TIMESTAMP_QUERY_INSIDE_PASSES, else each part gets a pass of
its own. Off by default; `on` records the same queries and passes as before.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
umcekoandClaude Opus 5.5 cb7a5c204e gpu-passes: time the frames the submit thread submits too
land_frame told the GPU timer and a shader pack's pass timer that their frame
was submitted, but not Horizon's own pass timer: with the submit thread on, its
first timed frame was read and every later one waited forever, so gpu-passes,
F3 and scene_replay showed the first frame's pass times only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
Umut Cevdet Koçak 7833ed6c05 Merge pull request #234 from UMCEKO/plat/P1
Perf platform P1: horizon-perf readers, latest/list/follow and a health-only summary
2026-11-07 23:07:48 +03:00
umcekoandClaude Sonnet 5.5 8b7cc25b66 perf: stream captures in 64 KiB slices, one-line doc, pin gzip trailer and header paths
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:05:29 +03:00
umcekoandClaude Sonnet 5.5 0689bc6505 horizon-perf: whole-file and follow readers, latest, list, follow and a health-only summary (P1)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:01:49 +03:00
Umut Cevdet Koçak e9e7050f32 Merge pull request #233 from UMCEKO/plat/A
Perf platform A: paired A/B statistics and the travel benchmark scenario
2026-11-07 22:51:54 +03:00
umcekoandClaude Sonnet 5.5 afdac528fc Benchmark A/B: drop the references to the removed bench_stats.py
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 22:42:49 +03:00
umcekoandClaude Sonnet 5.5 875c7d7a2b Benchmark: the travel scenario, --benchmark-set and bench raw
horizon-client --benchmark travel flies 512 blocks at 16 blocks/s with the time
and weather frozen, then holds at the end for four blocks: the renderer after
travel, where GPU culling's slowdown shows. Scenario::parse replaces scenes();
--benchmark-set SWITCH=VALUE sets one of the SWITCHES (defaults as gfx has them)
before the world opens, and a refused switch ends the run with the game's
reason. The bench control word takes raw like stats and fly, and the report gets
gpu.<phase>.p50_ms and cpu.<phase>.p50_ms for the trip and hold phases.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 22:30:08 +03:00
umcekoandClaude Sonnet 5.5 9a2931de68 Benchmark report: paired A/B statistics and the optional ab field
horizon_bench::compare pairs the ab.a/ab.b blocks (B A A B order) and gives the
Hodges-Lehmann effect, the exact Wilcoxon signed-rank interval, a verdict, the
blocks' noise and the first-block flag. tools/bench_stats.py computes the same
numbers in Python and checks a report's stored result against its frames.
Report gains an optional ab field; the format stays version 1.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 22:30:08 +03:00
umcekoandClaude Opus 5.5 52128e9f79 docs: perf platform's gzip writer goes through flate2 (pure Rust), as F1 built it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:17:42 +03:00
Umut Cevdet Koçak e635a9b0b4 horizon-perf-format: the .hzperf capture contract (F1) (#232)
horizon-perf-format: the .hzperf capture contract (F1)
2026-11-07 22:17:18 +03:00
umcekoandClaude Opus 5.5 0d21d07827 horizon-perf-format: the .hzperf capture contract (F1)
The new crate holds piece F1 of docs/perf-platform.md: capture.proto (Capture's
top level with Start, Stop, strings, marks, warnings, health, history, the slow
ring and counters), compiled by protox + prost-build in build.rs, and the gzip
writer (flate2, rust_backend) that appends whole top-level fields with a sync
flush after each, on a horizon-perf-writer thread fed by a 4,096-field bounded
channel that drops and counts instead of waiting.

Tests: the frozen tests/fixtures/v1.hzperf decodes to its listed values; after
every flush the file on disk inflates to exactly the fields written; a file cut
at random points yields only whole fields, in order; unknown field numbers are
skipped; the descriptor lint passes capture.proto and rejects a missing
_UNSPECIFIED, a JSON-name collision and a group; a full channel drops, counts
and never waits, and Stop records the drops.

Fixes #2

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:16:46 +03:00
umcekoandClaude Opus 5.5 b91b5516f3 fork: /claim bot and review batches for the perf platform fork
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:31:53 +03:00
umceko be1de92536 Merge branch 'docs/perf-platform-spec' into perf-platform 2026-11-07 20:28:31 +03:00
umcekoandClaude Opus 5.5 b3be633edf docs: perf platform tracker lives on UMCEKO/horizon#1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:22:26 +03:00
umcekoandClaude Opus 5.5 ae1555ab94 docs: perf platform pieces have no PR size limit; tracker #11902
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:17:03 +03:00
umcekoandClaude Opus 5.5 ee3a790501 docs: design spec for one performance platform (.hzperf, horizon-perf, web UI)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:10:46 +03:00
Horizon agentandClaude Opus 5.5 6ca76d9e9c Merge origin/main into #11507
dispense_fixture.rs: chains this branch's shield_hit fixture op and
main's wind_burst op.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:50:23 +01:00
Evrim Cicek c58a3eef6f Merge remote-tracking branch 'origin/fix/tnt-fuse-any-number' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 16:54:08 +03:00
Evrim Cicek ca57097ae8 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 16:53:53 +03:00
voidsip b98cb1a611 Give each shield fixture run a fresh game directory 2026-11-06 18:07:05 -06:00
Evrim Cicek b7eb967776 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 02:30:21 +03:00
voidsip c74851a64b Initialize starvation blocking state and bound fixture startup 2026-11-06 17:25:24 -06:00
voidsip 6b6a9577c7 Merge current upstream for shield blocking validation 2026-11-06 16:13:01 -06:00
voidsip 316f9680d5 Fix built-in shield blocking inputs and item wear 2026-11-06 16:12:41 -06:00
Evrim CicekandClaude Opus 5.5 5a60e2252b TNT minecarts keep explosion_power and explosion_speed_factor, and blow with them
MinecartTNT reads explosion_power and explosion_speed_factor with
getFloatOr (4 and 1), clamped to 0..128, writes each only off its
default, and explode() blows with explosionPowerBase +
explosionSpeedFactor * nextDouble() * 1.5 * min(speed, 5). Horizon blew
every cart at 4 + 1.5 * speed * r and kept whatever the loaded compound
had for the fields, unclamped.

Command harness group "tnt minecart explosion power": 13/18 -> 18/18.

Fixes #11167

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:28:56 +03:00
489 changed files with 59938 additions and 2832 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# `cargo xtask …` runs the repository's task crate, so the commands in docs/perf-platform.md and
# web/perf/README.md work as written. `cargo run -p xtask -- …` stays equivalent.
[alias]
xtask = "run --package xtask --"
+56
View File
@@ -0,0 +1,56 @@
name: perf-wasm
# The site consumes this reader bundle from horizon-rust/horizon-site.
on:
push:
branches: [main]
paths:
- 'crates/horizon-perf/**'
- 'crates/horizon-perf-format/**'
- 'crates/horizon-bench/**'
- 'xtask/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.github/workflows/perf-wasm.yml'
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'crates/horizon-perf/**'
- 'crates/horizon-perf-format/**'
- 'crates/horizon-bench/**'
- 'xtask/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.github/workflows/perf-wasm.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: perf-wasm-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
HORIZON_TELEMETRY_URL: "off"
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: perf-wasm
# The agreement test: the C ABI, called natively, must print the bytes the CLI prints.
- name: The wasm ABI agrees with the CLI
run: cargo test -p horizon-perf --features wasm --test wasm
- name: Build the wasm the page loads
run: cargo run -p xtask -- perf-wasm
- uses: actions/upload-artifact@v4
with:
name: perf-wasm
path: target/perf-wasm
if-no-files-found: error
+1
View File
@@ -102,6 +102,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-perf-format` | The `.hzperf` capture contract ([docs/perf-platform.md](docs/perf-platform.md) §3): `proto/horizon/perf/v1/capture.proto`, its generated types, the gzip writer and its thread. No Horizon dependencies; nothing in the game reads a capture |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
Generated
+343 -4
View File
@@ -18,6 +18,15 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "addr2line"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
@@ -129,6 +138,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.7.0"
@@ -264,6 +279,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "beef"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -559,7 +580,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width",
"unicode-width 0.2.2",
]
[[package]]
@@ -1011,6 +1032,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1109,6 +1136,18 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "framehop"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f54fe4785e899d4d6f43793b151c63c5647240fc630b005509d2614a939f693"
dependencies = [
"arrayvec",
"cfg-if",
"fallible-iterator",
"gimli",
]
[[package]]
name = "fs4"
version = "1.1.0"
@@ -1246,6 +1285,15 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "gimli"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -1426,7 +1474,7 @@ dependencies = [
name = "horizon-bench"
version = "0.1.0"
dependencies = [
"horizon-telemetry",
"horizon-system",
"serde",
"serde_json",
]
@@ -1482,10 +1530,13 @@ dependencies = [
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1560,6 +1611,8 @@ dependencies = [
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1614,6 +1667,8 @@ dependencies = [
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-polytone",
"horizon-protocol",
"horizon-shader-api",
@@ -1831,6 +1886,7 @@ dependencies = [
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-perf-share",
"horizon-platform",
"log",
"winresource",
@@ -1849,18 +1905,60 @@ dependencies = [
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-system",
"log",
"regex",
]
[[package]]
name = "horizon-perf"
version = "0.1.0"
dependencies = [
"flate2",
"horizon-bench",
"horizon-perf-format",
"horizon-perf-share",
"prost",
"regex",
"serde",
"serde_json",
]
[[package]]
name = "horizon-perf-format"
version = "0.1.0"
dependencies = [
"flate2",
"prost",
"prost-build",
"prost-types",
"protox",
]
[[package]]
name = "horizon-perf-share"
version = "0.1.0"
dependencies = [
"getrandom 0.3.4",
"horizon-addresses",
"horizon-platform",
"log",
"open",
"serde",
"serde_json",
"ureq",
"url",
]
[[package]]
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-hooks",
"horizon-perf-format",
"horizon-prepare",
"horizon-protocol",
"horizon-server-files",
@@ -1945,7 +2043,6 @@ dependencies = [
"chrono",
"getrandom 0.3.4",
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-mcp",
"horizon-platform",
@@ -2029,9 +2126,14 @@ version = "0.1.0"
name = "horizon-threads"
version = "0.1.0"
dependencies = [
"addr2line",
"framehop",
"libc",
"libmimalloc-sys",
"memmap2",
"mimalloc",
"object",
"rustc-demangle",
"windows-sys 0.61.2",
]
@@ -2041,6 +2143,7 @@ version = "0.1.0"
dependencies = [
"flate2",
"horizon-core",
"horizon-threads",
"libm",
"log",
"lz4_flex",
@@ -2282,6 +2385,15 @@ dependencies = [
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2508,6 +2620,72 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "logos"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154"
dependencies = [
"logos-derive 0.15.1",
]
[[package]]
name = "logos"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f"
dependencies = [
"logos-derive 0.16.1",
]
[[package]]
name = "logos-codegen"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c"
dependencies = [
"beef",
"fnv",
"lazy_static",
"proc-macro2",
"quote",
"regex-syntax",
"rustc_version",
"syn 2.0.119",
]
[[package]]
name = "logos-codegen"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970"
dependencies = [
"fnv",
"proc-macro2",
"quote",
"regex-automata",
"regex-syntax",
"syn 2.0.119",
]
[[package]]
name = "logos-derive"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470"
dependencies = [
"logos-codegen 0.15.1",
]
[[package]]
name = "logos-derive"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31"
dependencies = [
"logos-codegen 0.16.1",
]
[[package]]
name = "lru"
version = "0.18.5"
@@ -2550,6 +2728,28 @@ dependencies = [
"libc",
]
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"cfg-if",
"miette-derive",
"unicode-width 0.1.14",
]
[[package]]
name = "miette-derive"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
@@ -2603,6 +2803,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "multimap"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -3183,6 +3389,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
@@ -3514,6 +3729,16 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -3538,6 +3763,96 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "prost"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [
"heck",
"itertools",
"log",
"multimap",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"regex",
"syn 2.0.119",
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "prost-reflect"
version = "0.16.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1"
dependencies = [
"logos 0.16.1",
"miette",
"prost",
"prost-types",
]
[[package]]
name = "prost-types"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
dependencies = [
"prost",
]
[[package]]
name = "protox"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f25a07a73c6717f0b9bbbd685918f5df9815f7efba450b83d9c9dea41f0e3a1"
dependencies = [
"bytes",
"miette",
"prost",
"prost-reflect",
"prost-types",
"protox-parse",
"thiserror 2.0.21",
]
[[package]]
name = "protox-parse"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "072eee358134396a4643dff81cfff1c255c9fbd3fb296be14bdb6a26f9156366"
dependencies = [
"logos 0.15.1",
"miette",
"prost-types",
"thiserror 2.0.21",
]
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -3837,6 +4152,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rustc-demangle"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -4395,6 +4716,18 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -4664,6 +4997,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
+3
View File
@@ -37,6 +37,9 @@ members = [
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-perf-format",
"crates/horizon-perf",
"crates/horizon-perf-share",
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mods",
+6
View File
@@ -35,6 +35,12 @@ pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
/// "I'm playing" ping, benchmark uploads (`HORIZON_TELEMETRY_URL` overrides it).
pub const TELEMETRY: &str = "https://telemetry.playhorizon.dev";
/// The performance viewer page a saved capture opens in (`HORIZON_PERF_VIEWER` overrides it).
pub const PERF_VIEWER: &str = "https://playhorizon.dev/perf";
/// The capture upload API behind Share (`HORIZON_PERF_CAPTURES` overrides it).
pub const PERF_CAPTURES: &str = "https://playhorizon.dev/api/perf/captures";
/// Public STUN servers for Play with Friends' NAT discovery: two independent operators, so one
/// outage doesn't break discovery (`HORIZON_STUN_SERVERS` overrides them).
pub const STUN_SERVERS: &[&str] = &["stun.cloudflare.com:3478", "stun.l.google.com:19302"];
+8 -9
View File
@@ -1764,14 +1764,13 @@ fn stack_from_template(template: &Value) -> Option<StackView> {
/// `RangeSelectItemModel.lastIndexLessOrEqual`.
fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Option<usize> {
let haystack: Vec<f32> = entries.iter().map(|e| e.0).collect();
let found = if haystack.len() < 16 {
haystack
let found = if entries.len() < 16 {
entries
.iter()
.position(|h| *h > needle)
.map_or(haystack.len() as i64 - 1, |i| i as i64 - 1)
.position(|e| e.0 > needle)
.map_or(entries.len() as i64 - 1, |i| i as i64 - 1)
} else {
match binary_search(&haystack, needle) {
match binary_search(entries, needle) {
Ok(index) => index as i64,
Err(insertion) => insertion as i64 - 1,
}
@@ -1780,8 +1779,8 @@ fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Op
}
/// `Arrays.binarySearch(float[], float)`, including its handling of equal
/// values (-0.0 below 0.0, NaN above everything).
fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
let (mut low, mut high) = (0i64, a.len() as i64 - 1);
fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Result<usize, usize> {
let (mut low, mut high) = (0i64, entries.len() as i64 - 1);
let bits = |v: f32| -> i32 {
if v.is_nan() {
0x7fc0_0000
@@ -1791,7 +1790,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
};
while low <= high {
let mid = (low + high) >> 1;
let value = a[mid as usize];
let value = entries[mid as usize].0;
if value < key {
low = mid + 1;
} else if value > key {
@@ -158,3 +158,61 @@ fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
}
assert_eq!(view.to_map(), *merged);
}
#[test]
fn last_index_less_or_equal_linear_and_binary() {
use super::last_index_less_or_equal;
let make = |thresholds: &[f32]| -> Vec<(f32, ItemModelNode)> {
thresholds
.iter()
.map(|&t| (t, ItemModelNode::Empty))
.collect()
};
// Empty
assert_eq!(last_index_less_or_equal(&[], 0.0), None);
// Short (< 16): linear search
let short = make(&[-1.0, 0.0, 1.0, 2.5, 5.0]);
assert_eq!(last_index_less_or_equal(&short, -2.0), None);
assert_eq!(last_index_less_or_equal(&short, -1.0), Some(0));
assert_eq!(last_index_less_or_equal(&short, -0.5), Some(0));
assert_eq!(last_index_less_or_equal(&short, 0.0), Some(1));
assert_eq!(last_index_less_or_equal(&short, 2.5), Some(3));
assert_eq!(last_index_less_or_equal(&short, 3.0), Some(3));
assert_eq!(last_index_less_or_equal(&short, 5.0), Some(4));
assert_eq!(last_index_less_or_equal(&short, 10.0), Some(4));
// Long (>= 16): binary search
let thresholds: Vec<f32> = (0..32).map(|i| i as f32 * 10.0).collect();
let long = make(&thresholds);
assert_eq!(last_index_less_or_equal(&long, -5.0), None);
assert_eq!(last_index_less_or_equal(&long, 0.0), Some(0));
assert_eq!(last_index_less_or_equal(&long, 5.0), Some(0));
assert_eq!(last_index_less_or_equal(&long, 10.0), Some(1));
assert_eq!(last_index_less_or_equal(&long, 150.0), Some(15));
assert_eq!(last_index_less_or_equal(&long, 155.0), Some(15));
assert_eq!(last_index_less_or_equal(&long, 310.0), Some(31));
assert_eq!(last_index_less_or_equal(&long, 1000.0), Some(31));
// Short lists use upper-bound tie behavior, including signed zero.
let duplicates = make(&[1.0, 1.0, 2.0]);
assert_eq!(last_index_less_or_equal(&duplicates, 1.0), Some(1));
let zeroes = make(&[-0.0, 0.0]);
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(1));
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(1));
// At 16 entries, vanilla returns Arrays.binarySearch's hit, not the last tie.
// Expected indices were checked on the JVM.
let duplicates = make(&[
0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0,
]);
assert_eq!(last_index_less_or_equal(&duplicates, 7.0), Some(7));
let zeroes = make(&[
-8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -0.0, -0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0,
]);
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(7));
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(9));
}
+4 -2
View File
@@ -9,5 +9,7 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
# The hardware probes (cores, clocks, drive, VRAM, power, form); a reader of reports (the wasm
# build of horizon-perf) needs the types below, never the probes.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
horizon-system = { path = "../horizon-system" }
+434
View File
@@ -0,0 +1,434 @@
//! Paired A/B comparison of benchmark blocks (docs/benchmark-report.md, "A/B runs").
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{round, Frames};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
TooFewPairs,
WithinNoise,
Negligible,
Slower,
Faster,
}
/// B against A over paired blocks; lower milliseconds are better.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Paired {
pub pairs: usize,
pub a: f64,
pub b: f64,
/// Hodges-Lehmann estimate of b - a.
pub diff: f64,
/// Absent when `a` is zero.
pub diff_pct: Option<f64>,
/// Exact Wilcoxon signed-rank interval; absent below 6 pairs.
pub ci95: Option<[f64; 2]>,
pub confidence: Option<f64>,
/// Median distance between neighbouring blocks of the same arm.
pub noise: Option<f64>,
pub verdict: Verdict,
}
type Getter = fn(&Frames) -> Option<f64>;
fn median(values: &[f64]) -> f64 {
let mut v = values.to_vec();
v.sort_by(f64::total_cmp);
match v.len() {
0 => 0.0,
n if n % 2 == 1 => v[n / 2],
n => (v[n / 2 - 1] + v[n / 2]) / 2.0,
}
}
fn round4(v: f64) -> f64 {
(v * 10_000.0).round() / 10_000.0
}
/// `(a, b)` per pair, `noise` left for the caller.
pub fn paired(pairs: &[(f64, f64)]) -> Paired {
let n = pairs.len();
let a = median(&pairs.iter().map(|p| p.0).collect::<Vec<_>>());
let b = median(&pairs.iter().map(|p| p.1).collect::<Vec<_>>());
let d: Vec<f64> = pairs.iter().map(|p| p.1 - p.0).collect();
let mut walsh = Vec::with_capacity(n * (n + 1) / 2);
for i in 0..n {
for j in i..n {
walsh.push((d[i] + d[j]) / 2.0);
}
}
walsh.sort_by(f64::total_cmp);
let diff = median(&walsh);
let interval = interval(n, &walsh);
let verdict = match interval {
None => Verdict::TooFewPairs,
Some(([lo, hi], _)) if lo <= 0.0 && 0.0 <= hi => Verdict::WithinNoise,
Some(_) if diff.abs() < 0.05_f64.max(0.02 * a) => Verdict::Negligible,
Some(([lo, _], _)) if lo > 0.0 => Verdict::Slower,
Some(_) => Verdict::Faster,
};
Paired {
pairs: n,
a: round(a) + 0.0,
b: round(b) + 0.0,
diff: round(diff) + 0.0,
diff_pct: (a != 0.0).then(|| round(100.0 * diff / a) + 0.0),
ci95: interval.map(|([lo, hi], _)| [round(lo) + 0.0, round(hi) + 0.0]),
confidence: interval.map(|(_, c)| round4(c)),
noise: None,
verdict,
}
}
/// The widest symmetric interval of the sorted Walsh averages whose tails hold at most 2.5% each, and its confidence.
fn interval(n: usize, walsh: &[f64]) -> Option<([f64; 2], f64)> {
let m = walsh.len();
// Null probabilities of T+ (halving is exact in binary, so these are the counts over 2^n).
let mut p = vec![0.0f64; m + 1];
p[0] = 1.0;
for r in 1..=n {
for t in (0..=m).rev() {
p[t] = (p[t] + if t >= r { p[t - r] } else { 0.0 }) / 2.0;
}
}
let (mut k, mut tail, mut best) = (0, 0.0, 0.0);
while k < m {
tail += p[k];
if tail > 0.025 {
break;
}
k += 1;
best = tail;
}
(k >= 1).then(|| ([walsh[k - 1], walsh[m - k]], 1.0 - 2.0 * best))
}
/// The result of an A/B run: pairs of blocks in B A A B order, recomputable from the report's `frames`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Ab {
pub switch: String,
pub a: String,
pub b: String,
pub pairs: u32,
pub block_s: f64,
pub warmup_s: f64,
/// Block 0 (measured before any switch) lies outside the later B blocks.
pub first_block_outside_range: bool,
/// By `gpu_p50_ms`, `cpu_p50_ms`, `frame_p50_ms`; a metric missing from any block is left out.
pub result: BTreeMap<String, Paired>,
}
impl Ab {
/// Pairs the `ab.a`/`ab.b` entries of `frames` by `repeat` (0, 1, 2...): pair k is blocks 2k and 2k+1.
pub fn from_frames(
switch: &str,
a: &str,
b: &str,
block_s: f64,
warmup_s: f64,
frames: &[Frames],
) -> Result<Ab, String> {
let mut blocks: Vec<&Frames> = frames
.iter()
.filter(|f| f.phase == "ab.a" || f.phase == "ab.b")
.collect();
blocks.sort_by_key(|f| f.repeat);
if blocks.is_empty()
|| !blocks.len().is_multiple_of(2)
|| blocks
.iter()
.enumerate()
.any(|(i, f)| f.repeat as usize != i)
{
return Err("A/B blocks must be numbered 0..N with N even".to_owned());
}
if blocks.chunks(2).any(|p| p[0].phase == p[1].phase) {
return Err("each pair needs one ab.a and one ab.b block".to_owned());
}
let pairs = blocks.len() / 2;
let metrics: [(&str, Getter); 3] = [
("gpu_p50_ms", |f| f.gpu_p50_ms),
("cpu_p50_ms", |f| f.cpu_p50_ms),
("frame_p50_ms", |f| Some(f.p50_ms)),
];
let mut result = BTreeMap::new();
let mut series: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
for (name, get) in metrics {
let Some(values) = blocks.iter().map(|f| get(f)).collect::<Option<Vec<f64>>>() else {
continue;
};
let by_arm: Vec<(f64, f64)> = blocks
.chunks(2)
.zip(values.chunks(2))
.map(|(p, v)| {
if p[0].phase == "ab.a" {
(v[0], v[1])
} else {
(v[1], v[0])
}
})
.collect();
let mut stat = paired(&by_arm);
let gaps: Vec<f64> = (1..values.len().saturating_sub(1))
.step_by(2)
.filter(|&i| blocks[i].phase == blocks[i + 1].phase)
.map(|i| (values[i] - values[i + 1]).abs())
.collect();
stat.noise = (!gaps.is_empty()).then(|| round(median(&gaps)) + 0.0);
result.insert(name.to_owned(), stat);
series.insert(name, values);
}
let first_block_outside_range = series
.get("gpu_p50_ms")
.or_else(|| series.get("frame_p50_ms"))
.is_some_and(|values| {
let later: Vec<f64> = (1..values.len())
.filter(|&i| blocks[i].phase == "ab.b")
.map(|i| values[i])
.collect();
blocks[0].phase == "ab.b"
&& !later.is_empty()
&& (values[0] < later.iter().copied().fold(f64::INFINITY, f64::min)
|| values[0] > later.iter().copied().fold(f64::NEG_INFINITY, f64::max))
});
Ok(Ab {
switch: switch.to_owned(),
a: a.to_owned(),
b: b.to_owned(),
pairs: pairs as u32,
block_s,
warmup_s,
first_block_outside_range,
result,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{App, Kind, Report};
// Expected values: exact-rational Walsh averages and a brute-force sign enumeration, run once outside this code.
fn check(
a: &[f64],
b: &[f64],
diff: f64,
ci: Option<[f64; 2]>,
conf: Option<f64>,
verdict: Verdict,
) {
let pairs: Vec<(f64, f64)> = a.iter().copied().zip(b.iter().copied()).collect();
let p = paired(&pairs);
assert_eq!(
(p.pairs, p.diff, p.ci95, p.confidence, p.verdict),
(a.len(), diff, ci, conf, verdict),
"{b:?}"
);
}
#[test]
fn the_decided_vectors() {
use Verdict::*;
let z = |n: usize| vec![0.0; n];
let up = |n: usize| (1..=n).map(|v| v as f64).collect::<Vec<_>>();
check(&z(8), &up(8), 4.5, Some([2.0, 7.0]), Some(0.9609), Slower);
let wobble = [-0.3, 0.2, -0.1, 0.4, -0.2, 0.1, 0.3, -0.4];
check(
&z(8),
&wobble,
0.0,
Some([-0.3, 0.3]),
Some(0.9609),
WithinNoise,
);
check(&z(6), &up(6), 3.5, Some([1.0, 6.0]), Some(0.9688), Slower);
check(&z(5), &up(5), 3.0, None, None, TooFewPairs);
let a = [3.65, 3.70, 3.62, 3.68, 3.66, 3.71, 3.63, 3.69];
let b = [5.63, 5.60, 5.70, 5.58, 5.65, 5.62, 5.66, 5.61];
check(&a, &b, 1.96, Some([1.905, 2.03]), Some(0.9609), Slower);
let tiny: Vec<f64> = (1..=8).map(|v| 10.0 + v as f64 / 100.0).collect();
check(
&[10.0; 8],
&tiny,
0.045,
Some([0.02, 0.07]),
Some(0.9609),
Negligible,
);
let down: Vec<f64> = up(8).iter().map(|_| 0.0).collect();
check(
&up(8),
&down,
-4.5,
Some([-7.0, -2.0]),
Some(0.9609),
Faster,
);
let mixed = [0.5, -0.2, 0.0, 0.3, 0.9, -0.1, 0.4];
check(
&z(7),
&mixed,
0.225,
Some([-0.1, 0.65]),
Some(0.9531),
WithinNoise,
);
let ten = [0.3, -0.1, 0.8, 0.2, -0.5, 0.6, 0.1, 0.4, 0.7, 0.05];
check(
&z(10),
&ten,
0.25,
Some([-0.025, 0.55]),
Some(0.9512),
WithinNoise,
);
}
#[test]
fn medians_percent_and_empty_input() {
let a = [3.65, 3.70, 3.62, 3.68, 3.66, 3.71, 3.63, 3.69];
let b = [5.63, 5.60, 5.70, 5.58, 5.65, 5.62, 5.66, 5.61];
let p = paired(&a.iter().copied().zip(b).collect::<Vec<_>>());
assert_eq!(
(p.a, p.b, p.diff_pct, p.noise),
(3.67, 5.625, Some(53.406), None)
);
assert_eq!(
paired(&[(0.0, 1.0); 8]).diff_pct,
None,
"no percent of zero"
);
assert_eq!(paired(&[]).verdict, Verdict::TooFewPairs);
}
fn block(phase: &str, repeat: u32, frame: f64, cpu: Option<f64>, gpu: Option<f64>) -> Frames {
Frames {
phase: phase.into(),
repeat,
frames: 100,
seconds: 4.0,
avg_fps: 250.0,
low_1pct_fps: 100.0,
low_01pct_fps: 90.0,
p50_ms: frame,
p95_ms: frame + 1.0,
p99_ms: frame + 2.0,
p999_ms: frame + 3.0,
max_ms: frame + 4.0,
cpu_p50_ms: cpu,
gpu_p50_ms: gpu,
histogram: vec![0; crate::HISTOGRAM_EDGES_MS.len()],
}
}
// B A A B B A A B B A A B, as gpu milliseconds; frame time is gpu + 1.
fn twelve(first_b: f64) -> Vec<Frames> {
let gpu = [
first_b, 3.0, 3.2, 5.0, 5.2, 3.1, 3.1, 5.1, 5.4, 3.4, 3.3, 5.3,
];
gpu.iter()
.enumerate()
.map(|(i, &g)| {
block(
if matches!(i % 4, 0 | 3) {
"ab.b"
} else {
"ab.a"
},
i as u32,
g + 1.0,
Some(g / 2.0),
Some(g),
)
})
.collect()
}
fn from(frames: &[Frames]) -> Result<Ab, String> {
Ab::from_frames("gpu-culling", "off", "on", 4.0, 2.0, frames)
}
#[test]
fn noise_and_the_first_block_flag() {
let ab = from(&twelve(6.4)).unwrap();
assert!(
ab.first_block_outside_range,
"6.4 is 1 ms above the other B blocks"
);
let gpu = &ab.result["gpu_p50_ms"];
// Neighbours (1,2) (3,4) (5,6) (7,8) (9,10): 0.2 0.2 0 0.3 0.1.
assert_eq!(
(gpu.pairs, gpu.a, gpu.b, gpu.noise),
(6, 3.15, 5.25, Some(0.2))
);
assert_eq!(
(gpu.ci95, gpu.verdict),
(Some([1.8, 3.4]), Verdict::Slower),
"every d is positive: min and max Walsh averages"
);
assert_eq!(ab.result["cpu_p50_ms"].noise, Some(0.1));
assert_eq!(
(ab.pairs, ab.switch.as_str(), ab.a.as_str(), ab.b.as_str()),
(6, "gpu-culling", "off", "on")
);
assert!(!from(&twelve(5.2)).unwrap().first_block_outside_range);
}
#[test]
fn a_metric_missing_from_one_block_is_left_out() {
let mut frames = twelve(5.2);
frames[7].gpu_p50_ms = None;
let ab = from(&frames).unwrap();
assert_eq!(
ab.result.keys().collect::<Vec<_>>(),
["cpu_p50_ms", "frame_p50_ms"]
);
let mut frames = twelve(6.4);
frames[0].gpu_p50_ms = None;
frames[0].p50_ms = 6.0;
assert!(
!from(&frames).unwrap().first_block_outside_range,
"frame time 6.0 is inside 6.0..6.4 of the later B blocks"
);
}
#[test]
fn blocks_that_do_not_pair_are_refused() {
assert!(from(&[]).is_err());
assert!(from(&twelve(5.2)[..11]).is_err(), "odd number of blocks");
let mut frames = twelve(5.2);
frames[1].phase = "ab.b".into();
assert!(from(&frames).is_err(), "a pair of two B blocks");
let mut frames = twelve(5.2);
frames[3].repeat = 9;
assert!(from(&frames).is_err(), "a gap in the numbering");
}
#[test]
fn a_report_with_ab_round_trips_and_old_ones_still_parse() {
let mut r = Report::new(Kind::Game, App::default());
let old = r.to_json();
assert!(!old.contains("\"ab\""));
assert_eq!(Report::parse(&old).unwrap().ab, None);
r.frames = twelve(5.2);
r.ab = Some(from(&r.frames).unwrap());
let json = r.to_json();
assert_eq!(Report::parse(&json).unwrap(), r);
assert!(
json.contains("\"first_block_outside_range\": false")
&& json.contains("\"verdict\": \"slower\"")
);
assert_eq!(r.for_upload().ab, r.ab);
let mut few = paired(&[(1.0, 2.0); 3]);
few.noise = None;
assert!(serde_json::to_string(&few)
.unwrap()
.contains("\"ci95\":null,\"confidence\":null"));
}
}
+20 -125
View File
@@ -1,7 +1,8 @@
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
//! come from the program that rendered (the game knows its adapter, the launcher its window).
use horizon_telemetry::system::command_output as output;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use horizon_system::command_output as output;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -61,6 +62,7 @@ pub struct Display {
pub refresh_hz: Option<u32>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Hardware {
pub fn detect() -> Hardware {
let probes = probes();
@@ -68,12 +70,12 @@ impl Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: cpu(),
cpu: horizon_system::cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
cpu_cores: probes.cores,
cpu_base_mhz: probes.base,
cpu_boost_mhz: probes.boost,
ram_mb: ram_bytes().map(|b| b >> 20),
ram_mb: horizon_system::ram_bytes().map(|b| b >> 20),
storage: None,
form: probes.form,
gpu: None,
@@ -84,13 +86,14 @@ impl Hardware {
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
Hardware {
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
storage: horizon_system::storage(game_dir).map(str::to_owned),
..Hardware::detect()
}
}
}
/// What the telemetry probes read (`horizon_telemetry::system`).
#[cfg(not(target_arch = "wasm32"))]
/// What `horizon_system::Machine` reads.
struct Probes {
cores: Option<u16>,
base: Option<u32>,
@@ -98,95 +101,31 @@ struct Probes {
form: Option<String>,
}
#[cfg(not(target_arch = "wasm32"))]
fn probes() -> Probes {
let s = horizon_telemetry::payload::System::detect();
let s = horizon_system::Machine::detect();
let some = |n: u32| (n > 0).then_some(n);
Probes {
cores: (s.cores > 0).then_some(s.cores),
base: some(s.cpu_base_mhz),
boost: some(s.cpu_boost_mhz),
form: (!s.form.is_empty()).then_some(s.form),
}
}
/// The one-minute load average: above about the number of threads the machine was busy.
pub fn load_average() -> Option<f64> {
if cfg!(target_os = "macos") {
// "{ 1.23 1.10 1.00 }"
output("sysctl", &["-n", "vm.loadavg"])?
.trim_start_matches('{')
.split_whitespace()
.next()?
.parse()
.ok()
} else {
std::fs::read_to_string("/proc/loadavg")
.ok()?
.split_whitespace()
.next()?
.parse()
.ok()
form: (!s.form.is_empty()).then(|| s.form.to_owned()),
}
}
#[cfg(not(target_arch = "wasm32"))]
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
horizon_telemetry::system::power()
}
/// Resident memory of a process, in bytes (macOS, Linux).
pub fn rss_bytes(pid: u32) -> Option<u64> {
if cfg!(target_os = "macos") {
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024);
}
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
line.split_whitespace()
.nth(1)?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024)
}
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
pub fn cpu_seconds(pid: u32) -> Option<f64> {
if cfg!(target_os = "macos") {
// "[[dd-]hh:]mm:ss.cc"
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
let (rest, days) = match time.split_once('-') {
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
None => (time, 0.0),
};
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
})?;
return Some(days * 86_400.0 + seconds);
}
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
Some(ticks / 100.0)
horizon_system::power()
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "macos")]
fn os_version() -> Option<String> {
output("sw_vers", &["-productVersion"])
}
#[cfg(target_os = "macos")]
fn cpu() -> Option<String> {
output("sysctl", &["-n", "machdep.cpu.brand_string"])
}
#[cfg(target_os = "macos")]
fn ram_bytes() -> Option<u64> {
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "linux")]
fn os_version() -> Option<String> {
let release = std::fs::read_to_string("/etc/os-release").ok()?;
@@ -199,22 +138,7 @@ fn os_version() -> Option<String> {
})
}
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
fn ram_bytes() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let kb = key_value(&info, "MemTotal", ':')?
.trim_end_matches("kB")
.trim();
kb.parse::<u64>().ok().map(|kb| kb * 1024)
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "linux")]
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
text.lines().find_map(|line| {
@@ -223,45 +147,19 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
})
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "windows")]
fn os_version() -> Option<String> {
horizon_telemetry::system::windows_version()
}
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
horizon_telemetry::system::windows_cpu()
}
#[cfg(target_os = "windows")]
fn ram_bytes() -> Option<u64> {
let out = output(
"powershell",
&[
"-NoProfile",
"-Command",
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
],
)?;
out.parse().ok()
horizon_system::windows_version()
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn os_version() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn cpu() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn ram_bytes() -> Option<u64> {
None
}
#[cfg(test)]
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
@@ -274,9 +172,6 @@ mod tests {
assert!(hw.cpu.is_some(), "{hw:?}");
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
assert!(hw.os_version.is_some(), "{hw:?}");
assert!(load_average().is_some());
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
+12 -1
View File
@@ -11,9 +11,15 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
pub mod compare;
mod hardware;
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
#[cfg(not(target_arch = "wasm32"))]
pub use hardware::power_source;
pub use hardware::{Display, Gpu, Hardware};
/// The process probes, from the platform (`horizon-system`).
#[cfg(not(target_arch = "wasm32"))]
pub use horizon_system::{cpu_seconds, load_average, rss_bytes};
pub const FORMAT: &str = "horizon-benchmark-report";
pub const VERSION: u32 = 1;
@@ -234,6 +240,9 @@ pub struct Report {
pub metrics: BTreeMap<String, Summary>,
#[serde(default)]
pub frames: Vec<Frames>,
/// Present in A/B runs only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ab: Option<compare::Ab>,
/// Local only: a name the person running it chose.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
@@ -244,6 +253,7 @@ pub struct Report {
impl Report {
/// An empty report for this machine, created now.
#[cfg(not(target_arch = "wasm32"))]
pub fn new(kind: Kind, app: App) -> Report {
Self::with_hardware(kind, app, Hardware::detect())
}
@@ -261,6 +271,7 @@ impl Report {
conditions: Conditions::default(),
metrics: BTreeMap::new(),
frames: Vec::new(),
ab: None,
label: None,
local: Vec::new(),
}
+32 -1
View File
@@ -61,6 +61,8 @@ pub enum ClickEvent {
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
/// A `/profiler` line the client's own buttons run; never decoded from a server's text, so only they reach the local profiler.
Profiler(String),
}
/// A non-character `minecraft:object` embedded in a text component.
@@ -202,7 +204,12 @@ impl Text {
}
}
pub fn styled(s: impl Into<String>, style: Style) -> Self {
Self::literal(s).with_style(&style)
let mut text = Self::literal(s).with_style(&style);
// `inherit` never passes an object on, but a styled span's own style carries it.
for span in &mut text.spans {
span.style.inline_object.clone_from(&style.inline_object);
}
text
}
pub fn colored(s: impl Into<String>, color: u32) -> Self {
Self::styled(s, Style::color(color))
@@ -619,6 +626,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
event
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
ClickEvent::Profiler(command) => {
json!({ "action": "run_command", "command": command })
}
};
out.push(("click_event".to_owned(), event));
}
@@ -772,6 +782,27 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
mod tests {
use super::*;
#[test]
fn a_styled_object_span_keeps_its_object_through_append_and_slice() {
let object = InlineObject::Atlas {
atlas: "minecraft:gui".into(),
sprite: "horizon:gui/mark".into(),
fallback: "H".into(),
};
let sprite = Text::styled(
"\u{fffc}",
Style {
inline_object: Some(object.clone()),
..Style::color(WHITE)
},
);
let line = Text::colored("[", DARK_GRAY)
.append(sprite)
.append(Text::colored("] ", DARK_GRAY));
let line = line.slice(0, 3);
assert_eq!(line.spans[1].style.inline_object, Some(object));
assert_eq!(line.plain(), "[H]");
}
#[test]
fn appending_builds_the_same_tree_in_linear_time() {
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
// takes the siblings itself; a copy taken before an append keeps its own tree.
+4
View File
@@ -27,6 +27,8 @@ horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
# The .hzperf capture format and writer (docs/perf-platform.md).
horizon-perf-format = { path = "../horizon-perf-format" }
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
# once, not for every phase of every tick.
rayon = "1.10"
@@ -130,6 +132,8 @@ workspace = true
horizon-theme = { path = "../horizon-theme" }
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Reads back the captures the server_perf tests write.
horizon-perf = { path = "../horizon-perf" }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
+5
View File
@@ -23,6 +23,7 @@ horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-perf-format = { path = "../horizon-perf-format" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
@@ -74,6 +75,9 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
# The `scene_replay` example's allocator counts allocations (horizon-threads' `alloc-count`).
alloc-count = ["horizon-threads/alloc-count"]
# 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.
@@ -83,6 +87,7 @@ horizon-extras = []
workspace = true
[dev-dependencies]
horizon-perf = { path = "../horizon-perf" }
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
horizon-theme = { path = "../horizon-theme" }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
@@ -6,13 +6,14 @@
//! [--mipmaps N] [--lod-distance CHUNKS] [--render-distance CHUNKS] [--size WxH]
//! [--section-draws flat|indirect] [--section-cull on|off] [--section-sides on|off]
//! [--gpu-culling on|off] [--root RESOURCES] [--screenshot PNG]
//! [--world SAVE --leaf-culling MODE] [--memory on]`
//! [--world SAVE --leaf-culling MODE] [--memory on] [--gpu-detail on|off]`
//! `--screenshot` saves the first view's last frame, after the timed frames.
//! `--world` meshes the captured sections again from that save's region files
//! (blocks, biomes and saved light; `ao`, `biomeBlendRadius` and `cutoutLeaves`
//! from `./options.txt`) with `--leaf-culling` (default `off`), so a mesher
//! change can be measured on the captured view. A section whose 3x3 columns are
//! not all saved keeps its captured mesh.
//! `--gpu-detail on` adds GPU culling's dispatch times and, on Vulkan, the world passes' counters.
//! `--memory on` adds the renderer's largest GPU resources and, on macOS, the process's
//! physical footprint by category (`footprint`), taken after the timed frames.
use horizon_client_render::{
@@ -24,6 +25,7 @@ use horizon_client_render::{
gfx::{
PreparedTextures, Renderer, ScreenshotRequest,
capture::{self, AtlasCheck, Scene},
pack_timing,
panorama::Faces,
},
graphics::LeafCullingMode,
@@ -61,7 +63,10 @@ use winit::{
// The game's allocator, so that `--memory`'s footprint is the game's: the system heap kept
// gigabytes of the meshes `--world` frees dirty.
#[global_allocator]
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::ALLOCATOR;
static ALLOCATOR: horizon_threads::GameAllocator = horizon_threads::GAME_ALLOCATOR;
/// Frames drawn with detail on, enough for a few timed frames of each kind.
const DETAIL_FRAMES: usize = 60;
#[derive(Default)]
struct Options {
@@ -84,6 +89,7 @@ struct Options {
world: Option<PathBuf>,
leaf_culling: LeafCullingMode,
memory: bool,
gpu_detail: bool,
}
fn on_off(value: &str) -> Result<bool, String> {
@@ -136,6 +142,7 @@ fn options() -> Result<Options, String> {
"--screenshot" => o.screenshot = Some(value.into()),
"--world" => o.world = Some(value.into()),
"--memory" => o.memory = on_off(&value)?,
"--gpu-detail" => o.gpu_detail = on_off(&value)?,
"--leaf-culling" => {
o.leaf_culling = LeafCullingMode::ALL
.into_iter()
@@ -400,6 +407,23 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None => notes.push("screenshot: not saved".into()),
}
}
let mut detail = serde_json::Map::new();
if o.gpu_detail {
renderer.set_pass_detail(true);
for _ in 0..DETAIL_FRAMES {
draw(&mut renderer)?;
}
detail.insert(
"gpu_pass_parts_median_ms".into(),
pack_timing::parts_json(&renderer.own_pass_parts()),
);
let unit = renderer.pass_detail_support().and_then(|s| s.fragment_unit);
detail.insert(
"gpu_counters_median".into(),
pack_timing::counters_by_pass_json(&renderer.own_pass_counters(), unit),
);
renderer.set_pass_detail(false);
}
views.push(json!({
"yaw_offset": offset,
"gpu_ms": summary(&gpu),
@@ -417,6 +441,9 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
// indices hidden.
"culled": renderer.culled_sections(),
}));
if let Some(Value::Object(view)) = views.last_mut() {
view.extend(detail);
}
}
let section_draws = if renderer.flat_draws() {
"flat"
@@ -424,6 +451,11 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
"indirect"
};
let memory = o.memory.then(|| memory_report(&renderer));
let gpu_detail = o
.gpu_detail
.then(|| renderer.pass_detail_support())
.flatten()
.map(pack_timing::support_json);
let report = json!({
"capture": o.dir.display().to_string(),
"adapter": renderer.info.name,
@@ -454,6 +486,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
"notes": notes,
"views": views,
"memory": memory,
"gpu_detail": gpu_detail,
});
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
+4 -4
View File
@@ -36,7 +36,7 @@ struct Globals {
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_gains: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -292,7 +292,7 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
var mark = hdr_entity_mark(in);
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_gains), color.a);
}
if FRAME_LINEAR && (in.overlay == 11u || in.overlay == 12u) {
// The cracks scale what is under them by twice their colour (CRUMBLING_BLEND). Vanilla's
@@ -451,7 +451,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
}
return frame_stored(color);
}
@@ -472,7 +472,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
// or it would come out darker than the sky around it.
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_gains), color.a);
}
// Premultiplied after the decode: the light the texel adds, at its coverage.
if HDR_WRITE_LIGHT { return vec4(frame_stored(color).rgb * color.a, color.a); }
+88 -249
View File
@@ -192,6 +192,7 @@ fn packed_wanted() -> bool {
pub(crate) mod animate;
mod arena;
pub mod capture;
mod census;
pub mod compositor;
pub(crate) mod core_lines;
pub(crate) mod core_sky;
@@ -246,9 +247,11 @@ mod pack_post;
mod pack_shadow;
mod pack_sky;
mod pack_textures;
pub(crate) mod pack_timing;
pub mod pack_timing;
pub mod pack_uniforms;
mod pack_weather;
mod pass_timing_api;
use pass_timing_api::{after_culling_pass, pixel_count, world_pass_after_culling};
mod page_runs;
pub mod panorama;
mod readback;
@@ -337,42 +340,6 @@ fn surface_pass<'e>(
..Default::default()
})
}
fn world_pass_after_culling<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
depth: &wgpu::TextureView,
keep_depth: bool,
timing: Option<&mut pack_timing::PassTiming>,
name: &str,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world, after culling"),
timestamp_writes: timing.and_then(|t| t.writes(name)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
// A shader pack reads it as depthtex0.
store: if keep_depth {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// The point a `shaders/` pack's terrain is drawn relative to: the camera with the fraction of
/// each coordinate rounded to the nearest 1/65536 block. Read back with a probe pack of ours,
/// Iris 1.11.7's terrain `gl_Vertex` is the block's position minus this, for cameras from 13
@@ -427,10 +394,10 @@ struct Globals {
/// full (`hdr::Hdr::windows`); zero without HDR.
hdr_windows: [[f32; 4]; 2],
hdr_light: [f32; 4],
/// Headroom, effective scene gain, world/look and debug, as HDR output's pass has them
/// The scene's peak, HDR lighting's gain, world/look and debug; then the tiers' gains
/// (`Hdr::draw_params`).
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
hdr_gains: [f32; 4],
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -1473,10 +1440,14 @@ pub struct Renderer {
pack_loading: Option<PackLoading>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
native: Option<native_pack::NativePack>,
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
/// Time the shader pack's passes on the GPU (F3, or a capture made with `--gpu`).
pass_timing: bool,
/// Times the parts of passes too (`set_pass_detail`).
pass_detail: bool,
/// Least time between timed frames (`set_pass_timing_gap`).
pass_timing_gap: std::time::Duration,
/// Whether the timers keep every timed frame for `take_timed_frames`.
keep_timed_frames: bool,
/// Times Horizon's own passes on the GPU, as `pass_timing` does (`own_pass_times`).
own_timing: Option<pack_timing::PassTiming>,
/// The entity vertices Horizon draws itself while a shader pack draws the others.
@@ -2547,160 +2518,6 @@ enum FirstFrame {
fn on_off(on: bool) -> &'static str {
if on { "on" } else { "off" }
}
impl Renderer {
/// Sections with GPU buffers, for soak tests watching residency.
pub fn resident_sections(&self) -> usize {
self.sections.len()
}
/// Quads of the far terrain tiles with GPU buffers.
pub fn lod_quads(&self) -> u64 {
self.lods
.values()
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
.sum()
}
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
let mut levels = Vec::new();
for (key, tile) in &self.lods {
let level = usize::from(key.level);
if levels.len() <= level {
levels.resize(level + 1, [0, 0]);
}
levels[level][0] += 1;
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
}
levels
}
/// Whether solid quads go to the arena in the compact layout
/// (`packed_quads`), and whether far tiles' own records are on
/// (`lod::packed::far_packing`).
pub fn packed_layout(&self) -> [bool; 2] {
[self.packed_quads, crate::lod::packed::far_packing()]
}
/// Bytes of the arena the resident chunk sections hold, and of the one far
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
/// compact layouts can be seen working.
pub fn vertex_bytes_split(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
[
self.sections.values().map(used).sum(),
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
]
}
/// Bytes of the section vertex arenas' buffers, and of the vertices
/// resident sections hold in them.
pub fn section_vertex_bytes(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let vertices: u64 = self
.sections
.values()
.chain(self.lods.values().map(|tile| &*tile.mesh))
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
.sum();
let translucent = self.translucent_arena.as_ref();
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
[arenas, vertices * stride]
}
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
/// the terrain arenas (their pages, and what resident sections and far tiles use of
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
/// draws' buffers and the visibility buffer's.
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
let translucent = self.translucent_arena.as_ref();
vec![
("section_arena_pages", self.arena.bytes()),
(
"translucent_arena_pages",
translucent.map_or(0, arena::Arena::bytes),
),
("sorted_index_pages", self.sorted_indices.bytes()),
(
"near_section_vertices",
self.sections.values().map(used).sum(),
),
(
"held_section_vertices",
self.hold
.sections()
.filter_map(|held| held.gpu.as_ref())
.map(used)
.sum(),
),
(
"far_tile_vertices",
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
),
("quad_indices", self.indices.size()),
("block_sprites", textures(&self.sprite_textures)),
(
"pbr_maps",
self.pbr_maps
.as_ref()
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
),
(
"flat_draws",
self.flat.as_ref().map_or(0, flat::Flat::bytes),
),
(
"visibility_buffer",
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
),
]
}
/// Device memory the backend's allocator holds, by the label of what holds
/// it, largest first, with its allocated and reserved totals (Vulkan and
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
let report = self.device.generate_allocator_report()?;
let mut by_label: HashMap<String, u64> = HashMap::new();
for a in report.allocations {
*by_label.entry(a.name).or_default() += a.size;
}
let mut labels: Vec<_> = by_label.into_iter().collect();
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some((
report.total_allocated_bytes,
report.total_reserved_bytes,
labels,
))
}
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
/// resident sections, far tiles, block entities and light sources by section.
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
use std::mem::size_of;
let per_section = |map_len: usize, lists: usize| {
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
+ lists * size_of::<([i32; 3], u32)>()
};
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
per_section(
map.capacity(),
map.values().map(Vec::capacity).sum::<usize>(),
)
};
let animated: usize = self
.sections
.values()
.map(|s| s.animated_layers.len() * size_of::<u16>())
.sum();
[
("resident_sections", self.sections.heap_bytes() + animated),
(
"far_tiles",
self.lods.capacity()
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
),
("block_entities", lists(&self.block_entities)),
("light_sources", lists(&self.light_blocks)),
]
}
}
impl Renderer {
pub async fn new(
window: Arc<Window>,
@@ -5239,8 +5056,10 @@ impl Renderer {
pack_post: None,
pack_loading: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
pass_timing: false,
pass_detail: false,
pass_timing_gap: std::time::Duration::ZERO,
keep_timed_frames: false,
own_timing: None,
pack_entity_rest: Vec::new(),
cloud_bindings,
@@ -7602,51 +7421,6 @@ impl Renderer {
.terrain_inputs(self.pack_terrain_flat),
)
}
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open), or always
/// with `HORIZON_SHADER_PACK_TIMING`.
pub fn set_pass_timing(&mut self, on: bool) {
self.pass_timing = on || pack_timing::PassTiming::logging();
}
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
/// samples than a measurement). `HORIZON_SHADER_PACK_TIMING` keeps every sample.
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
self.pass_timing_gap = if pack_timing::PassTiming::logging() {
std::time::Duration::ZERO
} else {
gap
};
}
/// The running shader pack's timer, of either kind.
fn pass_timer<'a>(
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
native: &'a mut Option<native_pack::NativePack>,
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
match (pack, native) {
(Some((_, Some(post))), _) => Some(&mut post.timing),
(_, Some(native)) => Some(&mut native.timing),
_ => None,
}
}
/// Each pass of the running shader pack with its median GPU time in milliseconds, while
/// they are timed (`set_pass_timing`).
pub fn shader_pass_times(&self) -> Vec<(String, f32)> {
let timer = match (&self.pack_post, &self.native) {
(Some((_, Some(post))), _) => post.timing.as_ref(),
(_, Some(native)) => native.timing.as_ref(),
_ => None,
};
timer
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// Each of Horizon's own passes with its median GPU time in milliseconds, while they are
/// timed (`set_pass_timing`).
pub fn own_pass_times(&self) -> Vec<(String, f32)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// The macros this renderer gives shader packs: the GL extensions this device has
/// (subgroups, when it has them), and the vendor, renderer and mipmap levels
/// (`Macros::define_renderer`).
@@ -8255,6 +8029,8 @@ impl Renderer {
}
if let Some(t) = &mut self.own_timing {
t.gap = self.pass_timing_gap;
t.keep_frames(self.keep_timed_frames);
t.set_detail(self.pass_detail);
t.begin(&self.device, &self.queue);
}
if self.animated_textures {
@@ -8291,14 +8067,14 @@ impl Renderer {
self.shader_pack_kind().is_some(),
!frame.title,
);
let (hdr_settings, hdr_shares) = self
let (hdr_settings, hdr_gains) = self
.hdr
.as_ref()
.map_or(([0.0; 4], [0.0; 4]), |h| h.draw_params(!frame.title));
self.world_post.baked_light(
hdr_gain,
hdr_settings,
hdr_shares,
hdr_gains,
self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
);
let jitter = self.world_post.jitter(
@@ -8458,7 +8234,7 @@ impl Renderer {
f32::from(u8::from(hdr_gain)),
],
hdr_settings,
hdr_shares,
hdr_gains,
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
@@ -8674,6 +8450,7 @@ impl Renderer {
&mut self.uploads,
&mut encoder,
&input,
self.own_timing.as_mut(),
)
});
// A frame whose planes leave the test's integers (the camera millions of blocks
@@ -10022,6 +9799,7 @@ impl Renderer {
}
if let Some(Some(t)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
t.gap = self.pass_timing_gap;
t.keep_frames(self.keep_timed_frames);
}
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
@@ -10546,7 +10324,8 @@ impl Renderer {
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
let world_pixels = pixel_count(world_depth);
let first_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world"),
timestamp_writes: self.own_timing.as_mut().and_then(|t| t.writes("World")),
color_attachments: &attachments,
@@ -10566,9 +10345,16 @@ impl Renderer {
}),
..Default::default()
});
let mut pass = pack_timing::counted(
self.own_timing.as_mut(),
first_pass,
world_pixels,
"before translucent",
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
if frame.title || (sky_visible && (frame.skybox || frame.end_sky)) {
pass.stage(self.own_timing.as_mut(), "sky");
if !frame.title && frame.skybox && !pack_draws_sky && !native_sky_pass {
match self
.native
@@ -10655,16 +10441,15 @@ impl Renderer {
.forget_lifetime();
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass
pack_timing::CountedPass::plain(pass)
};
// The occlusion test's pipeline draws into the world's colour alone.
let plain_pass = |encoder: &mut wgpu::CommandEncoder| {
let mut pass =
world_pass_after_culling(encoder, world_view, world_depth, true, None, "")
.forget_lifetime();
let mut pass = after_culling_pass(encoder, world_view, world_depth, true, None)
.forget_lifetime();
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass
pack_timing::CountedPass::plain(pass)
};
if let Some(surface) = &surface {
drop(pass);
@@ -10718,6 +10503,7 @@ impl Renderer {
keep_depth,
self.own_timing.as_mut(),
"World after visibility buffer",
world_pixels,
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
@@ -10750,6 +10536,7 @@ impl Renderer {
flat.draw(pass, &flat.calls[calls.clone()], vertices);
}
};
pass.stage(self.own_timing.as_mut(), "solid terrain");
match cull {
// As below: phase 1, the test, then phase 2's draws
// that the test saw, copied after it.
@@ -10759,6 +10546,8 @@ impl Renderer {
drop(pass);
pass = plain_pass(&mut encoder);
}
pass.split(self.own_timing.as_mut(), "occlusion boxes");
pass.stage(self.own_timing.as_mut(), "occlusion boxes");
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
@@ -10772,10 +10561,12 @@ impl Renderer {
keep_depth,
self.own_timing.as_mut(),
"World after culling",
world_pixels,
),
};
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass.stage(self.own_timing.as_mut(), "solid terrain phase 2");
for (pipeline, _, calls) in passes {
pass.set_pipeline(pipeline);
flat.draw_phase2(&mut pass, calls.clone(), vertices);
@@ -10813,6 +10604,7 @@ impl Renderer {
let pulled = material.is_none() && self.flat.is_some();
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
pass.stage(self.own_timing.as_mut(), "solid terrain");
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
}
@@ -10837,6 +10629,8 @@ impl Renderer {
drop(pass);
pass = plain_pass(&mut encoder);
}
pass.split(self.own_timing.as_mut(), "occlusion boxes");
pass.stage(self.own_timing.as_mut(), "occlusion boxes");
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
@@ -10849,6 +10643,7 @@ impl Renderer {
keep_depth,
self.own_timing.as_mut(),
"World after culling",
world_pixels,
),
};
pass.set_bind_group(0, &self.group, &[]);
@@ -10858,6 +10653,7 @@ impl Renderer {
wgpu::IndexFormat::Uint32,
);
pass.set_vertex_buffer(1, self.instances.slice(..));
pass.stage(self.own_timing.as_mut(), "solid terrain phase 2");
for (pipeline, _, draws) in passes {
pass.set_pipeline(pipeline);
self.draw_sections_from(
@@ -10905,6 +10701,8 @@ impl Renderer {
drop(pass);
pass = plain_pass(&mut encoder);
}
pass.split(self.own_timing.as_mut(), "occlusion boxes");
pass.stage(self.own_timing.as_mut(), "occlusion boxes");
gpu.test(&mut pass);
drop(pass);
gpu.compact(&mut encoder, self.own_timing.as_mut());
@@ -10917,6 +10715,7 @@ impl Renderer {
keep_depth,
self.own_timing.as_mut(),
"World after culling",
world_pixels,
),
};
pass.set_bind_group(0, &self.group, &[]);
@@ -10925,6 +10724,7 @@ impl Renderer {
self.indices.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.stage(self.own_timing.as_mut(), "solid terrain phase 2");
for (pipeline, draws, _) in passes {
pass.set_pipeline(pipeline);
gpu.draw(&mut pass, &self.arena, page_group, 1);
@@ -10951,6 +10751,7 @@ impl Renderer {
&& let Some(d) = &n.displacement
&& let Some(pipeline) = n.material.as_ref().and_then(|m| m.displaced.as_ref())
{
pass.stage(self.own_timing.as_mut(), "displacement");
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
d.draw(&mut pass, &d.view, pipeline);
@@ -10967,6 +10768,7 @@ impl Renderer {
let hdr_entity_mark =
warped.map_or(&self.hdr_entity_mark_pipeline, |w| &w.hdr_entity_mark);
if static_entity_quads > 0 {
pass.stage(self.own_timing.as_mut(), "static block entities");
pass.set_pipeline(
warped.map_or(&self.static_entity_pipeline, |w| &w.static_entities),
);
@@ -10992,6 +10794,7 @@ impl Renderer {
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if entity_quads > 0 {
pass.stage(self.own_timing.as_mut(), "entities");
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11003,6 +10806,7 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if text_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "entity text");
pass.set_pipeline(warped.map_or(&self.text_polygon_offset_pipeline, |w| &w.text));
self.bind_world_text(&mut pass, warped.is_none(), false);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11012,6 +10816,7 @@ impl Renderer {
// FeatureRenderDispatcher.PreparedFrame.executeTranslucent: the shadows
// come before the translucent items, which then cover them (#7026).
if shadow_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "shadows");
pass.set_pipeline(warped.map_or(&self.shadow_blob_pipeline, |w| &w.entity_shadows));
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11027,6 +10832,7 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "translucent items");
pass.set_pipeline(
warped.map_or(&self.translucent_item_pipeline, |w| &w.translucent_items),
);
@@ -11062,6 +10868,7 @@ impl Renderer {
.enumerate()
{
if count > 0 {
pass.stage(self.own_timing.as_mut(), "terrain models");
pass.set_pipeline(&self.terrain_model_pipelines[i]);
pass.set_bind_group(
0,
@@ -11082,6 +10889,7 @@ impl Renderer {
// translucent material, before terrain transparency. This material
// always writes depth and never joins the improved-transparency queues.
if fluid_quads > 0 {
pass.stage(self.own_timing.as_mut(), "fluid block entities");
pass.set_pipeline(&self.fluid_pipeline);
pass.set_bind_group(0, &self.fluid_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11094,6 +10902,7 @@ impl Renderer {
}
// Cracks multiply the existing light; scalar OIT coverage cannot carry that factor.
if crumbling_quads > 0 && (!oit_on || hdr_gain) {
pass.stage(self.own_timing.as_mut(), "block breaking");
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11107,6 +10916,7 @@ impl Renderer {
// BoatRenderer's water patch (RenderTypes.waterMask): depth only,
// before translucent terrain, so no water shows inside the boat.
if water_mask_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "boat water mask");
pass.set_pipeline(&self.water_mask_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11119,6 +10929,7 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.lightning.bolts.is_empty() && !oit_on {
pass.stage(self.own_timing.as_mut(), "lightning");
pass.set_pipeline(&self.lightning_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
self.lightning.draw(&mut pass, &frame.lightning);
@@ -11129,6 +10940,7 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if swirl_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "energy swirl");
pass.set_pipeline(&self.energy_swirl_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11145,6 +10957,7 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if additive_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "additive entities");
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
let mut first = 0;
@@ -11170,20 +10983,24 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if outline_edges > 0 && !frame.outline_after_terrain && !pack_draws_outline {
pass.stage(self.own_timing.as_mut(), "block outline");
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
// DrawableGizmoPrimitives' opaque group writes depth, the translucent one does not.
if debug_opaque > 0 {
pass.stage(self.own_timing.as_mut(), "debug lines");
pass.set_pipeline(&self.debug_lines_opaque);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
pass.draw(0..4, 0..debug_opaque);
}
if debug_depth > debug_opaque {
pass.stage(self.own_timing.as_mut(), "debug lines");
pass.set_pipeline(&self.debug_lines);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
pass.draw(0..4, debug_opaque..debug_depth);
}
if debug_top > 0 {
pass.stage(self.own_timing.as_mut(), "debug lines");
pass.set_pipeline(&self.debug_lines_on_top);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
pass.draw(0..4, debug_depth..debug_depth + debug_top);
@@ -11211,6 +11028,7 @@ impl Renderer {
keep_depth,
self.own_timing.as_mut(),
"World after copy",
world_pixels,
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
@@ -11384,9 +11202,11 @@ impl Renderer {
true,
self.own_timing.as_mut(),
"World after OIT",
world_pixels,
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.stage(self.own_timing.as_mut(), "OIT marks");
if cloud_quads > 0 && !pack_draws_clouds {
self.mark_clouds(&mut pass, cloud_quads);
}
@@ -11419,6 +11239,8 @@ impl Renderer {
self.mark_translucent_terrain(&mut pass, flat, solid_draws, translucent_draws);
}
Some(flat) => {
pass.split(self.own_timing.as_mut(), "translucent terrain");
pass.stage(self.own_timing.as_mut(), "translucent terrain");
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
flat.draw(
&mut pass,
@@ -11433,8 +11255,11 @@ impl Renderer {
solid_draws,
translucent_draws,
);
pass.split(self.own_timing.as_mut(), "after translucent");
}
None => {
pass.split(self.own_timing.as_mut(), "translucent terrain");
pass.stage(self.own_timing.as_mut(), "translucent terrain");
pass.set_pipeline(translucent_pipeline);
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
@@ -11457,6 +11282,7 @@ impl Renderer {
translucent_draws,
);
}
pass.split(self.own_timing.as_mut(), "after translucent");
}
}
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
@@ -11464,6 +11290,7 @@ impl Renderer {
// the quads write depth, so before it they would hide the glass
// or water behind them.
if translucent_particle_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "translucent particles");
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
@@ -11492,6 +11319,7 @@ impl Renderer {
// particles (also after the OIT composite) and before the hand.
// Its own geometry is independent of block-entity lifetime.
if world_text_quads + world_text_offset_quads > 0 {
pass.stage(self.own_timing.as_mut(), "world text");
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11514,9 +11342,11 @@ impl Renderer {
// LevelRenderer.executeClassicTransparency: translucent targets'
// outlines follow terrain, so its blending cannot wash them out.
if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline {
pass.stage(self.own_timing.as_mut(), "block outline");
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
if translucent_crumbling_quads > 0 && (!oit_on || hdr_gain) {
pass.stage(self.own_timing.as_mut(), "block breaking");
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11527,6 +11357,7 @@ impl Renderer {
);
}
if cloud_quads > 0 && !pack_draws_clouds && !oit_on {
pass.stage(self.own_timing.as_mut(), "clouds");
pass.set_pipeline(&self.cloud_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(1, &self.cloud_group, &[]);
@@ -11538,6 +11369,7 @@ impl Renderer {
// terrain and clouds, so the water behind the rain is drawn first.
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if !pack_draws_weather && !oit_on {
pass.stage(self.own_timing.as_mut(), "weather");
let native_weather = self
.native
.as_ref()
@@ -11549,6 +11381,7 @@ impl Renderer {
}
// LevelRenderer: the world border after translucent terrain and weather.
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title && !oit_on) {
pass.stage(self.own_timing.as_mut(), "world border");
pass.set_pipeline(&self.border_pipeline);
pass.set_bind_group(0, &self.border_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -11561,6 +11394,7 @@ impl Renderer {
}
// LevelRenderer.executeSeeThrough, after the rest of the world.
if see_through_quads > 0 {
pass.stage(self.own_timing.as_mut(), "see-through");
pass.set_pipeline(&self.see_through_pipeline);
self.bind_world_text(&mut pass, true, true);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11573,9 +11407,11 @@ impl Renderer {
// Native mods' primitives: after the entities and the rest of the world, into its
// targets (its resolution, reversed depth and TAA), before the hand.
if world_primitives {
pass.stage(self.own_timing.as_mut(), "mod primitives");
self.world_primitives.draw(&mut pass);
}
if hand_in_world {
pass.stage(self.own_timing.as_mut(), "hand");
pass.set_pipeline(&self.depth_reset);
pass.set_bind_group(0, &self.group, &[]);
pass.draw(0..3, 0..1);
@@ -12563,6 +12399,9 @@ impl Renderer {
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some(timer) = &mut self.own_timing {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
@@ -0,0 +1,164 @@
//! What the renderer holds, for status, soak tests and memory reports (`horizon-ctl memory`,
//! `scene_replay --memory`): resident sections and far tiles, and the bytes of its largest
//! GPU resources and of its per-section bookkeeping.
use std::collections::HashMap;
use super::{
Renderer, SectionGpu, SectionKey, TerrainVertex, arena, flat, lod, texture_bytes, visbuffer,
};
impl Renderer {
/// Sections with GPU buffers, for soak tests watching residency.
pub fn resident_sections(&self) -> usize {
self.sections.len()
}
/// Quads of the far terrain tiles with GPU buffers.
pub fn lod_quads(&self) -> u64 {
self.lods
.values()
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
.sum()
}
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
let mut levels = Vec::new();
for (key, tile) in &self.lods {
let level = usize::from(key.level);
if levels.len() <= level {
levels.resize(level + 1, [0, 0]);
}
levels[level][0] += 1;
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
}
levels
}
/// Whether solid quads go to the arena in the compact layout
/// (`packed_quads`), and whether far tiles' own records are on
/// (`lod::packed::far_packing`).
pub fn packed_layout(&self) -> [bool; 2] {
[self.packed_quads, crate::lod::packed::far_packing()]
}
/// Bytes of the arena the resident chunk sections hold, and of the one far
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
/// compact layouts can be seen working.
pub fn vertex_bytes_split(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
[
self.sections.values().map(used).sum(),
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
]
}
/// Bytes of the section vertex arenas' buffers, and of the vertices
/// resident sections hold in them.
pub fn section_vertex_bytes(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let vertices: u64 = self
.sections
.values()
.chain(self.lods.values().map(|tile| &*tile.mesh))
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
.sum();
let translucent = self.translucent_arena.as_ref();
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
[arenas, vertices * stride]
}
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
/// the terrain arenas (their pages, and what resident sections and far tiles use of
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
/// draws' buffers and the visibility buffer's.
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
let translucent = self.translucent_arena.as_ref();
vec![
("section_arena_pages", self.arena.bytes()),
(
"translucent_arena_pages",
translucent.map_or(0, arena::Arena::bytes),
),
("sorted_index_pages", self.sorted_indices.bytes()),
(
"near_section_vertices",
self.sections.values().map(used).sum(),
),
(
"held_section_vertices",
self.hold
.sections()
.filter_map(|held| held.gpu.as_ref())
.map(used)
.sum(),
),
(
"far_tile_vertices",
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
),
("quad_indices", self.indices.size()),
("block_sprites", textures(&self.sprite_textures)),
(
"pbr_maps",
self.pbr_maps
.as_ref()
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
),
(
"flat_draws",
self.flat.as_ref().map_or(0, flat::Flat::bytes),
),
(
"visibility_buffer",
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
),
]
}
/// Device memory the backend's allocator holds, by the label of what holds
/// it, largest first, with its allocated and reserved totals (Vulkan and
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
let report = self.device.generate_allocator_report()?;
let mut by_label: HashMap<String, u64> = HashMap::new();
for a in report.allocations {
*by_label.entry(a.name).or_default() += a.size;
}
let mut labels: Vec<_> = by_label.into_iter().collect();
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some((
report.total_allocated_bytes,
report.total_reserved_bytes,
labels,
))
}
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
/// resident sections, far tiles, block entities and light sources by section.
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
use std::mem::size_of;
let per_section = |map_len: usize, lists: usize| {
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
+ lists * size_of::<([i32; 3], u32)>()
};
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
per_section(
map.capacity(),
map.values().map(Vec::capacity).sum::<usize>(),
)
};
let animated: usize = self
.sections
.values()
.map(|s| s.animated_layers.len() * size_of::<u16>())
.sum();
[
("resident_sections", self.sections.heap_bytes() + animated),
(
"far_tiles",
self.lods.capacity()
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
),
("block_entities", lists(&self.block_entities)),
("light_sources", lists(&self.light_blocks)),
]
}
}
@@ -33,7 +33,7 @@ struct Uniform {
mode: u32,
pad: [u32; 2],
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
hdr_gains: [f32; 4],
hdr_light: [f32; 4],
}
@@ -407,7 +407,7 @@ impl CustomSky {
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
let mark =
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
if globals.hdr_settings[2] > 0.5 {
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
} else {
1.0
@@ -428,7 +428,7 @@ impl CustomSky {
mode: mode as u32,
pad: [0; 2],
hdr_settings: globals.hdr_settings,
hdr_shares: globals.hdr_shares,
hdr_gains: globals.hdr_gains,
hdr_light: globals.hdr_light,
};
let offset = STRIDE * self.frame.len() as u64;
@@ -702,7 +702,7 @@ mod tests {
mode: mode_index as u32,
pad: [0; 2],
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
hdr_shares: [0.2, 0.5, 0.6, 1.0],
hdr_gains: [1.6, 2.4, 2.8, 4.0],
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
};
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -10,7 +10,7 @@ struct Layer {
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_gains: vec4<f32>,
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> layer: Layer;
@@ -54,7 +54,7 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
var color = t.rgb;
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains);
}
// On a frame of linear light (`FRAME_LINEAR`) the layer's colour is decoded as it is stored.
switch layer.mode {
@@ -84,5 +84,5 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
let color = custom_sky_color(in);
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains), color.a);
}
@@ -71,7 +71,7 @@ use super::{
Frustum, SectionKey,
arena::{Arena, try_create},
occlusion::Reach,
pack_timing::PassTiming,
pack_timing::{PassTiming, compute_parts},
resident,
submit::Queue,
upload::Uploads,
@@ -374,6 +374,21 @@ pub(super) enum Prepared {
OutOfMemory,
}
/// A part of a compute pass: `pipeline` over `[x, y]` workgroups, nothing when `x` is 0.
fn step<'a>(
group: &'a wgpu::BindGroup,
pipeline: &'a wgpu::ComputePipeline,
[x, y]: [u32; 2],
) -> impl FnMut(&mut wgpu::ComputePass<'_>) + 'a {
move |pass| {
if x > 0 {
pass.set_bind_group(0, group, &[]);
pass.set_pipeline(pipeline);
pass.dispatch_workgroups(x, y, 1);
}
}
}
/// The frame's `tables` before the regions, into `words`, and the parameters
/// the frustum test and facing read with them; `None` when the CPU draws the
/// frame (`Prepared::Cpu`): a sum of a plane's terms could leave i32
@@ -1190,12 +1205,13 @@ impl GpuCulling {
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
input: &FrameInputs<'_>,
timing: Option<&mut PassTiming>,
) -> Prepared {
self.pages.clear();
self.ids = 0;
self.n_updates = 0;
self.occluding = false;
self.prepare_buffers(device, queue, uploads, encoder, input)
self.prepare_buffers(device, queue, uploads, encoder, input, timing)
.unwrap_or(Prepared::OutOfMemory)
}
@@ -1206,6 +1222,7 @@ impl GpuCulling {
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
input: &FrameInputs<'_>,
mut timing: Option<&mut PassTiming>,
) -> Option<Prepared> {
// Every buffer grows before anything is written. One made anew drops
// the bind group at once, and a new section table needs every record:
@@ -1287,6 +1304,9 @@ impl GpuCulling {
occl = OCCLUDE | if probe { PROBE } else { 0 };
}
}
let uploading = timing
.as_mut()
.and_then(|t| t.encoder_begin(encoder, "GPU culling uploads"));
if self.records.all_dirty {
// The whole table, after it grew or more records changed than
// updates take (GPU culling switched on in a loaded world, a
@@ -1352,6 +1372,9 @@ impl GpuCulling {
// are written before they are read.
let cleared = if self.occluding { tests_at } else { phase1_end };
encoder.clear_buffer(&self.counts.buffer, 0, Some(cleared * 4));
if let Some(t) = timing {
t.encoder_end(encoder, uploading);
}
if self.group.is_none() {
self.group = Some(self.bind(device));
if let Some(test) = &mut self.test {
@@ -1403,23 +1426,24 @@ impl GpuCulling {
let Some(group) = self.group.as_ref().filter(|_| self.ids > 0) else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("GPU culling"),
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling")),
});
pass.set_bind_group(0, group, &[]);
let [apply, cull, scan, scatter] = &self.pipelines;
if self.n_updates > 0 {
pass.set_pipeline(apply);
pass.dispatch_workgroups(self.n_updates.div_ceil(WORKGROUP), 1, 1);
}
let blocks = self.ids.div_ceil(WORKGROUP);
pass.set_pipeline(cull);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
pass.set_pipeline(scan);
pass.dispatch_workgroups(self.pass_pages, 1, 1);
pass.set_pipeline(scatter);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
let rows = [blocks.min(ROW), blocks.div_ceil(ROW)];
let updates = [self.n_updates.div_ceil(WORKGROUP), 1];
let (mut apply, mut cull) = (step(group, apply, updates), step(group, cull, rows));
let pages = [self.pass_pages, 1];
let (mut scan, mut scatter) = (step(group, scan, pages), step(group, scatter, rows));
compute_parts(
encoder,
timing,
"GPU culling",
&mut [
("apply", &mut apply),
("cull", &mut cull),
("scan", &mut scan),
("scatter", &mut scatter),
],
);
}
/// The kept sections' solid draws, a count draw per page, with the quad
@@ -1494,19 +1518,22 @@ impl GpuCulling {
if !self.occluding {
return;
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("GPU culling phase 2"),
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling phase 2")),
});
pass.set_bind_group(0, group, &[]);
let [count2, scan2, scatter2] = &test.phase2;
let blocks = self.ids.div_ceil(WORKGROUP);
pass.set_pipeline(count2);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
pass.set_pipeline(scan2);
pass.dispatch_workgroups(self.pass_pages, 1, 1);
pass.set_pipeline(scatter2);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
let rows = [blocks.min(ROW), blocks.div_ceil(ROW)];
let pages = [self.pass_pages, 1];
let (mut count, mut scatter) = (step(group, count2, rows), step(group, scatter2, rows));
let mut scan = step(group, scan2, pages);
compute_parts(
encoder,
timing,
"GPU culling phase 2",
&mut [
("count2", &mut count),
("scan2", &mut scan),
("scatter2", &mut scatter),
],
);
}
/// After the frame's world passes (phase 2's counts too), in the same
@@ -2720,6 +2747,11 @@ mod tests {
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
let needed =
wgpu::Features::MULTI_DRAW_INDIRECT_COUNT | wgpu::Features::INDIRECT_FIRST_INSTANCE;
let timing = adapter.features()
& (wgpu::Features::TIMESTAMP_QUERY
| wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES
| wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS
| wgpu::Features::SHADER_EARLY_DEPTH_TEST);
if !adapter.features().contains(needed)
|| super::super::flat::is_broken_intel_vulkan(&adapter.get_info())
{
@@ -2727,7 +2759,7 @@ mod tests {
}
eprintln!("GPU culling on {} ({name})", adapter.get_info().name);
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features: needed,
required_features: needed | timing,
..Default::default()
}))
.ok()
@@ -2834,6 +2866,11 @@ mod tests {
// was drawn.
let (mut eyed, mut hidden, mut several) = (0, 0, 0);
let (mut edge, mut edge_drawn) = (None, false);
// Timed in detail as the renderer does: the commands, and so the draws, stay the same.
let mut timing = PassTiming::new(device, queue);
if let Some(t) = &mut timing {
t.set_detail(true);
}
for case in 0..25 {
let radius = [2, 5, 9][case % 3];
let at_edge = case == 24;
@@ -2981,6 +3018,9 @@ mod tests {
edge = Some(id);
}
let whole = gpu.records.all_dirty;
if let Some(t) = &mut timing {
t.begin(device, queue);
}
let mut encoder = device.create_command_encoder(&Default::default());
let input = FrameInputs {
camera,
@@ -3000,11 +3040,18 @@ mod tests {
camera: camera + DVec3::Y * 2.0e7,
..input
};
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up);
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up, None);
assert_eq!(prepared, Prepared::Cpu, "case 7");
assert!(gpu.records.all_dirty || !gpu.records.dirty.is_empty());
}
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
let prepared = gpu.prepare(
device,
queue,
&mut uploads,
&mut encoder,
&input,
timing.as_mut(),
);
assert_eq!(prepared, Prepared::Gpu, "case {case}");
match case {
4 => assert!(
@@ -3060,12 +3107,15 @@ mod tests {
culled += 1;
}
}
gpu.dispatch(&mut encoder, None);
gpu.dispatch(&mut encoder, timing.as_mut());
// Its statistics come back with a CPU part of the case's own.
let cpu = [case * 7, case * 3, case];
gpu.copy_stats(&mut encoder, case as u64, cpu);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
if let Some(t) = &mut timing {
t.submitted(queue);
}
gpu.submitted().expect("a readback free")();
let pages = u64::from(gpu.pass_pages);
let counts = read(device, queue, &gpu.counts.buffer, (STATS + pages) * 4);
@@ -3124,6 +3174,18 @@ mod tests {
"of {eyed} from an eye, {hidden} with a bucket left out, {several} in three runs"
);
assert!(edge_drawn, "the bucket at the section's edge");
if let Some(t) = &timing {
let parts = t.part_medians();
let names = |pass: &str| {
let parts = parts.iter().find(|p| p.0 == pass)?;
Some(parts.1.iter().map(|p| p.0.as_str()).collect::<Vec<_>>())
};
assert_eq!(
names("GPU culling"),
Some(vec!["apply", "cull", "scan", "scatter"]),
"{parts:?}"
);
}
assert!(culled > 2_000, "{culled} within the distance left out");
assert!(faded * 10 > compared, "{faded} fading of {compared} drawn");
assert!(
@@ -3136,6 +3198,113 @@ mod tests {
);
}
/// Detail times both phases and the uploads and leaves counts and draws as an untimed frame's.
#[test]
fn detail_times_both_phases_and_leaves_the_draws_alone() {
on_each_device(|device, queue| {
let mut rng = Rng(0x1F83_D9AB_FB41_BD6B);
let (frustum, _) = view(&mut rng);
let records: Vec<Record> = (0..400)
.map(|i| {
let key = (rng.range(-6, 6), rng.range(0, 23), rng.range(-6, 6));
Record::new(
key,
i % 3,
u64::from(i) * 1024,
1 + rng.below(500),
UNBOUNDED,
)
})
.collect();
let mut timing = PassTiming::new(device, queue);
let early = device
.features()
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST);
let Some(t) = timing.as_mut().filter(|_| early) else {
eprintln!("no timestamps or early depth test: skipped");
return;
};
t.set_detail(true);
let run = |timing: &mut Option<PassTiming>| {
let mut gpu = GpuCulling::new(device, Some(wgpu::TextureFormat::Rgba8Unorm))
.expect("the pass");
let mut uploads = Uploads::new(device);
for r in &records {
gpu.records.insert(*r);
}
let mut out = Vec::new();
for frame in 0..12 {
if let Some(t) = timing {
t.begin(device, queue);
}
let mut encoder = device.create_command_encoder(&Default::default());
let input = FrameInputs {
camera: DVec3::new(0.5, 70.0, 0.5),
min_y: -64,
sections: 24,
radius: 5,
frustum: &frustum,
reach: None,
eye: None,
view_proj: Mat4::IDENTITY,
occlude: true,
};
let prepared = gpu.prepare(
device,
queue,
&mut uploads,
&mut encoder,
&input,
timing.as_mut(),
);
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
assert!(gpu.occluding(), "frame {frame}: phase 2 runs");
gpu.dispatch(&mut encoder, timing.as_mut());
gpu.compact(&mut encoder, timing.as_mut());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
if let Some(t) = timing {
t.submitted(queue);
}
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pages = u64::from(gpu.pass_pages);
out.push((
read(device, queue, &gpu.counts.buffer, (STATS + 2 * pages) * 4),
read(device, queue, &gpu.draws.buffer, gpu.draws.bytes),
));
}
out
};
let timed = run(&mut timing);
let plain = run(&mut None);
assert_eq!(timed, plain, "detail changes what is drawn");
let t = timing.as_ref().expect("the timer");
let parts = t.part_medians();
let names = |pass: &str| {
let parts = parts.iter().find(|p| p.0 == pass)?;
Some(parts.1.iter().map(|p| p.0.as_str()).collect::<Vec<_>>())
};
assert_eq!(
names("GPU culling"),
Some(vec!["apply", "cull", "scan", "scatter"]),
"{parts:?}"
);
assert_eq!(
names("GPU culling phase 2"),
Some(vec!["count2", "scan2", "scatter2"]),
"{parts:?}"
);
// The uploads are a pass of their own, never a part of one.
assert_eq!(names("GPU culling uploads"), None, "{parts:?}");
let passes = t.medians();
assert_eq!(
passes.iter().any(|p| p.0 == "GPU culling uploads"),
t.detail_support().encoder_stamps,
"{passes:?}"
);
});
}
/// The statistics come back with their frame, in place and from the submit
/// thread, submitted as gfx.rs submits frames (`submit_later`, or in place
/// with its `after` handed back): three frames on their way fill the ring, a
@@ -3377,7 +3546,7 @@ struct Quad {
view_proj: Mat4::IDENTITY,
occlude: false,
};
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input, None);
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
let mut params = tables(&input, &mut words).expect("the planes fit");
params.pages = gpu.pass_pages;
+270 -90
View File
@@ -5,6 +5,10 @@
//! or scRGB swapchain at the player's paper white, expanding the world's
//! highlights towards their peak brightness. The world is copied aside before
//! the hand and HUD so only it is expanded.
//!
//! On the float frame Horizon's world light is the same on every display (`TIERS`,
//! `SUNLIT_GAIN`, up to `SCENE_PEAK`), and the tone curve alone fits it to the display's
//! peak (docs/design/hdr-linear-light.md, step 6a).
/// The swapchain HDR output presents to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -99,38 +103,53 @@ struct Layered {
params: wgpu::Buffer,
}
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
/// texel's own brightness before lighting.
/// How one tier of light sources (`mesher::hdr_tier`) shines: its brightest pixels are
/// multiplied by `gain`, in multiples of paper white, lifted from brightness `start` (none) to
/// `full` (all of it), the texel's own brightness before lighting.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tier {
pub share: f32,
pub gain: f32,
pub start: f32,
pub full: f32,
}
/// Low, incandescent, bright and very bright.
/// Low, incandescent, bright and very bright: faint glowers such as magma, flames and lava,
/// glowstone and campfires, the sun.
pub const TIERS: [Tier; 4] = [
Tier {
share: 0.2,
gain: 1.6,
start: 0.08,
full: 0.09,
},
Tier {
share: 0.5,
gain: 2.4,
start: 0.14,
full: 0.38,
},
Tier {
share: 0.6,
gain: 2.8,
start: 0.14,
full: 0.38,
},
Tier {
share: 1.0,
gain: 4.0,
start: 0.14,
full: 0.38,
},
];
/// The scene's peak, in multiples of paper white: just above the brightest light the world
/// writes (the sun's disc, white at the very bright tier's 4), and what the tone curve maps
/// onto the display's peak; light past it (where the sky adds onto the sun) is held at it. The
/// world is mastered for a 1000-nit display at ITU-R BT.2408's 203-nit reference white, as
/// HDR10 content usually is: there it shows as written, a dimmer display eases its highlights
/// into its peak (BT.2390's EETF), and a brighter one shows it as at 1000.
pub const SCENE_PEAK: f32 = 1000.0 / 203.0;
/// HDR lighting: what the strongest light multiplies a surface by (`hdr_light.wgsl`).
pub const SUNLIT_GAIN: f32 = 3.0;
/// The compatibility frame's tiers (the 16-bit or 8-bit frame, until step 5 removes it): each
/// one's share of the display's headroom, as before one peak, so the look HDR has by default
/// changes once, when the float frame replaces it.
const COMPAT_SHARES: [f32; 4] = [0.2, 0.5, 0.6, 1.0];
/// Which pixels of a light source's sprite may shine (`hdr_stats.wgsl`), as a fraction of
/// the way from its darkest to its brightest opaque pixel: `parts` for one whose bright parts
/// shine (a torch's flame, not its handle), `whole` for one that shines all over
@@ -331,7 +350,7 @@ impl Hdr {
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 64,
size: 80,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
@@ -944,16 +963,19 @@ impl Hdr {
})
}
/// What the world's writers read (`hdr_written`): the scene's peak, HDR lighting's gain,
/// world/look and debug, then the tiers' gains. No part of the display reaches them.
pub(super) fn draw_params(&self, world: bool) -> ([f32; 4], [f32; 4]) {
let p = self.params(world, false);
([p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap())
let sunlit = if self.lighting { SUNLIT_GAIN } else { 1.0 };
([SCENE_PEAK, sunlit, p[2], p[3]], self.tiers.map(|t| t.gain))
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
let paper = self.paper_white.max(1.0);
let c = self.calibration;
let mut params = [0.0; 16];
let mut params = [0.0; 20];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
@@ -968,12 +990,17 @@ impl Hdr {
} else {
1.0
};
for (i, t) in self.tiers.iter().enumerate() {
params[8 + i] = t.share;
}
params[8..12].copy_from_slice(&COMPAT_SHARES);
params[12] = c.map_or(0.0, |c| c.step as f32);
params[13] = c.map_or(0.0, |c| c.nits);
params[14] = c.map_or(0.0, |c| c.band);
// Only Horizon's lifted world is known to end at the scene's peak; elsewhere (HDR
// Lighting off, a pack's own light, the title) the curve's source follows the display.
params[16] = if world && self.lighting {
SCENE_PEAK
} else {
0.0
};
params
}
@@ -1050,8 +1077,9 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
.create_view(&Default::default())
}
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
/// The compatibility frame's HDR lighting (`fs_hdr`): what the strongest light multiplies a
/// surface by, 4, which looked right across biomes; less only where there is little headroom
/// to ease into. The float frame's is `SUNLIT_GAIN`.
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
}
@@ -1069,7 +1097,8 @@ mod tests {
return;
};
// All 8-bit marks, both easing policies, non-grey and over-white colours, look,
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries.
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries. The
// writers take the gain that a share gives.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ r#"
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
@@ -1088,19 +1117,20 @@ var<private> params: ReferenceParams;
select(0.0, 1.0, (mode & 32u) != 0u), vec4(0.2, 0.5, 0.6, 1.0));
let color = select(vec3(0.9, 0.4, 0.02), vec3(1.4, 0.8, 0.2), (mode & 64u) != 0u);
let expected = reference_lit(color, mark, shows, eased);
let gains = 1.0 + (params.peak - 1.0) * params.share;
let actual = hdr_mark_light(color, mark, shows, eased,
vec4(params.peak, params.scene, params.world, params.debug), params.share);
vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
// Gain stays above the tone knee until the separate tone curve runs.
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), gains);
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
let boundary = (f32(i % 255u) + 0.5) / 255.0;
for (var step = -1; step <= 1; step++) {
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
let quantized = round(saturate(stored) * 255.0) / 255.0;
let want = pow(reference_lit(color, quantized, params.world > 0.5 && params.peak > 1.0, false).light, vec3(1.0 / 2.2));
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), params.share);
let want = pow(reference_lit(color, quantized, params.world > 0.5, false).light, vec3(1.0 / 2.2));
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), gains);
errors[i] |= select(0u, 2u, any(abs(want - got) > vec3(1e-6)));
}
}
@@ -1319,10 +1349,12 @@ var<private> params: ReferenceParams;
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
// Float targets in these tests only receive the resulting colour and coverage.
// `lift`: the reference's peak, HDR lighting gain, world and debug, then the tiers' shares
// of the headroom up to that peak (`writers_lift`, `display_lift`).
pub(super) fn legacy_light(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
hdr: &Hdr,
lift: [[f32; 4]; 2],
source: &wgpu::Texture,
target: &wgpu::Texture,
) {
@@ -1345,7 +1377,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
@fragment fn fs() -> @location(0) vec4<f32> {
let c = textureLoad(source, vec2(0), 0);
let mark = round(saturate(c.a) * 255.0) / 255.0;
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
let light = reference_lit(c.rgb, mark, params.world > 0.5, false).light;
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
}
"#)
@@ -1373,10 +1405,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
multiview_mask: None,
cache: None,
});
let (settings, shares) = hdr.draw_params(true);
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&[settings, shares]),
contents: bytemuck::cast_slice(&lift),
usage: wgpu::BufferUsages::UNIFORM,
});
let view = source.create_view(&Default::default());
@@ -1404,6 +1435,19 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
);
}
/// The float frame's writers' light as `legacy_light` takes it: each tier's gain as a share
/// of the headroom up to the scene's peak.
pub(super) fn writers_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
let (settings, gains) = hdr.draw_params(true);
[settings, gains.map(|g| (g - 1.0) / (settings[0] - 1.0))]
}
/// The compatibility frame's display pass's light (`fs_hdr`) as `legacy_light` takes it.
fn display_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
let p = hdr.params(true, false);
[[p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap()]
}
#[test]
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
let Some((device, queue)) = gpu() else {
@@ -1416,8 +1460,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
return;
}
// Compare the compatibility display lift with direct colour under the layered HUD.
// The frozen oracle supplies the writer's light from a non-float attachment.
// Compare the compatibility display lift with direct colour under the layered HUD:
// the frozen oracle turns the marks into the light that display gives them, and the
// layered display, whose curve is the identity at 1000 nits, must show it the same.
let shown = |layered: bool, world: [f64; 4]| {
let mut hdr = Hdr::with_debug(
&device,
@@ -1441,7 +1486,14 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
if world[3] == 1.0 {
fill(&mut encoder, &frame, world);
} else {
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
// The light the compatibility frame shows: its own lift, not the writers'.
legacy_light(
&device,
&mut encoder,
display_lift(&legacy),
&marked,
&frame,
);
}
hdr.begin(&queue, true);
hdr.tonemap(&mut encoder, None);
@@ -1480,78 +1532,206 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
assert!(above[0] > white[0] && above[1] > white[1]);
}
/// The world's writers read nothing of the display: the same gains and the same scene peak
/// at any paper white and peak (docs/design/hdr-linear-light.md, step 6a).
#[test]
fn the_tone_curve_eases_light_into_the_peak() {
fn the_writers_light_is_the_same_on_every_display() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let headroom = 1000.0 / 203.0;
// The frame holding `color` (in gamma, as it stores light) after `passes` ran on it.
let after =
|hdr: &mut Hdr, color: [f64; 4], passes: &dyn Fn(&Hdr, &mut wgpu::CommandEncoder)| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, color);
hdr.begin(&queue, true);
passes(hdr, &mut encoder);
texel(&device, &queue, encoder, &frame)
};
let tonemap = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.tonemap(encoder, None);
let written = hdr.draw_params(true);
assert_eq!(
written,
(
[super::SCENE_PEAK, super::SUNLIT_GAIN, 1.0, 0.0],
[1.6, 2.4, 2.8, 4.0]
)
);
for (paper, peak) in [
(203.0, 300.0),
(203.0, 600.0),
(203.0, 1500.0),
(400.0, 600.0),
(480.0, 200.0),
(80.0, 10000.0),
] {
(hdr.paper_white, hdr.peak) = (paper, peak);
assert_eq!(hdr.draw_params(true), written, "{paper}/{peak}");
assert_eq!(
hdr.params(true, false)[16],
super::SCENE_PEAK,
"{paper}/{peak}"
);
}
// The title, and frames Horizon doesn't light (HDR Lighting off, a pack drawing):
// nothing says where their light ends, so the curve's source follows the display.
assert_eq!(hdr.params(false, false)[16], 0.0);
hdr.lighting = false;
assert_eq!(hdr.draw_params(true).0[1], 1.0);
assert_eq!(hdr.params(true, false)[16], 0.0);
}
/// The tone curve maps the scene's peak S onto the display's peak T with ITU-R BT.2390's
/// EETF, keeping hue, after holding light above S at S. Checkpoints are worked out apart
/// from the shader, in f64 from SMPTE ST 2084 and BT.2390.
#[test]
fn the_tone_curve_maps_the_scene_peak_onto_the_display() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let scene_peak = super::SCENE_PEAK;
// The frame holding `color` (in gamma, as it stores light), after the curve if `curve`.
let through = |hdr: &mut Hdr, color: [f64; 4], curve: bool| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, color);
hdr.begin(&queue, true);
if curve {
hdr.tonemap(&mut encoder, None);
}
texel(&device, &queue, encoder, &frame)
};
// `exact`, or within one half-float step: S's own gamma rounds up in half float, to
// light just above S that the curve holds at S, and whether that comes back to the same
// half float depends on the GPU's rounding (one step lower on an Apple M4 Pro).
let unchanged = |hdr: &mut Hdr, color: [f64; 4], exact: bool| {
let (got, stored) = (through(hdr, color, true), through(hdr, color, false));
(0..4).all(|c| {
got[c] == stored[c] || !exact && (got[c] - stored[c]).abs() <= stored[c] / 1024.0
})
};
// Linear light in and out, in units of paper white.
let eased = |hdr: &mut Hdr, light: [f32; 3]| {
let mapped = |hdr: &mut Hdr, light: [f32; 3]| {
let [r, g, b] = light.map(|c| f64::from(c.powf(1.0 / 2.2)));
let [r, g, b, a] = after(hdr, [r, g, b, 1.0], &tonemap);
let [r, g, b, a] = through(hdr, [r, g, b, 1.0], true);
assert_eq!(a, 1.0);
[r, g, b].map(|c| c.powf(2.2))
};
let near = |got: f32, want: f32| (got - want).abs() <= 0.005 * want.max(1.0);
// Up to the knee (60% of the peak) the light stays exactly as stored.
for light in [0.0f32, 0.5, 1.0, 2.0, 2.9] {
let g = f64::from(light.powf(1.0 / 2.2));
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
assert_eq!(
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
stored,
"{light}"
);
}
// Past it, BT.2390's EETF in PQ at 203 and 1000 nits, with the source's peak at 2771
// nits (13.7 times paper white), worked out apart from the shader.
for (light, want) in [
(3.5, 3.43791),
(4.0, 3.78351),
(headroom, 4.23556),
(8.0, 4.82043),
(12.0, 4.92462),
(50.0, headroom),
// A display at or above the mastering one (T >= S): every stored value up to S comes
// out exactly as it went in, grey or coloured, and light above S is held at S with its
// hue, so 1500 and 10 000 nits show what 1000 does; so do the settings' extremes (Paper
// White 10 to 480 nits, Peak up to 10 000) wherever T reaches S.
for (paper, peak) in [
(203.0, 1000.0),
(203.0, 1500.0),
(203.0, 10000.0),
(480.0, 10000.0),
(10.0, 200.0),
] {
let [r, ..] = eased(&mut hdr, [light; 3]);
assert!(near(r, want), "{light}: {r}, not {want}");
(hdr.paper_white, hdr.peak) = (paper, peak);
for i in 0..=64 {
let g = f64::from((scene_peak * i as f32 / 64.0).powf(1.0 / 2.2));
let exact = i < 64;
assert!(unchanged(&mut hdr, [g, g, g, 1.0], exact), "{peak}: {i}");
assert!(
unchanged(&mut hdr, [g, g * 0.6, g * 0.1, 1.0], exact),
"{peak}: {i}"
);
}
for light in [8.0f32, 50.0] {
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
assert!(
near(r, scene_peak) && near(g * 4.0, r) && b == 0.0,
"{peak}: {light}: {r} {g} {b}"
);
}
}
// It rises, stays under the peak, and the brightest channel scales the others: the
// light keeps its hue.
let mut last = 0.0;
for i in 0..40 {
let light = 2.5 * 1.08f32.powi(i);
let [r, g, b] = eased(&mut hdr, [light, light / 4.0, 0.0]);
assert!(r >= last - 0.001 && r <= headroom * 1.002, "{light}: {r}");
assert!(near(g * 4.0, r) && b == 0.0, "{light}: {r} {g} {b}");
last = r;
// Dimmer displays: unchanged up to the knee, then the EETF into T, T at S and past it.
// At 300 nits the knee falls just below white (161 nits): white shows at 198 nits
// where the gates' curve, whose source followed the display, kept it at 203; the
// highlights above it keep more of their steps.
for (paper, peak, knee, checkpoints) in [
(
203.0,
600.0,
2.0,
&[
(2.5, 2.47516),
(3.0, 2.75699),
(4.0, 2.9404),
(scene_peak, 600.0 / 203.0),
(8.0, 600.0 / 203.0),
][..],
),
(
203.0,
300.0,
0.75,
&[
(0.9, 0.89289),
(1.0, 0.97365),
(1.2, 1.10406),
(1.4, 1.20134),
(2.0, 1.36833),
(4.0, 1.47641),
(8.0, 300.0 / 203.0),
],
),
(
400.0,
600.0,
0.8,
&[
(1.0, 0.97973),
(1.2, 1.11501),
(1.5, 1.25607),
(2.0, 1.38818),
(scene_peak, 1.5),
],
),
// No headroom (a peak under paper white): the scene is compressed into white.
(
203.0,
150.0,
0.4,
&[
(1.0, 0.78346),
(1.5, 0.90244),
(2.0, 0.95589),
(4.0, 0.99944),
(scene_peak, 1.0),
],
),
] {
(hdr.paper_white, hdr.peak) = (paper, peak);
for light in [0.0f32, 0.3, knee] {
let g = f64::from(light.powf(1.0 / 2.2));
assert!(
unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true),
"{paper}/{peak}: {light}"
);
}
for &(light, want) in checkpoints {
let [r, ..] = mapped(&mut hdr, [light; 3]);
assert!(near(r, want), "{paper}/{peak}: {light}: {r}, not {want}");
}
// It rises, stays under the peak, and the brightest channel scales the others; a
// half float's step in gamma is 0.1% of the light near white.
let headroom = (peak / paper).max(1.0);
let mut last = 0.0;
for i in 0..40 {
let light = 0.5 * 1.08f32.powi(i);
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
assert!(
r >= last * 0.998 && r <= headroom * 1.002,
"{peak}: {light}: {r}"
);
assert!(near(g * 4.0, r) && b == 0.0, "{peak}: {light}: {r} {g} {b}");
last = r;
}
}
// A display with little headroom, 300 nits at a paper white of 203: the knee sits at
// white, so all that SDR shows stays as it is, and the source's peak is 645 nits.
hdr.peak = 300.0;
// Light Horizon doesn't lift (HDR Lighting off, a 0.2 pack's own) has no known peak:
// the curve's source follows the display, the knee at 60% of its peak and at least
// white, as before one peak. At 300 nits the source's peak is 645 nits.
hdr.lighting = false;
(hdr.paper_white, hdr.peak) = (203.0, 300.0);
let headroom = 300.0 / 203.0;
for light in [0.5f32, 0.9, 1.0] {
for light in [0.0f32, 0.5, 0.9, 1.0] {
let g = f64::from(light.powf(1.0 / 2.2));
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
assert_eq!(
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
stored,
"{light}"
);
assert!(unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true), "{light}");
}
for (light, want) in [
(1.2, 1.16839),
@@ -1560,12 +1740,12 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
(2.0, 1.44024),
(4.0, headroom),
] {
let [r, ..] = eased(&mut hdr, [light; 3]);
let [r, ..] = mapped(&mut hdr, [light; 3]);
assert!(near(r, want), "{light}: {r}, not {want}");
}
// No headroom (a peak below paper white): a clip at white, channel by channel.
// With no headroom there, a clip at white, channel by channel.
hdr.peak = 150.0;
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
let [r, g, b] = mapped(&mut hdr, [1.5, 0.5, 0.2]);
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
}
@@ -1582,7 +1762,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
hdr.masks.parts = 0.4;
hdr.tiers[1].share = 0.7;
hdr.tiers[1].gain = 3.0;
hdr.debug = true;
hdr.calibration = Some(super::Calibration {
step: 2,
@@ -1,6 +1,6 @@
use super::{
Hdr, Output,
tests::{gpu, legacy_light, texel},
tests::{gpu, legacy_light, texel, writers_lift},
};
use wgpu::TextureFormat as F;
use wgpu::util::DeviceExt;
@@ -648,7 +648,7 @@ impl<'a> Draw<'a> {
}
if !written && !self.raw {
let light = hdr.frame(self.device, [1, 1]);
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
legacy_light(self.device, &mut encoder, writers_lift(hdr), &frame, &light);
return texel(self.device, self.queue, encoder, &light);
}
texel(self.device, self.queue, encoder, &frame)
@@ -662,7 +662,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let mut cases = 0;
for hdr_on in [false, true] {
let globals = super::super::Globals {
@@ -677,7 +677,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
@@ -739,7 +739,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ include_str!("stars.wgsl")
@@ -750,7 +750,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
@fragment fn reference() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
}
return vec4(stars.color.rgb * b, 0.175);
}
@@ -763,7 +763,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
let mut data = [0.0_f32; 32];
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
data[20..24].copy_from_slice(&settings);
data[24..28].copy_from_slice(&shares);
data[24..28].copy_from_slice(&gains);
data[31] = gain;
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
@@ -908,7 +908,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let mut count = 0;
for fog in [0.0, 0.5, 1.0] {
for visibility in [1.0, 0.4] {
@@ -921,7 +921,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
@@ -968,7 +968,7 @@ fn actual_translucent_writers_blend_their_own_light() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
@@ -977,7 +977,7 @@ fn actual_translucent_writers_blend_their_own_light() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
// Texel alpha 100 is clear glass's kind of texel, below the 0.6 the marked frame's
@@ -1035,14 +1035,14 @@ fn unmarked_entity_users_keep_their_source_gain() {
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
hdr_light: [1.0, 1.0, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
let draw = Draw::new(&device, &queue, false, &globals, 255);
@@ -1079,13 +1079,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
hdr_windows: hdr.windows(),
hdr_light: [1.0, 1.0, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
let draw = Draw::new(&device, &queue, false, &globals, 16);
@@ -1129,7 +1129,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
}))
.unwrap();
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
@@ -1138,7 +1138,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
for &alpha in alphas {
@@ -2770,6 +2770,8 @@ file = \"b.wgsl\"
true,
);
(display.paper_white, display.peak) = (paper, peak);
// As the renderer has it under a pack that draws: Horizon lights none of it.
display.lighting = false;
if switched {
use super::super::hdr::Output::{Pq, ScRgb};
display = display.retargeted(&device, &queue, ScRgb);
+4 -1
View File
@@ -696,13 +696,16 @@ impl Oit {
),
] {
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
});
let pixels = super::pixel_count(terrain_depth);
let mut pass =
super::pack_timing::counted(timing.as_deref_mut(), pass, pixels, label);
pass.set_bind_group(GROUP, group, &[]);
draw(&mut pass, stage);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,169 @@
//! The world pass after culling, and the renderer's GPU pass timing switches and readings.
use super::{Renderer, native_pack, pack_post, pack_timing};
pub(super) fn world_pass_after_culling<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
depth: &wgpu::TextureView,
keep_depth: bool,
mut timing: Option<&mut pack_timing::PassTiming>,
name: &str,
pixels: u64,
) -> pack_timing::CountedPass<'e> {
let pass = after_culling_pass(
encoder,
view,
depth,
keep_depth,
timing.as_deref_mut().and_then(|t| t.writes(name)),
);
pack_timing::counted(timing, pass, pixels, "before translucent")
}
/// The pixels of a depth attachment, for `fragments_per_pixel`.
pub(super) fn pixel_count(depth: &wgpu::TextureView) -> u64 {
let texture = depth.texture();
u64::from(texture.width()) * u64::from(texture.height())
}
pub(super) fn after_culling_pass<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
depth: &wgpu::TextureView,
keep_depth: bool,
timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world, after culling"),
timestamp_writes,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
// A shader pack reads it as depthtex0.
store: if keep_depth {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
}),
stencil_ops: None,
}),
..Default::default()
})
}
impl Renderer {
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open or a capture records them).
pub fn set_pass_timing(&mut self, on: bool) {
self.pass_timing = on;
}
/// Times the parts of Horizon's own passes while `on`.
pub fn set_pass_detail(&mut self, on: bool) {
self.pass_detail = on;
}
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
/// samples than a measurement).
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
self.pass_timing_gap = gap;
}
/// Keeps each timed frame's pass times for `take_timed_frames` while `on` (a capture records them).
pub fn set_keep_timed_frames(&mut self, on: bool) {
self.keep_timed_frames = on;
}
/// The frames both timers timed since the last call, without waiting on the GPU.
pub fn take_timed_frames(&mut self) -> pack_timing::TimedFrames {
let own = self
.own_timing
.as_mut()
.map(pack_timing::PassTiming::take_frames)
.unwrap_or_default();
let pack = match Self::pass_timer(&mut self.pack_post, &mut self.native) {
Some(Some(t)) => t.take_frames(),
_ => Vec::new(),
};
pack_timing::TimedFrames { own, pack }
}
/// The running shader pack's timer, of either kind.
pub(super) fn pass_timer<'a>(
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
native: &'a mut Option<native_pack::NativePack>,
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
match (pack, native) {
(Some((_, Some(post))), _) => Some(&mut post.timing),
(_, Some(native)) => Some(&mut native.timing),
_ => None,
}
}
/// Each pass of the running shader pack with its median GPU time in milliseconds, while
/// they are timed (`set_pass_timing`).
pub fn shader_pass_times(&self) -> Vec<(String, f32)> {
let timer = match (&self.pack_post, &self.native) {
(Some((_, Some(post))), _) => post.timing.as_ref(),
(_, Some(native)) => native.timing.as_ref(),
_ => None,
};
timer
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// Each of Horizon's own passes with its median GPU time in milliseconds, while they are
/// timed (`set_pass_timing`).
pub fn own_pass_times(&self) -> Vec<(String, f32)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// Each of Horizon's own passes with its parts' median GPU times in milliseconds.
pub fn own_pass_parts(&self) -> Vec<(String, Vec<(String, f32)>)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::part_medians)
.unwrap_or_default()
}
/// Each counted pass's counters (medians), while detail is on and the device has them.
pub fn own_pass_counters(&self) -> Vec<(String, pack_timing::PassCounters)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::counter_medians)
.unwrap_or_default()
}
/// Both timers' readings and this adapter, for a capture; `None` when neither has timed a pass.
pub fn gpu_readout(&self) -> Option<pack_timing::GpuReadout> {
let own = self
.own_timing
.as_ref()
.map(pack_timing::PassTiming::readings)
.filter(|r| !r.is_empty());
let pack = match (&self.pack_post, &self.native) {
(Some((_, Some(post))), _) => post.timing.as_ref(),
(_, Some(native)) => native.timing.as_ref(),
_ => None,
}
.map(pack_timing::PassTiming::readings)
.filter(|r| !r.is_empty());
(own.is_some() || pack.is_some()).then(|| pack_timing::GpuReadout {
adapter: self.info.clone(),
own,
pack,
})
}
/// Whether dispatch parts and counters are being asked for (`set_pass_detail`).
pub fn pass_detail(&self) -> bool {
self.pass_detail
}
/// What detail timing can show on this device; `None` without pass timing.
pub fn pass_detail_support(&self) -> Option<pack_timing::DetailSupport> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::detail_support)
}
}
@@ -47,7 +47,7 @@ struct Uniform {
clip_from_sky: [[f32; 4]; 4],
color: [f32; 4],
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
hdr_gains: [f32; 4],
hdr_light: [f32; 4],
}
@@ -184,7 +184,7 @@ impl Stars {
clip_from_sky: clip_from_sky.to_cols_array_2d(),
color: [b; 4],
hdr_settings: globals.hdr_settings,
hdr_shares: globals.hdr_shares,
hdr_gains: globals.hdr_gains,
hdr_light: globals.hdr_light,
};
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&uniform));
@@ -5,7 +5,7 @@ struct Stars {
// starBrightness in every channel (RenderSystem.setShaderColor).
color: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_gains: vec4<f32>,
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> stars: Stars;
@@ -20,7 +20,7 @@ struct Stars {
@fragment fn fs_stars() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
}
if HDR_WRITE_LIGHT { return vec4(frame_stored(stars.color).rgb * b, b); }
return vec4(stars.color.rgb * b, 0.175);
@@ -2,8 +2,8 @@
//! an adapter that can't present to the window gives way to another of its backend
//! (#13493).
use super::{
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
timing,
Renderer, check_layers, cull, describe_adapter, is_software, oit, pack_timing, 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"]
@@ -135,6 +135,7 @@ pub(super) async fn open_adapter(
// atomics on images: their buffers and images. Filtering 32-bit float textures: shader
// packs' RGBA32F buffers, which GL filters and mipmaps. Count draws: GPU culling's (`gpu_culling`).
let features = timing::GpuTimer::features(adapter)
| pack_timing::PassTiming::features(adapter)
| cull::Cull::features(adapter)
| oit::features(adapter)
| ray_query::features(adapter)
@@ -239,17 +239,17 @@ impl Visbuffer {
encoder: &'e mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
clear: bool,
timing: Option<&mut PassTiming>,
) -> wgpu::RenderPass<'e> {
mut timing: Option<&mut PassTiming>,
) -> super::pack_timing::CountedPass<'e> {
let (ids, _) = self.ids.as_ref().expect("fitted before the world pass");
let name = if clear {
"Visibility IDs"
} else {
"Visibility IDs after culling"
};
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon visibility buffer"),
timestamp_writes: timing.and_then(|t| t.writes(name)),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(name)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: ids,
resolve_target: None,
@@ -272,7 +272,8 @@ impl Visbuffer {
stencil_ops: None,
}),
..Default::default()
})
});
super::pack_timing::counted(timing, pass, super::pixel_count(depth), name)
}
/// Phase 1's solid calls into the IDs, as the normal passes draw them: the opaque
@@ -360,13 +360,13 @@ impl Post {
&mut self,
gain: bool,
settings: [f32; 4],
shares: [f32; 4],
gains: [f32; 4],
windows: [[f32; 4]; 2],
) {
// These uniforms do not change baked colour without gain. Keep compatibility
// history stable when only its final display settings change.
let key = Some(if gain {
(true, settings, shares, windows)
(true, settings, gains, windows)
} else {
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
});
@@ -887,7 +887,7 @@ mod tests {
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
eprintln!(
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
"TAA baked-light GPU checked gain, all settings/gains/windows and the first frame after a pack"
);
}
@@ -64,7 +64,7 @@ fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
}
return frame_stored(finished);
}
+50 -26
View File
@@ -20,15 +20,20 @@ struct Params {
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
// The compatibility frame's lift (`lit`; the float frame's writers have theirs from
// Hdr::draw_params). HDR lighting: what the strongest light multiplies a surface by, 1 for
// none.
scene: f32,
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// Per tier, low to very bright (hdr.rs `COMPAT_SHARES`): the share of the headroom
// its fully lifted pixels get.
share: vec4<f32>,
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
// a share of the height.
calibration: vec4<f32>,
// The scene's peak in units of paper white where Horizon lifts the world (hdr.rs
// `SCENE_PEAK`), else 0: what the tone curve maps onto the display's peak (`tone_scale`).
scene_peak: f32,
};
@group(0) @binding(2) var<uniform> params: Params;
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
@@ -155,7 +160,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
}
fn lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> Lit {
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), params.share);
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
}
// Calibration: above its controls the test picture, straight in nits, past
@@ -251,33 +256,52 @@ fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
return display(light, pos);
}
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
// Where the scene's peak is unknown (`tone_scale`), the curve's knee: up to it light stays as it
// is. Where HDR lighting eased into the peak before the curve (`lit`), 60% of it, so bright
// textures keep their detail; at least paper white.
fn tone_knee(headroom: f32) -> f32 {
return max(0.6 * headroom, 1.0);
}
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper`
// nits: what to scale the light by, so it ends at most `headroom` (above 1). ITU-R BT.2390's
// EETF in PQ, without the black level lift (`display` does that): the same up to its knee, then
// a Hermite spline that reaches the peak level, at the source's peak. That peak is where the knee
// lands at `tone_knee`, PQ's 10 000 nits at most; light past it shows at the peak. The brightest
// channel sets the scale for all three, so light keeps its hue as it eases (lava stays orange)
// and no channel passes the peak.
fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
if top <= tone_knee(headroom) {
return 1.0;
}
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper` nits:
// what to scale the light by, so it ends at most `headroom` (T). ITU-R BT.2390's EETF in PQ,
// without the black level lift (`display` does that): the same up to its knee, then a Hermite
// spline reaching T at the source's peak, past which light shows at T. The brightest channel sets
// the scale for all three, so light keeps its hue (lava stays orange) and no channel passes T.
//
// The source is the scene's peak S where Horizon lifts the world (`scene_peak`, hdr.rs
// `SCENE_PEAK`): the world's light is the same on every display, light past S is held at S, and
// where T reaches S that is all, so a display above S shows the world as one at S. Where the
// scene's range is unknown (`scene_peak` 0: a 0.2 pack's own light, HDR Lighting off), the
// source follows the display, as the 0.2 contract has it: the peak that puts the EETF's knee at
// `tone_knee`, PQ's 10 000 nits at most.
fn tone_scale(top: f32, scene_peak: f32, headroom: f32, paper: f32) -> f32 {
let peak = pq(vec3(headroom * paper)).x;
let source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
let e = pq(vec3(top * paper)).x / source;
var source: f32;
var e: f32;
var held = 1.0;
if scene_peak > 0.0 {
held = min(1.0, scene_peak / max(top, 1e-6));
if scene_peak <= headroom {
return held;
}
source = pq(vec3(scene_peak * paper)).x;
e = pq(vec3(min(top, scene_peak) * paper)).x / source;
} else {
if top <= tone_knee(headroom) {
return 1.0;
}
source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
e = pq(vec3(top * paper)).x / source;
}
let max_lum = peak / source;
let ks = 1.5 * max_lum - 0.5;
// At least 0: past 3 to 1 in PQ the knee would leave the source's range.
let ks = max(1.5 * max_lum - 0.5, 0.0);
if e >= 1.0 {
return headroom / top;
}
if e <= ks {
return 1.0;
return held;
}
let t = (e - ks) / (1.0 - ks);
let t2 = t * t;
@@ -287,16 +311,16 @@ fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
}
// Hdr::tonemap, after the post effects and before the camera overlays (docs/design/
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) eased into the peak, in
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light up to the
// knee stays exactly as stored. With no headroom, a clip at white.
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) fitted to the display, in
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light the curve
// leaves alone stays exactly as stored. With no headroom and no scene peak, a clip at white.
@fragment fn fs_tonemap(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let g = textureLoad(world, vec2<i32>(pos.xy), 0).rgb;
if params.peak <= 1.0 {
if params.scene_peak == 0.0 && params.peak <= 1.0 {
return vec4(min(g, vec3(1.0)), 1.0);
}
let top = pow(max(max(g.r, max(g.g, g.b)), 0.0), 2.2);
let s = tone_scale(top, params.peak, params.paper * 80.0);
let s = tone_scale(top, params.scene_peak, params.peak, params.paper * 80.0);
return vec4(select(g * pow(s, 1.0 / 2.2), g, s == 1.0), 1.0);
}
@@ -331,7 +355,7 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
}
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
// paper white in linear light. Unlike the display curve (whose knee is at least white),
// paper white in linear light. Unlike the display curve (which ends at the display's peak),
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
@@ -27,7 +27,8 @@ struct Lit {
lifted: bool,
};
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, shares: vec4<f32>) -> Lit {
// `lifts`, per tier: how much its fully lifted pixels gain over their own light (their gain - 1).
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, lifts: vec4<f32>) -> Lit {
var lifted = false;
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
@@ -60,7 +61,7 @@ fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
var gain = 1.0 + (settings.x - 1.0) * shares[tier] * lift;
var gain = 1.0 + lifts[tier] * lift;
if eased {
gain = min(gain, settings.x / max(top, 1e-4));
}
@@ -88,7 +89,8 @@ fn hdr_quantized_mark(mark: f32) -> f32 {
return round(saturate(mark) * 255.0) / 255.0;
}
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, shares: vec4<f32>) -> vec3<f32> {
let shows = settings.z > 0.5 && settings.x > 1.0;
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), shows, false, settings, shares).light, vec3(1.0 / 2.2));
// The world's writers' light, from the tiers' `gains` (Hdr::draw_params): the same on every
// display; the tone curve fits it to the display afterwards (fs_tonemap).
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, gains: vec4<f32>) -> vec3<f32> {
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), settings.z > 0.5, false, settings, gains - vec4(1.0)).light, vec3(1.0 / 2.2));
}
+3 -3
View File
@@ -1401,9 +1401,9 @@ impl HudAtlas {
edition.3 = pixels;
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
// The controller's art, unless a pack brought its own (walked above, as a mod's GUI
// textures are).
for image in crate::pad_glyph::images() {
// The controller's art and Horizon's mark, unless a pack brought its own (walked above, as a
// mod's GUI textures are).
for image in crate::pad_glyph::images().chain(crate::mark_sprite::image()) {
if !images.iter().any(|i| i.0 == image.0) {
images.push(image);
}
+4
View File
@@ -74,6 +74,7 @@ pub mod level_loading;
pub mod light;
pub mod lod;
pub mod logging;
pub mod mark_sprite;
pub mod menu_blur;
pub mod menu_style;
pub mod mesher;
@@ -102,6 +103,9 @@ pub mod pad_glyph;
pub mod paged_maps;
pub mod par;
pub mod particles;
#[cfg(target_os = "linux")]
pub mod perf_capture;
pub mod perf_gpu;
pub mod poi_sections;
pub mod polytone_post;
pub mod post_chain;
@@ -0,0 +1,11 @@
//! Horizon's mark (`docs/media/brand/mark-dark.svg`) on the text grid, for an inline text sprite.
/// The mark's name in the HUD atlas.
pub const SPRITE: &str = "horizon:gui/mark";
const PNG: &[u8] = include_bytes!("../../horizon-client/assets/horizon/textures/gui/mark.png");
/// The mark as the HUD atlas takes an image.
pub fn image() -> std::iter::Once<(String, u32, u32, Vec<u8>)> {
let (width, height, rgba) = crate::hud::decode_bytes(PNG).expect("Horizon's mark is PNG");
std::iter::once((SPRITE.to_owned(), width, height, rgba))
}
@@ -24,7 +24,7 @@ fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
var color = oit_cloud_color(in, front, true);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
color = vec4(hdr_written(color.rgb, hdr_scene_alpha(globals.hdr_light.y),
globals.hdr_settings, globals.hdr_shares), color.a);
globals.hdr_settings, globals.hdr_gains), color.a);
}
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
@@ -62,7 +62,7 @@
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
var color = celestial_color(in);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
}
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
}
@@ -41,7 +41,7 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
globals.hdr_light.y, f * globals.fog_color.a));
// Convert the surface before weighting; its mark never belongs to the light behind it.
let fogged = mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a);
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_shares) * weighted.a, weighted.a);
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_gains) * weighted.a, weighted.a);
}
return vec4(rgb, weighted.a);
}
@@ -1100,6 +1100,9 @@ impl PackScreen {
0,
);
self.search.draw(&mut out, m, blink_ms, None);
if self.browser.is_none() && self.search.contains(f64::from(mouse.0), f64::from(mouse.1)) {
crate::widgets::text_cursor(true, true);
}
for side in 0..2 {
self.draw_list(&mut out, m, side, mouse);
}
@@ -1302,6 +1305,9 @@ impl PackScreen {
field.x = qx;
field.y = qy;
field.draw(out, m, 0, None);
if field.contains(f64::from(mouse.0), f64::from(mouse.1)) {
crate::widgets::text_cursor(true, true);
}
let (sbx, sby) = self.browser_search_button();
d::button(
out,
@@ -0,0 +1,406 @@
//! A stopped sampler session as the capture's `samples` message (docs/perf-platform.md §3.5).
use std::collections::HashMap;
use horizon_perf_format::Interner;
use horizon_perf_format::v1::{
BlockUnits, Contexts, Functions, Nodes, Overhead, SampleKind, SampleProfile, SampleSource,
SampleUnit, SampledThread, Side, Unit, Weights,
};
use horizon_threads::heap_sample::HeapProfile;
use horizon_threads::profile::{Aggregate, Frame, Profile, State, Symbols, symbolize};
use horizon_threads::work::{self, Space};
#[cfg(test)]
mod tests;
const UNKNOWN: &str = "[unknown]";
/// The name `work::set_dim` ids stand for: 1 overworld, 2 the nether, 3 the end.
fn dimension_name(id: u8) -> Option<&'static str> {
match id {
1 => Some("overworld"),
2 => Some("the_nether"),
3 => Some("the_end"),
_ => None,
}
}
/// The crate a demangled function name belongs to, or "" when it names none.
fn crate_of(name: &str) -> &str {
if let Some(inner) = name.strip_prefix('[') {
let module = inner.split([' ', ']']).next().unwrap_or("");
return if module == "unmapped" { "" } else { module };
}
let name = name.trim_start_matches(['<', '&', '*']);
let name = name.strip_prefix("dyn ").unwrap_or(name);
match name.split_once("::") {
Some((krate, _)) if krate.chars().all(|c| c.is_alphanumeric() || c == '_') => krate,
_ => "",
}
}
#[derive(Default)]
struct FunctionTable {
rows: Functions,
index: HashMap<(u32, u32), u32>,
allocator: Vec<bool>,
}
impl FunctionTable {
fn id(
&mut self,
strings: &mut Interner,
name: &str,
file: Option<&str>,
line: u32,
addr: u64,
) -> u32 {
let (name_id, file_id) = (
strings.intern(name),
file.map_or(0, |file| strings.intern(file)),
);
if let Some(&row) = self.index.get(&(name_id, file_id)) {
return row;
}
let row = self.rows.name.len() as u32;
self.rows.name.push(name_id);
self.rows.file.push(file_id);
self.rows.line.push(line);
self.rows.crate_.push(match crate_of(name) {
"" => 0,
krate => strings.intern(krate),
});
self.rows.addr.push(addr);
self.allocator.push(allocator_frame(name));
self.index.insert((name_id, file_id), row);
row
}
}
/// The thread each trie node belongs to; node 0 is the root.
fn thread_of(agg: &Aggregate) -> Vec<u16> {
let mut of = vec![0u16; agg.nodes()];
for n in 1..agg.nodes() {
of[n] = match agg.frame(n as u32) {
Frame::Thread(i) => i,
_ => of[agg.parent(n as u32) as usize],
};
}
of
}
/// The trie nodes some counted sample passes through.
fn live_nodes(agg: &Aggregate) -> Vec<bool> {
let mut live = vec![false; agg.nodes()];
for key in agg.counts.keys() {
let mut n = key.node as usize;
while n != 0 && !live[n] {
live[n] = true;
n = agg.parent(n as u32) as usize;
}
}
live
}
#[derive(Default)]
struct NodeTable {
rows: Nodes,
index: HashMap<(Option<u32>, u32, u16), u32>,
}
impl NodeTable {
fn id(&mut self, parent: Option<u32>, function: u32, thread: u16) -> u32 {
let key = (parent, function, if parent.is_none() { thread } else { 0 });
if let Some(&row) = self.index.get(&key) {
return row;
}
let nodes = &mut self.rows;
nodes.parent.push(parent.unwrap_or(0));
nodes.function.push(function);
nodes.thread_root.push(match parent {
None => u32::from(thread) + 1,
Some(_) => 0,
});
let row = nodes.parent.len() as u32 - 1;
self.index.insert(key, row);
row
}
}
fn named(strings: &mut Interner, space: Space, id: u16) -> u32 {
match id {
0 => 0,
id => match work::name(space, id) {
Some(name) => strings.intern(name),
None => strings.intern(&format!("#{id}")),
},
}
}
/// Whether `name` is the allocator's own frame, which a heap sample's stack drops from its leaf end.
fn allocator_frame(name: &str) -> bool {
let name = name.trim_start_matches('<');
name.starts_with("horizon_threads::heap_sample::")
|| name.starts_with("__rust")
|| name.starts_with("__rg_")
|| name.starts_with("__rdl_")
|| name.starts_with("alloc::alloc::")
|| name.starts_with("alloc::raw_vec::")
|| name.starts_with("mimalloc::")
}
/// One aggregate's trie nodes as rows of the shared tables: `last[n]` is the output node of trie node `n`.
fn add_tree(
agg: &Aggregate,
threads: &[u16],
symbols: &Symbols,
functions: &mut FunctionTable,
nodes: &mut NodeTable,
strings: &mut Interner,
) -> (Vec<u16>, Vec<Option<u32>>) {
let (local, live) = (thread_of(agg), live_nodes(agg));
let thread_of: Vec<u16> = local
.iter()
.map(|&t| threads.get(usize::from(t)).copied().unwrap_or(0))
.collect();
let mut last: Vec<Option<u32>> = vec![None; agg.nodes()];
for n in (1..agg.nodes()).filter(|&n| live[n]) {
let inherited = last[agg.parent(n as u32) as usize];
last[n] = match agg.frame(n as u32) {
Frame::Thread(_) => None,
Frame::Wall(_) => inherited,
Frame::Addr(addr) => {
let offset = symbols.module_offset(addr);
let mut at = inherited;
for sym in symbols.frames(addr).iter().rev() {
let f = functions.id(strings, &sym.name, sym.file.as_deref(), sym.line, offset);
at = Some(nodes.id(at, f, thread_of[n]));
}
if at == inherited {
let f = functions.id(strings, UNKNOWN, None, 0, offset);
at = Some(nodes.id(at, f, thread_of[n]));
}
at
}
};
}
(thread_of, last)
}
fn trie_addrs(agg: &Aggregate) -> impl Iterator<Item = u64> + '_ {
let live = live_nodes(agg);
(1..agg.nodes())
.filter(move |&n| live[n])
.filter_map(|n| match agg.frame(n as u32) {
Frame::Addr(a) => Some(a),
_ => None,
})
}
/// `profile` (and the heap session's `heap`) as a `SampleProfile` of `side`; strings go through `strings`, whose new entries the caller appends first.
pub fn sample_profile(
profile: &Profile,
heap: Option<&HeapProfile>,
side: Side,
unit: Unit,
block_name: &dyn Fn(u16) -> Option<String>,
strings: &mut Interner,
) -> SampleProfile {
let agg = &profile.agg;
let addrs: Vec<u64> = trie_addrs(agg)
.chain(heap.into_iter().flat_map(|h| trie_addrs(&h.agg)))
.collect();
let symbols = symbolize(addrs);
let mut names: Vec<String> = agg.threads.clone();
let cpu_threads: Vec<u16> = (0..agg.threads.len() as u16).collect();
let heap_threads: Vec<u16> = heap.map_or_else(Vec::new, |h| {
h.agg
.threads
.iter()
.map(|name| match names.iter().position(|n| n == name) {
Some(at) => at as u16,
None => {
names.push(name.clone());
names.len() as u16 - 1
}
})
.collect()
});
let mut functions = FunctionTable::default();
let mut nodes = NodeTable::default();
let mut trees = vec![(
agg,
add_tree(
agg,
&cpu_threads,
&symbols,
&mut functions,
&mut nodes,
strings,
),
)];
if let Some(h) = heap {
let tree = add_tree(
&h.agg,
&heap_threads,
&symbols,
&mut functions,
&mut nodes,
strings,
);
trees.push((&h.agg, tree));
}
let wall = agg.counts.keys().any(|k| k.state != State::Cpu);
let period_ms = 1000.0 / f64::from(profile.config.sampler.hz.max(1));
let kind = |source: SampleSource, unit: SampleUnit, period: f64| SampleKind {
source: source as i32,
unit: unit as i32,
period,
};
let mut kinds = vec![kind(SampleSource::Cpu, SampleUnit::Ms, period_ms)];
if wall {
kinds.push(kind(SampleSource::Wall, SampleUnit::Ms, period_ms));
}
if heap.is_some() {
kinds.push(kind(SampleSource::Alloc, SampleUnit::Bytes, 1.0));
kinds.push(kind(SampleSource::Live, SampleUnit::Bytes, 1.0));
}
let kind_of = |state: State| -> u32 {
let source = match state {
State::Cpu => SampleSource::Cpu,
State::Running | State::Waiting => SampleSource::Wall,
State::Alloc => SampleSource::Alloc,
State::Live => SampleSource::Live,
};
kinds
.iter()
.position(|k| k.source == source as i32)
.unwrap_or(0) as u32
};
let mut contexts = Contexts::default();
let mut context_ids: HashMap<[u32; 6], u32> = HashMap::new();
let mut weights = Weights::default();
let mut unknown_roots: HashMap<u16, u32> = HashMap::new();
for (agg, (thread_of, last)) in &trees {
let mut counted: Vec<_> = agg.counts.iter().collect();
counted.sort_by_key(|(k, _)| {
(
thread_of[k.node as usize],
k.node,
k.state as u8,
k.slow,
k.time_block,
(k.span, k.entity, k.block, k.dim, k.chunk),
)
});
for (key, &count) in counted {
let thread = thread_of[key.node as usize];
let mut node = match last[key.node as usize] {
Some(node) => node,
None => *unknown_roots.entry(thread).or_insert_with(|| {
let f = functions.id(strings, UNKNOWN, None, 0, 0);
nodes.id(None, f, thread)
}),
};
if matches!(key.state, State::Alloc | State::Live) {
while nodes.rows.thread_root[node as usize] == 0
&& functions.allocator[nodes.rows.function[node as usize] as usize]
{
node = nodes.rows.parent[node as usize];
}
}
let row = [
named(strings, Space::Span, key.span),
named(strings, Space::Entity, key.entity),
match key.block {
0 => 0,
id => match block_name(id) {
Some(name) => strings.intern(&name),
None => strings.intern(&format!("#{id}")),
},
},
dimension_name(key.dim).map_or(0, |name| strings.intern(name)),
key.chunk.0 as u32,
key.chunk.1 as u32,
];
let context = *context_ids.entry(row).or_insert_with(|| {
contexts.span.push(row[0]);
contexts.entity.push(row[1]);
contexts.block.push(row[2]);
contexts.dim.push(row[3]);
contexts.chunk_x.push(key.chunk.0);
contexts.chunk_z.push(key.chunk.1);
contexts.span.len() as u32 - 1
});
weights.node.push(node);
weights.thread.push(u32::from(thread));
weights.kind.push(kind_of(key.state));
weights.state.push(match key.state {
State::Running => 1,
State::Waiting => 2,
State::Cpu | State::Alloc | State::Live => 0,
});
weights.slow.push(key.slow);
weights.context.push(context);
weights.block.push(u32::from(key.time_block));
weights.count.push(count);
}
}
let blocks = agg.time_blocks();
let mut threads = Vec::new();
let mut block_units = Vec::new();
for (i, name) in names.iter().enumerate() {
let ticks = agg.ticks.get(&(i as u16)).filter(|_| i < agg.threads.len());
threads.push(SampledThread {
name: strings.intern(name),
units: ticks.map_or(0, |t| t.count),
slow_units: ticks.map_or(0, |t| t.slow),
unit: unit as i32,
});
if i >= agg.threads.len() {
continue;
}
let mut units = agg
.block_ticks
.get(&(i as u16))
.cloned()
.unwrap_or_default();
units.resize(blocks, 0);
block_units.push(BlockUnits {
thread: i as u32,
units,
});
}
SampleProfile {
side: side as i32,
kinds,
threads,
block_s: profile.config.block_s,
block_units,
functions: Some(functions.rows),
nodes: Some(nodes.rows),
contexts: Some(contexts),
weights: Some(weights),
slow_over_ms: profile.config.ticks_over_ms as f32,
}
}
/// What the sampler and its writer thread cost during the session.
pub fn overhead(profile: &Profile, heap: Option<&HeapProfile>) -> Overhead {
let wall_ns = profile.elapsed.as_nanos().max(1) as f64;
let handler_ns = profile.stats.handler_ns.unwrap_or(0.0) * profile.stats.samples as f64;
Overhead {
sampler_share_of_thread: (handler_ns / wall_ns) as f32,
writer_core_share: (profile.writer_cpu_ns as f64 / wall_ns) as f32,
dropped_samples: profile.stats.dropped + profile.agg.lost + heap.map_or(0, |h| h.agg.lost),
heap_samples: heap.map_or(0, |h| h.samples),
heap_sample_ns: heap.map_or(0, |h| h.sample_ns),
..Overhead::default()
}
}
@@ -0,0 +1,649 @@
use std::collections::HashMap;
use std::hint::black_box;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, SystemTime};
use horizon_perf_format::v1::{
Kind as CaptureKind, SampleProfile, Side, Start, Stop, StringEntry, Unit,
};
use horizon_perf_format::{CaptureWriter, Field};
use horizon_threads::profile::{self, Aggregate, Config, Profile};
use horizon_threads::sampler::{self, Context, Kind, Record, Stats};
use horizon_threads::unwind::MAX_FRAMES;
use horizon_threads::work;
use super::*;
#[inline(never)]
fn alpha() {
black_box(1);
}
#[inline(never)]
fn beta() {
black_box(2);
}
#[inline(never)]
fn gamma() {
black_box(3);
}
fn at(f: fn(), offset: usize) -> usize {
f as *const () as usize + offset
}
fn record(kind: Kind, ctx: Context, value: u64, stack: &[usize]) -> Record {
let mut frames = [0; MAX_FRAMES];
frames[..stack.len()].copy_from_slice(stack);
Record {
kind,
depth: stack.len() as u8,
ctx,
value,
frames,
}
}
fn profile_of(agg: Aggregate, config: Config) -> Profile {
Profile {
agg,
config,
stats: Stats::default(),
elapsed: Duration::from_secs(10),
started: SystemTime::now(),
writer_cpu_ns: 0,
}
}
struct Converted {
samples: SampleProfile,
strings: Vec<StringEntry>,
}
impl Converted {
fn text(&self, id: u32) -> &str {
match id {
0 => "",
id => &self.strings.iter().find(|s| s.id == id).unwrap().text,
}
}
fn function(&self, row: u32) -> &str {
self.text(self.samples.functions.as_ref().unwrap().name[row as usize])
}
/// Function names from the root down to `node`.
fn path(&self, node: u32) -> Vec<&str> {
let nodes = self.samples.nodes.as_ref().unwrap();
let mut path = vec![self.function(nodes.function[node as usize])];
let mut at = node as usize;
while nodes.thread_root[at] == 0 {
at = nodes.parent[at] as usize;
path.push(self.function(nodes.function[at]));
}
path.reverse();
path
}
/// Samples per entity name, optionally only the slow ones.
fn by_entity(&self, slow_only: bool) -> HashMap<String, u64> {
let (w, c) = (
self.samples.weights.as_ref().unwrap(),
self.samples.contexts.as_ref().unwrap(),
);
let mut by = HashMap::new();
for i in 0..w.node.len() {
if slow_only && !w.slow[i] {
continue;
}
let entity = self.text(c.entity[w.context[i] as usize]).to_owned();
*by.entry(entity).or_default() += w.count[i];
}
by
}
}
fn convert(profile: &Profile) -> Converted {
let mut interner = Interner::default();
let samples = sample_profile(
profile,
None,
Side::Server,
Unit::Tick,
&|id| (id == 7).then(|| "minecraft:hopper".to_owned()),
&mut interner,
);
Converted {
samples,
strings: interner.take_new(Side::Server),
}
}
fn short(name: &str) -> &str {
name.rsplit("::").next().unwrap()
}
#[test]
fn a_stack_becomes_a_root_to_leaf_chain_with_its_context() {
let entity = work::entity_id("test:zombie");
let ctx = Context {
tick: 1,
entity_type: entity,
block: 7,
dim: 2,
chunk: (-3, 9),
..Context::default()
};
let mut agg = Aggregate::new(50_000_000);
for _ in 0..5 {
agg.add(
"tick",
&record(Kind::Cpu, ctx, 0, &[at(alpha, 4), at(gamma, 3)]),
);
}
agg.add("tick", &record(Kind::Tick, ctx, 9_000_000, &[]));
agg.finish();
let c = convert(&profile_of(agg, Config::default()));
let w = c.samples.weights.as_ref().unwrap();
assert_eq!(
(w.node.len(), w.count[0], w.state[0], w.slow[0]),
(1, 5, 0, false)
);
let path: Vec<_> = c.path(w.node[0]).into_iter().map(short).collect();
assert_eq!(path, ["gamma", "alpha"]);
let nodes = c.samples.nodes.as_ref().unwrap();
assert_eq!(nodes.thread_root, [1, 0]);
let ctxs = c.samples.contexts.as_ref().unwrap();
let row = w.context[0] as usize;
assert_eq!(c.text(ctxs.entity[row]), "test:zombie");
assert_eq!(c.text(ctxs.block[row]), "minecraft:hopper");
assert_eq!(c.text(ctxs.dim[row]), "the_nether");
assert_eq!((ctxs.chunk_x[row], ctxs.chunk_z[row]), (-3, 9));
assert_eq!(c.text(c.samples.threads[0].name), "tick");
assert_eq!(
(c.samples.threads[0].units, c.samples.threads[0].slow_units),
(1, 0)
);
}
#[test]
fn addresses_of_one_function_share_one_row() {
let ctx = Context::default();
let mut agg = Aggregate::new(50_000_000);
for (inner, count) in [(4, 2), (8, 3)] {
for _ in 0..count {
agg.add(
"tick",
&record(Kind::Cpu, ctx, 0, &[at(alpha, inner), at(gamma, 3)]),
);
}
}
agg.add(
"tick",
&record(Kind::Cpu, ctx, 0, &[at(beta, 4), at(gamma, 3)]),
);
agg.finish();
let c = convert(&profile_of(agg, Config::default()));
let f = c.samples.functions.as_ref().unwrap();
let names: Vec<_> = (0..f.name.len() as u32)
.map(|row| short(c.function(row)))
.collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(sorted, ["alpha", "beta", "gamma"]);
let w = c.samples.weights.as_ref().unwrap();
let on_alpha: u64 = (0..w.node.len())
.filter(|&i| short(c.path(w.node[i]).last().unwrap()) == "alpha")
.map(|i| w.count[i])
.sum();
assert_eq!(on_alpha, 5);
let n = c.samples.nodes.as_ref().unwrap();
let alphas: Vec<usize> = (0..n.function.len())
.filter(|&i| short(c.function(n.function[i])) == "alpha")
.collect();
assert_eq!(alphas.len(), 1, "one node per (parent, function)");
assert_eq!(
short(c.function(n.function[n.parent[alphas[0]] as usize])),
"gamma"
);
let weight_on_alpha: u64 = (0..w.node.len())
.filter(|&i| w.node[i] as usize == alphas[0])
.map(|i| w.count[i])
.sum();
assert_eq!(weight_on_alpha, 5);
let alpha_row = names.iter().position(|n| *n == "alpha").unwrap();
assert!(f.addr[alpha_row] > 0, "a module-relative address is kept");
}
#[test]
fn time_blocks_and_ticks_per_block_follow_when_they_were_added() {
let ctx = |tick| Context {
tick,
..Context::default()
};
let mut agg = Aggregate::new(50_000_000);
for (block, tick, samples) in [(0, 1, 2u64), (1, 2, 3), (1, 3, 1), (3, 4, 4)] {
agg.set_time_block(block);
for _ in 0..samples {
agg.add("tick", &record(Kind::Cpu, ctx(tick), 0, &[at(alpha, 1)]));
}
agg.add("tick", &record(Kind::Tick, ctx(tick), 1_000_000, &[]));
}
agg.finish();
let config = Config {
block_s: 2.5,
..Config::default()
};
let c = convert(&profile_of(agg, config));
let w = c.samples.weights.as_ref().unwrap();
let mut per_block = [0u64; 4];
for i in 0..w.node.len() {
per_block[w.block[i] as usize] += w.count[i];
}
assert_eq!(per_block, [2, 4, 0, 4]);
assert_eq!(c.samples.block_s, 2.5);
assert_eq!(c.samples.block_units[0].units, [1, 2, 0, 1]);
assert_eq!(c.samples.threads[0].units, 4);
}
#[test]
fn a_sample_with_no_stack_hangs_off_an_unknown_root() {
let mut agg = Aggregate::new(50_000_000);
agg.add("tick", &record(Kind::Cpu, Context::default(), 0, &[]));
agg.finish();
let c = convert(&profile_of(agg, Config::default()));
let w = c.samples.weights.as_ref().unwrap();
assert_eq!(c.path(w.node[0]), ["[unknown]"]);
}
#[test]
fn wall_samples_carry_their_state_and_a_second_kind() {
let mut agg = Aggregate::new(50_000_000);
let ctx = Context::default();
agg.add("tick", &record(Kind::Cpu, ctx, 0, &[at(alpha, 1)]));
agg.add("tick", &record(Kind::Wall, ctx, 1, &[at(alpha, 1)]));
agg.add("tick", &record(Kind::Wall, ctx, 0, &[at(beta, 1)]));
agg.finish();
let config = Config {
sampler: sampler::Config {
hz: 200,
..sampler::Config::default()
},
..Config::default()
};
let c = convert(&profile_of(agg, config));
assert_eq!(c.samples.kinds.len(), 2);
assert_eq!(c.samples.kinds[0].period, 5.0);
let w = c.samples.weights.as_ref().unwrap();
let mut seen: Vec<_> = (0..w.node.len())
.map(|i| {
(
short(c.path(w.node[i]).last().unwrap()).to_owned(),
w.kind[i],
w.state[i],
)
})
.collect();
seen.sort();
assert_eq!(
seen,
[
("alpha".into(), 0, 0),
("alpha".into(), 1, 1),
("beta".into(), 1, 2)
]
);
}
#[inline(never)]
fn spin_fast() {
spin(8);
black_box(0);
}
#[inline(never)]
fn spin_slow() {
spin(120);
black_box(1);
}
fn spin(cpu_ms: u64) {
let until = sampler::thread_cpu_ns() + cpu_ms * 1_000_000;
let mut x = 1u64;
while sampler::thread_cpu_ns() < until {
for _ in 0..1 << 14 {
x = black_box(x.wrapping_mul(6364136223846793005).wrapping_add(1));
}
}
black_box(x);
}
fn live_config() -> Config {
Config {
sampler: sampler::Config {
hz: 1000,
ring_samples: 512,
unwind: sampler::Unwind::StackCopy,
stack_copy_kib: 16,
wall: false,
},
ticks_over_ms: 50.0,
..Config::default()
}
}
/// Runs `body` on a thread opted in as `name`, profiled for exactly its duration.
fn profiled(name: &'static str, body: fn()) -> Profile {
let (ready, wait) = mpsc::channel();
let (go, start) = mpsc::channel::<()>();
let worker = thread::spawn(move || {
assert!(sampler::sampled_thread(name));
ready.send(()).unwrap();
start.recv().unwrap();
body();
});
wait.recv().unwrap();
let session = profile::start(Config {
threads: vec![name.into()],
..live_config()
})
.expect("start profiling");
go.send(()).unwrap();
worker.join().unwrap();
session.stop().expect("stop profiling")
}
fn entity_units() {
let (a, b) = (work::entity_id("test:a"), work::entity_id("test:b"));
for unit in 0..4 {
work::tick_start();
work::set_entity(if unit < 3 { a } else { b });
let started = sampler::thread_cpu_ns();
spin(40);
work::clear_work();
work::tick_end(sampler::thread_cpu_ns() - started);
}
}
#[test]
fn three_units_of_a_and_one_of_b_split_the_samples_three_to_one() {
let c = convert(&profiled("perf-capture-entities", entity_units));
let by = c.by_entity(false);
let total = (by["test:a"] + by["test:b"]) as f64;
let (a, b) = (by["test:a"] as f64 / total, by["test:b"] as f64 / total);
assert!((a - 0.75).abs() < 0.1 && (b - 0.25).abs() < 0.1, "{by:?}");
assert!(total > 100.0, "{total} samples");
assert_eq!(c.samples.threads[0].units, 4);
}
fn ticks_with_one_slow() {
for tick in 0..6 {
work::tick_start();
let started = sampler::thread_cpu_ns();
if tick == 3 {
spin_slow();
} else {
spin_fast();
}
work::tick_end(sampler::thread_cpu_ns() - started);
}
}
#[test]
fn only_the_slow_ticks_function_is_in_the_slow_weights() {
let c = convert(&profiled("perf-capture-slow", ticks_with_one_slow));
let w = c.samples.weights.as_ref().unwrap();
let (mut slow, mut fast) = (0, 0);
for i in 0..w.node.len() {
let names: Vec<_> = c.path(w.node[i]).into_iter().map(short).collect();
if w.slow[i] {
slow += w.count[i];
assert!(
names.contains(&"spin_slow") && !names.contains(&"spin_fast"),
"{names:?}"
);
} else {
fast += w.count[i];
assert!(!names.contains(&"spin_slow"), "{names:?}");
}
}
assert!(slow > 60 && fast > 20, "{slow} slow, {fast} other");
assert_eq!(
(c.samples.threads[0].units, c.samples.threads[0].slow_units),
(6, 1)
);
}
#[test]
fn overhead_counts_lost_samples_and_the_writers_share_of_a_core() {
let mut agg = Aggregate::new(1);
agg.lost = 4;
let mut profile = profile_of(agg, Config::default());
profile.stats.dropped = 3;
profile.stats.samples = 1000;
profile.stats.handler_ns = Some(2_000.0);
profile.writer_cpu_ns = 50_000_000;
let o = overhead(&profile, None);
assert_eq!(o.dropped_samples, 7);
assert!((o.sampler_share_of_thread - 0.0002).abs() < 1e-7);
assert!((o.writer_core_share - 0.005).abs() < 1e-6);
}
#[test]
fn crates_come_from_the_first_path_segment() {
for (name, krate) in [
(
"horizon_world::level::tick::random_tick_at",
"horizon_world",
),
(
"<horizon_world::X as core::ops::Drop>::drop",
"horizon_world",
),
("core::ptr::drop_in_place<alloc::vec::Vec<u8>>", "core"),
("clone3", ""),
("[horizon-server 0x4f2a]", "horizon-server"),
("[unmapped 0x7ffd]", ""),
] {
assert_eq!(crate_of(name), krate, "{name}");
}
}
fn ask(
file: &std::path::Path,
parts: &[&str],
) -> horizon_perf::wire::Envelope<horizon_perf::query::Row> {
let env = horizon_perf::cli::Env {
horizon_home: None,
poll: Duration::from_millis(2),
};
let mut args = vec!["query".to_owned()];
args.extend(parts.iter().map(|p| (*p).to_owned()));
args.push(file.to_string_lossy().into_owned());
let mut out = Vec::new();
if let Err(e) = horizon_perf::cli::run(&args, &env, &mut out) {
panic!("{e:?}");
}
serde_json::from_slice(&out)
.unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&out)))
}
#[test]
fn a_converted_profile_written_to_a_file_reads_back_through_the_queries() {
let ctx = Context::default();
let mut agg = Aggregate::new(50_000_000);
for _ in 0..5 {
agg.add(
"tick",
&record(Kind::Cpu, ctx, 0, &[at(alpha, 4), at(gamma, 3)]),
);
}
for _ in 0..2 {
agg.add(
"tick",
&record(Kind::Cpu, ctx, 0, &[at(beta, 4), at(gamma, 3)]),
);
}
for _ in 0..2 {
agg.add("tick", &record(Kind::Tick, ctx, 9_000_000, &[]));
}
for _ in 0..3 {
agg.add("worker", &record(Kind::Cpu, ctx, 0, &[at(beta, 4)]));
}
agg.add("worker", &record(Kind::Tick, ctx, 9_000_000, &[]));
agg.finish();
let config = Config {
sampler: sampler::Config {
hz: 100,
..sampler::Config::default()
},
..Config::default()
};
let c = convert(&profile_of(agg, config));
let mut fields = vec![Field::Start(Start {
side: Side::Server as i32,
kind: CaptureKind::Dedicated as i32,
..Default::default()
})];
fields.extend(c.strings.into_iter().map(Field::String));
fields.push(Field::Samples(c.samples));
fields.push(Field::Stop(Stop {
side: Side::Server as i32,
t: 10.0,
..Default::default()
}));
let dir = std::env::temp_dir().join(format!("horizon-render-perf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("real.hzperf");
let mut writer = CaptureWriter::new(std::fs::File::create(&file).unwrap()).unwrap();
for field in fields {
writer.write(field).unwrap();
}
writer.finish().unwrap();
// 10 ms samples: alpha has 5 over the tick thread's 2 ticks.
let hot = ask(&file, &["hot"]);
let alpha = hot.rows().iter().find_map(|r| match r {
horizon_perf::query::Row::Hot(h) if h.full.ends_with("alpha") => Some(h),
_ => None,
});
assert_eq!(alpha.map(|h| (h.self_ms, h.samples)), Some((25.0, 5)));
let worker = ask(&file, &["split", "t:worker", "--by", "thread"]);
let sel = worker.selection.as_ref().unwrap();
assert_eq!(
(sel.thread.as_deref(), sel.ticks, sel.samples),
(Some("worker"), Some(1), Some(3))
);
let values: Vec<_> = worker
.rows()
.iter()
.map(|r| match r {
horizon_perf::query::Row::Split(s) => (s.key.clone(), s.value),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(values, [(Some("t:worker".to_owned()), 30.0)]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn heap_samples_share_the_tables_without_the_allocators_own_frames() {
let mut cpu = Aggregate::new(50_000_000);
cpu.add(
"tick",
&record(
Kind::Cpu,
Context::default(),
0,
&[at(alpha, 4), at(gamma, 3)],
),
);
cpu.finish();
let mut heap = Aggregate::new(0);
let inside = HeapProfile::bytes as *const () as usize + 4;
let on_tick = heap.thread_index("tick");
let key = heap.stack_key(
on_tick,
profile::State::Alloc,
&Context::default(),
&[inside, at(beta, 4), at(gamma, 3)],
);
heap.add_weight(key, 3000);
heap.add_weight(
profile::Key {
state: profile::State::Live,
..key
},
1000,
);
let on_worker = heap.thread_index("worker");
let key = heap.stack_key(
on_worker,
profile::State::Live,
&Context::default(),
&[inside, at(alpha, 4)],
);
heap.add_weight(key, 500);
let heap = HeapProfile {
agg: heap,
interval: 4096,
samples: 3,
live_samples: 2,
sample_ns: 0,
freed: 2000,
elapsed: Duration::from_secs(10),
};
let mut interner = Interner::default();
let samples = sample_profile(
&profile_of(cpu, Config::default()),
Some(&heap),
Side::Server,
Unit::Tick,
&|_| None,
&mut interner,
);
let c = Converted {
samples,
strings: interner.take_new(Side::Server),
};
let sources: Vec<_> = c
.samples
.kinds
.iter()
.map(|k| (k.source(), k.period))
.collect();
assert_eq!(
sources,
[
(SampleSource::Cpu, 4.0),
(SampleSource::Alloc, 1.0),
(SampleSource::Live, 1.0)
]
);
let names: Vec<_> = c.samples.threads.iter().map(|t| c.text(t.name)).collect();
assert_eq!(names, ["tick", "worker"]);
let w = c.samples.weights.as_ref().unwrap();
let mut rows: Vec<_> = (0..w.node.len())
.map(|i| {
let path: Vec<_> = c.path(w.node[i]).into_iter().map(short).collect();
(w.kind[i], w.thread[i], path.join(">"), w.count[i])
})
.collect();
rows.sort();
assert_eq!(
rows,
[
(0, 0, "gamma>alpha".to_owned(), 1),
(1, 0, "gamma>beta".to_owned(), 3000),
(2, 0, "gamma>beta".to_owned(), 1000),
(2, 1, "alpha".to_owned(), 500),
]
);
let o = overhead(
&profile_of(Aggregate::new(0), Config::default()),
Some(&heap),
);
assert_eq!(o.heap_samples, 3);
}
@@ -0,0 +1,484 @@
//! The GPU pass timers' readings as the capture's `gpu` messages (docs/perf-platform.md §3.5); apart from `perf_capture`, which only Linux has.
use horizon_perf_format::Interner;
use horizon_perf_format::v1::{
GpuBackend, GpuCounter, GpuCounts, GpuDevice, GpuFragmentUnit, GpuFrame, GpuFrames, GpuPart,
GpuPartCounts, GpuPartsMode, GpuPass, GpuPassSplit, GpuPasses, GpuSource, Side,
};
use std::time::Instant;
use crate::gfx::pack_timing::{
Counters, FragmentUnit, GpuReadout, PartsMode, PassCounters, Readings, TimedFrame, TimedFrames,
};
/// Every time one timer's frames gave a pass or a part during a capture.
#[derive(Default)]
struct Times {
passes: Vec<(String, Vec<f32>)>,
parts: Vec<(String, Vec<(String, Vec<f32>)>)>,
}
fn times_of<'a>(list: &'a mut Vec<(String, Vec<f32>)>, name: &str) -> &'a mut Vec<f32> {
let i = match list.iter().position(|e| e.0 == name) {
Some(i) => i,
None => {
list.push((name.to_owned(), Vec::new()));
list.len() - 1
}
};
&mut list[i].1
}
impl Times {
fn add(&mut self, frame: &TimedFrame) {
for (pass, ms) in &frame.passes {
times_of(&mut self.passes, pass).push(*ms);
}
for (pass, parts) in &frame.parts {
let i = match self.parts.iter().position(|e| e.0 == *pass) {
Some(i) => i,
None => {
self.parts.push((pass.clone(), Vec::new()));
self.parts.len() - 1
}
};
for (part, ms) in parts {
times_of(&mut self.parts[i].1, part).push(*ms);
}
}
}
fn is_empty(&self) -> bool {
self.passes.is_empty() && self.parts.is_empty()
}
/// `readings` with its times the medians over the whole capture instead of its last frames.
fn medians_into(&self, readings: &Readings) -> Readings {
let median = |v: &[f32]| {
let mut v = v.to_vec();
v.sort_by(f32::total_cmp);
v[v.len() / 2]
};
Readings {
passes: self
.passes
.iter()
.map(|(name, v)| (name.clone(), median(v)))
.collect(),
parts: self
.parts
.iter()
.map(|(pass, parts)| {
let parts = parts.iter().map(|(n, v)| (n.clone(), median(v))).collect();
(pass.clone(), parts)
})
.collect(),
..readings.clone()
}
}
}
/// A capture's timed frames on their way into `GpuFrames` blocks, every time kept for the whole capture's medians.
#[derive(Default)]
pub struct FrameLog {
pending: Vec<GpuFrame>,
cpu_frame_ms: Vec<f32>,
own: Times,
pack: Times,
}
impl FrameLog {
/// The CPU's own ms of one frame the window drew.
pub fn cpu_frame(&mut self, ms: f32) {
self.cpu_frame_ms.push(ms);
}
/// The frames timed since the last call; `t_of` puts a frame's start on the capture clock.
pub fn add(
&mut self,
frames: &TimedFrames,
t_of: impl Fn(Instant) -> f64,
strings: &mut Interner,
) {
for (source, list, times) in [
(GpuSource::Horizon, &frames.own, &mut self.own),
(GpuSource::ShaderPack, &frames.pack, &mut self.pack),
] {
for frame in list {
times.add(frame);
self.pending
.push(frame_message(frame, source, t_of(frame.at), strings));
}
}
}
/// The frames and CPU frame times since the last block; none when there are neither.
pub fn take_block(&mut self, side: Side) -> Option<GpuFrames> {
if self.pending.is_empty() && self.cpu_frame_ms.is_empty() {
return None;
}
Some(GpuFrames {
side: side as i32,
frames: std::mem::take(&mut self.pending),
cpu_frame_ms: std::mem::take(&mut self.cpu_frame_ms),
})
}
}
fn frame_message(
frame: &TimedFrame,
source: GpuSource,
t: f64,
strings: &mut Interner,
) -> GpuFrame {
let mut passes: Vec<GpuPass> = frame
.passes
.iter()
.map(|(name, ms)| GpuPass {
name: strings.intern(name),
ms: Some(*ms),
parts: Vec::new(),
})
.collect();
for (pass, parts) in &frame.parts {
let parts = parts
.iter()
.map(|(part, ms)| GpuPart {
name: strings.intern(part),
ms: *ms,
})
.collect();
let name = strings.intern(pass);
match passes.iter_mut().find(|p| p.name == name) {
Some(p) => p.parts = parts,
None => passes.push(GpuPass {
name,
ms: None,
parts,
}),
}
}
GpuFrame {
source: source as i32,
t,
total_ms: frame.total_ms,
passes,
}
}
fn backend(backend: wgpu::Backend) -> GpuBackend {
match backend {
wgpu::Backend::Vulkan => GpuBackend::Vulkan,
wgpu::Backend::Metal => GpuBackend::Metal,
wgpu::Backend::Dx12 => GpuBackend::Dx12,
wgpu::Backend::Gl => GpuBackend::Gl,
_ => GpuBackend::Other,
}
}
fn counts(c: Counters) -> GpuCounts {
GpuCounts {
vertex_shader_invocations: c.vertex_shader_invocations,
clipper_invocations: c.clipper_invocations,
clipper_primitives_out: c.clipper_primitives_out,
fragment_shader_invocations: c.fragment_shader_invocations,
}
}
fn counter(strings: &mut Interner, pass: &str, c: &PassCounters) -> GpuCounter {
GpuCounter {
pass: strings.intern(pass),
total: Some(counts(c.total)),
pixels: c.pixels,
parts: c
.parts
.iter()
.map(|(name, part)| GpuPartCounts {
name: strings.intern(name),
counts: Some(counts(*part)),
})
.collect(),
}
}
fn device(adapter: &wgpu::AdapterInfo, readings: &Readings) -> GpuDevice {
let driver = format!("{} {}", adapter.driver, adapter.driver_info);
GpuDevice {
adapter: adapter.name.clone(),
backend: backend(adapter.backend) as i32,
driver: driver.trim().to_owned(),
parts_mode: match readings.support.parts {
PartsMode::InsidePasses => GpuPartsMode::InsidePasses,
PartsMode::SeparatePasses => GpuPartsMode::SeparatePasses,
} as i32,
pass_split: match readings.support.parts {
PartsMode::InsidePasses => GpuPassSplit::DrawGroups,
PartsMode::SeparatePasses => GpuPassSplit::PassesOnly,
} as i32,
counters: readings.support.counters,
fragment_unit: match readings.support.fragment_unit {
None => GpuFragmentUnit::Unspecified,
Some(FragmentUnit::Pixel) => GpuFragmentUnit::Pixel,
Some(FragmentUnit::Quad) => GpuFragmentUnit::Quad,
} as i32,
}
}
fn section(
adapter: &wgpu::AdapterInfo,
side: Side,
source: GpuSource,
readings: &Readings,
strings: &mut Interner,
) -> GpuPasses {
let mut passes: Vec<GpuPass> = readings
.passes
.iter()
.map(|(name, ms)| GpuPass {
name: strings.intern(name),
ms: Some(*ms),
parts: Vec::new(),
})
.collect();
for (pass, parts) in &readings.parts {
let name = strings.intern(pass);
let at = passes
.iter()
.position(|p| p.name == name)
.unwrap_or_else(|| {
passes.push(GpuPass {
name,
ms: None,
parts: Vec::new(),
});
passes.len() - 1
});
passes[at].parts = parts
.iter()
.map(|(part, ms)| GpuPart {
name: strings.intern(part),
ms: *ms,
})
.collect();
}
GpuPasses {
side: side as i32,
device: Some(device(adapter, readings)),
passes,
counters: readings
.counters
.iter()
.map(|(pass, c)| counter(strings, pass, c))
.collect(),
source: source as i32,
}
}
/// One `GpuPasses` per timer that has timed something, its times the medians of `log` where it has them; the caller appends `strings`' new entries first.
pub fn gpu_passes(
readout: &GpuReadout,
log: &FrameLog,
side: Side,
strings: &mut Interner,
) -> Vec<GpuPasses> {
[
(GpuSource::Horizon, &readout.own, &log.own),
(GpuSource::ShaderPack, &readout.pack, &log.pack),
]
.into_iter()
.filter_map(|(source, readings, times)| {
let readings = readings.as_ref().filter(|r| !r.is_empty())?;
let whole = (!times.is_empty()).then(|| times.medians_into(readings));
let readings = whole.as_ref().unwrap_or(readings);
Some(section(&readout.adapter, side, source, readings, strings))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gfx::pack_timing::DetailSupport;
fn adapter() -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: "Test GPU".into(),
vendor: 0x10de,
device: 1,
device_type: wgpu::DeviceType::DiscreteGpu,
device_pci_bus_id: String::new(),
driver: "NVIDIA".into(),
driver_info: "570.1".into(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
}
}
fn support(unit: Option<FragmentUnit>) -> DetailSupport {
DetailSupport {
parts: PartsMode::InsidePasses,
encoder_stamps: true,
counters: true,
fragment_unit: unit,
}
}
#[test]
fn known_medians_parts_and_counters_come_through_with_their_names() {
let c = |v, f| Counters {
vertex_shader_invocations: v,
clipper_invocations: 2_050_000,
clipper_primitives_out: 1_200_000,
fragment_shader_invocations: f,
};
let own = Readings {
passes: vec![("World".into(), 1.5), ("HUD".into(), 0.25)],
parts: vec![("GPU culling".into(), vec![("cull".into(), 0.125)])],
counters: vec![(
"World".into(),
PassCounters {
total: c(4_100_000, 6_300_000),
pixels: 2_073_600,
parts: vec![
("before translucent".into(), c(3_000_000, 5_100_000)),
("translucent terrain".into(), c(1_100_000, 1_200_000)),
],
},
)],
support: support(Some(FragmentUnit::Quad)),
};
let readout = GpuReadout {
adapter: adapter(),
own: Some(own),
pack: None,
};
let mut strings = Interner::default();
let got = gpu_passes(&readout, &FrameLog::default(), Side::Client, &mut strings);
assert_eq!(got.len(), 1);
let g = &got[0];
assert_eq!((g.side(), g.source()), (Side::Client, GpuSource::Horizon));
let d = g.device.as_ref().unwrap();
assert_eq!(d.adapter, "Test GPU");
assert_eq!(d.backend(), GpuBackend::Vulkan);
assert_eq!(d.driver, "NVIDIA 570.1");
assert_eq!(d.parts_mode(), GpuPartsMode::InsidePasses);
assert!(d.counters);
assert_eq!(d.fragment_unit(), GpuFragmentUnit::Quad);
// World, HUD, then the pass known only through its parts.
let entries = strings.take_new(Side::Client);
let name = |id: u32| entries.iter().find(|e| e.id == id).unwrap().text.as_str();
let named: Vec<_> = g.passes.iter().map(|p| name(p.name)).collect();
assert_eq!(named, ["World", "HUD", "GPU culling"]);
let shown: Vec<_> = g.passes.iter().map(|p| p.ms).collect();
assert_eq!(shown, [Some(1.5), Some(0.25), None]);
assert_eq!(name(g.passes[2].parts[0].name), "cull");
assert_eq!(g.passes[2].parts[0].ms, 0.125);
let counted = &g.counters[0];
assert_eq!(counted.pixels, 2_073_600);
assert_eq!(
counted.total.as_ref().unwrap().fragment_shader_invocations,
6_300_000
);
assert_eq!(counted.parts.len(), 2);
assert_eq!(
counted.parts[1]
.counts
.as_ref()
.unwrap()
.vertex_shader_invocations,
1_100_000
);
// The pass's counters and its time name the same string.
assert_eq!(counted.pass, g.passes[0].name);
}
#[test]
fn a_timer_that_timed_nothing_adds_no_section() {
let idle = Readings {
passes: Vec::new(),
parts: Vec::new(),
counters: Vec::new(),
support: support(None),
};
let pack = Readings {
passes: vec![("composite".into(), 0.75)],
..idle.clone()
};
let readout = GpuReadout {
adapter: adapter(),
own: Some(idle),
pack: Some(pack),
};
let got = gpu_passes(
&readout,
&FrameLog::default(),
Side::Client,
&mut Interner::default(),
);
assert_eq!(got.len(), 1);
assert_eq!(got[0].source(), GpuSource::ShaderPack);
assert_eq!(
got[0].device.as_ref().unwrap().fragment_unit(),
GpuFragmentUnit::Unspecified
);
}
#[test]
fn the_end_section_takes_the_whole_capture_median_and_each_block_its_frames() {
let start = Instant::now();
let frame = |i: u32, world: f32| TimedFrame {
at: start + std::time::Duration::from_millis(u64::from(i) * 10),
total_ms: Some(world + 1.0),
passes: vec![("World".into(), world), ("HUD".into(), 0.5)],
parts: vec![("World".into(), vec![("sky".into(), world / 4.0)])],
};
let mut log = FrameLog::default();
let mut strings = Interner::default();
let worlds = [1.0, 2.0, 3.0, 4.0, 100.0];
let frames = TimedFrames {
own: worlds
.iter()
.enumerate()
.map(|(i, &w)| frame(i as u32, w))
.collect(),
pack: Vec::new(),
};
log.add(
&frames,
|at| at.duration_since(start).as_secs_f64(),
&mut strings,
);
log.cpu_frame(6.0);
let block = log.take_block(Side::Client).expect("a block");
assert_eq!(block.frames.len(), 5);
assert_eq!(block.cpu_frame_ms, [6.0]);
assert!((block.frames[4].t - 0.04).abs() < 1e-9);
assert_eq!(block.frames[4].total_ms, Some(101.0));
assert_eq!(block.frames[4].passes[0].parts[0].ms, 25.0);
assert!(log.take_block(Side::Client).is_none());
// The timer's own median (9.0) is of its last frames; the capture's is the third of five sorted.
let own = Readings {
passes: vec![("World".into(), 9.0)],
parts: Vec::new(),
counters: Vec::new(),
support: support(None),
};
let readout = GpuReadout {
adapter: adapter(),
own: Some(own),
pack: None,
};
let got = gpu_passes(&readout, &log, Side::Client, &mut strings);
let world = &got[0].passes[0];
assert_eq!(world.ms, Some(3.0));
assert_eq!(world.parts[0].ms, 0.75);
assert_eq!(
got[0].device.as_ref().unwrap().pass_split(),
GpuPassSplit::DrawGroups
);
}
}
@@ -310,6 +310,9 @@ pub(crate) fn search_field(
let frame = [x, y, clear[0] - x, h];
search.frame = Some(frame);
search.draw(batch, atlas, None);
if ui::hovered(cursor, x, y, frame[2], h) {
crate::widgets::text_cursor(true, true);
}
if search.value.is_empty() && !focused {
let hint = Text::literal(search.message.clone());
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
+304 -171
View File
@@ -1,10 +1,4 @@
//! Named timings within a frame, for finding what makes a frame slow:
//! `HORIZON_SPIKES=MS` prints the spans of every frame whose CPU time
//! exceeds MS milliseconds, and of every server tick (dedicated or
//! integrated) longer than MS. Each span keeps its wall time and the
//! thread's CPU time inside it, so a span that waited (for a lock, the GPU
//! or a core on a busy machine) shows apart from one that worked. Costs two
//! clock reads per span when enabled and a relaxed load otherwise.
//! Named timings (wall and thread CPU) of a frame or tick, on with `HORIZON_SPIKES` or while a capture records; the slow ones go to the slow ring, not as span lists to the log.
use std::{
cell::{Cell, RefCell},
sync::atomic::{AtomicBool, Ordering},
@@ -58,10 +52,10 @@ pub fn record_here(on: bool) {
/// Whether this thread records spans: all of them on, or [`record_here`].
#[inline]
pub fn recording_here() -> bool {
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) || trace::on()
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) || timeline::on()
}
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
/// print it.
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): spans recorded
/// by `record_here` alone keep wall time only.
#[inline]
fn cpu_if_enabled() -> Option<Duration> {
if ENABLED.load(Ordering::Relaxed) {
@@ -82,31 +76,41 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
pub struct Guard {
open: Option<(&'static str, Instant, Option<Duration>, u8)>,
_heap: Option<horizon_threads::heap_census::Tag>,
/// The sampler's span before this one.
outer: u16,
}
/// Start a span named `name` that ends when the returned [`Guard`] is dropped.
#[inline]
pub fn guard(name: &'static str) -> Guard {
let _heap = horizon_threads::heap_tag(name);
let outer = horizon_threads::work::enter_span(name);
if !recording_here() {
return Guard { open: None, _heap };
return Guard {
open: None,
_heap,
outer,
};
}
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
Guard {
open: Some((name, Instant::now(), cpu_if_enabled(), depth)),
_heap,
outer,
}
}
impl Drop for Guard {
fn drop(&mut self) {
horizon_threads::work::leave_span(self.outer);
let Some((name, started, cpu, depth)) = self.open.take() else {
return;
};
let ms = started.elapsed().as_secs_f32() * 1000.0;
let elapsed = started.elapsed();
let ms = elapsed.as_secs_f32() * 1000.0;
let cpu_ms = cpu
.and_then(|start| Some((thread_cpu()?, start)))
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
DEPTH.with(|d| d.set(depth));
trace::span(name, started, ms);
timeline::span(name, started, elapsed, depth);
push(Span {
name,
ms,
@@ -115,6 +119,22 @@ impl Drop for Guard {
});
}
}
/// How many spans this thread holds.
fn recorded() -> usize {
SPANS.with(|s| s.borrow().len())
}
/// Moves the spans recorded since `mark` one level in: they ran inside the lap now ending.
fn nest_since(mark: usize, depth: u8) {
SPANS.with(|s| {
let mut s = s.borrow_mut();
let start = mark.min(s.len());
for span in &mut s[start..] {
if span.depth >= depth {
span.depth = span.depth.saturating_add(1);
}
}
});
}
/// Keeps a finished span for the thread's next [`take_raw`].
fn push(span: Span) {
SPANS.with(|s| {
@@ -125,22 +145,6 @@ fn push(span: Span) {
s.push(span);
});
}
/// Attach per-tick counts without interpreting them as durations.
pub fn counters(counts: &[(&str, u64)]) {
if !enabled() {
return;
}
NOTES.with(|n| {
n.borrow_mut().push((
0.0,
counts
.iter()
.map(|(name, count)| format!("{name}={count}"))
.collect::<Vec<_>>()
.join(", "),
))
});
}
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
/// span says where a slow tick went, its notes whose doing it was. Only the slowest few are
@@ -173,34 +177,36 @@ pub fn take_notes() -> Vec<(f32, String)> {
}
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
/// the previous one (or since [`laps`]) under its name. Nothing is read while spans are off.
pub struct Laps(Option<(Instant, Option<Duration>)>);
pub struct Laps(Option<(Instant, Option<Duration>, usize)>);
/// Start timing the steps of a function (see [`Laps`]).
#[inline]
pub fn laps() -> Laps {
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled())))
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled(), recorded())))
}
impl Laps {
/// Record the step that just ended under `name`, and start the next.
#[inline]
pub fn lap(&mut self, name: &'static str) {
let Some((started, cpu)) = &mut self.0 else {
let Some((started, cpu, mark)) = &mut self.0 else {
return;
};
let now = Instant::now();
let now_cpu = cpu.and_then(|_| thread_cpu());
let ms = (now - *started).as_secs_f32() * 1000.0;
let elapsed = now - *started;
let ms = elapsed.as_secs_f32() * 1000.0;
let cpu_ms = now_cpu
.zip(*cpu)
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
let depth = DEPTH.with(Cell::get);
trace::span(name, *started, ms);
timeline::span(name, *started, elapsed, depth);
nest_since(*mark, depth);
push(Span {
name,
ms,
cpu_ms,
depth,
});
(*started, *cpu) = (now, now_cpu);
(*started, *cpu, *mark) = (now, now_cpu, recorded());
}
}
/// The CPU time this thread has used, where the platform tells it: a tick
@@ -283,173 +289,176 @@ pub fn merge(spans: &[Span]) -> Vec<Span> {
sums.sort_by(|a, b| b.ms.total_cmp(&a.ms));
sums
}
/// `name wall` for spans of at least 0.5 ms, with `/cpu` when the thread
/// worked for less than nine tenths of it: the slow-frame and slow-tick lines.
pub fn describe(spans: &[Span]) -> String {
let list: Vec<String> = spans
.iter()
.filter(|s| s.ms >= 0.5)
.map(|s| {
if s.cpu_ms < s.ms * 0.9 {
format!("{} {:.1}/{:.1}", s.name, s.ms, s.cpu_ms)
} else {
format!("{} {:.1}", s.name, s.ms)
}
})
.collect();
list.join(", ")
}
/// The run's timeline: every span ([`time`], [`guard`], [`Laps::lap`]) on every thread, as
/// Chrome trace events that Perfetto (ui.perfetto.dev) opens as they are. `HORIZON_TRACE=FILE`
/// writes it from the start; [`start`](trace::start) and [`stop`](trace::stop) write one for a
/// while (`horizon-trace` on a dedicated server's console, `trace` on the control socket). Spans
/// show on their threads where they ran, nested by time, so what ran beside what, and what a
/// thread waited for, can be read off. Off, a span costs one relaxed load more.
pub mod trace {
/// The run's timeline: every span on every thread, kept in per-thread buffers until a capture drains them as `timeline` chunks.
pub mod timeline {
use std::{
cell::Cell,
fs::File,
io::{BufWriter, Write as _},
path::Path,
cell::RefCell,
sync::{
Mutex, PoisonError,
Arc, Mutex, PoisonError,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::Instant,
time::{Duration, Instant},
};
/// The most spans a thread holds between two drains; a thread that records more drops them.
const THREAD_EVENTS_MAX: usize = 1 << 18;
static ON: AtomicBool = AtomicBool::new(false);
static SINK: Mutex<Option<Sink>> = Mutex::new(None);
/// Counts the traces started: a thread names itself again in each one.
static TRACES: AtomicU32 = AtomicU32::new(0);
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
thread_local! {
/// This thread's id in traces (0: none yet).
static THREAD: Cell<u32> = const { Cell::new(0) };
/// The trace this thread last named itself in.
static NAMED_IN: Cell<u32> = const { Cell::new(0) };
}
/// How often a trace's buffer goes to the file, at most: a run that ends without
/// [`stop`] loses no more than this.
const FLUSH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
static GENERATION: AtomicU32 = AtomicU32::new(0);
static RUN: Mutex<Option<Run>> = Mutex::new(None);
struct Sink {
file: BufWriter<File>,
struct Run {
origin: Instant,
/// Until when it records ([`start`]'s `seconds`).
until: Option<Instant>,
flushed: Instant,
/// Events written: the next one is preceded by a comma.
events: u64,
}
impl Sink {
fn event(&mut self, event: std::fmt::Arguments) {
let comma = if self.events == 0 { "" } else { ",\n" };
let _ = write!(self.file, "{comma}{{\"pid\":1,{event}}}");
self.events += 1;
}
threads: Vec<Arc<Mutex<Thread>>>,
}
/// Whether a trace is being written.
struct Thread {
name: String,
label: String,
events: Vec<Event>,
}
/// This thread's buffer in the timeline it last joined.
struct Mine {
generation: u32,
origin: Instant,
thread: Arc<Mutex<Thread>>,
}
thread_local! {
static MINE: RefCell<Option<Mine>> = const { RefCell::new(None) };
}
/// One finished span.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Event {
pub name: &'static str,
/// Microseconds from the timeline's start.
pub start_us: u64,
pub dur_us: u32,
/// How many spans it ran inside on its thread.
pub depth: u8,
}
/// The spans one thread finished since the last drain, in the order they finished.
#[derive(Debug, PartialEq, Eq)]
pub struct ThreadEvents {
/// The thread's name; threads alive together under one name get `#2`, `#3`... after it.
pub thread: String,
pub events: Vec<Event>,
}
/// Whether a timeline is being recorded.
#[inline]
pub fn on() -> bool {
ON.load(Ordering::Relaxed)
}
/// Starts a trace written to `path` (replacing a trace being written), for `seconds` or
/// until [`stop`].
pub fn start(path: &Path, seconds: Option<u32>) -> std::io::Result<()> {
let mut file = BufWriter::with_capacity(1 << 18, File::create(path)?);
file.write_all(b"[\n")?;
let now = Instant::now();
let sink = Sink {
file,
origin: now,
until: seconds.map(|s| now + std::time::Duration::from_secs(u64::from(s))),
flushed: now,
events: 0,
};
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(old) = slot.take() {
close(old);
/// Starts recording, timed from `origin`; false when a timeline already runs (the process has one).
pub fn start(origin: Instant) -> bool {
let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
if run.is_some() {
return false;
}
*slot = Some(sink);
TRACES.fetch_add(1, Ordering::Relaxed);
*run = Some(Run {
origin,
threads: Vec::new(),
});
GENERATION.fetch_add(1, Ordering::Relaxed);
ON.store(true, Ordering::Relaxed);
Ok(())
true
}
/// Starts the trace `HORIZON_TRACE` names, if it names one.
pub fn start_from_env() {
if let Some(path) = std::env::var_os("HORIZON_TRACE")
&& let Err(e) = start(Path::new(&path), None)
{
log::warn!("Horizon: HORIZON_TRACE: {e}");
}
/// Takes what the threads finished since the last call.
pub fn drain() -> Vec<ThreadEvents> {
let run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
run.as_ref().map_or_else(Vec::new, take_all)
}
/// Ends the trace being written: the file is complete JSON. The events written so far
/// and whether one was being written.
pub fn stop() -> Option<u64> {
/// Stops recording and returns what the threads finished since the last drain.
pub fn finish() -> Vec<ThreadEvents> {
ON.store(false, Ordering::Relaxed);
let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).take()?;
let events = sink.events;
close(sink);
Some(events)
let run = RUN.lock().unwrap_or_else(PoisonError::into_inner).take();
run.as_ref().map_or_else(Vec::new, take_all)
}
/// Whether the trace being written has run its time ([`start`]'s `seconds`): the caller
/// then [`stop`]s it.
pub fn due() -> bool {
on() && SINK
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
.and_then(|s| s.until)
.is_some_and(|until| Instant::now() >= until)
fn take_all(run: &Run) -> Vec<ThreadEvents> {
run.threads
.iter()
.filter_map(|thread| {
let mut thread = thread.lock().unwrap_or_else(PoisonError::into_inner);
(!thread.events.is_empty()).then(|| ThreadEvents {
thread: thread.label.clone(),
events: std::mem::take(&mut thread.events),
})
})
.collect()
}
fn close(mut sink: Sink) {
let _ = sink.file.write_all(b"\n]\n");
let _ = sink.file.flush();
/// Takes a buffer for this thread: the one of an exited thread of the same name, else a new one.
fn join() -> Option<Mine> {
let mut slot = RUN.lock().unwrap_or_else(PoisonError::into_inner);
let run = slot.as_mut()?;
let name = std::thread::current()
.name()
.unwrap_or("unnamed")
.to_owned();
let of_name =
|t: &Arc<Mutex<Thread>>| t.lock().unwrap_or_else(PoisonError::into_inner).name == name;
let thread = match run
.threads
.iter()
.find(|t| Arc::strong_count(t) == 1 && of_name(t))
{
Some(exited) => Arc::clone(exited),
None => {
let same = run.threads.iter().filter(|t| of_name(t)).count();
let label = if same == 0 {
name.clone()
} else {
format!("{name} #{}", same + 1)
};
let thread = Arc::new(Mutex::new(Thread {
name,
label,
events: Vec::new(),
}));
run.threads.push(Arc::clone(&thread));
thread
}
};
Some(Mine {
generation: GENERATION.load(Ordering::Relaxed),
origin: run.origin,
thread,
})
}
/// A span of this thread from `started`, `ms` long.
pub(super) fn span(name: &'static str, started: Instant, ms: f32) {
/// A span of this thread from `started`, `elapsed` long, inside `depth` others.
pub(super) fn span(name: &'static str, started: Instant, elapsed: Duration, depth: u8) {
if !on() {
return;
}
let tid = THREAD.with(|t| {
if t.get() == 0 {
t.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
let generation = GENERATION.load(Ordering::Relaxed);
let _ = MINE.try_with(|mine| {
let mut mine = mine.borrow_mut();
if mine.as_ref().is_none_or(|m| m.generation != generation) {
*mine = join();
}
let Some(mine) = mine.as_ref() else {
return;
};
let event = Event {
name,
start_us: started.saturating_duration_since(mine.origin).as_micros() as u64,
dur_us: elapsed.as_micros().min(u128::from(u32::MAX)) as u32,
depth,
};
let mut thread = mine.thread.lock().unwrap_or_else(PoisonError::into_inner);
if thread.events.len() < THREAD_EVENTS_MAX {
thread.events.push(event);
}
t.get()
});
let trace = TRACES.load(Ordering::Relaxed);
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
let Some(sink) = slot.as_mut() else {
return;
};
if NAMED_IN.with(|n| n.replace(trace)) != trace {
let name = std::thread::current()
.name()
.unwrap_or("unnamed")
.replace(['"', '\\'], "_");
sink.event(format_args!(
r#""ph":"M","name":"thread_name","tid":{tid},"args":{{"name":"{name}"}}"#
));
}
let ts = started.saturating_duration_since(sink.origin).as_secs_f64() * 1e6;
sink.event(format_args!(
r#""ph":"X","name":"{name}","tid":{tid},"ts":{ts:.3},"dur":{:.3}"#,
f64::from(ms) * 1e3
));
let now = Instant::now();
if now.duration_since(sink.flushed) >= FLUSH_EVERY {
let _ = sink.file.flush();
sink.flushed = now;
}
}
}
@@ -654,6 +663,46 @@ mod tests {
SWITCH.lock().unwrap_or_else(|e| e.into_inner())
}
/// Samples taken inside a named span carry its id, with spans off, and the span is left after it.
#[cfg(target_os = "linux")]
#[test]
fn samples_inside_a_span_carry_its_id() {
use horizon_threads::{sampler, work};
use std::time::{Duration, Instant};
let worker = std::thread::spawn(|| {
assert!(sampler::sampled_thread("spans-id-test"));
let config = sampler::Config {
hz: 1000,
..Default::default()
};
let session = sampler::start(config, |n| n == "spans-id-test").unwrap();
let busy = |ms| {
let until = Instant::now() + Duration::from_millis(ms);
while Instant::now() < until {
std::hint::black_box(0);
}
};
super::time("spans_id_test_named", || busy(60));
assert_eq!(sampler::span(), 0);
busy(60);
let mut spans = Vec::new();
session.stop(|_, r| spans.push(r.ctx.span));
spans
});
let spans = worker.join().unwrap();
let inside = |s: &u16| work::name(work::Space::Span, *s) == Some("spans_id_test_named");
let (named, plain) = (
spans.iter().filter(|s| inside(s)).count(),
spans.iter().filter(|s| **s == 0).count(),
);
assert!(
named > 30 && plain > 30,
"{named} named, {plain} outside of {} samples",
spans.len()
);
assert_eq!(named + plain, spans.len(), "no other span id");
}
/// A slow tick's notes are its slowest steps, slowest first; short steps and steps on a
/// thread that records no spans leave none.
#[test]
@@ -680,8 +729,7 @@ mod tests {
assert!(super::take_notes().is_empty(), "taking them clears them");
}
/// Spans of one name add up; a span the thread spent mostly waiting
/// shows its CPU time after the wall time, short ones are left out.
/// Spans of one name add up, the slowest first; a span that waited keeps its small CPU time.
#[test]
fn spans_sum_by_name_and_show_waits() {
super::SPANS.with(|s| {
@@ -723,7 +771,6 @@ mod tests {
}
);
assert_eq!((spans[1].name, spans[1].ms), ("tick", 5.0));
assert_eq!(super::describe(&spans), "submit 30.0/0.2, tick 5.0");
assert!(super::take().is_empty());
}
@@ -843,6 +890,22 @@ mod tests {
assert!(spans[0].ms >= 2.0);
}
/// A span that ran during a lap belongs inside that lap, and not inside the next one.
#[test]
fn a_span_during_a_lap_nests_under_that_lap() {
let _switch = spans_switch();
let _ = super::take_raw();
super::enable(true);
let mut laps = super::laps();
super::time("inner", || {});
laps.lap("first");
laps.lap("second");
super::enable(false);
let spans = super::take_raw();
let depths: Vec<_> = spans.iter().map(|s| (s.name, s.depth)).collect();
assert_eq!(depths, [("inner", 1), ("first", 0), ("second", 0)]);
}
/// The thread's CPU clock moves with work done on it, not with time
/// spent asleep.
#[cfg(any(unix, all(windows, target_arch = "x86_64")))]
@@ -865,4 +928,74 @@ mod tests {
assert!(slept.as_millis() < 25, "{slept:?} while asleep");
assert!(worked.as_millis() >= 10, "{worked:?} while busy");
}
/// Each thread's finished spans reach the timeline with their depth and nesting in time; threads alive together under one name get labels of their own, ones after another share a lane.
#[test]
fn the_timeline_keeps_each_threads_spans_with_their_depth_and_place_in_time() {
use super::timeline;
use std::sync::{Arc, Barrier};
use std::time::{Duration, Instant};
let _switch = spans_switch();
assert!(timeline::start(Instant::now()));
assert!(
!timeline::start(Instant::now()),
"the process has one timeline"
);
let spawn = |name: &str, alive_with: Option<Arc<Barrier>>| {
std::thread::Builder::new()
.name(name.into())
.spawn(move || {
super::time("outer", || {
std::thread::sleep(Duration::from_millis(4));
super::time("inner", || std::thread::sleep(Duration::from_millis(3)));
});
if let Some(barrier) = alive_with {
barrier.wait();
}
})
.unwrap()
};
spawn("tl-solo", None).join().unwrap();
let together = Arc::new(Barrier::new(2));
let twins = [
spawn("tl-twin", Some(together.clone())),
spawn("tl-twin", Some(together)),
];
for twin in twins {
twin.join().unwrap();
}
spawn("tl-seq", None).join().unwrap();
spawn("tl-seq", None).join().unwrap();
let mut threads = timeline::finish();
threads.retain(|t| t.thread.starts_with("tl-"));
threads.sort_by(|a, b| a.thread.cmp(&b.thread));
let labels: Vec<_> = threads.iter().map(|t| t.thread.as_str()).collect();
assert_eq!(labels, ["tl-seq", "tl-solo", "tl-twin", "tl-twin #2"]);
for thread in &threads {
let runs = if thread.thread == "tl-seq" { 2 } else { 1 };
assert_eq!(thread.events.len(), 2 * runs, "{thread:?}");
for pair in thread.events.chunks(2) {
let [inner, outer] = pair else { unreachable!() };
assert_eq!((inner.name, inner.depth), ("inner", 1));
assert_eq!((outer.name, outer.depth), ("outer", 0));
assert!(
inner.dur_us >= 3_000 && outer.dur_us >= 7_000,
"{inner:?} {outer:?}"
);
assert!(inner.start_us >= outer.start_us + 4_000);
assert!(
inner.start_us + u64::from(inner.dur_us)
<= outer.start_us + u64::from(outer.dur_us)
);
}
}
let seq = threads.iter().find(|t| t.thread == "tl-seq").unwrap();
assert!(seq.events[2].start_us >= seq.events[1].start_us + u64::from(seq.events[1].dur_us));
spawn("tl-after", None).join().unwrap();
assert!(
timeline::drain().is_empty(),
"a stopped timeline records nothing"
);
}
}
+13 -31
View File
@@ -28,37 +28,7 @@ pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
/// Worker CPU time and job counts by kind, for benchmarks
/// (`examples/gen_light_bench.rs`). Off unless enabled: a relaxed load per
/// job then.
pub mod job_stats {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
pub const KINDS: [&str; 7] = [
"load", "generate", "decorate", "finish", "light", "mesh", "other",
];
static ENABLED: AtomicBool = AtomicBool::new(false);
static NANOS: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
static COUNT: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
pub fn enable(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
pub(super) fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
pub(super) fn add(kind: usize, nanos: u64) {
NANOS[kind].fetch_add(nanos, Ordering::Relaxed);
COUNT[kind].fetch_add(1, Ordering::Relaxed);
}
/// (kind, nanoseconds, count) since the last call; resets them.
pub fn take() -> Vec<(&'static str, u64, u64)> {
(0..KINDS.len())
.map(|i| {
(
KINDS[i],
NANOS[i].swap(0, Ordering::Relaxed),
COUNT[i].swap(0, Ordering::Relaxed),
)
})
.collect()
}
}
pub mod job_stats;
/// A chunk under generation, opaque to the streamer.
pub type Proto = Box<dyn std::any::Any + Send>;
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
@@ -1070,6 +1040,8 @@ pub struct WorkerContext {
pub light: Arc<LightTables>,
}
mod bounded;
mod queue_census;
pub use queue_census::QueueCensus;
mod forced_cover;
mod forced_ring;
mod relight_now;
@@ -1683,6 +1655,7 @@ impl Streaming {
&mut self,
world: &mut World,
) -> (Vec<((i32, i32, i32), SectionMesh)>, Vec<Arc<Chunk>>) {
let mut hz_laps = crate::spans::laps();
let mut uploads = Vec::new();
if self.paused {
return (uploads, Vec::new());
@@ -1708,6 +1681,7 @@ impl Streaming {
self.scan_age = if sweep { 0 } else { self.scan_age + 1 };
self.requeue();
self.relight_edits(world);
hz_laps.lap("st_relight_edits");
let deferred = std::mem::take(&mut self.deferred);
let received = self.shared.done.take();
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
@@ -1757,6 +1731,7 @@ impl Streaming {
}
}
}
hz_laps.lap("st_results");
self.free(stale);
let mut removed = Vec::new();
if sweep {
@@ -1827,6 +1802,7 @@ impl Streaming {
}
self.save_checked_out(written);
}
hz_laps.lap("st_sweep");
let (protos, finishing) = (&self.protos, &self.finishing);
self.decorated.retain(|k| {
protos.contains_key(k) || finishing.contains(k) || world.chunks.contains_key(k)
@@ -1858,6 +1834,7 @@ impl Streaming {
self.submit(Some((x, z)), 0, Job::Finish(proto));
}
}
hz_laps.lap("st_finalize");
// Decorate chunks whose 3x3 proto neighbourhood is available, in
// vanilla's order: decoration is order dependent where write regions
// overlap (see `DecorationOrder`).
@@ -1983,7 +1960,9 @@ impl Streaming {
self.submit(Some((x, z)), 1, Job::Decorate(x, z, region, finals));
}
}
hz_laps.lap("st_decorate");
self.schedule_generation(world, workers);
hz_laps.lap("st_generate");
// Lighting once all neighbours exist, nearest first and only as
// much of it as the workers can chew. Vanilla's light engine also
// hands a bounded queue to its workers and reschedules what did
@@ -2019,6 +1998,7 @@ impl Streaming {
Job::Light(x, z, region, self.sequence, self.sky_light),
);
}
hz_laps.lap("st_light");
// A server draws nothing: its sections are never meshed.
if !self.mesh {
world.dirty_sections.clear();
@@ -2059,6 +2039,7 @@ impl Streaming {
!back
});
}
hz_laps.lap("st_waiting");
// Column by column: the checks below hold for all of a column's
// sections, and columns waiting for light cost one look per frame.
columns.extend(world.dirty_sections.columns());
@@ -2091,6 +2072,7 @@ impl Streaming {
}
candidates.push((self.distance(x, z), x, z, sections));
}
hz_laps.lap("st_candidates");
// Nearest first, and only as many sections as the workers can chew.
// Every ready column used to be submitted at once, so at a wide
// render distance the queue — and `meshing`, and each job's 3x3
@@ -0,0 +1,29 @@
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
pub const KINDS: [&str; 7] = [
"load", "generate", "decorate", "finish", "light", "mesh", "other",
];
static ENABLED: AtomicBool = AtomicBool::new(false);
static NANOS: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
static COUNT: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
pub fn enable(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
pub(super) fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
pub(super) fn add(kind: usize, nanos: u64) {
NANOS[kind].fetch_add(nanos, Ordering::Relaxed);
COUNT[kind].fetch_add(1, Ordering::Relaxed);
}
/// (kind, nanoseconds, count) since the last call; resets them.
pub fn take() -> Vec<(&'static str, u64, u64)> {
(0..KINDS.len())
.map(|i| {
(
KINDS[i],
NANOS[i].swap(0, Ordering::Relaxed),
COUNT[i].swap(0, Ordering::Relaxed),
)
})
.collect()
}
@@ -0,0 +1,39 @@
//! What the streamer's queues hold, for `horizon-ctl memory` and captures.
use super::{Queued, Streaming};
/// What the streamer's work queues hold, each as (entries, estimated bytes).
#[derive(Debug, Default)]
pub struct QueueCensus {
/// Jobs queued and chunks generating.
pub generation: (usize, usize),
/// Chunks being lit and the edit log their jobs replay.
pub light: (usize, usize),
}
impl Streaming {
/// The queues' lengths and the bytes of their tables, from capacities. For `horizon-ctl memory`.
pub fn queue_census(&self) -> QueueCensus {
use std::mem::size_of;
let table = |capacity: usize, entry: usize| capacity * (entry + 1);
let queued = {
let queue = self.shared.queue.lock().unwrap();
(queue.len(), queue.capacity() * size_of::<Queued>())
};
QueueCensus {
generation: (
queued.0 + self.generating.len(),
queued.1 + table(self.generating.capacity(), size_of::<(i32, i32)>()),
),
light: (
self.lighting.len(),
table(self.lighting.capacity(), size_of::<((i32, i32), u64)>())
+ table(
self.light_snapshots.capacity(),
size_of::<((i32, i32), u64)>(),
)
+ self.light_log.capacity() * size_of::<(u64, (i32, i32, i32))>()
+ self.light_scratch.capacity() * size_of::<(i64, i32, i32)>(),
),
}
}
}
@@ -43,7 +43,7 @@ struct Globals {
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_gains: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -788,7 +788,7 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT {
if globals.hdr_light.w != 0.0 {
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_gains), 1.0);
}
return frame_stored(vec4(finished.rgb, 1.0));
}
@@ -870,7 +870,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
}
return frame_stored(finished);
}
@@ -1076,7 +1076,7 @@ fn sky_finished(color: vec3<f32>) -> vec4<f32> {
let mark = 1.0 - 0.24 * globals.hdr_light.y;
if HDR_WRITE_LIGHT {
if globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_gains), 1.0);
}
return frame_stored(vec4(color, 1.0));
}
@@ -13,6 +13,8 @@ pub enum Cursor {
#[default]
Arrow,
PointingHand,
/// `CursorTypes.IBEAM`: over a text field that takes input.
Ibeam,
NotAllowed,
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
ResizeNs,
@@ -55,6 +57,18 @@ pub fn slider_cursor(hovered: bool, active: bool, dragging: bool) {
}
}
/// `EditBox.handleCursor`: a hovered text field shows the I-beam, or the
/// forbidden sign when it takes no input.
pub fn text_cursor(hovered: bool, editable: bool) {
if hovered {
request_cursor(if editable {
Cursor::Ibeam
} else {
Cursor::NotAllowed
});
}
}
/// The cursor the frame asked for, and back to the arrow for the next one
/// (the window shows the arrow unless a widget asks).
pub fn take_cursor() -> Cursor {
@@ -429,6 +443,16 @@ pub fn record_as(label: &str, [x, y, w, h]: [f32; 4], enabled: bool, kind: Kind)
mod cursor_tests {
use super::*;
#[test]
fn a_hovered_text_field_asks_for_the_i_beam() {
let _ = take_cursor();
text_cursor(false, true);
assert_eq!(take_cursor(), Cursor::Arrow);
text_cursor(true, true);
assert_eq!(take_cursor(), Cursor::Ibeam);
text_cursor(true, false);
assert_eq!(take_cursor(), Cursor::NotAllowed);
}
#[test]
fn a_dragged_slider_asks_for_the_horizontal_resize_cursor() {
let _ = take_cursor();
+7 -1
View File
@@ -26,6 +26,7 @@ horizon-hooks = { path = "../horizon-hooks" }
horizon-optifine = { path = "../horizon-optifine" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
horizon-perf-format = { path = "../horizon-perf-format" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
@@ -73,6 +74,8 @@ horizon-theme = { path = "../horizon-theme" }
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
# The machine and process probes (the dedicated server's performance report).
horizon-system = { path = "../horizon-system" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
@@ -132,7 +135,9 @@ friends = ["dep:horizon-friends"]
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
# malloc (heaptrack, valgrind's massif) see them. For profiling only: mimalloc is faster.
system-allocator = []
system-allocator = ["horizon-threads/system-allocator"]
# mimalloc that counts each thread's allocations and frees (`stats` replies with `alloc`).
alloc-count = ["horizon-threads/alloc-count"]
# mimalloc with a tag on each block: `horizon-ctl memory` lists the live heap by startup step and
# thread (`heap_by_tag_mb`). For measuring only: each block costs 16 bytes more.
alloc-census = ["horizon-threads/alloc-census"]
@@ -176,5 +181,6 @@ workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
horizon-perf = { path = "../horizon-perf" }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -1129,6 +1129,34 @@
"horizon.recipe_transfer.no_room": "Inventory full",
"horizon.recipe_transfer.unavailable": "Recipe does not fit this menu",
"horizon.recipe_transfer.unsupported": "Server requires a known recipe",
"horizon.debug.pass_detail.title": "Horizon pass detail (%s)",
"horizon.debug.pass_detail.inside_passes": "timestamps inside passes",
"horizon.debug.pass_detail.separate_passes": "separate passes, adds GPU time",
"horizon.debug.pass_detail.part": "%s › %s: %s ms",
"horizon.debug.pass_detail.counters": "%s: %s fragments, %s per pixel, %s vertices, %s of %s triangles kept",
"horizon.debug.pass_detail.no_counters": "GPU counters: Vulkan only",
"horizon.perf.started": "Profiling the client and server. %s",
"horizon.perf.uploading": "Uploading the profile…",
"horizon.perf.shared": "Shared: %s %s",
"horizon.perf.upload_failed": "Could not share the profile: %s. %s",
"horizon.perf.upload_interrupted": "the upload worker stopped",
"horizon.perf.unavailable": "Opening and sharing profiles is not available in this build.",
"horizon.perf.share_missing": "No profile saved in this session yet. %s",
"horizon.perf.saved": "Profile saved (%s s). %s",
"horizon.perf.open_button": "[Open]",
"horizon.perf.open_hover": "Open this profile in your browser. It is served once to your own browser and goes nowhere else.",
"horizon.perf.share_button": "[Share]",
"horizon.perf.share_hover": "Upload this profile and get a link to share. Nothing is uploaded until you click this.",
"horizon.perf.opened": "Opened in your browser",
"horizon.perf.open_failed": "Could not open the profile: %s. %s",
"horizon.perf.not_found": "No saved profile named %s.",
"horizon.perf.error": "Profiler: %s",
"horizon.perf.not_horizon": "this server isn't running Horizon",
"horizon.perf.denied": "a server profile needs operator level 4",
"horizon.perf.already_running": "the server is already running a profile",
"horizon.perf.not_running": "no server profile of yours is running",
"horizon.perf.failed": "the server could not write the profile; see its log",
"horizon.perf.part_incomplete": "the server's part of the profile did not arrive whole",
"horizon.studio.timeline.freecam_on": "Freecam: ON",
"horizon.studio.timeline.freecam_off": "Freecam: OFF",
"horizon.studio.timeline.freecam_short": "Cam",
@@ -1164,5 +1192,25 @@
"horizon.studio.topbar.split_scene": "Split Scene",
"horizon.recipes.fuel.options": "Hold SHIFT to pin this tooltip, then click an ingredient to view recipes.",
"horizon.recipes.fuel.any": "Any Fuel",
"horizon.recipes.fuel.minecraft": "Minecraft"
"horizon.recipes.fuel.minecraft": "Minecraft",
"horizon.perf.idle": "Not profiling. %s",
"horizon.perf.marked": "Marked %s",
"horizon.perf.started.client": "Profiling the client. %s",
"horizon.perf.started.server": "Profiling the server. %s",
"horizon.perf.saving": "Saving the profile…",
"horizon.perf.running.client": "Profiling the client for %s s. %s",
"horizon.perf.running.server": "Profiling the server for %s s. %s",
"horizon.perf.running.both": "Profiling the client and server for %s s. %s",
"horizon.perf.client_only": "Profiling the client only: %s",
"horizon.perf.stop_button": "[Stop]",
"horizon.perf.stop_hover": "Stop profiling and save the profile.",
"horizon.perf.start_button": "[Start]",
"horizon.perf.start_hover": "Start profiling.",
"horizon.perf.folder_button": "[Folder]",
"horizon.perf.folder_hover": "Show %s in its folder.",
"horizon.perf.copy_button": "[Copy]",
"horizon.perf.copy_hover": "Copy the link.",
"horizon.perf.retry_button": "[Retry]",
"horizon.perf.retry_hover": "Try the upload again. The profile is still saved on your computer.",
"horizon.perf.link_hover": "Open the shared profile in your browser."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

+824 -92
View File
@@ -11,6 +11,7 @@ use std::sync::mpsc::{self, Sender};
use std::thread;
use std::time::{Duration, Instant};
use horizon_bench::compare::Ab;
use horizon_bench::{App, Frames, Kind, Report};
use serde_json::Value;
@@ -54,14 +55,105 @@ const FLIGHT_SECONDS: f64 = 12.0;
const SETTLE: Duration = Duration::from_secs(2);
const LOAD_LIMIT: Duration = Duration::from_secs(600);
/// `standard` flies every scene; a scene's name flies only that one.
pub fn scenes(scenario: &str) -> Option<Vec<Scene>> {
match scenario {
"standard" => Some(SCENES.to_vec()),
name => SCENES.iter().find(|s| s.0 == name).map(|s| vec![*s]),
/// Where the trip starts and ends: 512 blocks at 16 blocks/s, the pace that scrambles section ids.
const TRAVEL_FROM: [f64; 3] = [-960.0, 150.0, -176.0];
const TRAVEL_TO: [f64; 3] = [-960.0, 150.0, 336.0];
const TRAVEL_PITCH: f64 = 30.0;
const TRAVEL_SECONDS: f64 = 32.0;
const HOLD_BLOCKS: u32 = 4;
const BLOCK_SECONDS: f64 = 4.0;
const WARMUP_SECONDS: f64 = 2.0;
const PAIRS: u32 = 8;
/// Frame-rate settings the benchmark can set for a run; `a` is the default.
pub struct Switch {
pub name: &'static str,
pub word: &'static str,
pub key: &'static str,
pub a: &'static str,
pub b: &'static str,
}
pub const SWITCHES: [Switch; 4] = [
Switch {
name: "gpu-culling",
word: "gpu-culling",
key: "gpu_culling",
a: "off",
b: "on",
},
Switch {
name: "section-cull",
word: "section-cull",
key: "cull",
a: "on",
b: "off",
},
Switch {
name: "section-sides",
word: "section-sides",
key: "sides",
a: "on",
b: "off",
},
Switch {
name: "section-draws",
word: "section-draws",
key: "flat",
a: "flat",
b: "indirect",
},
];
/// What `--benchmark` runs.
pub enum Scenario {
/// `standard` flies every scene; a scene's name flies only that one.
Scenes(Vec<Scene>),
/// A 512-block flight, then held at its end.
Travel,
/// The flight with the switch's B applied, then its two values interleaved B A A B.
Ab(&'static Switch),
}
impl Scenario {
pub fn parse(name: &str) -> Result<Scenario, String> {
match name {
"standard" => Ok(Scenario::Scenes(SCENES.to_vec())),
"travel" => Ok(Scenario::Travel),
name if name.starts_with("ab:") => SWITCHES
.iter()
.find(|s| s.name == &name[3..])
.map(Scenario::Ab)
.ok_or_else(|| {
format!(
"unknown switch {}: {}",
&name[3..],
SWITCHES.map(|s| s.name).join(", ")
)
}),
name => SCENES
.iter()
.find(|s| s.0 == name)
.map(|s| Scenario::Scenes(vec![*s]))
.ok_or_else(|| {
format!("unknown benchmark {name}: standard, village, forest, ocean, mountain, travel or ab:SWITCH ({})", SWITCHES.map(|s| s.name).join(", "))
}),
}
}
}
/// `--benchmark-set SWITCH=VALUE`: the value must be one of the switch's two.
pub fn parse_set(value: &str) -> Result<(String, String), String> {
let (name, setting) = value.split_once('=').ok_or("expected SWITCH=VALUE")?;
let names = || SWITCHES.map(|s| s.name).join(", ");
let switch = SWITCHES
.iter()
.find(|s| s.name == name)
.ok_or_else(|| format!("unknown switch {name}: {}", names()))?;
if setting != switch.a && setting != switch.b {
return Err(format!("{name} is {} or {}", switch.a, switch.b));
}
Ok((name.to_owned(), setting.to_owned()))
}
/// What the game knows about the run before it starts.
pub struct Setup {
pub scenario: String,
@@ -73,6 +165,10 @@ pub struct Setup {
pub fps_cap: Option<u32>,
pub offscreen: bool,
pub game_dir: PathBuf,
/// `--benchmark-set`: a switch and its value, applied before the world opens.
pub set: Option<(String, String)>,
/// `--profile`: record a .hzperf capture of the run and embed the report in it.
pub profile: bool,
}
/// Starts the driver; the game quits when it is done, and the report's path is printed.
@@ -150,8 +246,7 @@ fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bo
}
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
let scenes =
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
let scenario = Scenario::parse(&setup.scenario)?;
let mut report = Report::new(Kind::Game, setup.app.clone());
let pid = std::process::id();
report.conditions.load_avg_1m = horizon_bench::load_average();
@@ -191,85 +286,12 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
horizon_telemetry::system::storage(&setup.game_dir).map(str::to_owned);
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
replace_world(&setup.game_dir)?;
control.call(&format!("open {WORLD} {SEED} creative"))?;
// The world opens on a worker: its folder may not exist yet when `open` replies.
std::fs::create_dir_all(setup.game_dir.join("saves").join(WORLD)).map_err(|e| e.to_string())?;
std::fs::write(
setup.game_dir.join("saves").join(WORLD).join(MARKER),
"Created by horizon-client --benchmark; replaced by the next run.\n",
)
.map_err(|e| e.to_string())?;
let loaded = wait_loaded(control, |_| true)?;
report.metric("startup.world_ms", &[ms(loaded - setup.started)]);
let mut steady = Vec::new();
for (name, start, end, pitch) in scenes {
control.call("time 6000")?;
let meshed = control.status()["meshed_sections"].as_f64().unwrap_or(0.0);
let generated = control.status()["generated_chunks"].as_f64().unwrap_or(0.0);
let [x, y, z] = start;
control.call(&format!("tp {x} {y} {z}"))?;
let teleported = Instant::now();
// A zero-length flight holds the player at the start, flying.
control.call(&format!("fly {x} {y} {z} {x} {y} {z} 0.1 {pitch}"))?;
control.call(&format!("look {:.3} {pitch}", yaw(start, end)))?;
let near = |p: &Value| {
let p: Vec<f64> = p
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_f64)
.collect();
p.len() == 3 && (0..3).map(|i| (p[i] - start[i]).powi(2)).sum::<f64>() < 4.0
};
let loaded = wait_loaded(control, near)?;
let load = loaded - teleported;
report.metric(&format!("chunks.{name}.load_ms"), &[ms(load)]);
let status = control.status();
let meshes = status["meshed_sections"].as_f64().unwrap_or(0.0) - meshed;
let chunks = status["generated_chunks"].as_f64().unwrap_or(0.0) - generated;
report.metric(&format!("chunks.{name}.meshed_count"), &[meshes]);
report.metric(&format!("chunks.{name}.generated_count"), &[chunks]);
if load.as_secs_f64() > 0.0 {
report.metric(
&format!("chunks.{name}.generated_per_s"),
&[chunks / load.as_secs_f64()],
);
}
if !time_passes(control, name, &mut report)? {
thread::sleep(SETTLE);
}
let [ex, ey, ez] = end;
let begin = Instant::now();
let flight = control.call(&format!(
"fly {x} {y} {z} {ex} {ey} {ez} {FLIGHT_SECONDS} {pitch} raw"
))?;
steady.push((begin, Instant::now()));
let series = |key: &str| -> Vec<f32> {
flight[key]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_f64)
.map(|v| v as f32)
.collect()
};
let frames = Frames::of(
name,
0,
&series("frame_ms"),
&series("cpu_ms"),
&series("gpu_ms"),
)
.ok_or("the flight drew no frames")?;
report.metric(&format!("fps.{name}.avg_fps"), &[frames.avg_fps]);
report.metric(&format!("fps.{name}.low_1pct_fps"), &[frames.low_1pct_fps]);
report.metric(
&format!("fps.{name}.low_01pct_fps"),
&[frames.low_01pct_fps],
);
report.frames.push(frames);
match &scenario {
Scenario::Ab(switch) => keeping_switch(control, switch, || {
measure(control, &setup, &scenario, &mut report, &mut steady)
})?,
_ => measure(control, &setup, &scenario, &mut report, &mut steady)?,
}
let status = control.status();
@@ -287,8 +309,12 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
c.vsync = Some(setup.vsync);
c.fps_cap = setup.fps_cap;
c.offscreen = Some(setup.offscreen);
c.repeats = Some(1);
c.warmup_s = Some(SETTLE.as_secs_f64());
let (repeats, warmup) = match scenario {
Scenario::Travel => (HOLD_BLOCKS, None),
Scenario::Ab(_) => (2 * PAIRS, Some(WARMUP_SECONDS)),
Scenario::Scenes(_) => (1, Some(SETTLE.as_secs_f64())),
};
(c.repeats, c.warmup_s) = (Some(repeats), warmup);
if let Some(bytes) = renderer["gpu_allocated_bytes"].as_f64() {
report.metric("memory.gpu_mb", &[bytes / f64::from(1 << 20)]);
}
@@ -312,9 +338,342 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
report.metric("game.binary_bytes", &[meta.len() as f64]);
}
crate::telemetry::keep_benchmark(&crate::logging::game_home(), &report);
report
let written = report
.write_to(&setup.game_dir.join("benchmarks"))
.map_err(|e| e.to_string())
.map_err(|e| e.to_string())?;
if setup.profile {
match control.call(&stop_capture(&written)) {
Ok(done) => println!("Horizon: benchmark capture {}", text(&done["path"])),
Err(e) => eprintln!("Horizon: the benchmark capture failed: {e}"),
}
}
Ok(written)
}
/// Starts the capture the run's frames feed; its trigger is the benchmark's.
const START_CAPTURE: &str = "profile client start";
/// Ends the capture with the written report embedded in it.
fn stop_capture(report: &Path) -> String {
format!("profile client stop --bench {}", report.display())
}
/// Opens the benchmark world and flies the scenario in it.
fn measure(
control: &Control,
setup: &Setup,
scenario: &Scenario,
report: &mut Report,
steady: &mut Vec<(Instant, Instant)>,
) -> Result<(), String> {
match (scenario, &setup.set) {
(Scenario::Ab(switch), _) => apply_set(control, switch.name, switch.b)?,
(_, Some((name, value))) => apply_set(control, name, value)?,
_ => {}
}
replace_world(&setup.game_dir)?;
control.call(&format!("open {WORLD} {SEED} creative"))?;
// The world opens on a worker: its folder may not exist yet when `open` replies.
std::fs::create_dir_all(setup.game_dir.join("saves").join(WORLD)).map_err(|e| e.to_string())?;
std::fs::write(
setup.game_dir.join("saves").join(WORLD).join(MARKER),
"Created by horizon-client --benchmark; replaced by the next run.\n",
)
.map_err(|e| e.to_string())?;
let loaded = wait_loaded(control, |_| true)?;
report.metric("startup.world_ms", &[ms(loaded - setup.started)]);
if setup.profile {
control.call(START_CAPTURE)?;
}
match scenario {
Scenario::Scenes(scenes) => {
for &(name, start, end, pitch) in scenes {
control.call("time 6000")?;
load_scene(control, name, start, end, pitch, report)?;
if !time_passes(control, name, report)? {
thread::sleep(SETTLE);
}
let frames = fly(control, name, start, end, FLIGHT_SECONDS, pitch, steady)?;
fps_metrics(report, name, std::slice::from_ref(&frames));
report.frames.push(frames);
}
}
Scenario::Travel => travel(control, report, steady)?,
Scenario::Ab(switch) => {
trip(control, report, steady)?;
let plan = Plan {
pairs: PAIRS,
warmup: Duration::from_secs_f64(WARMUP_SECONDS),
};
ab_blocks(control, switch, &plan, report, steady)?;
}
}
Ok(())
}
/// Reads a switch before the run and puts it back after, whatever the run did and however it ended.
fn keeping_switch<T>(
control: &Control,
switch: &Switch,
run: impl FnOnce() -> Result<T, String>,
) -> Result<T, String> {
let initial = control.call(switch.word)?[switch.key].clone();
let result = run();
let restored = restore(control, switch, &initial);
match result {
Ok(value) => restored.map(|()| value),
Err(e) => Err(e),
}
}
/// Sends the arm whose reply has the value the switch had.
fn restore(control: &Control, switch: &Switch, initial: &Value) -> Result<(), String> {
if control.call(switch.word)?[switch.key] == *initial {
return Ok(());
}
for value in [switch.a, switch.b] {
if control.call(&format!("{} {value}", switch.word))?[switch.key] == *initial {
return Ok(());
}
}
Err(format!("{} could not be put back", switch.name))
}
/// Sets a switch with its control word; a refusal (GPU culling not offered) is the game's reason.
fn apply_set(control: &Control, name: &str, value: &str) -> Result<(), String> {
let switch = SWITCHES
.iter()
.find(|s| s.name == name)
.ok_or("unknown switch")?;
control.call(&format!("{} {value}", switch.word)).map(drop)
}
/// Teleports to a scene's start, holds there and waits for the view to load, reporting the load.
fn load_scene(
control: &Control,
name: &str,
start: [f64; 3],
end: [f64; 3],
pitch: f64,
report: &mut Report,
) -> Result<(), String> {
let meshed = control.status()["meshed_sections"].as_f64().unwrap_or(0.0);
let generated = control.status()["generated_chunks"].as_f64().unwrap_or(0.0);
let [x, y, z] = start;
control.call(&format!("tp {x} {y} {z}"))?;
let teleported = Instant::now();
// A zero-length flight holds the player at the start, flying.
control.call(&format!("fly {x} {y} {z} {x} {y} {z} 0.1 {pitch}"))?;
control.call(&format!("look {:.3} {pitch}", yaw(start, end)))?;
let loaded = wait_loaded(control, near(start))?;
let load = loaded - teleported;
report.metric(&format!("chunks.{name}.load_ms"), &[ms(load)]);
let status = control.status();
let meshes = status["meshed_sections"].as_f64().unwrap_or(0.0) - meshed;
let chunks = status["generated_chunks"].as_f64().unwrap_or(0.0) - generated;
report.metric(&format!("chunks.{name}.meshed_count"), &[meshes]);
report.metric(&format!("chunks.{name}.generated_count"), &[chunks]);
if load.as_secs_f64() > 0.0 {
report.metric(
&format!("chunks.{name}.generated_per_s"),
&[chunks / load.as_secs_f64()],
);
}
Ok(())
}
/// Whether a status position is within 2 blocks of `target`.
fn near(target: [f64; 3]) -> impl Fn(&Value) -> bool {
move |p| {
let p: Vec<f64> = p
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_f64)
.collect();
p.len() == 3 && (0..3).map(|i| (p[i] - target[i]).powi(2)).sum::<f64>() < 4.0
}
}
/// Flies the camera from `start` to `end` and returns the flight's frames.
fn fly(
control: &Control,
phase: &str,
start: [f64; 3],
end: [f64; 3],
seconds: f64,
pitch: f64,
steady: &mut Vec<(Instant, Instant)>,
) -> Result<Frames, String> {
let ([x, y, z], [ex, ey, ez]) = (start, end);
let begin = Instant::now();
let flight = control.call(&format!(
"fly {x} {y} {z} {ex} {ey} {ez} {seconds} {pitch} raw"
))?;
steady.push((begin, Instant::now()));
frames_of(&flight, phase, 0)
}
/// The frames of a `fly ... raw` or `bench ... raw` reply.
fn frames_of(reply: &Value, phase: &str, repeat: u32) -> Result<Frames, String> {
let series = |key: &str| -> Vec<f32> {
reply[key]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_f64)
.map(|v| v as f32)
.collect()
};
Frames::of(
phase,
repeat,
&series("frame_ms"),
&series("cpu_ms"),
&series("gpu_ms"),
)
.ok_or_else(|| format!("{phase} drew no frames"))
}
/// The phase's frame figures over its blocks (one for a flight).
fn fps_metrics(report: &mut Report, phase: &str, blocks: &[Frames]) {
let each = |f: fn(&Frames) -> f64| blocks.iter().map(f).collect::<Vec<_>>();
report.metric(&format!("fps.{phase}.avg_fps"), &each(|b| b.avg_fps));
report.metric(
&format!("fps.{phase}.low_1pct_fps"),
&each(|b| b.low_1pct_fps),
);
report.metric(
&format!("fps.{phase}.low_01pct_fps"),
&each(|b| b.low_01pct_fps),
);
}
/// The median CPU and GPU time of a frame over the phase's blocks, where measured.
fn timing_metrics(report: &mut Report, phase: &str, blocks: &[Frames]) {
let cpu: Vec<f64> = blocks.iter().filter_map(|b| b.cpu_p50_ms).collect();
let gpu: Vec<f64> = blocks.iter().filter_map(|b| b.gpu_p50_ms).collect();
report.metric(&format!("cpu.{phase}.p50_ms"), &cpu);
report.metric(&format!("gpu.{phase}.p50_ms"), &gpu);
}
/// The flight, then the view at its end loaded.
fn trip(
control: &Control,
report: &mut Report,
steady: &mut Vec<(Instant, Instant)>,
) -> Result<(), String> {
for line in [
"command gamerule advance_time false",
"command gamerule advance_weather false",
"command weather clear",
"time 6000",
] {
control.call(line)?;
}
let (from, to) = (TRAVEL_FROM, TRAVEL_TO);
load_scene(control, "travel", from, to, TRAVEL_PITCH, report)?;
let flight = fly(
control,
"travel",
from,
to,
TRAVEL_SECONDS,
TRAVEL_PITCH,
steady,
)?;
let blocks = std::slice::from_ref(&flight);
fps_metrics(report, "travel", blocks);
timing_metrics(report, "travel", blocks);
report.frames.push(flight);
let [x, y, z] = to;
control.call(&format!("fly {x} {y} {z} {x} {y} {z} 0.1 {TRAVEL_PITCH}"))?;
control.call(&format!("look {:.3} {TRAVEL_PITCH}", yaw(from, to)))?;
wait_loaded(control, near(to))?;
Ok(())
}
/// The state that matters: fly 512 blocks over terrain that loads and unloads, then hold at the end.
fn travel(
control: &Control,
report: &mut Report,
steady: &mut Vec<(Instant, Instant)>,
) -> Result<(), String> {
trip(control, report, steady)?;
let mut blocks = Vec::new();
for repeat in 0..HOLD_BLOCKS {
let held = control.call(&format!("bench {BLOCK_SECONDS} raw"))?;
blocks.push(frames_of(&held, "hold", repeat)?);
}
fps_metrics(report, "hold", &blocks);
timing_metrics(report, "hold", &blocks);
report.frames.extend(blocks);
Ok(())
}
struct Plan {
pairs: u32,
warmup: Duration,
}
/// The two values of a switch in B A A B order, a block each, then each arm's passes timed once.
fn ab_blocks(
control: &Control,
switch: &Switch,
plan: &Plan,
report: &mut Report,
steady: &mut Vec<(Instant, Instant)>,
) -> Result<(), String> {
let began = Instant::now();
let mut blocks = Vec::new();
for pair in 0..plan.pairs {
control.call("time 6000")?;
for (n, b) in [(0, pair % 2 == 0), (1, pair % 2 == 1)] {
let (phase, value) = if b {
("ab.b", switch.b)
} else {
("ab.a", switch.a)
};
control.call(&format!("{} {value}", switch.word))?;
thread::sleep(plan.warmup);
let held = control.call(&format!("bench {BLOCK_SECONDS} raw"))?;
blocks.push(frames_of(&held, phase, 2 * pair + n)?);
}
}
steady.push((began, Instant::now()));
for phase in ["ab.a", "ab.b"] {
let arm: Vec<Frames> = blocks
.iter()
.filter(|f| f.phase == phase)
.cloned()
.collect();
fps_metrics(report, phase, &arm);
timing_metrics(report, phase, &arm);
}
let ab = Ab::from_frames(
switch.name,
switch.a,
switch.b,
BLOCK_SECONDS,
plan.warmup.as_secs_f64(),
&blocks,
)?;
if ab.first_block_outside_range {
eprintln!(
"Horizon: benchmark warning: the first B block, measured before any switch, is outside the later B blocks: switching {} may reset what is measured",
switch.name
);
}
report.frames.extend(blocks);
report.ab = Some(ab);
for (scene, value) in [("ab_a", switch.a), ("ab_b", switch.b)] {
control.call(&format!("{} {value}", switch.word))?;
time_passes(control, scene, report)?;
}
Ok(())
}
/// Removes the previous run's world, never one the benchmark didn't create.
@@ -472,11 +831,65 @@ impl RssSampler {
mod tests {
use super::*;
#[test]
fn the_capture_commands_are_the_ones_the_game_parses() {
use crate::client_perf::request::{self, ProfileSide, Request, StartOptions};
let words = |line: &str| request::parse(&line.split(' ').skip(1).collect::<Vec<_>>());
let start = words(START_CAPTURE).unwrap();
assert_eq!(start.side, Some(ProfileSide::Client));
assert_eq!(start.request, Request::Start(StartOptions::default()));
let path = Path::new("/home/a b/game/benchmarks/game-20261108-travel.json");
let stop = words(&stop_capture(path)).unwrap();
assert_eq!(stop.side, Some(ProfileSide::Client));
assert_eq!(
stop.request,
Request::Stop {
bench: Some(path.display().to_string())
}
);
}
#[test]
fn scenarios() {
assert_eq!(scenes("standard").unwrap().len(), 4);
assert_eq!(scenes("ocean").unwrap()[0].0, "ocean");
assert!(scenes("nether").is_none());
let scenes = |name: &str| match Scenario::parse(name) {
Ok(Scenario::Scenes(scenes)) => scenes,
_ => panic!("{name} is not scenes"),
};
assert_eq!(scenes("standard").len(), 4);
assert_eq!(scenes("ocean")[0].0, "ocean");
assert!(matches!(Scenario::parse("travel"), Ok(Scenario::Travel)));
assert!(matches!(
Scenario::parse("ab:gpu-culling"),
Ok(Scenario::Ab(s)) if s.name == "gpu-culling"
));
let Err(e) = Scenario::parse("ab:nope") else {
panic!("nope is no switch")
};
assert!(
e.contains("gpu-culling") && e.contains("section-draws"),
"{e}"
);
let Err(e) = Scenario::parse("nether") else {
panic!("nether is no scenario")
};
assert!(e.contains("travel") && e.contains("mountain"), "{e}");
}
#[test]
fn a_set_names_a_switch_and_one_of_its_two_values() {
assert_eq!(
parse_set("gpu-culling=on"),
Ok(("gpu-culling".into(), "on".into()))
);
assert_eq!(
parse_set("section-draws=indirect"),
Ok(("section-draws".into(), "indirect".into()))
);
let nope = parse_set("gpu-culling=maybe").unwrap_err();
assert!(nope.contains("off or on"), "{nope}");
let unknown = parse_set("fog=on").unwrap_err();
assert!(unknown.contains("gpu-culling") && unknown.contains("section-draws"));
assert!(parse_set("gpu-culling").is_err());
}
#[test]
@@ -588,6 +1001,325 @@ mod tests {
assert!(metrics.is_empty());
}
/// A fake game for the trip: the player stands where `tp` or `fly` put it and counts settle.
fn trip_server(rx: mpsc::Receiver<Request>) -> thread::JoinHandle<Vec<String>> {
thread::spawn(move || {
let (mut lines, mut at, mut polls) = (Vec::new(), [0.0_f64; 3], 0_u64);
while let Ok(request) = rx.recv() {
let words: Vec<&str> = request.line.split(' ').collect();
let reply = match words[0] {
"status" => {
polls += 1;
let mut s: Value =
serde_json::from_str(&status(400 + polls.min(3))).unwrap();
s["player"]["position"] = serde_json::json!(at);
s
}
"bench" | "fly" if words.contains(&"raw") => {
serde_json::json!({
"frames": 200,
"frame_ms": vec![5.0; 200],
"cpu_ms": vec![2.0; 200],
"gpu_ms": vec![4.0; 200],
})
}
_ => serde_json::json!({ "ok": true }),
};
if matches!(words[0], "tp" | "fly") {
let n: Vec<f64> = words[1..4].iter().filter_map(|w| w.parse().ok()).collect();
at = [n[0], n[1], n[2]];
polls = 0;
}
let _ = request.reply.send(reply.to_string());
lines.push(request.line);
}
lines
})
}
#[test]
fn the_trip_flies_512_blocks_then_holds_four_blocks_at_its_end() {
let (tx, rx) = mpsc::channel::<Request>();
let server = trip_server(rx);
let mut report = Report::new(Kind::Game, App::default());
let control = Control(tx);
let mut steady = Vec::new();
travel(&control, &mut report, &mut steady).expect("the trip runs");
drop(control);
let lines: Vec<String> = server
.join()
.unwrap()
.into_iter()
.filter(|l| l != "status")
.map(|l| l.replace("-0.000", "0.000"))
.collect();
// Facing +Z is yaw 0, which the formatter may print as -0.
let mut expected = vec![
"command gamerule advance_time false",
"command gamerule advance_weather false",
"command weather clear",
"time 6000",
"tp -960 150 -176",
"fly -960 150 -176 -960 150 -176 0.1 30",
"look 0.000 30",
"fly -960 150 -176 -960 150 336 32 30 raw",
"fly -960 150 336 -960 150 336 0.1 30",
"look 0.000 30",
];
expected.extend(["bench 4 raw"; 4]);
assert_eq!(lines, expected);
let phases: Vec<_> = report
.frames
.iter()
.map(|f| (f.phase.as_str(), f.repeat))
.collect();
assert_eq!(
phases,
[
("travel", 0),
("hold", 0),
("hold", 1),
("hold", 2),
("hold", 3)
]
);
assert_eq!(steady.len(), 1);
let metric = |name: &str| report.metrics.get(name).map(|s| (s.n, s.mean));
assert_eq!(metric("fps.travel.avg_fps"), Some((1, 200.0)));
assert_eq!(metric("fps.hold.avg_fps"), Some((4, 200.0)));
assert_eq!(metric("cpu.hold.p50_ms"), Some((4, 2.0)));
assert_eq!(metric("gpu.hold.p50_ms"), Some((4, 4.0)));
assert_eq!(metric("gpu.travel.p50_ms"), Some((1, 4.0)));
assert!(metric("chunks.travel.load_ms").is_some());
}
#[test]
fn a_refused_command_ends_the_trip_with_the_games_reason() {
let (tx, rx) = mpsc::channel::<Request>();
let server = thread::spawn(move || {
while let Ok(request) = rx.recv() {
let reply = if request.line.starts_with("command weather") {
serde_json::json!({ "error": "no such weather" })
} else {
serde_json::json!({ "ok": true })
};
let _ = request.reply.send(reply.to_string());
}
});
let mut report = Report::new(Kind::Game, App::default());
let control = Control(tx);
let error = travel(&control, &mut report, &mut Vec::new()).unwrap_err();
assert!(error.contains("no such weather"), "{error}");
assert!(report.frames.is_empty());
drop(control);
server.join().unwrap();
}
#[test]
fn a_refused_switch_is_the_games_reason() {
let (tx, rx) = mpsc::channel::<Request>();
let server = thread::spawn(move || {
let mut lines = Vec::new();
while let Ok(request) = rx.recv() {
let reply = match request.line.as_str() {
"gpu-culling on" => serde_json::json!({ "error": "not offered here" }),
_ => serde_json::json!({ "ok": true }),
};
let _ = request.reply.send(reply.to_string());
lines.push(request.line);
}
lines
});
let control = Control(tx);
apply_set(&control, "section-draws", "indirect").unwrap();
let error = apply_set(&control, "gpu-culling", "on").unwrap_err();
assert!(error.contains("not offered here"), "{error}");
drop(control);
assert_eq!(
server.join().unwrap(),
["section-draws indirect", "gpu-culling on"]
);
}
/// A fake game with GPU culling: `on` is its state; `fail_at` makes that `bench` (counted from 1) fail.
fn culling_server(
rx: mpsc::Receiver<Request>,
mut on: bool,
offered: bool,
fail_at: Option<usize>,
) -> thread::JoinHandle<(Vec<String>, bool)> {
thread::spawn(move || {
let (mut lines, mut benches) = (Vec::new(), 0);
while let Ok(request) = rx.recv() {
let reply = match request.line.as_str() {
"gpu-culling on" if !offered => {
serde_json::json!({ "error": "not offered here" })
}
"gpu-culling on" | "gpu-culling off" => {
on = request.line.ends_with("on");
serde_json::json!({ "gpu_culling": on })
}
"gpu-culling" => serde_json::json!({ "gpu_culling": on }),
"bench 4 raw" => {
benches += 1;
let gpu = if on { 5.0 } else { 3.0 };
if Some(benches) == fail_at {
serde_json::json!({ "error": "the window closed" })
} else {
serde_json::json!({
"frame_ms": vec![gpu + 1.0; 200],
"cpu_ms": vec![1.0; 200],
"gpu_ms": vec![gpu; 200],
})
}
}
"bench 2" => serde_json::json!({
"gpu_passes": {"World": if on { 1.5 } else { 0.5 }},
}),
_ => serde_json::json!({ "ok": true }),
};
let _ = request.reply.send(reply.to_string());
lines.push(request.line);
}
(lines, on)
})
}
const GPU_CULLING: &Switch = &SWITCHES[0];
fn quick(pairs: u32) -> Plan {
Plan {
pairs,
warmup: Duration::ZERO,
}
}
fn vulkan_report() -> Report {
let gpu = horizon_bench::Gpu {
backend: "vulkan".into(),
..Default::default()
};
let hardware = horizon_bench::Hardware {
gpu: Some(gpu),
..Default::default()
};
Report::with_hardware(Kind::Game, App::default(), hardware)
}
#[test]
fn the_blocks_run_b_a_a_b_and_each_arm_is_timed_by_pass() {
let (tx, rx) = mpsc::channel::<Request>();
let server = culling_server(rx, false, true, None);
let mut report = vulkan_report();
let control = Control(tx);
keeping_switch(&control, GPU_CULLING, || {
ab_blocks(
&control,
GPU_CULLING,
&quick(6),
&mut report,
&mut Vec::new(),
)
})
.expect("the blocks run");
drop(control);
let (lines, on) = server.join().unwrap();
let arms: String = lines
.iter()
.take_while(|l| *l != "gpu-passes on")
.filter_map(|l| match l.as_str() {
"gpu-culling on" => Some('B'),
"gpu-culling off" => Some('A'),
_ => None,
})
.collect();
assert_eq!(arms, "BAABBAABBAABA");
assert_eq!(lines.iter().filter(|l| *l == "time 6000").count(), 6);
assert!(!on, "the initial value is back");
assert_eq!(lines.last().unwrap(), "gpu-culling off");
let positions: Vec<_> = report
.frames
.iter()
.map(|f| (f.phase.as_str(), f.repeat))
.collect();
assert_eq!(
positions[..4],
[("ab.b", 0), ("ab.a", 1), ("ab.a", 2), ("ab.b", 3)]
);
let ab = report.ab.as_ref().expect("an A/B run has its result");
let gpu = &ab.result["gpu_p50_ms"];
assert_eq!((gpu.pairs, gpu.a, gpu.b, gpu.diff), (6, 3.0, 5.0, 2.0));
assert_eq!(gpu.verdict, horizon_bench::compare::Verdict::Slower);
assert!(!ab.first_block_outside_range);
let metric = |name: &str| report.metrics.get(name).map(|s| (s.n, s.mean));
assert_eq!(metric("gpu.ab.a.p50_ms"), Some((6, 3.0)));
assert_eq!(metric("gpu.ab.b.p50_ms"), Some((6, 5.0)));
assert_eq!(metric("gpu_pass.ab_a.world_ms"), Some((1, 0.5)));
assert_eq!(metric("gpu_pass.ab_b.world_ms"), Some((1, 1.5)));
}
#[test]
fn a_setting_that_was_on_is_on_again_after_the_run() {
let (tx, rx) = mpsc::channel::<Request>();
let server = culling_server(rx, true, true, None);
let control = Control(tx);
keeping_switch(&control, GPU_CULLING, || {
ab_blocks(
&control,
GPU_CULLING,
&quick(2),
&mut vulkan_report(),
&mut Vec::new(),
)
})
.expect("the blocks run");
drop(control);
assert!(server.join().unwrap().1);
}
#[test]
fn the_setting_is_put_back_when_the_run_fails_midway() {
let (tx, rx) = mpsc::channel::<Request>();
// The 3rd block is A: the setting is off when it fails.
let server = culling_server(rx, true, true, Some(3));
let control = Control(tx);
let mut report = vulkan_report();
let error = keeping_switch(&control, GPU_CULLING, || {
ab_blocks(
&control,
GPU_CULLING,
&quick(6),
&mut report,
&mut Vec::new(),
)
})
.unwrap_err();
assert!(error.contains("the window closed"), "{error}");
assert!(report.frames.is_empty() && report.ab.is_none());
drop(control);
let (lines, on) = server.join().unwrap();
assert!(on, "on was the player's setting");
assert_eq!(lines.last().unwrap(), "gpu-culling on");
}
#[test]
fn a_refused_switch_ends_the_run_with_the_games_reason_and_changes_nothing() {
let (tx, rx) = mpsc::channel::<Request>();
let server = culling_server(rx, false, false, None);
let control = Control(tx);
let error = keeping_switch(&control, GPU_CULLING, || {
apply_set(&control, "gpu-culling", "on")
})
.unwrap_err();
assert!(error.contains("not offered here"), "{error}");
drop(control);
let (lines, on) = server.join().unwrap();
assert_eq!(lines, ["gpu-culling", "gpu-culling on", "gpu-culling"]);
assert!(!on);
}
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
@@ -589,12 +589,22 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("mcp", Scripting, "mcp [--paced]", "serve the Model Context Protocol on stdin/stdout, one tool per command",
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image. --paced stops a single-player world between tool calls: the pause menu opens after each action and closes before the next, so time passes only while an action (or wait) runs, not while the agent thinks; reading tools (state, inventory, entities, events...) leave it paused.",
["horizon-ctl mcp", "horizon-ctl mcp --paced"], &[], mcp: false, local: true),
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
cmd!("trace", Scripting, "trace [FILE [SECONDS] | stop]", "write every thread's timed spans to FILE as a timeline that ui.perfetto.dev opens, for SECONDS or until stop",
"Each span (ticks by system, streaming, uploads, far terrain, lighting, the renderer) shows on the thread that ran it, nested by time. Without arguments, only reports whether a trace is being written. HORIZON_TRACE=FILE writes one from the start; horizon-trace on a dedicated server's console writes one to reports/. See docs/graphics.md.",
["horizon-ctl trace /tmp/frame.json 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
cmd!("bench", Scripting, "bench SECONDS [raw]", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10", "horizon-ctl bench 4 raw"], &[p("seconds", Ty::Num, "how long to measure"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms; alloc_count and alloc_bytes in an alloc-count build)")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "A build with the alloc-count feature adds alloc: the window thread's allocations and KiB per frame and all threads' allocations per second, else null.", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms; alloc_count and alloc_bytes in an alloc-count build)")], mcp: true, local: false),
cmd!("gpu-passes", Scripting, "gpu-passes [on|off|detail]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench; detail adds dispatch times and, on Vulkan, fragment and vertex counts per world pass", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off", "detail"]), "on, off or detail; without it, only reports")], mcp: false, local: false),
cmd!("trace", Scripting, "trace [FILE [SECONDS] | stop]", "write every thread's timed spans to FILE as a .hzperf capture holding a timeline, for SECONDS or until stop",
"Each span (ticks by system, streaming, uploads, far terrain, lighting, the renderer) is recorded on the thread that ran it, with its depth; the timeline stops after 60 seconds. trace stop replies with the file's path once it is whole. Without arguments, only reports whether a trace is being written. HORIZON_TRACE=FILE writes one from the start; horizon-trace on a dedicated server's console writes one to reports/. Open the file in the web UI. See docs/graphics.md.",
["horizon-ctl trace /tmp/frame.hzperf 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
cmd!("profile", Scripting, "profile [client|server] start [--seconds S] [--hz N] [--over MS] [--threads main|all] [--wall] [--gpu[=detail]] [--no-samples] [--timeline] | stop [--bench REPORT] | status | mark NAME | open FILE | share FILE", "capture the game's performance to a .hzperf file in reports/ in the game home",
"start records the frame times, the health history and the render thread's call trees until stop or --seconds (at most 600). stop replies with the file's path once the file is whole, and with the viewer's address (open_at) only in a build that has a part to open captures; open FILE serves the file once on 127.0.0.1 and opens the viewer on it, share FILE uploads it and replies with the link; both fail in a build without that part and work with every telemetry switch off; status says whether a capture runs, its age and its size; mark NAME puts a label at the current time. F3+O does start and stop from the keyboard; HORIZON_PROFILE=DIR records the first 10 minutes of a run into DIR. --gpu adds each render pass's median GPU time and --gpu=detail the dispatch times and, on Vulkan, fragment and vertex counts per pass; HORIZON_SHADER_PACK_TIMING and HORIZON_GPU_PASS_DETAIL start a capture with them. stop --bench REPORT embeds a benchmark report (benchmarks/*.json) in the capture; --benchmark --profile does this by itself. Read the file with horizon-perf (query gpu). With no side a single-player run captures both sides in one file (the window, and the server thread as its own section); client or server captures one. Connected to a Horizon server, server asks that server to capture its side: it needs operator level 4, only one server capture runs at a time, and the server keeps the file in its reports/ and logs who asked; a refusal is a warning in the window's own capture.",
["horizon-ctl profile start --seconds 30", "horizon-ctl profile mark loaded-the-nether", "horizon-ctl profile stop", "horizon-ctl profile open reports/profile-20251107-143844Z.hzperf"],
&[p("action", Ty::Enum(&["start", "stop", "status", "mark", "open", "share"]), "what to do"), opt("name", Ty::Rest, "mark: the label; open and share: the file"),
flag("side", Ty::Enum(&["client", "server"]), "client or server, for the window or the server (the single-player server thread, or the server joined); both when omitted"), flag("seconds", Ty::Int, "start: stop after this many seconds, 1 to 600"),
flag("hz", Ty::Int, "start: samples per second, 10 to 1000 (default 250)"), flag("over", Ty::Num, "start: sample only frames slower than this many milliseconds"),
flag("threads", Ty::Enum(&["main", "all"]), "start: the render thread (default) or every thread that opted in"), flag("wall", Ty::Bool, "start: also sample on the wall clock, to find waits"),
flag("gpu", Ty::Enum(&["passes", "detail"]), "start: also record each render pass's GPU time (detail: its parts and counters too)"),
flag("no-samples", Ty::Bool, "start: record frame times and health only"), flag("timeline", Ty::Bool, "start: also record every thread's spans, for 60 seconds at most"),
flag("bench", Ty::Str, "stop: the path of a benchmark report to embed in the capture")], mcp: true, local: false),
cmd!("screenshot-sequence", Scripting, "screenshot-sequence FRAMES STEP_MS PATH|cancel", "draw a shader pack's first FRAMES frames on a set clock and write the last as a PNG",
"Needs HORIZON_STILL and a frozen world (docs/agent-control.md). The pack starts over, frame n's clock is n frames and (n - 1) × STEP_MS milliseconds, the PNG is written as screenshot PATH writes it, and the reply comes once it is. Relative paths are taken from horizon-ctl's directory. cancel stops a running sequence.",
["horizon-ctl screenshot-sequence 64 50 frame64.png", "horizon-ctl screenshot-sequence 64 16.666667 frame64.png", "horizon-ctl screenshot-sequence cancel"],
@@ -40,6 +40,17 @@ pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
.is_some_and(|data| data.get("RecordItem").is_some())
}
/// `LecternBlock.getStateForPlacement`: the item's `minecraft:block_entity_data`
/// carries a `Book` (`TypedEntityData.contains` checks the key only), so the
/// placed lectern's `has_book` state says so. Vanilla reads the item's data,
/// not the block entity the placement just filled.
pub fn carries_book(stack: &crate::items::ItemStack) -> bool {
stack
.data()
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
.is_some_and(|data| data.get("Book").is_some())
}
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
/// the item carries data of an op-only type.
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
+15 -10
View File
@@ -19,16 +19,16 @@ pub use horizon_client_render::{
effects, environment, extras, fast_hash, fluid_render, fog, font, gfx, graphics,
graphics_style, graphics_ui, hdr_calibration, hud, hud_kit, hud_layout, hud_overlays,
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
menu_blur, menu_style, mesher, minimap, mipmap, mod_browser, mod_list, model_library, models,
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay,
pack_browser, pack_formats, pack_hooks, pack_palette, pack_screen, packs, pad_cursor,
pad_glyph, par, particles, poi_sections, polytone_creative_tabs, polytone_post, post_chain,
potent_sulfur, preedit, providers, pvp, pvp_hud, random_source, screen_draw, screen_effects,
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
social, sound_engine, sound_events, sound_preview, spans, split_layout, split_map,
split_profiles, sprites, streaming, structure_block, tab_list, terrain_model, test_block,
test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets, world,
worldborder, zip,
mark_sprite, menu_blur, menu_style, mesher, minimap, mipmap, mod_browser, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_browser, pack_formats, pack_hooks, pack_palette, pack_screen, packs,
pad_cursor, pad_glyph, par, particles, poi_sections, polytone_creative_tabs, polytone_post,
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, random_source, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, social, sound_engine, sound_events, sound_preview, spans,
split_layout, split_map, split_profiles, sprites, streaming, structure_block, tab_list,
terrain_model, test_block, test_instance_block, testgen, title_edition, trims, ui, vault,
weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
@@ -43,6 +43,7 @@ pub mod boss_overlay;
pub mod breathing;
pub mod cauldron;
pub mod chunk_io;
pub mod client_perf;
pub mod clock;
pub mod command_storage;
pub mod confirm_screen;
@@ -107,6 +108,7 @@ pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_net;
pub mod perf_overlay;
pub mod pick_item;
pub mod player_sync;
@@ -129,6 +131,7 @@ pub mod sound_playback;
pub use horizon_server_files as server;
pub mod controller_notice;
pub mod server_packs;
pub mod server_perf;
pub mod shadows;
pub mod spear;
pub mod spectator_menu;
@@ -139,6 +142,8 @@ pub mod stopwatches;
pub mod structure_blocks;
pub mod studio;
pub mod telemetry;
#[cfg(test)]
pub(crate) mod test_fixtures;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
+321
View File
@@ -0,0 +1,321 @@
//! The client's .hzperf captures (docs/perf-platform.md §4.1): the window's frames feed the always-on health history, and a capture writes it with the samples of the render thread.
use crate::gfx::pack_timing::{GpuReadout, TimedFrames};
use crate::server_perf::traffic::{Conn, Wire};
use crate::server_perf::{
self, CaptureEnd, CaptureOptions, CaptureStatus, MAX_WINDOW_S, Role, SamplerOptions, Saved,
ServerPerf, TickDetail, WorldCounts,
};
use horizon_perf_format::FieldSender;
use horizon_perf_format::v1::{
BenchResult, Machine, Side, Start, StopReason, Trigger, WarningCode,
};
use horizon_protocol::connection::Counters;
use request::{GpuSwitch, StartOptions, Threads};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
pub mod request;
#[cfg(test)]
mod tests;
/// A frame this slow enters the slow ring when `HORIZON_SPIKES` names no other threshold.
pub const SLOW_FRAME_MS: f32 = 50.0;
/// The variables that start a capture with the GPU passes timed: pass timing, and pass timing with its parts.
pub const ENV_PASS_TIMING: &str = "HORIZON_SHADER_PACK_TIMING";
pub const ENV_PASS_DETAIL: &str = "HORIZON_GPU_PASS_DETAIL";
/// The GPU switch a run's environment asks for; the detail variable wins over the timing one.
pub fn env_gpu(timing: Option<&OsStr>, detail: Option<&OsStr>) -> GpuSwitch {
if detail.is_some() {
GpuSwitch::Detail
} else if timing.is_some() {
GpuSwitch::Passes
} else {
GpuSwitch::Off
}
}
/// Where a client's captures go: `HORIZON_PROFILE` when set, else `reports/` in the game home.
pub fn reports_dir(profile: Option<&OsStr>, home: &Path) -> PathBuf {
match profile.filter(|p| !p.is_empty()) {
Some(dir) => PathBuf::from(dir),
None => home.join("reports"),
}
}
/// What a side on another thread needs to write into the running capture's file.
#[derive(Clone)]
pub struct Join {
pub sender: FieldSender,
pub path: PathBuf,
/// The instant every `t` of the capture counts from.
pub origin: Instant,
/// When the capture's window ends, and why it stops then.
pub ends: Option<(Instant, StopReason)>,
pub options: StartOptions,
pub trigger: Trigger,
}
/// The client's health history, always on, and the capture running from it, if any.
pub struct ClientPerf {
perf: ServerPerf,
epoch: Instant,
cpu_ms: Vec<f32>,
gpu_ms: Vec<f32>,
/// When the running capture's window ends, and why it stops then.
window: Option<(f64, StopReason)>,
/// The running capture's switches, which a side that joins it follows.
running: Option<(StartOptions, Trigger)>,
/// The server connection's byte counts while the window plays on one: the capture's own traffic.
connection: Option<Arc<Counters>>,
}
/// The window's one connection as the traffic recorder's wire list.
fn wire_of(connection: Option<&Counters>) -> Wire {
let order = Ordering::Relaxed;
connection
.map(|c| {
let count = |n: &std::sync::atomic::AtomicI64| n.load(order).max(0) as u64;
vec![Conn {
id: 1,
bytes: (count(&c.bytes_in), count(&c.bytes_out)),
..Conn::default()
}]
})
.unwrap_or_default()
}
impl ClientPerf {
pub fn new(slow_ms: f32) -> Self {
Self {
perf: ServerPerf::with_role(Role::CLIENT, 0.0, slow_ms),
epoch: Instant::now(),
cpu_ms: Vec::new(),
gpu_ms: Vec::new(),
window: None,
running: None,
connection: None,
}
}
/// The connection whose bytes a capture records, or None in a world of the window's own.
pub fn set_connection(&mut self, connection: Option<Arc<Counters>>) {
self.connection = connection;
}
/// Seconds since the game began, the clock every other time here is on.
pub fn now(&self) -> f64 {
self.epoch.elapsed().as_secs_f64()
}
pub fn capturing(&self) -> bool {
self.perf.capturing()
}
pub fn health(&self) -> &horizon_perf_format::HealthRecorder {
self.perf.health()
}
/// One rendered frame that took `wall_ms`, of which `cpu_ms` was the CPU's own work, ending at `t`.
pub fn frame(
&mut self,
t: f64,
wall_ms: f32,
cpu_ms: f32,
gpu_ms: Option<f32>,
detail: Option<TickDetail>,
world: impl FnOnce() -> WorldCounts,
) {
self.frame_with_gpu(t, wall_ms, cpu_ms, gpu_ms, detail, world, || None);
}
/// [`ClientPerf::frame`], where `gpu` is asked for the pass timings only when this frame ends the capture's window.
#[allow(clippy::too_many_arguments)]
pub fn frame_with_gpu(
&mut self,
t: f64,
wall_ms: f32,
cpu_ms: f32,
gpu_ms: Option<f32>,
detail: Option<TickDetail>,
world: impl FnOnce() -> WorldCounts,
gpu: impl FnOnce() -> Option<GpuReadout>,
) {
self.cpu_ms.push(cpu_ms);
self.gpu_ms.extend(gpu_ms);
if self.perf.gauges_due(t) {
self.perf
.set_frame_medians(median(&mut self.cpu_ms), median(&mut self.gpu_ms));
}
let connection = self.connection.as_deref();
self.perf
.tick(t, wall_ms, detail, world, || wire_of(connection));
if let Some((end, reason)) = self.window
&& t >= end
{
self.stop_with_gpu(t, reason, gpu());
}
}
/// The GPU frames timed since the last frame, with this frame's CPU ms, for a capture that times the GPU.
pub fn gpu_frames(&mut self, t: f64, cpu_ms: f32, frames: &TimedFrames) {
self.perf.gpu_frames(t, cpu_ms, frames);
}
/// Opens a capture in `dir`; call it on the render thread, which it samples.
pub fn start(
&mut self,
t: f64,
dir: &Path,
trigger: Trigger,
options: &StartOptions,
start: Start,
block_names: Vec<String>,
) -> std::io::Result<PathBuf> {
let capture = CaptureOptions {
window_s: options.seconds.map(f64::from),
trigger,
start,
samples: options.samples,
sampler: SamplerOptions {
hz: options.hz,
over_ms: options.over_ms,
wall: options.wall,
all_threads: options.threads == Threads::All,
},
block_names,
levels: Vec::new(),
wire: wire_of(self.connection.as_deref()),
gpu: options.gpu.mode(),
timeline: options.timeline.then(Default::default),
file: options.file.clone(),
};
let path = self.perf.start_capture(t, dir, capture)?;
self.window = Some(match options.seconds {
Some(s) => (t + f64::from(s), StopReason::Normal),
None => (t + MAX_WINDOW_S, StopReason::WindowCap),
});
self.running = Some((options.clone(), trigger));
Ok(path)
}
/// What the server thread needs to write its side into the running capture; None when none runs.
pub fn join(&self) -> Option<Join> {
let (sender, path, origin) = self.perf.joinable()?;
let (options, trigger) = self.running.clone()?;
let at = |t: f64| self.epoch + Duration::from_secs_f64(t.max(0.0));
Some(Join {
sender,
path,
origin: at(origin),
ends: self.window.map(|(end, reason)| (at(end), reason)),
options,
trigger,
})
}
/// Ends the running capture; its file is finished off this thread and comes back from [`ClientPerf::take_saved`].
pub fn stop(&mut self, t: f64, reason: StopReason) -> bool {
self.stop_with(t, reason, None, Vec::new())
}
/// [`ClientPerf::stop`], recording `gpu`'s pass timings if the capture asked for them.
pub fn stop_with_gpu(&mut self, t: f64, reason: StopReason, gpu: Option<GpuReadout>) -> bool {
self.stop_with(t, reason, gpu, Vec::new())
}
/// [`ClientPerf::stop_with_gpu`] with benchmark reports embedded in the capture.
pub fn stop_with(
&mut self,
t: f64,
reason: StopReason,
gpu: Option<GpuReadout>,
bench: Vec<BenchResult>,
) -> bool {
self.window = None;
self.running = None;
let end = CaptureEnd {
gpu,
bench,
wire: wire_of(self.connection.as_deref()),
..CaptureEnd::default()
};
self.perf.begin_stop(t, reason, end)
}
/// Whether a stopped capture's file is still being finished or waits to be taken.
pub fn saving(&self) -> bool {
self.perf.finishing()
}
/// The captures whose files are done; with `wait`, every one begun.
pub fn take_saved(&mut self, wait: bool) -> Vec<std::io::Result<Saved>> {
self.perf.take_saved(wait)
}
pub fn mark(&mut self, t: f64, name: &str) -> bool {
self.perf.mark(t, name)
}
/// Records a memory snapshot in the running capture; false when none runs.
pub fn memory_snapshot(&mut self, t: f64, label: &str, rows: &[(String, f64)]) -> bool {
self.perf.memory_snapshot(t, label, rows)
}
/// Records `code` in the running capture; false when none runs.
pub fn warn(&mut self, t: f64, code: WarningCode) -> bool {
self.perf.warn(t, code)
}
pub fn status(&self, t: f64) -> Option<CaptureStatus> {
self.perf.status(t)
}
/// The seconds left in the running capture's window.
pub fn remaining_s(&self, t: f64) -> Option<f64> {
self.window.map(|(end, _)| (end - t).max(0.0))
}
}
/// A benchmark report as the capture embeds it, minus its local-only label and paths; anything but a `horizon-benchmark-report` is refused.
pub fn bench_result(report_json: String) -> Result<BenchResult, String> {
let mut report = horizon_bench::Report::parse(&report_json)?;
report.label = None;
report.local.clear();
Ok(BenchResult {
side: Side::Client as i32,
format: horizon_bench::FORMAT.to_owned(),
report_json: report.to_json(),
corpus_id: String::new(),
})
}
/// The median of the values, which are cleared; None when there were none.
fn median(values: &mut Vec<f32>) -> Option<f32> {
values.sort_by(f32::total_cmp);
let rank = values.len().div_ceil(2);
let m = rank.checked_sub(1).map(|i| values[i]);
values.clear();
m
}
/// `Start` for a client capture: its build and machine; the capture fills the rest.
pub fn start_message(same_process: bool, gpu: Option<&str>) -> Start {
let mut machine: Machine = server_perf::machine(Some(
server_perf::hardware().unwrap_or_else(server_perf::hardware_now),
));
if let Some(gpu) = gpu {
machine.gpu = gpu.to_owned();
}
Start {
build: Some(server_perf::build()),
machine: Some(machine),
same_process,
..Start::default()
}
}
@@ -0,0 +1,519 @@
//! `horizon-ctl profile`: the words the game receives, as typed requests, and the typed replies.
use horizon_perf_format::v1::GpuMode;
use serde::{Deserialize, Serialize};
use std::ops::RangeInclusive;
use std::path::PathBuf;
use std::str::FromStr;
/// The longest name a mark takes; it is the one free text in a capture.
pub const MARK_NAME_MAX: usize = 100;
/// The sampler's rate bounds, per second.
pub const HZ_RANGE: RangeInclusive<u32> = 10..=1000;
/// Which side `profile` addresses; no side means every side the game can reach.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProfileSide {
Client,
Server,
}
impl FromStr for ProfileSide {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
"client" => Ok(Self::Client),
"server" => Ok(Self::Server),
other => Err(format!("unknown side {other}; use client or server")),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Threads {
Main,
All,
}
impl FromStr for Threads {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
"main" => Ok(Self::Main),
"all" => Ok(Self::All),
other => Err(format!("unknown threads {other}; use main or all")),
}
}
}
/// What `--gpu` records: nothing, the passes' times, or also their parts and counters.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GpuSwitch {
#[default]
Off,
Passes,
Detail,
}
impl FromStr for GpuSwitch {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
"passes" => Ok(Self::Passes),
"detail" => Ok(Self::Detail),
other => Err(format!(
"unknown --gpu value {other}; use --gpu, --gpu=passes or --gpu=detail"
)),
}
}
}
impl GpuSwitch {
pub fn mode(self) -> GpuMode {
match self {
Self::Off => GpuMode::Off,
Self::Passes => GpuMode::Passes,
Self::Detail => GpuMode::Detail,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Action {
Start,
Stop,
Status,
Mark,
Open,
Share,
}
impl FromStr for Action {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
"start" => Ok(Self::Start),
"stop" => Ok(Self::Stop),
"status" => Ok(Self::Status),
"mark" => Ok(Self::Mark),
"open" => Ok(Self::Open),
"share" => Ok(Self::Share),
other => Err(format!("unknown profile action {other}")),
}
}
}
/// The switches of `profile start`.
#[derive(Clone, Debug, PartialEq)]
pub struct StartOptions {
/// Unset runs until `stop`, or the longest window.
pub seconds: Option<u32>,
pub hz: Option<u32>,
pub over_ms: Option<f32>,
pub threads: Threads,
pub wall: bool,
pub samples: bool,
pub gpu: GpuSwitch,
/// Also record every thread's spans (`--timeline`), for 60 s at most.
pub timeline: bool,
/// Write the capture here instead of into the reports folder: `trace` and `HORIZON_TRACE` only, never a profile switch.
pub file: Option<PathBuf>,
}
impl Default for StartOptions {
fn default() -> Self {
Self {
seconds: None,
hz: None,
over_ms: None,
threads: Threads::Main,
wall: false,
samples: true,
gpu: GpuSwitch::Off,
timeline: false,
file: None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Request {
Start(StartOptions),
/// With the path of a benchmark report to embed in the capture.
Stop {
bench: Option<String>,
},
Status,
Mark(String),
/// Open the saved capture in the viewer, through the captures hook.
Open(PathBuf),
/// Upload the saved capture, through the captures hook; the reply is the link.
Share(PathBuf),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Parsed {
pub side: Option<ProfileSide>,
pub request: Request,
}
const USAGE: &str = "profile [client|server] start [--seconds S] [--hz N] [--over MS] [--threads main|all] [--wall] [--gpu[=detail]] [--no-samples] [--timeline] | stop [--bench REPORT] | status | mark NAME | open FILE | share FILE";
/// The words after `profile`; the side comes first or as `--side`, and the switches follow `start`.
pub fn parse(words: &[&str]) -> Result<Parsed, String> {
let (mut side, words) = match words.first().map(|w| w.parse::<ProfileSide>()) {
Some(Ok(side)) => (Some(side), &words[1..]),
_ => (None, words),
};
let Some((action, rest)) = words.split_first() else {
return Err(format!("usage: {USAGE}"));
};
let action: Action = action.parse().map_err(|e| format!("{e}; usage: {USAGE}"))?;
if action == Action::Mark {
return mark(side, rest);
}
if matches!(action, Action::Open | Action::Share) {
let file = rest.join(" ");
if file.is_empty() {
return Err(format!("{} needs a FILE; usage: {USAGE}", words[0]));
}
let file = PathBuf::from(file);
let request = match action {
Action::Open => Request::Open(file),
_ => Request::Share(file),
};
return Ok(Parsed {
side: None,
request,
});
}
let mut options = StartOptions::default();
let mut switched = false;
let mut bench = None;
let mut words = rest.iter().copied().peekable();
while let Some(word) = words.next() {
let mut value = || {
words
.next()
.ok_or_else(|| format!("{word} needs a value; usage: {USAGE}"))
};
match word {
"--side" => side = Some(value()?.parse()?),
"--seconds" => options.seconds = Some(number(word, value()?, 1..=600)?),
"--hz" => options.hz = Some(number(word, value()?, HZ_RANGE)?),
"--over" => {
let ms: f32 = value()?
.parse()
.map_err(|_| "--over must be a number of milliseconds".to_owned())?;
if !ms.is_finite() || ms <= 0.0 {
return Err("--over must be more than 0".into());
}
options.over_ms = Some(ms);
}
"--threads" => options.threads = value()?.parse()?,
"--wall" => options.wall = true,
"--gpu" => {
options.gpu = match words.next_if(|w| w.parse::<GpuSwitch>().is_ok()) {
Some(kind) => kind.parse()?,
None => GpuSwitch::Passes,
}
}
_ if word.starts_with("--gpu=") => options.gpu = word["--gpu=".len()..].parse()?,
"--no-samples" => options.samples = false,
"--timeline" => options.timeline = true,
"--bench" => {
// The line arrives split on whitespace, so a run of spaces or a tab in the path becomes one space.
let path = words.by_ref().collect::<Vec<_>>().join(" ");
if path.is_empty() {
return Err(format!("--bench needs a report path; usage: {USAGE}"));
}
bench = Some(path);
continue;
}
other => return Err(format!("unexpected {other}; usage: {USAGE}")),
}
switched |= word != "--side";
}
let request = match action {
Action::Start if bench.is_some() => {
return Err(format!("only stop takes --bench; usage: {USAGE}"));
}
Action::Start => Request::Start(options),
_ if switched => return Err(format!("only start takes options; usage: {USAGE}")),
Action::Stop => Request::Stop { bench },
_ if bench.is_some() => return Err(format!("only stop takes --bench; usage: {USAGE}")),
_ => Request::Status,
};
Ok(Parsed { side, request })
}
fn mark(mut side: Option<ProfileSide>, mut words: &[&str]) -> Result<Parsed, String> {
if let [flag @ "--side", value, rest @ ..] = words {
let _ = flag;
side = Some(value.parse()?);
words = rest;
}
let name = words.join(" ");
if name.is_empty() {
return Err("mark needs a NAME".into());
}
if name.chars().count() > MARK_NAME_MAX || name.chars().any(char::is_control) {
return Err(format!(
"a mark name is at most {MARK_NAME_MAX} characters without control characters"
));
}
Ok(Parsed {
side,
request: Request::Mark(name),
})
}
fn number(flag: &str, text: &str, range: RangeInclusive<u32>) -> Result<u32, String> {
match text.parse::<u32>() {
Ok(n) if range.contains(&n) => Ok(n),
_ => Err(format!(
"{flag} must be a whole number from {} to {}",
range.start(),
range.end()
)),
}
}
/// The reply to `profile start`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Started {
pub ok: bool,
/// The sides asked for, server first; the server thread's join is confirmed by `status` and `stop`.
pub sides: Vec<ProfileSide>,
pub path: String,
pub seconds: Option<u32>,
pub samples: bool,
pub gpu: GpuSwitch,
}
/// The reply to `profile stop`, once the file is whole.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Stopped {
pub ok: bool,
pub sides: Vec<ProfileSide>,
pub path: String,
/// The viewer page, when the build has a part that opens captures.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub open_at: Option<String>,
pub removed: Vec<String>,
}
/// The viewer page a registered captures hook offers.
pub fn viewer() -> Option<String> {
horizon_hooks::engine::captures().map(|c| c.viewer().to_owned())
}
/// The reply to `profile status`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Status {
pub ok: bool,
pub capturing: bool,
pub sides: Vec<ProfileSide>,
pub path: Option<String>,
pub elapsed_s: Option<f64>,
pub bytes: Option<u64>,
}
/// The reply of a deferred `profile stop` that failed.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Failed {
pub error: String,
}
/// The reply to `trace`: whether a timeline capture runs, and where it writes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Traced {
pub ok: bool,
pub tracing: bool,
pub file: Option<String>,
}
/// The reply to `profile open`: the viewer the capture was opened in.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Opened {
pub ok: bool,
pub viewer: String,
}
/// The reply to `profile share`: the capture's share link.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Shared {
pub ok: bool,
pub url: String,
}
/// The reply to `profile mark`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Marked {
pub ok: bool,
pub name: String,
/// Seconds on the game clock, which counts from when the game began, for every side.
pub t: f64,
}
#[cfg(test)]
mod tests {
use super::*;
fn p(line: &str) -> Result<Parsed, String> {
parse(&line.split_whitespace().collect::<Vec<_>>())
}
#[test]
fn open_and_share_take_a_file_with_spaces_kept() {
assert_eq!(
p("open reports/a.hzperf").unwrap().request,
Request::Open(PathBuf::from("reports/a.hzperf"))
);
assert_eq!(
p("share /tmp/my capture.hzperf").unwrap().request,
Request::Share(PathBuf::from("/tmp/my capture.hzperf"))
);
assert!(p("open").is_err());
assert!(p("share").is_err());
}
#[test]
fn the_side_comes_first_or_as_a_flag() {
let a = p("client start --seconds 30").unwrap();
let b = p("start --side client --seconds 30").unwrap();
assert_eq!(a, b);
assert_eq!(a.side, Some(ProfileSide::Client));
assert_eq!(p("start").unwrap().side, None);
assert_eq!(p("server stop").unwrap().side, Some(ProfileSide::Server));
assert_eq!(
p("stop --side client").unwrap().side,
Some(ProfileSide::Client)
);
}
#[test]
fn every_start_switch_lands_in_its_field() {
let Request::Start(o) = p(
"start --hz 500 --over 12.5 --threads all --wall --gpu=detail --no-samples --timeline --seconds 9",
)
.unwrap()
.request
else {
panic!("not a start");
};
assert_eq!(
o,
StartOptions {
seconds: Some(9),
hz: Some(500),
over_ms: Some(12.5),
threads: Threads::All,
wall: true,
samples: false,
gpu: GpuSwitch::Detail,
timeline: true,
file: None,
}
);
}
#[test]
fn a_mark_takes_the_rest_of_the_line_as_its_name() {
assert_eq!(
p("mark loaded the nether").unwrap().request,
Request::Mark("loaded the nether".into())
);
assert_eq!(
p("client mark --wall").unwrap(),
Parsed {
side: Some(ProfileSide::Client),
request: Request::Mark("--wall".into())
}
);
assert_eq!(
p("mark --side client the nether").unwrap(),
Parsed {
side: Some(ProfileSide::Client),
request: Request::Mark("the nether".into())
}
);
assert!(p("mark").is_err());
assert!(p("mark --side client").is_err());
assert!(p(&format!("mark {}", "x".repeat(MARK_NAME_MAX + 1))).is_err());
assert!(p(&format!("mark {}", "x".repeat(MARK_NAME_MAX))).is_ok());
}
#[test]
fn stop_takes_the_rest_of_the_line_as_a_report_path() {
assert_eq!(p("stop").unwrap().request, Request::Stop { bench: None });
let with = p("client stop --bench /home/a b/games/benchmarks/game-1.json").unwrap();
assert_eq!(with.side, Some(ProfileSide::Client));
assert_eq!(
with.request,
Request::Stop {
bench: Some("/home/a b/games/benchmarks/game-1.json".into())
}
);
for line in [
"stop --bench",
"start --bench x.json",
"status --bench x.json",
] {
assert!(p(line).is_err(), "{line:?} was accepted");
}
}
#[test]
fn bad_words_are_refused_with_the_usage() {
for line in [
"",
"client",
"begin",
"start --seconds 0",
"start --seconds 601",
"start --seconds",
"start --hz 5",
"start --over 0",
"start --over nan",
"start --threads some",
"start --gpu=all",
"start --gpu=",
"stop now",
"stop --wall",
"status --hz 100",
"start --side everyone",
] {
assert!(p(line).is_err(), "{line:?} was accepted");
}
assert!(p("start --gpu=all").unwrap_err().contains("--gpu=detail"));
assert!(
p("start --gpu fast")
.unwrap_err()
.contains("usage: profile")
);
}
#[test]
fn gpu_alone_records_the_passes_and_detail_asks_for_more() {
let gpu = |line: &str| match p(line).unwrap().request {
Request::Start(o) => o.gpu,
other => panic!("{other:?}"),
};
assert_eq!(gpu("start"), GpuSwitch::Off);
assert_eq!(gpu("start --gpu"), GpuSwitch::Passes);
assert_eq!(
gpu("client start --seconds 5 --gpu=detail"),
GpuSwitch::Detail
);
assert_eq!(gpu("start --gpu detail --wall"), GpuSwitch::Detail);
assert_eq!(gpu("start --gpu --wall"), GpuSwitch::Passes);
assert!(p("stop --gpu").is_err(), "only start takes options");
}
}
File diff suppressed because it is too large. Load diff
+164 -9
View File
@@ -10,6 +10,7 @@
//! writes `{"transport":"tcp","port":N,"token":"..."}` to the path, readable
//! only by its user; a client's first line must then be `auth TOKEN`.
use crate::block_entity_render::StaticCacheFrameStats;
use horizon_threads::alloc_count::{Counts, ON};
use std::{
io::{self, BufRead, BufReader, Read, Write},
net::{Ipv4Addr, TcpListener, TcpStream},
@@ -20,6 +21,7 @@ use std::{
mpsc,
},
thread,
time::Instant,
};
#[cfg(unix)]
@@ -497,6 +499,7 @@ mod tests {
}
use super::*;
use std::time::Duration;
/// The check agrees with the system at every length around its limit.
#[cfg(unix)]
@@ -602,6 +605,22 @@ mod tests {
assert!(same_token("abc", "abc") && !same_token("abc", "abd") && !same_token("a", "ab"));
}
#[test]
fn the_series_cover_every_frame_the_summary_counts() {
let mut stats = FrameStats::default();
for ms in [4.0, 5.0, 6.0] {
stats.record(ms);
stats.record_cpu(ms / 2.0);
}
let mut summary = stats.summary();
assert!(summary.get("frame_ms").is_none());
stats.add_series(&mut summary);
assert_eq!(summary["frame_ms"], serde_json::json!([4.0, 5.0, 6.0]));
assert_eq!(summary["cpu_ms"], serde_json::json!([2.0, 2.5, 3.0]));
assert_eq!(summary["gpu_ms"], serde_json::json!([]));
assert_eq!(summary["frames"], 3);
}
#[test]
fn frame_summary_reports_block_entity_cache_work() {
let mut stats = FrameStats::default();
@@ -625,6 +644,67 @@ mod tests {
assert_eq!(summary["static_entities_drawn_avg_per_frame"], 4.0);
assert_eq!(summary["dynamic_entities_drawn_avg_per_frame"], 3.0);
}
fn counts(allocs: u64, bytes: u64, frees: u64, freed: u64) -> Counts {
Counts {
allocs,
bytes,
frees,
freed,
}
}
#[test]
fn allocations_are_summarized_per_frame_and_per_second() {
let mut stats = FrameStats::default();
let start = Instant::now();
for (i, a) in [100u64, 200, 300, 400, 1000].into_iter().enumerate() {
// Window: 10 blocks kept; process: twice the blocks, 1 MiB per 100, 20 blocks and 1 MiB kept.
let window = counts(a, a * 1024, a - 10, 0);
let all = counts(
2 * a,
(2 * a / 100) << 20,
2 * a - 20,
((2 * a / 100) - 1) << 20,
);
stats.record_allocs(window, all, start + Duration::from_millis(100 * i as u64));
}
let alloc = stats.alloc_summary();
let window = &alloc["window_thread"];
assert_eq!(window["allocs_per_frame"]["avg"], 400.0);
assert_eq!(window["allocs_per_frame"]["p50"], 300.0);
assert_eq!(window["allocs_per_frame"]["p95"], 1000.0);
assert_eq!(window["allocs_per_frame"]["max"], 1000.0);
assert_eq!(window["kib_per_frame"]["p50"], 300.0);
assert_eq!(window["frees_per_frame_avg"], 390.0);
// The first frame only starts the clock: the rates cover the four after it, 0.4 s.
let process = &alloc["process"];
assert_eq!(process["allocs_per_s"], 9500.0);
assert_eq!(process["frees_per_s"], 9300.0);
assert_eq!(process["mib_per_s"], 95.0);
assert_eq!(process["net_mib"], 4.0);
assert_eq!(stats.alloc_counts(), [100, 200, 300, 400, 1000]);
}
#[test]
fn one_frame_has_counts_but_no_rate() {
let mut stats = FrameStats::default();
stats.record_allocs(counts(5, 64, 5, 64), counts(9, 99, 9, 99), Instant::now());
let alloc = stats.alloc_summary();
assert_eq!(alloc["window_thread"]["allocs_per_frame"]["max"], 5.0);
assert!(alloc["process"]["allocs_per_s"].is_null());
assert!(FrameStats::default().alloc_summary().is_null());
}
#[test]
fn the_summary_has_alloc_only_when_the_game_counts() {
let mut stats = FrameStats::default();
stats.record(10.0);
stats.record_allocs(counts(5, 64, 5, 64), Counts::default(), Instant::now());
assert_eq!(stats.summary()["alloc"].is_null(), !ON);
stats.reset();
assert!(stats.alloc_counts().is_empty() && stats.alloc_summary().is_null());
}
}
/// Frame durations since the last reset, summarized like the Java recorder,
@@ -641,6 +721,15 @@ pub struct FrameStats {
static_bytes_uploaded: u64,
static_entities_drawn: u64,
dynamic_entities_drawn: u64,
/// Window-thread allocations and bytes of each rendered frame (`alloc-count` builds).
allocs: Vec<u32>,
alloc_bytes: Vec<u64>,
/// Window-thread frees summed over the frames in `allocs`.
alloc_frees: u64,
/// When the first and the last frame since the reset were recorded.
alloc_span: Option<(Instant, Instant)>,
/// All threads' counts over the frames after the first, the time base of the rates.
alloc_process: Counts,
}
/// The parts of a frame's CPU time: world streaming and uploads, game ticks,
@@ -717,17 +806,77 @@ impl FrameStats {
self.static_entities_drawn += stats.static_entities_drawn;
self.dynamic_entities_drawn += stats.dynamic_entities_drawn;
}
/// Every recorded frame time, in milliseconds, in order.
pub fn frame_millis(&self) -> &[f32] {
&self.millis
/// Adds every frame's time (`frame_ms`, `cpu_ms`, `gpu_ms`) to a summary.
pub fn add_series(&self, summary: &mut serde_json::Value) {
summary["frame_ms"] = self.millis.clone().into();
summary["cpu_ms"] = self.cpu.clone().into();
summary["gpu_ms"] = self.gpu.clone().into();
}
/// CPU time of every recorded frame, in milliseconds.
pub fn cpu_millis(&self) -> &[f32] {
&self.cpu
/// One frame's allocations: this thread's since the last frame, all threads' since then.
pub fn record_allocs(&mut self, window: Counts, process: Counts, now: Instant) {
if self.allocs.len() < Self::LIMIT {
self.allocs
.push(u32::try_from(window.allocs).unwrap_or(u32::MAX));
self.alloc_bytes.push(window.bytes);
self.alloc_frees += window.frees;
}
match &mut self.alloc_span {
None => self.alloc_span = Some((now, now)),
Some((_, last)) => {
*last = now;
let sum = &mut self.alloc_process;
sum.allocs += process.allocs;
sum.bytes += process.bytes;
sum.frees += process.frees;
sum.freed += process.freed;
}
}
}
/// GPU time of every frame the GPU timed, in milliseconds.
pub fn gpu_millis(&self) -> &[f32] {
&self.gpu
/// Window-thread allocations of every recorded frame, in order.
pub fn alloc_counts(&self) -> &[u32] {
&self.allocs
}
/// Window-thread bytes allocated in every recorded frame, in order.
pub fn alloc_bytes(&self) -> &[u64] {
&self.alloc_bytes
}
/// The `alloc` object of [`summary`](Self::summary), `null` before a frame was recorded.
fn alloc_summary(&self) -> serde_json::Value {
let n = self.allocs.len();
if n == 0 {
return serde_json::Value::Null;
}
let round = |v: f64| (v * 10.0).round() / 10.0;
let stats = |mut values: Vec<f64>| {
values.sort_by(f64::total_cmp);
let pick = |p: f64| values[((n as f64 * p).ceil() as usize).clamp(1, n) - 1];
serde_json::json!({
"avg": round(values.iter().sum::<f64>() / n as f64),
"p50": round(pick(0.5)),
"p95": round(pick(0.95)),
"max": round(values[n - 1]),
})
};
let per_second =
|amount: u64, seconds: f64| (seconds > 0.0).then(|| round(amount as f64 / seconds));
let seconds = self.alloc_span.map_or(0.0, |(first, last)| {
last.duration_since(first).as_secs_f64()
});
let mib = |bytes: u64| bytes as f64 / 1048576.0;
let process = &self.alloc_process;
serde_json::json!({
"window_thread": {
"allocs_per_frame": stats(self.allocs.iter().map(|&a| f64::from(a)).collect()),
"kib_per_frame": stats(self.alloc_bytes.iter().map(|&b| b as f64 / 1024.0).collect()),
"frees_per_frame_avg": round(self.alloc_frees as f64 / n as f64),
},
"process": {
"allocs_per_s": per_second(process.allocs, seconds),
"mib_per_s": (seconds > 0.0).then(|| round(mib(process.bytes) / seconds)),
"frees_per_s": per_second(process.frees, seconds),
"net_mib": round(mib(process.bytes) - mib(process.freed)),
},
})
}
pub fn reset(&mut self) {
self.millis.clear();
@@ -739,6 +888,11 @@ impl FrameStats {
self.static_bytes_uploaded = 0;
self.static_entities_drawn = 0;
self.dynamic_entities_drawn = 0;
self.allocs.clear();
self.alloc_bytes.clear();
self.alloc_frees = 0;
self.alloc_span = None;
self.alloc_process = Counts::default();
}
pub fn summary(&self) -> serde_json::Value {
let mut sorted = self.millis.clone();
@@ -789,6 +943,7 @@ impl FrameStats {
"dynamic_entities_drawn": self.dynamic_entities_drawn,
"static_entities_drawn_avg_per_frame": self.static_entities_drawn as f64 / n as f64,
"dynamic_entities_drawn_avg_per_frame": self.dynamic_entities_drawn as f64 / n as f64,
"alloc": if ON { self.alloc_summary() } else { serde_json::Value::Null },
})
}
}
+154
View File
@@ -8,6 +8,7 @@ use crate::{
java_text,
text::Text,
};
use horizon_client_render::gfx::pack_timing::{Counters, DetailSupport, PartsMode, PassCounters};
use std::time::{Duration, Instant};
/// `extractLines`'s background: ARGB 0x90505050.
@@ -148,6 +149,76 @@ pub fn labelled_pass_lines(label: &str, passes: &[(String, f32)]) -> Vec<String>
lines
}
/// `n` in three digits and an SI suffix: `512`, `83.0k`, `6.30M`, `1.20G`.
pub fn compact_count(n: u64) -> String {
let (mut value, mut unit) = (n as f64, 0);
while value >= 999.5 && unit < 4 {
value /= 1000.0;
unit += 1;
}
let suffix = ["", "k", "M", "G", "T"][unit];
match value {
_ if unit == 0 => n.to_string(),
v if v < 9.995 => format!("{v:.2}{suffix}"),
v if v < 99.95 => format!("{v:.1}{suffix}"),
v => format!("{v:.0}{suffix}"),
}
}
/// F3's detail lines: each pass's parts, then the six passes with the most fragments and their segments.
pub fn pass_detail_lines(
parts: &[(String, Vec<(String, f32)>)],
counters: &[(String, PassCounters)],
support: DetailSupport,
) -> Vec<String> {
const SHOWN: usize = 6;
let tr = |key: &str, args: &[&dyn std::fmt::Display]| Text::tr(key, args).plain();
let mode = match support.parts {
PartsMode::InsidePasses => "horizon.debug.pass_detail.inside_passes",
PartsMode::SeparatePasses => "horizon.debug.pass_detail.separate_passes",
};
let mut lines = vec![tr("horizon.debug.pass_detail.title", &[&tr(mode, &[])])];
for (pass, parts) in parts {
for (part, ms) in parts {
let ms = format!("{ms:.2}");
lines.push(tr("horizon.debug.pass_detail.part", &[pass, part, &ms]));
}
}
if !support.counters {
lines.push(tr("horizon.debug.pass_detail.no_counters", &[]));
return lines;
}
let per_pixel = |fragments: u64, pixels: u64| match support.fragment_unit {
Some(unit) if pixels > 0 => {
format!("{:.2}", unit.pixels_of(fragments) as f64 / pixels as f64)
}
_ => "?".to_owned(),
};
let line = |name: &str, c: &Counters, pixels: u64| {
let (fragments, vertices) = (c.fragment_shader_invocations, c.vertex_shader_invocations);
tr(
"horizon.debug.pass_detail.counters",
&[
&name,
&compact_count(fragments),
&per_pixel(fragments, pixels),
&compact_count(vertices),
&compact_count(c.clipper_primitives_out),
&compact_count(c.clipper_invocations),
],
)
};
let mut busiest: Vec<&(String, PassCounters)> = counters.iter().collect();
busiest.sort_by_key(|c| std::cmp::Reverse(c.1.total.fragment_shader_invocations));
for (pass, c) in busiest.into_iter().take(SHOWN) {
lines.push(line(pass, &c.total, c.pixels));
for (part, counted) in &c.parts {
lines.push(format!(" {}", line(part, counted, c.pixels)));
}
}
lines
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(angle: f32) -> f32 {
let wrapped = angle % 360.0;
@@ -547,6 +618,7 @@ pub fn memory_lines(m: ProcessMemory, rate: i64) -> [String; 3] {
#[cfg(test)]
mod tests {
use super::*;
use horizon_client_render::gfx::pack_timing;
fn lines(v: &[&str]) -> Vec<String> {
v.iter().map(|s| (*s).to_owned()).collect()
@@ -584,6 +656,88 @@ mod tests {
assert_eq!(cache.fresh(start, layout).map(<[_]>::len), Some(0));
}
#[test]
fn counts_read_in_three_digits_with_an_si_suffix() {
for (n, want) in [
(0, "0"),
(999, "999"),
(1000, "1.00k"),
(83_049, "83.0k"),
(999_999, "1.00M"),
(6_300_000, "6.30M"),
(1_200_000_000, "1.20G"),
] {
assert_eq!(compact_count(n), want, "{n}");
}
}
fn counted(fragments: u64, parts: &[(&str, u64)]) -> PassCounters {
let c = |fragment_shader_invocations| Counters {
vertex_shader_invocations: 1000,
clipper_invocations: 400,
clipper_primitives_out: 300,
fragment_shader_invocations,
};
PassCounters {
total: c(fragments),
pixels: 1000,
parts: parts.iter().map(|p| (p.0.to_owned(), c(p.1))).collect(),
}
}
#[test]
fn pass_detail_lines_list_parts_then_the_six_busiest_passes_with_segments() {
let support = DetailSupport {
parts: PartsMode::InsidePasses,
encoder_stamps: true,
counters: true,
fragment_unit: Some(pack_timing::FragmentUnit::Quad),
};
let parts = vec![("GPU culling".to_owned(), vec![("cull".to_owned(), 0.3141)])];
let mut counters: Vec<(String, PassCounters)> = (1..=7)
.map(|i| (format!("P{i}"), counted(i * 100, &[])))
.collect();
counters[2].1 = counted(1_000_000, &[("a", 600_000), ("b", 400_000)]);
let (lines, _) = crate::lang::scoped(crate::lang::Language::load(&[], "en_us"), || {
pass_detail_lines(&parts, &counters, support)
});
assert_eq!(
lines,
[
"Horizon pass detail (timestamps inside passes)",
"GPU culling › cull: 0.31 ms",
"P3: 1.00M fragments, 4000.00 per pixel, 1.00k vertices, 300 of 400 triangles kept",
" a: 600k fragments, 2400.00 per pixel, 1.00k vertices, 300 of 400 triangles kept",
" b: 400k fragments, 1600.00 per pixel, 1.00k vertices, 300 of 400 triangles kept",
"P7: 700 fragments, 2.80 per pixel, 1.00k vertices, 300 of 400 triangles kept",
"P6: 600 fragments, 2.40 per pixel, 1.00k vertices, 300 of 400 triangles kept",
"P5: 500 fragments, 2.00 per pixel, 1.00k vertices, 300 of 400 triangles kept",
"P4: 400 fragments, 1.60 per pixel, 1.00k vertices, 300 of 400 triangles kept",
"P2: 200 fragments, 0.80 per pixel, 1.00k vertices, 300 of 400 triangles kept",
]
);
}
#[test]
fn pass_detail_lines_say_so_when_the_device_has_no_counters() {
let support = DetailSupport {
parts: PartsMode::SeparatePasses,
encoder_stamps: false,
counters: false,
fragment_unit: None,
};
let (lines, _) = crate::lang::scoped(crate::lang::Language::load(&[], "en_us"), || {
pass_detail_lines(&[], &[], support)
});
assert_eq!(
lines,
[
"Horizon pass detail (separate passes, adds GPU time)",
"GPU counters: Vulkan only"
]
);
}
#[test]
fn looking_at_lines_match_vanilla() {
let props = [
+54 -19
View File
@@ -84,6 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
mod fluids;
mod item_fall;
mod item_no_physics;
pub mod nameplate;
mod obstacles;
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
pub use obstacles::ObstacleGrid;
@@ -1289,8 +1290,41 @@ pub struct ExperienceOrb {
/// `ExperienceOrb.followingPlayer`: the followed player's entity id, kept
/// until they are gone or further than 8 blocks. Never saved.
pub following: Option<u32>,
/// `Entity.fallDistance` (`fall_distance` in its save).
pub fall_distance: f64,
}
/// An orb's `Entity.move` this tick, for the fall bookkeeping its caller
/// finishes (the long-move cast, `checkFallDamage`'s HIT_GROUND).
#[derive(Clone, Copy, Debug)]
pub struct OrbMove {
pub from: Vec3,
pub moved: Vec3,
/// `Entity.isInWater` from this tick's fluid step.
pub in_water: bool,
}
impl ExperienceOrb {
/// A fresh orb (`new ExperienceOrb(level, pos, motion, value)`): full
/// health, no age, fall or followed player, not yet ticked.
pub fn new(id: u32, position: Vec3, velocity: Vec3, value: i32) -> Self {
Self {
rotation: [0.0, 0.0],
join: Default::default(),
id,
position,
previous: position,
velocity,
value,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
air: 300,
no_gravity: false,
following: None,
fall_distance: 0.0,
}
}
/// `ExperienceOrb.hurtServer`: the damage comes off the health as an
/// `int`; an orb at 0 or below is discarded. True when it is gone.
pub fn hurt(&mut self, damage: f32) -> bool {
@@ -1414,7 +1448,7 @@ impl ExperienceOrb {
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
) -> bool {
self.tick_among(
let (stays, _) = self.tick_among(
world,
facts,
collision,
@@ -1425,10 +1459,12 @@ impl ExperienceOrb {
fluids,
on_sound,
&[],
)
);
stays
}
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
/// Also gives the move it made, whose fall bookkeeping the caller finishes.
#[allow(clippy::too_many_arguments)]
pub fn tick_among(
&mut self,
@@ -1442,12 +1478,12 @@ impl ExperienceOrb {
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
entities: &[Aabb],
) -> bool {
) -> (bool, OrbMove) {
self.ticks += 1;
self.previous = self.position;
// `Entity.baseTick`'s fluid step, whose `updateFluidOnEyes` answer the
// water branch below reads: the orb's eye, not its feet, decides.
let eye_in_water = self.update_fluids(world, fluids);
let (eye_in_water, in_water) = self.update_fluids(world, fluids);
let block = world.block(
self.position.x.floor() as i32,
self.position.y.floor() as i32,
@@ -1514,6 +1550,7 @@ impl ExperienceOrb {
let moved = scene
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
.unwrap_or(Vec3::ZERO);
let from = self.position;
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
@@ -1561,7 +1598,12 @@ impl ExperienceOrb {
self.velocity.y = -wanted.y * 0.4;
}
self.age += 1;
!discarded && self.age < 6000
let step = OrbMove {
from,
moved,
in_water,
};
(!discarded && self.age < 6000, step)
}
/// `LavaFluid.entityInside` -> `Entity.lavaHurt`: four damage on the
/// server, through the orb's existing hurt/discard policy, playing the generic
@@ -2020,6 +2062,8 @@ pub struct ArmorStand {
/// (`getAttribute`) on the stand's first server tick, and a save that lists
/// it loads it back, so a stand summoned this tick has none yet.
pub speed_instance: bool,
/// `NAME_TAG_DISTANCE` and `BELOW_NAME_DISTANCE` from server attribute updates.
pub nameplate: crate::entities::nameplate::NameplateDistances,
}
/// The fluid a stand travels in (`LivingEntity.travelInFluid`: water first,
/// then lava), from the heights `Entity::push_by_fluid` measured.
@@ -2103,20 +2147,6 @@ impl ArmorStand {
}
}
impl ArmorStand {
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
/// only a living target changes. Stack consumption belongs to the caller.
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:name_tag" {
return false;
}
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
return false;
};
if self.health > 0.0 {
self.custom_name = Some(crate::component::name_text(name));
}
true
}
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
/// The `Pose` compound's keys.
pub const POSE_KEYS: [&'static str; 6] =
@@ -2273,6 +2303,7 @@ impl ArmorStand {
enchantment_equipment: Default::default(),
support: Default::default(),
speed_instance: false,
nameplate: Default::default(),
}
}
/// `Entity.getOnPos(0.2)` (`getOnPosLegacy`): the supporting block's
@@ -3346,6 +3377,7 @@ mod world_border_tests {
None,
);
let mut orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -4757,6 +4789,7 @@ mod orb_underwater_tests {
.iter()
.map(|(v, _)| {
let mut orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -4792,6 +4825,7 @@ mod render_light_tests {
fn orb(y: f64) -> ExperienceOrb {
ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -4994,6 +5028,7 @@ mod orb_lava_tests {
fn sample_orb(at: Vec3, health: i32) -> ExperienceOrb {
ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
+18 -7
View File
@@ -159,19 +159,28 @@ impl ItemEntity {
}
impl ExperienceOrb {
/// `Entity.updateFluidInteraction`: refreshes fluid immersion over the
/// orb's box and pushes its velocity by any water or lava current.
/// Answers `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads,
/// `0.425` above the orb's feet.
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
update_entity_fluids(
/// orb's box and pushes its velocity by any water or lava current; water
/// resets its fall, and `Entity.baseTick` halves it in lava (`isInLava`
/// is false on the orb's first tick, `Entity.firstTick`). Answers
/// `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads, `0.425`
/// above the orb's feet, and `isInWater`.
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (bool, bool) {
let (water, lava, eye) = update_entity_fluids(
self.bounds(),
self.position,
self.position.y + f64::from(0.425f32),
&mut self.velocity,
world,
fluids,
)
.2
);
if water > 0.0 {
self.fall_distance = 0.0;
}
// `tick_among` counts this tick first: `tickCount` is 1 on the first.
if lava > 0.0 && self.ticks > 1 {
self.fall_distance *= 0.5;
}
(eye, water > 0.0)
}
}
impl ArmorStand {
@@ -471,6 +480,7 @@ mod tests {
}
let at = Vec3::new(20.5, 4.0, 20.5);
let orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -562,6 +572,7 @@ mod tests {
}
let at = Vec3::new(20.5, 4.6, 20.5);
let mut orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -0,0 +1,150 @@
//! The nameplate attributes carried by armor stands and mobs.
use super::ArmorStand;
use crate::items::ItemStack;
use crate::mobs::Mob;
/// The two synced attributes that control name tags and below-name lines.
/// `None` means the server has not sent a sync for that attribute yet.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct NameplateDistances {
/// `minecraft:name_tag_distance` from the server's attribute updates.
pub name_tag: Option<f64>,
/// `minecraft:below_name_distance` from the server's attribute updates.
pub below_name: Option<f64>,
}
impl NameplateDistances {
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`: the
/// default is 64 and vanilla's maximum is 512.
pub fn name_tag_distance(&self) -> f64 {
name_tag_distance_of(self.name_tag)
}
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`: the
/// default is 10 and vanilla's maximum is 512.
pub fn below_name_distance(&self) -> f64 {
below_name_distance_of(self.below_name)
}
}
impl Mob {
/// Stores both distances, from a model's own live attributes.
pub fn set_nameplate(&mut self, distances: NameplateDistances) {
let attributes = self.extra.attributes.get_or_insert_with(Default::default);
attributes.name_tag_distance = distances.name_tag;
attributes.below_name_distance = distances.below_name;
}
/// Stores `NAME_TAG_DISTANCE` from a server attribute update.
pub fn set_name_tag_distance(&mut self, value: f64) {
self.extra
.attributes
.get_or_insert_with(Default::default)
.name_tag_distance = Some(value);
}
/// Stores `BELOW_NAME_DISTANCE` from a server attribute update.
pub fn set_below_name_distance(&mut self, value: f64) {
self.extra
.attributes
.get_or_insert_with(Default::default)
.below_name_distance = Some(value);
}
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
pub fn name_tag_distance(&self) -> f64 {
name_tag_distance_of(
self.extra
.attributes
.as_ref()
.and_then(|attributes| attributes.name_tag_distance),
)
}
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
pub fn below_name_distance(&self) -> f64 {
below_name_distance_of(
self.extra
.attributes
.as_ref()
.and_then(|attributes| attributes.below_name_distance),
)
}
}
impl ArmorStand {
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
/// only a living target changes. Stack consumption belongs to the caller.
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:name_tag" {
return false;
}
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
return false;
};
if self.health > 0.0 {
self.custom_name = Some(crate::component::name_text(name));
}
true
}
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
pub fn name_tag_distance(&self) -> f64 {
self.nameplate.name_tag_distance()
}
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
pub fn below_name_distance(&self) -> f64 {
self.nameplate.below_name_distance()
}
}
/// The vanilla default (64) and maximum (512) for `NAME_TAG_DISTANCE`.
pub fn name_tag_distance_of(value: Option<f64>) -> f64 {
value
.filter(|v| v.is_finite())
.unwrap_or(64.0)
.clamp(0.0, 512.0)
}
/// The vanilla default (10) and maximum (512) for `BELOW_NAME_DISTANCE`.
pub fn below_name_distance_of(value: Option<f64>) -> f64 {
value
.filter(|v| v.is_finite())
.unwrap_or(10.0)
.clamp(0.0, 512.0)
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::geometry::Vec3;
#[test]
fn nameplate_distances_use_vanilla_defaults_and_clamp() {
assert_eq!(name_tag_distance_of(None), 64.0);
assert_eq!(name_tag_distance_of(Some(5.0)), 5.0);
assert_eq!(name_tag_distance_of(Some(600.0)), 512.0);
assert_eq!(name_tag_distance_of(Some(-4.0)), 0.0);
assert_eq!(below_name_distance_of(None), 10.0);
assert_eq!(below_name_distance_of(Some(2.0)), 2.0);
assert_eq!(below_name_distance_of(Some(-1.0)), 0.0);
}
#[test]
fn armor_stand_nameplate_values_override_defaults_and_clamp() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0);
assert_eq!(stand.name_tag_distance(), 64.0);
assert_eq!(stand.below_name_distance(), 10.0);
stand.nameplate = NameplateDistances {
name_tag: Some(5.0),
below_name: Some(2.0),
};
assert_eq!(
(stand.name_tag_distance(), stand.below_name_distance()),
(5.0, 2.0)
);
stand.nameplate.name_tag = Some(600.0);
assert_eq!(stand.name_tag_distance(), 512.0);
}
}
+92 -22
View File
@@ -916,15 +916,7 @@ pub fn mob_to_nbt(
Tag::Int(m.extra.more_carrot_ticks),
);
}
"minecraft:goat" => {
map.insert(
"IsScreamingGoat".into(),
Tag::Byte(i8::from(m.extra.screaming)),
);
let [left, right] = m.extra.lost_horns;
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
}
"minecraft:goat" => crate::mobs::goat::write_nbt(m, map),
"minecraft:creeper" => {
map.insert("Fuse".into(), Tag::Short(m.max_swell as i16));
map.insert(
@@ -2353,11 +2345,7 @@ pub fn mob_from_nbt_with_random(
_ => 0,
};
}
crate::mobs::Species::Goat => {
m.extra.screaming = flag(tag, "IsScreamingGoat");
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
}
crate::mobs::Species::Goat => crate::mobs::goat::read_nbt(&mut m, tag),
_ => {}
}
// `Rabbit.readAdditionalSaveData`.
@@ -2372,8 +2360,8 @@ pub fn mob_from_nbt_with_random(
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
crate::mobs::tame::restore_sitting(&mut m, flag(tag, "Sitting"));
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
}
if m.species() == crate::mobs::Species::Panda {
let gene = |key| text(tag.get(key)).map_or(0, |n| crate::mobs::panda::gene_from_name(&n));
@@ -2398,8 +2386,8 @@ pub fn mob_from_nbt_with_random(
.and_then(crate::save::item_from_nbt);
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
m.extra.body_extra = armor.and_then(|s| s.extra);
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
}
if matches!(
m.species(),
@@ -2652,7 +2640,7 @@ pub fn mob_from_nbt_with_random(
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Shulker {
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) as i8 % 6).unsigned_abs();
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
m.anim.v[0] = m.extra.counter as f32 * 0.01;
@@ -2913,7 +2901,7 @@ pub fn entity_base_tag(
motion,
rotation,
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
fall_distance: 0.0,
fall_distance: raw.map_or(0.0, |t| ground_from_nbt(t).1),
}
.tag(raw)
}
@@ -3049,6 +3037,7 @@ pub fn arrow_to_nbt(
}
for (key, value) in [
("Fire", Tag::Short(a.fire_ticks as i16)),
("Air", Tag::Short(p.air_supply as i16)),
("life", Tag::Short(a.life as i16)),
("shake", Tag::Byte(a.shake as i8)),
("inGround", Tag::Byte(i8::from(a.in_ground))),
@@ -3127,6 +3116,7 @@ pub fn arrow_from_nbt(
p.owner_uuid = owner;
p.no_gravity = flag(tag, "NoGravity");
p.on_ground = flag(tag, "OnGround");
projectile_fields::load_arrow_entity_fields(&mut p, tag);
let a = p.arrow.as_mut()?;
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
@@ -3146,8 +3136,6 @@ pub fn arrow_from_nbt(
_ => false,
})
.cloned();
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
a.spectral = spectral;
// `SpectralArrow.readAdditionalSaveData`: absent Duration defaults to 200.
if spectral {
@@ -4174,6 +4162,30 @@ mod tests {
.is_none()
);
}
/// `Shulker` reads `AttachFace` as a byte before
/// `Direction.from3DDataValue` (`abs(data % 6)`): an int is narrowed
/// first, so 258 is byte 2, not 258 % 6 = 0, and 250 is byte -6, face 0.
#[test]
fn a_shulkers_attach_face_is_narrowed_to_a_byte_first() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:shulker".into();
ty.species = crate::mobs::Species::Shulker;
let types = [("minecraft:shulker".into(), kind)].into();
let face = |saved: Tag| {
let raw = Tag::compound([
("id", string("minecraft:shulker")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
("AttachFace", saved),
]);
mob_from_nbt(&raw, &types, 1, None).unwrap().extra.variant3
};
assert_eq!(face(Tag::Byte(3)), 3);
assert_eq!(face(Tag::Byte(-2)), 2);
assert_eq!(face(Tag::Int(258)), 2);
assert_eq!(face(Tag::Int(250)), 0);
}
use super::*;
use crate::items::ItemStack;
@@ -4221,6 +4233,33 @@ mod tests {
assert_eq!(tag.get("UUID"), Some(&Tag::IntArray(vec![9, 9, 9, 9])));
}
/// An entity that never moves (an `AreaEffectCloud`) saves the
/// `fall_distance` `Entity.load` read, and 0 when it was made in play.
#[test]
fn a_base_tag_keeps_the_loaded_fall_distance() {
let raw = horizon_world::nbt::parse_snbt(
"{id:\"minecraft:area_effect_cloud\",Pos:[1d,200d,1d],fall_distance:5.0d}",
)
.unwrap()
.0;
let fall = |raw: Option<&super::Tag>| {
let tag = super::entity_base_tag(
"minecraft:area_effect_cloud",
super::Vec3::new(1.0, 200.0, 1.0),
super::Vec3::ZERO,
[0.0, 0.0],
[1; 4],
raw,
);
match tag.get("fall_distance") {
Some(super::Tag::Double(bits)) => f64::from_bits(*bits),
other => panic!("no fall_distance: {other:?}"),
}
};
assert_eq!(fall(Some(&raw)), 5.0);
assert_eq!(fall(None), 0.0);
}
#[test]
fn empty_saved_tags_are_omitted_without_changing_raw_or_nonempty_tags() {
let base = Base {
@@ -4551,6 +4590,35 @@ mod tests {
assert_eq!(m.extra.head.as_deref(), Some("minecraft:iron_helmet"));
}
/// `Wolf` and `Cat` read `CollarColor` with `DyeColor.LEGACY_ID_CODEC`: a
/// byte through `DyeColor.byId`, whose out-of-range answer is the first
/// colour, white (`OutOfBoundsStrategy.ZERO`). Nothing wraps.
#[test]
fn a_collar_colour_outside_the_sixteen_reads_white() {
for (species, id) in [
(crate::mobs::Species::Wolf, "minecraft:wolf"),
(crate::mobs::Species::Cat, "minecraft:cat"),
] {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = id.into();
ty.species = species;
let types = [(id.into(), kind)].into();
let colour = |saved: Option<Tag>| {
let mut fields = vec![("id", string(id)), ("Pos", doubles(&[4.5, 220.0, 4.5]))];
fields.extend(saved.map(|c| ("CollarColor", c)));
let m = mob_from_nbt(&Tag::compound(fields), &types, 1, None).unwrap();
m.extra.color
};
assert_eq!(colour(Some(Tag::Byte(11))), 11, "{id}");
assert_eq!(colour(Some(Tag::Byte(17))), 0, "{id}: not 17 & 15");
assert_eq!(colour(Some(Tag::Byte(-1))), 0, "{id}: not 15");
// Codec.BYTE narrows an int first: 259 is byte 3.
assert_eq!(colour(Some(Tag::Int(259))), 3, "{id}");
assert_eq!(colour(None), crate::mobs::tame::RED, "{id}");
}
}
fn fused_creeper(fuse: Option<i16>) -> (Mob, Tag) {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
@@ -4794,6 +4862,7 @@ mod tests {
no_gravity: true,
on_ground: true,
fire_ticks: 0,
fall_distance: 0.0,
sound_event: TRIDENT_HIT_GROUND,
item: &trident,
},
@@ -5799,6 +5868,7 @@ mod tests {
no_gravity: false,
on_ground: false,
fire_ticks: 0,
fall_distance: 0.0,
sound_event: TRIDENT_HIT_GROUND,
item: &trident,
},
+5 -2
View File
@@ -1,5 +1,7 @@
//! Experience orbs: `ExperienceOrb` save data.
use super::{Base, ExperienceOrb, Tag, flag, int_value, motion, number, position, put, rotation};
use super::{
Base, ExperienceOrb, Tag, flag, float, int_value, motion, number, position, put, rotation,
};
/// `ExperienceOrb`: Health, Age, Value, Count.
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
@@ -10,7 +12,7 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
motion: o.velocity,
rotation: o.rotation,
on_ground: o.on_ground,
fall_distance: 0.0,
fall_distance: o.fall_distance,
};
let mut tag = base.tag(None);
for (key, value) in [
@@ -35,6 +37,7 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
};
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
let orb = ExperienceOrb {
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
rotation: rotation(tag),
join: Default::default(),
no_gravity: flag(tag, "NoGravity"),
@@ -45,3 +45,17 @@ pub(super) fn load_projectile_flags_and_entity_fields(p: &mut Projectile, tag: &
p.no_gravity = flag(tag, "NoGravity");
load_projectile_entity_fields(p, tag);
}
/// `Entity.load` for an arrow: fire and fall distance live on its arrow state,
/// air on the projectile and defaults to the 300 of `getMaxAirSupply`.
pub(super) fn load_arrow_entity_fields(p: &mut Projectile, tag: &Tag) {
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(300);
if let Some(a) = p.arrow.as_mut() {
a.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
}
}
#[cfg(test)]
#[path = "projectile_fields_tests.rs"]
mod tests;
@@ -0,0 +1,17 @@
use crate::entity_nbt::{arrow_from_nbt, arrow_to_nbt};
use horizon_world::nbt::{Tag, parse_snbt};
/// `Entity.load` reads `Air` and `saveWithoutId` writes it back.
#[test]
fn arrow_keeps_its_saved_air() {
let parse = |text: &str| parse_snbt(text).unwrap().0;
let raw = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d],Air:32767s,Fire:7s}");
let (arrow, _) = arrow_from_nbt(&raw, None, |_| None).unwrap();
assert_eq!(arrow.air_supply, 32767);
let saved = arrow_to_nbt(&arrow, [197, 0, 0, 1], None, None, Some(&raw)).unwrap();
assert_eq!(saved.get("Air"), Some(&Tag::Short(32767)));
assert_eq!(saved.get("Fire"), Some(&Tag::Short(7)));
let absent = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d]}");
let (fresh, _) = arrow_from_nbt(&absent, None, |_| None).unwrap();
assert_eq!(fresh.air_supply, 300);
}
@@ -1,6 +1,6 @@
//! Thrown tridents: `AbstractArrow`, `Projectile` and `ThrownTrident` save data.
use super::{
Base, Pickup, Tag, Vec3, flag, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
Base, Pickup, Tag, Vec3, flag, float, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
position, put, rotation, string, text,
};
@@ -22,6 +22,8 @@ pub struct TridentSave<'a> {
/// `Entity.onGround`: a trident moves by `setPos`, so it keeps the loaded one.
pub on_ground: bool,
pub fire_ticks: i32,
/// `Entity.fallDistance`.
pub fall_distance: f64,
/// `AbstractArrow.soundEvent`: the sound of its next block hit.
pub sound_event: &'a str,
pub item: &'a crate::items::ItemStack,
@@ -38,7 +40,7 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
motion: t.motion,
rotation: [t.yaw, t.pitch],
on_ground: t.on_ground,
fall_distance: 0.0,
fall_distance: t.fall_distance,
}
.tag(raw);
// Projectile.addAdditionalSaveData omits LeftOwner until it is true,
@@ -95,6 +97,8 @@ pub struct TridentNbt {
pub no_gravity: bool,
pub on_ground: bool,
pub fire_ticks: i32,
/// `Entity.load`: `getDoubleOr("fall_distance", 0.0)`.
pub fall_distance: f64,
/// `SoundEvent`, or the trident's default when missing or no identifier.
pub sound_event: String,
pub item: crate::items::ItemStack,
@@ -120,6 +124,7 @@ pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
no_gravity: flag(tag, "NoGravity"),
on_ground: flag(tag, "OnGround"),
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i16 as i32,
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
sound_event: text(tag.get("SoundEvent"))
.and_then(|s| horizon_client_core::identifier::normalize_id(&s))
.unwrap_or_else(|| TRIDENT_HIT_GROUND.to_owned()),
-1
View File
@@ -33,7 +33,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
// The text crates ship (`horizon_hooks::lang`), loaded with the game's own languages.
horizon_client::lang::install_crate_files(horizon_hooks::lang::engine::all());
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
let help = game_args
+54 -102
View File
@@ -219,6 +219,7 @@ mod lan_far;
mod lan_idle;
mod lan_mobs;
mod lan_mount_menu;
mod lan_op;
mod lan_particles;
mod lan_pvp;
mod lan_quiet;
@@ -362,6 +363,8 @@ mod paintings;
mod pandas;
mod particle_culling;
mod passengers;
mod perf_link;
mod perf_remote;
mod persist;
mod perspective;
mod physical_trigger;
@@ -384,6 +387,8 @@ mod potent_sulfur;
mod potions;
mod powder_snow;
mod prediction;
mod profile_capture;
mod profile_lines;
mod pumpkin;
mod quick_actions;
mod raids;
@@ -1499,8 +1504,8 @@ impl LevelAccess for GameLevel<'_> {
signal
};
}
if let Some(signal) = books::lectern_signal(self.world, pos) {
return signal * i32::from(books::has_book_state(self.registry, state));
if let Some(signal) = books::lectern_signal(self.world, self.registry, state, pos) {
return signal;
}
if let Some(signal) =
machines::shelf_signal(self.world, self.registry, state, pos, direction)
@@ -2322,6 +2327,8 @@ struct Game<'a> {
debug: bool,
/// `horizon-ctl gpu-passes`: time Horizon's own passes on the GPU with F3 shut.
gpu_passes: bool,
/// `horizon-ctl gpu-passes detail`: also dispatch times and counters.
gpu_pass_detail: bool,
screen: Screen,
observed_screen: Option<native_hooks::ObservedScreen>,
window: Option<Arc<Window>>,
@@ -2359,9 +2366,6 @@ struct Game<'a> {
/// `SpectatorGui`: the menu a spectator opens with the hotbar keys.
spectator_gui: horizon_client::spectator_menu::SpectatorGui,
previous: Instant,
/// When the last frame's work ended: what runs before the next one starts (`idle`, the
/// event loop) shows in HORIZON_SPIKES as the time between frames.
frame_end: Instant,
/// FramerateLimitTracker.latestInputTime.
last_input: Instant,
/// Controllers (Horizon's own).
@@ -2805,6 +2809,11 @@ struct Game<'a> {
/// When an agent's last command came.
agent_last: Option<Instant>,
frame_stats: horizon_client::control::FrameStats,
/// This thread's and all threads' allocation counts at the last rendered frame.
alloc_mark: Option<(
horizon_threads::alloc_count::Counts,
horizon_threads::alloc_count::Counts,
)>,
/// This play session's frame and tick times, for opt-in performance statistics.
perf: horizon_client::telemetry::Session,
/// Control replies waiting for a rendered frame or a benchmark deadline.
@@ -2813,7 +2822,7 @@ struct Game<'a> {
screenshot_sequence: Option<screenshot_sequence::Sequence>,
/// `render-capture DIR`: replied once the renderer wrote the scene.
pending_render_capture: Option<std::sync::mpsc::Sender<String>>,
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>, bool)>,
/// Recent frame times for the performance overlay.
frame_graph: horizon_client::perf_overlay::FrameGraph,
/// Horizon's extras (minimap, auto-reconnect; mods' counters): `game/extras.rs`.
@@ -2869,6 +2878,8 @@ struct Game<'a> {
input_trace: input_trace::InputTrace,
/// A repro recording, export or replay (game/repro.rs).
repro: repro::Repro,
/// The health history and client captures (game/profile_capture.rs).
profile: profile_capture::Profile,
/// `HORIZON_TICK_SCRIPT`'s scenario (game/tick_script.rs).
tick_script: tick_script::TickScript,
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
@@ -3357,11 +3368,10 @@ impl Game<'_> {
fn aim(&self) -> Option<([f64; 3], [f64; 3])> {
// Minecraft.pick: from the camera entity's eyes along its view
// (LivingEntity.getViewYRot: the head's yaw).
if let Some(m) = self.camera_mob() {
return Some((
[m.position.x, m.eye_y(), m.position.z],
pick_view_vector(m.head_yaw, m.head_pitch),
));
if let Some(e) = self.camera_entity() {
let [x, y, z] = e.position;
let eye = [x, y + f64::from(e.eye_height), z];
return Some((eye, pick_view_vector(e.y_rot, e.x_rot)));
}
Some((self.eye_position()?, pick_view_vector(self.yaw, self.pitch)))
}
@@ -3404,7 +3414,7 @@ impl Game<'_> {
Some(b)
}
fn outline_context_kind(&self) -> horizon_world::collision_context::EntityContextKind {
if self.camera_mob().is_some() {
if self.camera_entity().is_some() {
horizon_world::collision_context::EntityContextKind::Living
} else {
horizon_world::collision_context::EntityContextKind::Player
@@ -8321,22 +8331,6 @@ impl Game<'_> {
}
self.tracked_special_box(id as i32)
}
/// The renderer for the window, with the current block sprites, GUI
/// atlas and world border texture.
/// A new renderer's atlas holds only the pack's sprites: what was drawn into it at run time
/// (skins, maps, the themed UI's glyphs and corners) has to be drawn again.
pub(super) fn forget_atlas_slots(&mut self) {
self.skin_slots.forget();
self.maps.slots.forget();
self.trim_slots.forget();
self.map_patches.clear();
self.kept_block_entities.get_mut().clear();
horizon_client::ui_theme::reset_atlas();
}
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
self.renderer_for(window, &self.pack, &self.gui)
}
/// Rebuild GPU-owned state after the backend reports device loss.
fn recover_device(&mut self) -> Result<()> {
if !self.renderer.as_ref().is_some_and(Renderer::device_lost) {
@@ -8399,34 +8393,6 @@ impl Game<'_> {
log::info!("Horizon: graphics device recovered; rebuilding world meshes");
Ok(())
}
/// A renderer made again from the resources loaded takes over: what named the old one's
/// atlas slots is forgotten, the world is meshed again, and the shader pack and the
/// settings are applied again.
pub(super) fn adopt_renderer(&mut self, mut renderer: Renderer) {
// A new renderer starts with the vanilla column and without the world border's wall.
renderer.clear_sections(self.world.layout());
self.border_renderer.invalidate();
self.renderer = Some(renderer);
self.forget_atlas_slots();
self.clear_lod();
self.pending_uploads.clear();
let context = self.worker_context();
self.streaming.reload_resources(&mut self.world, context);
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
&mut self.native_shaders.picked,
));
self.apply_ui_settings();
}
/// `LevelExtractor.allChanged`: displayed sections and mesh work are
/// discarded, then every loaded section is rebuilt.
fn all_sections_changed(&mut self) {
self.clear_lod();
self.pending_uploads.clear();
if let Some(renderer) = self.renderer.as_mut() {
renderer.clear_sections(self.world.layout());
}
self.streaming.reload_sections(&mut self.world);
}
/// A translated message for the player the game acts for: `overlay` is
/// `sendOverlayMessage` (the action bar), else `sendSystemMessage` (chat).
/// A guest gets a packet, so its own client translates it.
@@ -9110,7 +9076,7 @@ impl Game<'_> {
// What faces the camera (particles, item sprites, block entity text) faces a mod's.
// A spectated entity's view is not the local player's yaw and pitch
// (`Camera.setup` reads the camera entity), so it is kept too.
let spectating = self.camera_mob().is_some();
let spectating = self.camera_entity().is_some();
self.posed_rotation = view
.filter(|posed| spectating || Some(*posed) != vanilla)
.map(|(_, yaw, pitch)| (yaw, pitch));
@@ -9807,9 +9773,14 @@ impl Game<'_> {
let debug_passes = self.debug_lists_gpu_passes();
let result = match self.renderer.as_mut() {
Some(renderer) => {
renderer.set_pass_timing(debug_passes || self.gpu_passes);
// `gpu_passes` is a measurement: it takes every sample it can.
renderer.set_pass_timing_gap(if self.gpu_passes {
use horizon_client::client_perf::request::GpuSwitch;
let capture_gpu = self.profile.gpu();
let recording = capture_gpu != GpuSwitch::Off;
renderer.set_pass_timing(debug_passes || self.gpu_passes || recording);
renderer.set_keep_timed_frames(recording);
renderer.set_pass_detail(self.gpu_pass_detail || capture_gpu == GpuSwitch::Detail);
// `gpu_passes` and a capture's `--gpu` are measurements: they take every sample they can.
renderer.set_pass_timing_gap(if self.gpu_passes || recording {
Duration::ZERO
} else {
horizon_client::debug_overlay::GPU_TIMING_GAP
@@ -11092,12 +11063,8 @@ impl Game<'_> {
self.observe_fullscreen();
self.sync_ime();
let now = Instant::now();
let frame_cpu = self
.options
.spikes
.and_then(|_| horizon_client::spans::thread_cpu());
horizon_threads::work::tick_start();
let elapsed = now.duration_since(self.previous);
let between = now.saturating_duration_since(self.frame_end);
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
self.previous = now;
self.studio_fly(elapsed);
@@ -11185,46 +11152,19 @@ impl Game<'_> {
result => result?,
};
horizon_client::spans::time("pr_cursor", || self.apply_cursor_icon());
// The frame's spans, taken every frame they are recorded so they never
// pile up: the slow-frame line's, and the session's (`perf.frame`).
// The frame's spans, taken every frame they are recorded so they never pile up.
let frame_spans = if horizon_client::spans::recording_here() {
horizon_client::spans::take_raw()
} else {
Vec::new()
};
if horizon_client::spans::trace::due() {
horizon_client::spans::trace::stop();
}
if let Some(threshold) = self.options.spikes
&& let Some(r) = self.renderer.as_ref()
{
let cpu = now.elapsed().saturating_sub(r.last_wait).as_secs_f32() * 1000.0;
// Spans timed between frames (`idle`) come first.
let spans = horizon_client::spans::merge(&frame_spans);
let between = between.as_secs_f32() * 1000.0;
if cpu > threshold || between > threshold {
// The render thread's own CPU time in the frame:
// far below the wall time, the frame waited.
let worked = horizon_client::spans::thread_cpu()
.zip(frame_cpu)
.map_or_else(String::new, |(end, start)| {
format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0)
});
let gap = if between > threshold {
format!(", {between:.1} ms before it")
} else {
String::new()
};
log::info!(
"Horizon: slow frame {cpu:.1} ms{worked}{gap} ({steps} ticks): {}",
horizon_client::spans::describe(&spans)
);
}
}
let mut perf_times = None;
if let Some(r) = self.renderer.as_mut() {
let cpu = now.elapsed().saturating_sub(r.last_wait);
horizon_threads::work::tick_end(cpu.as_nanos() as u64);
if rendered {
let ms = |d: Duration| d.as_secs_f32() * 1000.0;
perf_times = Some((ms(cpu), r.gpu_times.last().copied()));
self.frame_graph.record(
ms(elapsed),
ms(cpu),
@@ -11304,6 +11244,17 @@ impl Game<'_> {
}
if rendered {
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
if let Some((cpu, gpu)) = perf_times {
self.record_perf_frame(
elapsed.as_secs_f32() * 1000.0,
cpu,
gpu,
&frame_spans,
);
}
if horizon_threads::alloc_count::ON {
self.record_frame_allocs();
}
self.debug_charts.frame_times.log(elapsed.as_nanos() as i64);
if self.frames == 0 {
// The launcher times Play to this line (launcher/src/perf.rs).
@@ -11344,7 +11295,6 @@ impl Game<'_> {
self.fps_frames = 0;
self.fps_at = Instant::now();
}
self.frame_end = Instant::now();
if self.options.frames.is_some_and(|f| self.frames >= f)
|| self
.options
@@ -11428,6 +11378,9 @@ impl<'a> Game<'a> {
let inventory = vec![None; 36];
let now = Instant::now();
let look = options.look;
let slow_frame_ms = options
.spikes
.unwrap_or(horizon_client::client_perf::SLOW_FRAME_MS);
let particles = horizon_client::particles::ParticleEngine::new(
&root,
&gui.atlas.sprites,
@@ -11544,6 +11497,7 @@ impl<'a> Game<'a> {
pause: false,
debug: false,
gpu_passes: false,
gpu_pass_detail: false,
screen: Screen::Playing,
observed_screen: None,
window: None,
@@ -11566,7 +11520,6 @@ impl<'a> Game<'a> {
started: now,
spectator_gui: Default::default(),
previous: now,
frame_end: now,
last_input: now,
pad: Default::default(),
frames: 0,
@@ -11820,6 +11773,7 @@ impl<'a> Game<'a> {
agent_control_on: false,
agent_last: None,
frame_stats: Default::default(),
alloc_mark: None,
perf: Default::default(),
pending_screenshot: None,
screenshot_sequence: None,
@@ -11830,6 +11784,7 @@ impl<'a> Game<'a> {
events: Default::default(),
input_trace: Default::default(),
repro: Default::default(),
profile: profile_capture::Profile::new(slow_frame_ms),
tick_script: tick_script::TickScript::from_env(),
agent: agent::Agent::default(),
view_frustum: None,
@@ -12324,10 +12279,7 @@ pub fn run_with() -> Result<()> {
horizon_threads::catch_stop_signals();
}
let started = Instant::now();
// A dedicated server writes its slow ticks to the log with the spans
// they spent their time in (`HORIZON_SPIKES`'s line; the variable still
// sets another threshold), so a lag spike players felt can be traced
// from the log alone. Timing the spans costs microseconds a tick.
// A dedicated server times its spans always, so its slow ring holds the steps of a lag spike (microseconds a tick).
if options.spikes.is_none()
&& options.execution_role == execution_role::ExecutionRole::DedicatedServer
{
@@ -533,7 +533,8 @@ impl Game<'_> {
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, a) in self.armor_stands.iter().enumerate() {
if a.marker {
// Minecraft.pick never hits the camera entity it looks from.
if a.marker || self.remote_camera_id() == Some(a.id) {
continue;
}
let b = a.bounds();
@@ -1437,7 +1438,11 @@ impl Game<'_> {
) {
// The stands' models posed on the GPU path (`mobs::skin`), when it runs this frame.
let skinning = self.skinning();
for a in &self.armor_stands {
for a in self
.armor_stands
.iter()
.filter(|a| !self.hides_as_camera(a.id))
{
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
// edge (4 for a marker's empty box), whatever the entity distance.
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
@@ -15,6 +15,28 @@ use horizon_client::{
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
/// The block entity type a sample is attributed to while its ticker runs.
fn ticker_name(entity: &BlockEntity) -> &'static str {
match entity {
BlockEntity::Chest(_) => "hopper",
BlockEntity::Crafter(_) => "crafter",
BlockEntity::Jukebox(_) => "jukebox",
BlockEntity::Beacon(_) => "beacon",
BlockEntity::CreakingHeart(_) => "creaking_heart",
BlockEntity::Spawner(_) => "mob_spawner",
BlockEntity::TrialSpawner(_) => "trial_spawner",
BlockEntity::Vault(_) => "vault",
BlockEntity::Beehive(_) => "beehive",
BlockEntity::Furnace(_) => "furnace",
BlockEntity::Brewing(_) => "brewing_stand",
BlockEntity::DaylightDetector => "daylight_detector",
BlockEntity::Campfire(_) => "campfire",
BlockEntity::Mod(_) => "mod_block_entity",
BlockEntity::PotentSulfur(_) => "potent_sulfur",
_ => "block_entity",
}
}
impl Game<'_> {
pub(super) fn tick_block_entities(&mut self) -> Result<()> {
self.tick_mount_menu();
@@ -65,7 +87,12 @@ impl Game<'_> {
{
continue;
}
match self.world.block_entities.get(&pos) {
horizon_threads::work::set_chunk(pos.0 >> 4, pos.2 >> 4);
let entity = self.world.block_entities.get(&pos);
horizon_threads::work::set_entity(horizon_threads::work::entity_id(
entity.map_or("", ticker_name),
));
match entity {
Some(BlockEntity::Chest(items)) if items.len() == HOPPER_SLOTS => {
self.tick_hopper(pos)
}
@@ -100,6 +127,7 @@ impl Game<'_> {
_ => {}
}
}
horizon_threads::work::clear_work();
// Items move between passes: the columns hold for this one only.
self.item_columns = None;
self.pass_mobs = None;
@@ -224,6 +224,7 @@ impl Game<'_> {
if !debug {
self.tick_scheduled()?;
}
horizon_threads::work::clear_work();
self.with_level(|b, level, ticks| {
// The masks the random ticks will read, built on helper threads first while the
// parallel tick is on (`HORIZON_PARALLEL_TICK`): the draws and ticks don't depend on them.
@@ -271,6 +272,7 @@ impl Game<'_> {
}
});
});
horizon_threads::work::clear_work();
laps = horizon_client::spans::laps();
let sent = self.lan_broadcast_changes();
laps.lap("tb_broadcast");
+77
View File
@@ -1765,6 +1765,82 @@ impl Game<'_> {
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
self.hurt_boat_result(i, damage, creative).unwrap_or(false)
}
/// Whether a spectator must be refused this boat's menu.
pub(super) fn boat_loot_pending(&self, id: u32) -> bool {
matches!(
self.persist.raw.get(&id),
Some(horizon_world::nbt::Tag::Compound(map)) if map.contains_key(&"LootTable".into())
)
}
/// `ContainerEntity.unpackChestVehicleLootTable`: slot access rolls once;
/// only a player opening the menu fires `CriteriaTriggers.GENERATE_LOOT`, before filling.
pub(super) fn unpack_boat_loot(&mut self, i: usize, player: bool) {
use horizon_world::nbt::Tag;
let id = self.boats[i].id;
let (table, seed) = {
let Some(Tag::Compound(map)) = self.persist.raw.get(&id) else {
return;
};
let Some(table) = crate::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
else {
return;
};
let seed = match map.get(&"LootTableSeed".into()) {
Some(Tag::Long(seed)) => Some(*seed),
_ => None,
};
(table, seed)
};
if player {
self.trigger_loot_table(&table);
}
// Advancement rewards can remove the boat or shift its index.
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
return;
};
if let Some(Tag::Compound(map)) = self.persist.raw.get_mut(&id) {
map.remove(&"LootTable".into());
map.remove(&"LootTableSeed".into());
}
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, id as i32);
self.store_cart_items(key);
let loot = crate::structure_blocks::loot_data(&self.options.resources, &self.pack_files());
let b = &self.boats[i];
let at = Vec3::new(b.position.x, b.position.y, b.position.z);
let biome = self.biome_name_at(at).unwrap_or_default();
let pos = (
b.position.x.floor() as i32,
b.position.y.floor() as i32,
b.position.z.floor() as i32,
);
crate::structure_blocks::roll_container(
&mut self.boats[i].items,
loot.as_deref(),
table,
seed,
pos,
&biome,
);
if let Some(horizon_client::containers::BlockEntity::Chest(items)) =
self.world.block_entities.get_mut(&key)
{
items.clone_from(&self.boats[i].items);
}
}
/// `AbstractChestBoat.remove`: destroying removals spill the live inventory.
pub(super) fn kill_boat(&mut self, id: u32) {
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.release_vehicle_container(id);
self.unpack_boat_loot(i, false);
let b = self.boats.remove(i);
self.drop_vehicle_contents(b.position.components(), &b.items);
self.boat_removed(&b);
}
}
/// None means a canceled hit; Some records whether an accepted hit destroyed it.
pub(super) fn hurt_boat_result(
&mut self,
@@ -1788,6 +1864,7 @@ impl Game<'_> {
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
self.unpack_boat_loot(i, false);
let b = self.boats.remove(i);
// `AbstractChestBoat.remove` spills the chest through
// `Containers.dropContents` for every destroying removal reason
+94 -46
View File
@@ -5,6 +5,7 @@
//! with a redstone pulse, the comparator output and taking the book).
use super::{Game, chat::PLAYER_NAME, lan::LanHost, screen_info};
use horizon_client::{
block_entity_item,
containers::{BlockEntity, Lectern},
first_person::Hand,
hud::{HudBatch, rgba},
@@ -18,8 +19,7 @@ use horizon_client::{
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use horizon_world::level::flags as level_flags;
use horizon_world::nbt::Tag;
use horizon_world::{level::flags as level_flags, nbt::Tag, registry::BlockStateId};
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
@@ -202,7 +202,7 @@ impl Game<'_> {
}
// SuggestCommand's insertText does nothing on a book; a server's
// text never opens a file.
ClickEvent::SuggestCommand(_) | ClickEvent::OpenFile(_) => {}
ClickEvent::SuggestCommand(_) | ClickEvent::OpenFile(_) | ClickEvent::Profiler(_) => {}
}
}
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
@@ -914,6 +914,32 @@ impl Game<'_> {
self.set_lectern_powered(key, false);
}
}
/// `LecternBlock.getStateForPlacement`: a lectern placed from an item
/// whose `block_entity_data` holds a `Book`, by a placer who may use
/// game-master blocks, starts with `has_book` on. It runs before the
/// item's `block_state` component, which vanilla applies afterwards and
/// which may set `has_book` back. Returns the state to go on with.
pub(super) fn lectern_placed(
&mut self,
pos: [i32; 3],
state: BlockStateId,
stack: &ItemStack,
game_master: bool,
) -> horizon_client::Result<BlockStateId> {
if !game_master
|| !block_entity_item::carries_book(stack)
|| &*self.res.facts().get(state).name != "minecraft:lectern"
{
return Ok(state);
}
match self.with_props(state, &[("has_book", "true")]) {
Some(new) if new != state => {
self.level_set(pos, new)?;
Ok(new)
}
_ => Ok(state),
}
}
/// `LecternBlock.useItemOn`: a book goes onto an empty lectern.
pub(super) fn use_lectern(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
let key = (pos[0], pos[1], pos[2]);
@@ -1686,20 +1712,25 @@ impl Game<'_> {
}
}
}
/// `LecternBlock.getAnalogOutputSignal`'s `HAS_BOOK` check.
pub(super) fn has_book_state(
registry: &'static horizon_world::registry::BlockRegistry,
state: horizon_world::registry::BlockStateId,
) -> bool {
registry
.state(state)
.is_some_and(|s| s.properties.get("has_book").is_some_and(|v| v == "true"))
}
/// `LecternBlockEntity.getRedstoneSignal`.
pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos) -> Option<i32> {
/// `LecternBlock.getAnalogOutputSignal` and
/// `LecternBlockEntity.getRedstoneSignal`: the state's `has_book` gates the
/// answer, and an entity with no book reads 14 (a page count of 0 is a page
/// progress of 1.0 with no book to add).
pub(super) fn lectern_signal(
world: &horizon_client::world::World,
registry: &horizon_world::registry::BlockRegistry,
state: BlockStateId,
pos: BlockPos,
) -> Option<i32> {
let s = registry.state(state)?;
if s.name != "minecraft:lectern" {
return None;
}
if s.properties.get("has_book").is_none_or(|v| v != "true") {
return Some(0);
}
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(0, |b| {
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(14, |b| {
let data = b.data();
let count = data
.and_then(|d| d.written.as_ref().map(|w| w.pages.len()))
@@ -1809,6 +1840,10 @@ mod lectern_books_tag_tests {
}
}
#[cfg(test)]
#[path = "lectern_tests.rs"]
mod lectern_tests;
#[cfg(test)]
mod lectern_signal_tests {
use super::super::native_machines::tests::server_game;
@@ -1822,19 +1857,26 @@ mod lectern_signal_tests {
use horizon_world::level::LevelAccess;
use horizon_world::registry::BlockStateId;
fn signal(game: &mut super::Game<'_>, book: Option<ItemStack>, page: i32) -> i32 {
fn signal(
game: &mut super::Game<'_>,
has_book: bool,
book: Option<ItemStack>,
page: i32,
) -> i32 {
let key = (8, 64, 8);
let state: BlockStateId = game
.pack
.assets
.registry
.default_state("minecraft:lectern")
.unwrap();
let registry = game.pack.assets.registry;
let default = registry.default_state("minecraft:lectern").unwrap();
let mut state = registry.state(default).unwrap().clone();
state
.properties
.insert("has_book".into(), has_book.to_string());
let state: BlockStateId = registry.id(&state).unwrap();
game.world.set(key.0, key.1, key.2, state).unwrap();
game.world
.block_entities
.insert(key, BlockEntity::Lectern(Lectern { book, page }));
lectern_signal(&game.world, BlockPos::new(key.0, key.1, key.2)).unwrap()
let pos = BlockPos::new(key.0, key.1, key.2);
lectern_signal(&game.world, registry, state, pos).unwrap()
}
/// `LecternBlock.getAnalogOutputSignal` reads the block entity only while
@@ -1886,30 +1928,19 @@ mod lectern_signal_tests {
);
}
/// `LecternBlockEntity.getRedstoneSignal`: `floor(page / (pageCount - 1)
/// * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a book-content
/// component, so a stack that only sits in the lectern (a non-book item a
/// command left in `Book`) reads 14, not the book's 15.
/// `LecternBlock.getAnalogOutputSignal` asks for the state's `HAS_BOOK`
/// first, so a lectern without it reads 0 whatever its block entity holds;
/// with it, `LecternBlockEntity.getRedstoneSignal` is `floor(page /
/// (pageCount - 1) * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a
/// book-content component, so a stack that only sits in the lectern (a
/// non-book item a command left in `Book`) reads 14, not the book's 15.
/// An entity with no book at all reads 14 too: a page count of 0 is a page
/// progress of 1.0 with no book to add.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_lecterns_signal_adds_one_only_for_a_book() {
let mut game = server_game();
assert_eq!(signal(&mut game, None, 0), 0, "no book");
assert_eq!(
signal(&mut game, Some(ItemStack::new("minecraft:stone", 1)), 0),
14,
"a non-book stack has no book content"
);
// `writable_book_content` is a writable book's default component.
assert_eq!(
signal(
&mut game,
Some(ItemStack::new("minecraft:writable_book", 1)),
0
),
15,
"a writable book is a book"
);
let writable = Some(ItemStack::new("minecraft:writable_book", 1));
let mut written = ItemStack::new("minecraft:written_book", 1);
written.extra = Some(Box::new(StackData {
written: Some(WrittenBook {
@@ -1920,8 +1951,25 @@ mod lectern_signal_tests {
}),
..Default::default()
}));
assert_eq!(signal(&mut game, Some(written.clone()), 0), 1, "page 0");
assert_eq!(signal(&mut game, Some(written), 1), 15, "last page");
// `has_book=false`: no signal, whatever the block entity holds.
assert_eq!(signal(&mut game, false, None, 0), 0);
assert_eq!(
signal(&mut game, false, writable.clone(), 0),
0,
"state denies"
);
// `has_book=true`, an entity with no book: page progress 1.0.
assert_eq!(signal(&mut game, true, None, 0), 14, "no book");
let stone = Some(ItemStack::new("minecraft:stone", 1));
assert_eq!(signal(&mut game, true, stone, 0), 14, "no book content");
// `writable_book_content` is a writable book's default component.
assert_eq!(signal(&mut game, true, writable, 0), 15, "a book");
assert_eq!(
signal(&mut game, true, Some(written.clone()), 0),
1,
"page 0"
);
assert_eq!(signal(&mut game, true, Some(written), 1), 15, "last page");
}
}
impl BookScreen {
+50 -1
View File
@@ -33,6 +33,18 @@ pub(super) fn chat_tree(
None => horizon_client::command::vanilla::game_dispatcher(),
}
}
/// The arguments of a typed `/profiler` the client answers itself: only when the chat tree's `profiler` is Horizon's, else the server or mod that owns the name runs it.
fn horizon_profiler_args<'a>(
message: &'a str,
server_tree: Option<&horizon_client::command::Dispatcher>,
) -> Option<&'a str> {
let args = message
.strip_prefix('/')
.and_then(super::profile_capture::profiler_args)?;
server_tree
.is_none_or(|tree| tree.has_horizon_profiler())
.then_some(args)
}
use winit::{
event::KeyEvent,
keyboard::{KeyCode, PhysicalKey},
@@ -155,7 +167,7 @@ impl Game<'_> {
Some(tree) => tree,
None => horizon_client::command::vanilla::game_dispatcher(),
};
with_studio_commands(&with_native_commands(base, &names))
with_studio_commands(&with_native_commands(base, &names)).with_profiler()
}
}
@@ -1403,6 +1415,12 @@ impl Game<'_> {
return;
}
self.ui.chat.add_recent(&message);
if let Some(args) =
horizon_profiler_args(&message, self.remote.as_ref().map(|r| &r.chat_tree))
{
self.profiler_chat(args);
return;
}
// On a server (single player's own included) the server runs it,
// unless a client mod owns the command.
if self.remote.is_some() {
@@ -1530,6 +1548,14 @@ impl Game<'_> {
}
}
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
Some(ClickEvent::Profiler(command)) => {
if let Some(args) = command
.strip_prefix('/')
.and_then(super::profile_capture::profiler_args)
{
self.profiler_chat(args);
}
}
// Screen.defaultHandleGameClickEvent: the dialog opens over the
// screen, and closes back to it.
Some(ClickEvent::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
@@ -2100,6 +2126,29 @@ const LINK_COPY: usize = 1;
mod tests {
use super::*;
#[test]
fn a_typed_profiler_is_the_clients_unless_the_server_owns_the_name() {
let vanilla = horizon_client::command::vanilla::game_dispatcher();
let ours = vanilla.with_profiler();
assert_eq!(
super::horizon_profiler_args("/profiler share", Some(&ours)),
Some(" share")
);
assert_eq!(super::horizon_profiler_args("/profiler", None), Some(""));
assert_eq!(
super::horizon_profiler_args("/profilers", Some(&ours)),
None
);
let mut server = vanilla.clone();
let node = server.literal(0, "profiler");
server.executes(node, "server:profiler");
let theirs = server.with_profiler();
assert_eq!(
super::horizon_profiler_args("/profiler share", Some(&theirs)),
None
);
}
/// A mod's text reaches the chat box as typing would leave it: emoji and
/// accents stay, control characters, DEL and the section sign go.
#[test]
+3
View File
@@ -143,6 +143,9 @@ pub(super) struct CombatTracker {
/// A firework rocket's burst is hurting the player now: the rocket is
/// the direct entity (`rocket_hurt_player`).
pub(super) rocket_hit: bool,
/// The arrow hurting the player now pierces (`AbstractArrow.getPierceLevel` above 0):
/// `LivingEntity.applyItemBlocking` lets it past a raised shield (`arrow_hits_player`).
pub(super) piercing_hit: bool,
/// The player whose melee hit is landing on the player now: where they
/// stand and the seconds their weapon disables a shield for
/// (`getSecondsToDisableBlocking`).
+10 -29
View File
@@ -310,7 +310,7 @@ impl Game<'_> {
self.command_source = outer;
ok
}
fn syntax_failure(&mut self, e: &CommandError, command: &str) {
pub(in crate::game) fn syntax_failure(&mut self, e: &CommandError, command: &str) {
self.command_failure(e.message.clone());
let Some(input) = &e.input else { return };
let cursor = e.cursor.min(input.len());
@@ -2487,17 +2487,7 @@ impl Game<'_> {
self.item_frames.retain(|f| f.id != id);
}
Target::Painting(id) => self.paintings.retain(|p| p.id != id),
Target::Boat(id) => {
// AbstractBoat.remove: a killed boat drops its lead.
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.release_vehicle_container(id);
let b = self.boats.remove(i);
// AbstractChestBoat.remove, after the live menu writes
// back its slots.
self.drop_vehicle_contents(b.position.components(), &b.items);
self.boat_removed(&b);
}
}
Target::Boat(id) => self.kill_boat(id),
Target::Cart(id) => {
self.release_vehicle_container(id);
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
@@ -2613,23 +2603,14 @@ impl Game<'_> {
// `new ExperienceOrb(level, pos, Vec3.ZERO, 0)`, unstuck like any orb spawn.
let at = self.unstuck_orb(pos);
self.next_entity += 1;
self.orbs.push(horizon_client::entities::ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: self.next_entity,
position: Vec3::new(at[0], at[1], at[2]),
previous: Vec3::new(at[0], at[1], at[2]),
velocity: Vec3::new(0.0, 0.0, 0.0),
value: 0,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
air: 300,
following: None,
});
let at = Vec3::new(at[0], at[1], at[2]);
let orb = horizon_client::entities::ExperienceOrb::new(
self.next_entity,
at,
Vec3::ZERO,
0,
);
self.orbs.push(orb);
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
if !self.native_joins_flush(false, self.next_entity) {
return Err(err("commands.summon.failed.uuid", &[]));
@@ -646,6 +646,7 @@ impl Game<'_> {
orb.count = new.count;
orb.health = new.health;
orb.no_gravity = new.no_gravity;
orb.fall_distance = new.fall_distance;
// Entity.load reads portalCooldown and leaves the portal
// process as it is.
if let Some(portal) = self.portals.entities.get_mut(&id) {
@@ -1039,14 +1039,7 @@ impl Game<'_> {
.unwrap_or(Tag::Compound(Default::default())),
v => horizon_client::scoreboard::json_to_tag(v),
};
edit_components(&mut stack, |c| {
let mut data = match c.get(&"minecraft:custom_data".into()) {
Some(t @ Tag::Compound(_)) => t.clone(),
_ => Tag::Compound(Default::default()),
};
merge_compound(&mut data, &tag);
c.insert("minecraft:custom_data".into(), data);
});
set_custom_data(&mut stack, &tag);
}
"minecraft:set_components" => {
let patch = m["components"].clone();
@@ -1577,6 +1570,22 @@ fn edit_components(stack: &mut ItemStack, f: impl FnOnce(&mut horizon_world::nbt
stack.extra = extra;
stack.tidy();
}
/// `SetCustomDataFunction.run`: `CustomData.update(stack, tag -> tag.merge(value))`.
fn set_custom_data(stack: &mut ItemStack, value: &Tag) {
edit_components(stack, |c| {
let mut data = match c.get(&"minecraft:custom_data".into()) {
Some(t @ Tag::Compound(_)) => t.clone(),
_ => Tag::Compound(Default::default()),
};
merge_compound(&mut data, value);
// Vanilla CustomData.update removes the component when the result is empty.
if matches!(&data, Tag::Compound(m) if m.is_empty()) {
c.remove(&"minecraft:custom_data".into());
} else {
c.insert("minecraft:custom_data".into(), data);
}
});
}
/// `CompoundTag.merge`: nested compounds merge, other values replace.
fn merge_compound(into: &mut Tag, from: &Tag) {
let (Tag::Compound(a), Tag::Compound(b)) = (into, from) else {
@@ -2815,3 +2824,16 @@ mod registered_slot_source_preservation_tests {
}
}
}
#[cfg(test)]
mod set_custom_data_tests {
use super::*;
#[test]
fn merging_an_empty_tag_into_a_stack_without_custom_data_adds_no_component() {
let mut stack = ItemStack::new("minecraft:stick", 1);
set_custom_data(&mut stack, &Tag::Compound(Default::default()));
let saved = horizon_client::item_data::encode_components(&stack);
assert!(saved.is_none_or(|c| c.get("minecraft:custom_data").is_none()));
}
}
+7 -3
View File
@@ -52,12 +52,15 @@ impl Game<'_> {
consumable: &ItemConsumable,
stack: &ItemStack,
) {
// Consumable.onConsume runs on the stack's own component.
let consumable = horizon_client::item_sync::stack_consumable(stack, consumable);
let consumable = &*consumable;
self.emit_consume(consumable, stack, 16);
// ServerPlayer: the used statistic and the consume_item trigger.
self.award_stat("used", &stack.item, 1);
self.trigger_item("minecraft:consume_item", stack);
// FoodProperties.onConsume, the player's one ConsumableListener.
if let Some((nutrition, saturation)) = info.food {
if let Some((nutrition, saturation)) = horizon_client::item_sync::food(stack, info.food) {
let at = self.feet();
let pitch = self.world.random.triangle_float(1.0, 0.4);
self.audio.play(&consumable.sound, at, 1.0, pitch);
@@ -96,10 +99,11 @@ impl Game<'_> {
// with infinite materials, the hand when emptied, else the inventory
// (ServerPlayer.handleExtraItemsCreatedOnUse) or a drop.
self.consume_held();
if let Some(remainder) = &info.use_remainder
if let Some(remainder) =
horizon_client::item_sync::use_remainder(stack, info.use_remainder.clone())
&& !self.creative
{
let remainder = ItemStack::new(remainder, 1);
let remainder = ItemStack::new(&remainder, 1);
if self.held().is_none() {
*self.held_mut() = Some(remainder);
} else if let Some(rest) = self.give(remainder) {
+167 -39
View File
@@ -1,6 +1,7 @@
//! Commands from the control socket (`horizon-ctl`), run on the game thread.
use super::event_loop::EventLoopCtl;
use super::{Game, Screen};
use horizon_client::gfx::pack_timing;
use horizon_client::held::Source;
use horizon_client::{Result, items::ItemStack};
use horizon_core::geometry::Vec3;
@@ -59,8 +60,9 @@ const HELP: &[&str] = &[
"chat-log [N] the newest N chat messages (default 10)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr video-vanilla)",
"stats [reset] [raw] frame-time summary since the last reset (raw: every frame time too)",
"profile [client|server] start|stop|status|mark NAME capture the performance of the window and, in single player, its server thread to reports/ (horizon-ctl profile --help)",
"narration the narrator's status and the last messages it was asked to say",
"bench SECONDS reset stats, wait, then reply with the summary",
"bench SECONDS [raw] reset stats, wait, then reply with the summary (raw: every frame time too)",
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
"open WORLD [SEED] [creative] open or create a world from the title screen",
"save save the world",
@@ -125,9 +127,7 @@ impl Game<'_> {
summary["tick_ms"] = (flight.tick_ms.iter().sum::<f32>() / n).into();
summary["tick_ms_max"] = flight.tick_ms.iter().copied().fold(0.0, f32::max).into();
if flight.raw {
summary["frame_ms"] = self.frame_stats.frame_millis().into();
summary["cpu_ms"] = self.frame_stats.cpu_millis().into();
summary["gpu_ms"] = self.frame_stats.gpu_millis().into();
self.add_raw_series(&mut summary);
}
let _ = flight.reply.send(summary.to_string());
}
@@ -167,6 +167,14 @@ impl Game<'_> {
.collect(),
);
}
if let Some(r) = self.renderer.as_ref().filter(|r| r.pass_detail())
&& let Some(support) = r.pass_detail_support()
&& let Value::Object(detail) =
pack_timing::detail_json(&r.own_pass_parts(), &r.own_pass_counters(), support)
&& let Some(summary) = summary.as_object_mut()
{
summary.extend(detail);
}
self.add_async_occlusion_stats(&mut summary);
summary
}
@@ -177,6 +185,23 @@ impl Game<'_> {
}
}
/// Adds the allocations since the last rendered frame to the frame stats; the first frame has no mark.
pub(super) fn record_frame_allocs(&mut self) {
use horizon_threads::alloc_count::{here, process};
let now = (here(), process());
if let Some((window, all)) = self.alloc_mark.replace(now) {
self.frame_stats
.record_allocs(now.0.since(window), now.1.since(all), Instant::now());
}
}
/// Every frame's times, and its allocations in builds that count (`raw`).
fn add_raw_series(&self, summary: &mut Value) {
self.frame_stats.add_series(summary);
if horizon_threads::alloc_count::ON {
summary["alloc_count"] = self.frame_stats.alloc_counts().into();
summary["alloc_bytes"] = self.frame_stats.alloc_bytes().into();
}
}
fn reset_frame_stats(&mut self) {
self.frame_stats.reset();
if let Some(culler) = &self.async_occlusion_culler {
@@ -184,16 +209,21 @@ impl Game<'_> {
}
}
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
if let Some((deadline, reply)) = self.pending_bench.take() {
if let Some((deadline, reply, raw)) = self.pending_bench.take() {
if Instant::now() >= deadline {
let _ = reply.send(self.summary_with_passes().to_string());
let mut summary = self.summary_with_passes();
if raw {
self.add_raw_series(&mut summary);
}
let _ = reply.send(summary.to_string());
} else {
self.pending_bench = Some((deadline, reply));
self.pending_bench = Some((deadline, reply, raw));
}
}
horizon_client::widgets::end_frame(self.frames);
self.narrate_screen();
self.poll_repro(event_loop);
self.poll_profile();
self.advance_agent();
for request in self.ui_wait.ready() {
self.answer(request, event_loop);
@@ -391,12 +421,21 @@ impl Game<'_> {
if let Some(value) = self.repro_command(&words, line, reply)? {
return Ok(value);
}
if words[0] == "profile" {
return self.profile_command(&words[1..], reply);
}
if words[0] == "trace" {
return self.trace_command(&words[1..], reply);
}
if let Some(value) = self.saves_control(&words, line, reply)? {
return Ok(value);
}
if let Some(value) = self.options_control(&words, line)? {
return Ok(Some(value));
}
if let Some(value) = self.lan_op_control(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.lan_control(&words)? {
return Ok(Some(value));
}
@@ -461,35 +500,17 @@ impl Game<'_> {
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
}
"gpu-passes" => {
match words.get(1).copied() {
None => {}
Some("on") => self.gpu_passes = true,
Some("off") => self.gpu_passes = false,
Some(other) => return Err(format!("unknown gpu pass mode {other}").into()),
}
Ok(Some(json!({ "ok": true, "gpu_passes": self.gpu_passes })))
(self.gpu_passes, self.gpu_pass_detail) = gpu_pass_switches(
words.get(1).copied(),
(self.gpu_passes, self.gpu_pass_detail),
)?;
let support = self.renderer.as_ref().and_then(|r| r.pass_detail_support());
Ok(Some(gpu_passes_reply(
self.gpu_passes,
self.gpu_pass_detail,
support,
)))
}
// Every thread's spans as a timeline Perfetto opens (`spans::trace`).
"trace" => match words.get(1).copied() {
None => Ok(Some(
json!({ "ok": true, "tracing": horizon_client::spans::trace::on() }),
)),
Some("stop") => Ok(Some(
json!({ "ok": true, "events": horizon_client::spans::trace::stop() }),
)),
Some(file) => {
let seconds = match words.get(2) {
None => None,
Some(s) => Some(
s.parse::<u32>()
.map_err(|_| "trace [FILE [SECONDS] | stop]")?,
),
};
horizon_client::spans::trace::start(std::path::Path::new(file), seconds)
.map_err(|e| format!("trace {file}: {e}"))?;
Ok(Some(json!({ "ok": true, "tracing": true, "file": file })))
}
},
"render-capture" => match words.get(1).copied() {
None => {
let r = self.renderer.as_ref().ok_or("no renderer")?;
@@ -579,7 +600,7 @@ impl Game<'_> {
"sculk" => Ok(Some(self.sculk_command(&words)?)),
"hello" => Ok(Some(hello())),
"status" => Ok(Some(self.control_status())),
"memory" => Ok(Some(self.memory_report())),
"memory" => Ok(Some(self.memory_value())),
"stall" => {
let millis: u64 = arg(1)?.parse()?;
if millis > 5000 {
@@ -1188,9 +1209,7 @@ impl Game<'_> {
"stats" => {
let mut summary = self.summary_with_passes();
if words[1..].contains(&"raw") {
summary["frame_ms"] = self.frame_stats.frame_millis().into();
summary["cpu_ms"] = self.frame_stats.cpu_millis().into();
summary["gpu_ms"] = self.frame_stats.gpu_millis().into();
self.add_raw_series(&mut summary);
}
if words[1..].contains(&"reset") {
self.reset_frame_stats();
@@ -1210,6 +1229,7 @@ impl Game<'_> {
self.pending_bench = Some((
Instant::now() + Duration::from_secs_f64(seconds),
reply.clone(),
words[2..].contains(&"raw"),
));
Ok(None)
}
@@ -1773,6 +1793,35 @@ fn key_code(name: &str) -> Option<KeyCode> {
})
}
/// The (times, detail) switches `gpu-passes [on|off|detail]` leaves, given the current ones.
fn gpu_pass_switches(word: Option<&str>, now: (bool, bool)) -> Result<(bool, bool)> {
match word {
None => Ok(now),
Some("on") => Ok((true, false)),
Some("detail") => Ok((true, true)),
Some("off") => Ok((false, false)),
Some(other) => Err(format!("unknown gpu pass mode {other}").into()),
}
}
/// The `gpu-passes` reply: the switches and what detail can show on this device.
fn gpu_passes_reply(
passes: bool,
detail: bool,
support: Option<pack_timing::DetailSupport>,
) -> Value {
let shown = support.map(pack_timing::support_json);
let field = |key: &str, none: Value| shown.as_ref().map_or(none, |s| s[key].clone());
json!({
"ok": true,
"gpu_passes": passes,
"detail": detail,
"parts_mode": field("parts_mode", Value::Null),
"counters": field("counters", json!(false)),
"fragment_unit": field("fragment_unit", Value::Null),
})
}
/// The title's own screens (not one opened over it, as Options is) take the
/// text: the world name, the seed, a server's address.
pub(super) fn title_owns_text(screen: &Screen, title_up: bool) -> bool {
@@ -1810,6 +1859,39 @@ mod tests {
assert_eq!(names.len(), features.len(), "a group is named twice");
}
#[test]
fn gpu_passes_detail_replies_detail_and_on_turns_it_off_again() {
let support = pack_timing::DetailSupport {
parts: pack_timing::PartsMode::SeparatePasses,
encoder_stamps: false,
counters: true,
fragment_unit: Some(pack_timing::FragmentUnit::Quad),
};
let (passes, detail) = gpu_pass_switches(Some("detail"), (false, false)).unwrap();
let reply = gpu_passes_reply(passes, detail, Some(support));
assert_eq!(reply["detail"], true);
assert_eq!(reply["gpu_passes"], true);
assert_eq!(reply["parts_mode"], "separate_passes");
assert_eq!(reply["counters"], true);
assert_eq!(reply["fragment_unit"], "quad");
let (passes, detail) = gpu_pass_switches(Some("on"), (passes, detail)).unwrap();
let reply = gpu_passes_reply(passes, detail, None);
assert_eq!(
(reply["gpu_passes"].as_bool(), reply["detail"].as_bool()),
(Some(true), Some(false))
);
assert_eq!(
(reply["parts_mode"].is_null(), reply["counters"].as_bool()),
(true, Some(false))
);
assert_eq!(
gpu_pass_switches(Some("off"), (true, true)).unwrap(),
(false, false)
);
assert_eq!(gpu_pass_switches(None, (true, true)).unwrap(), (true, true));
assert!(gpu_pass_switches(Some("nope"), (false, false)).is_err());
}
#[test]
fn taps_and_ups_always_release_their_key() {
for line in ["key shift tap", "key shift up", "key Shift up"] {
@@ -1825,4 +1907,50 @@ mod tests {
assert_eq!(released_modifier(line), None, "{line}");
}
}
#[test]
fn a_benchmark_profile_only_goes_with_a_benchmark() {
let parse = |args: &[&str]| {
super::super::Options::parse(args.iter().map(|a| a.to_string())).map(|o| o.unwrap())
};
assert!(
parse(&["--benchmark", "village", "--profile"])
.unwrap()
.benchmark_profile
);
assert!(
!parse(&["--benchmark", "village"])
.unwrap()
.benchmark_profile
);
assert!(parse(&["--profile"]).is_err());
}
#[test]
fn a_benchmark_set_only_goes_with_the_trip() {
let parse = |args: &[&str]| {
super::super::Options::parse(args.iter().map(|a| a.to_string())).map(|o| o.unwrap())
};
let set = parse(&["--benchmark", "travel", "--benchmark-set", "gpu-culling=on"]).unwrap();
assert_eq!(set.benchmark_set, Some(("gpu-culling".into(), "on".into())));
let before = parse(&["--benchmark-set", "gpu-culling=on", "--benchmark", "travel"]);
assert!(before.is_ok());
for args in [
&[
"--benchmark",
"standard",
"--benchmark-set",
"gpu-culling=on",
][..],
&["--benchmark-set", "gpu-culling=on"],
&[
"--benchmark",
"travel",
"--benchmark-set",
"gpu-culling=auto",
],
] {
assert!(parse(args).is_err(), "{args:?}");
}
}
}
+22 -10
View File
@@ -247,8 +247,16 @@ impl Game<'_> {
}
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
/// death and a natural heart drops experience. `onExplosionHit` does the
/// same for the creaking (`by_player` false), without the experience.
/// same for the creaking, and awards the experience only when its
/// blast's indirect source is a player (`creaking_heart_broken_by`).
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
let award = by_player && !self.creative && !self.spectator();
self.creaking_heart_broken_by(pos, award);
}
/// `creaking_heart_broken` once the breaker is known: `award` is
/// `tryAwardExperience`'s player check (a player who neither builds
/// instantly, `preventsBlockDrops`, nor spectates).
pub(super) fn creaking_heart_broken_by(&mut self, pos: [i32; 3], award: bool) {
let key = (pos[0], pos[1], pos[2]);
if !matches!(
self.world.block_entities.get(&key),
@@ -271,16 +279,20 @@ impl Game<'_> {
.state(state)
.and_then(|s| s.properties.get("natural").cloned())
.is_some_and(|v| v == "true");
if by_player && natural && !self.creative && !self.spectator() {
if award && natural {
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
self.spawn_orbs(
[
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
],
xp,
);
// Block.popExperience: the amount is drawn, then nothing pops
// with `block_drops` off.
if self.ui.rules.bool("block_drops") {
self.spawn_orbs(
[
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
],
xp,
);
}
}
self.world
.block_entities
+24 -32
View File
@@ -3,6 +3,8 @@
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod bred_draws;
mod effect_instance;
use effect_instance::mob_effect_instance;
mod mob_geometry;
mod mob_group;
mod mob_islands;
@@ -2310,10 +2312,9 @@ impl Game<'_> {
self.post_game_event(event, at, ctx);
}
// A mob's own `gameEvent(..)` calls: Armadillo.rollUp/rollOut's
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, the
// shulker's lid (Shulker.setRawPeekAmount), an evoker's vexes
// (ENTITY_PLACE), Enderman.teleport's TELEPORT,
// LivingEntity.onEquipItem's EQUIP/UNEQUIP.
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, a fox's
// DRINK, the shulker's lid, an evoker's vexes (ENTITY_PLACE),
// Enderman.teleport's TELEPORT, onEquipItem's EQUIP/UNEQUIP.
MobEvent::GameEvent {
event, at, source, ..
} if matches!(
@@ -2321,6 +2322,7 @@ impl Game<'_> {
"minecraft:entity_action"
| "minecraft:prime_fuse"
| "minecraft:eat"
| "minecraft:drink"
| "minecraft:container_open"
| "minecraft:container_close"
| "minecraft:entity_place"
@@ -2532,6 +2534,13 @@ impl Game<'_> {
});
self.combat.explosion_source = source_mob;
self.combat.explosion_source_named = noted;
let portal_proof = species == Species::SulfurCube
&& source_mob.is_some_and(|id| {
self.portals
.entities
.get(&id)
.is_some_and(|p| p.cooldown > 0)
});
self.combat.explosion_cause = match species {
Species::Creeper => Some("minecraft:creeper"),
Species::Wither => Some("minecraft:wither"),
@@ -2561,7 +2570,11 @@ impl Game<'_> {
Species::Creeper | Species::Wither => {
self.with_hive_emptying_blast(|g| g.explode(at, radius, source))
}
Species::SulfurCube => self.explode_sulfur_cube(at, radius, source),
Species::SulfurCube => self.explode_sulfur_cube(
at,
radius,
source.with_portal_proof(portal_proof),
),
_ => self.explode(at, radius, source),
};
self.combat.explosion_source = None;
@@ -3970,6 +3983,7 @@ impl Game<'_> {
) -> bool {
let post_attack_if_alive = std::mem::take(&mut self.combat.post_attack_if_alive);
let rocket = std::mem::take(&mut self.combat.rocket_hit);
let pierce = std::mem::take(&mut self.combat.piercing_hit);
let melee = self.combat.melee_mob.take();
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
@@ -3984,7 +3998,7 @@ impl Game<'_> {
.damage_tags
.get("minecraft:is_projectile")
.is_some_and(|t| t.contains(damage_type));
let blocking = self.player_blocking(from, damage_type, false);
let blocking = self.player_blocking(from, damage_type, pierce);
let raised = blocking.is_some();
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
let attacker = match known {
@@ -5307,11 +5321,7 @@ impl Game<'_> {
}
let hurt = match who {
Who::Player => {
let hurt = self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
);
let hurt = self.arrow_hits_player(amount, p);
// AbstractArrow.onHitEntity: a hit that hurt leaves a
// non-piercing arrow stuck in the body.
if hurt && p.arrow.as_ref().is_some_and(|a| a.pierce == 0) {
@@ -5575,7 +5585,7 @@ impl Game<'_> {
if let Some(id) = self.spawn_mob_raw("minecraft:chicken", atp, true)
&& let Some(c) = self.mobs.iter_mut().find(|m| m.id == id)
{
c.extra.age = -24000;
(c.extra.age, c.extra.variant) = (-24000, p.egg_variant.unwrap_or(0));
}
}
}
@@ -7138,12 +7148,12 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return Ok(false);
};
// Projectile.shootFromRotation adds getKnownMovement: what the thrower
// last moved, not a velocity the server kept for a guest.
// Projectile.shootFromRotation adds getKnownMovement: what the thrower last moved.
let on_ground = p.ground.on_ground;
let velocity = self.known_player_movement();
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
proj.set_egg_item(stack);
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
self.add_projectile(proj);
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
@@ -7642,24 +7652,6 @@ fn knock_back(
);
}
/// The instance a mob effect gives the player; `None` for a creative or
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
fn mob_effect_instance(
effect: &'static str,
ticks: i32,
amplifier: i32,
ambient: bool,
immune: bool,
) -> Option<horizon_client::effects::EffectInstance> {
if immune && !ambient {
return None;
}
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
instance.ambient = ambient;
Some(instance)
}
#[cfg(test)]
mod local_sound_event_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
@@ -0,0 +1,17 @@
/// The instance a mob effect gives the player; `None` for a creative or
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
pub(super) fn mob_effect_instance(
effect: &'static str,
ticks: i32,
amplifier: i32,
ambient: bool,
immune: bool,
) -> Option<horizon_client::effects::EffectInstance> {
if immune && !ambient {
return None;
}
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
instance.ambient = ambient;
Some(instance)
}
@@ -333,9 +333,7 @@ impl<M: MobSlot> Group<M> {
};
w.controlling_player_movement = controlling_player_movement;
self.ticked.insert(self.mobs[i].mob().id);
// `horizon-report`'s cost by mob type (tick_profile.rs).
let sampled =
super::super::tick_profile::start().map(|t| (t, self.mobs[i].mob().kind.clone()));
let sampled = super::super::tick_profile::start_mob(&self.mobs[i].mob().kind);
let kept = horizon_client::mobs::riders::tick(
&mut self.mobs,
i,
@@ -343,9 +341,7 @@ impl<M: MobSlot> Group<M> {
&mut self.random,
&mut self.events,
);
if let Some((started, kind)) = sampled {
super::super::tick_profile::add(shared.level_id, &kind, started);
}
super::super::tick_profile::end_mob(shared.level_id, sampled);
let m = self.mobs[i].mob_mut();
if seen_guest.is_none() {
self.player = w.player;
@@ -174,6 +174,19 @@ impl Game<'_> {
if self.debug && !own_lines.is_empty() {
d.group("Horizon passes", own_lines);
}
let detail_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
let support = r.pass_detail_support().filter(|_| r.pass_detail());
support.map_or_else(Vec::new, |support| {
overlay::pass_detail_lines(
&r.own_pass_parts(),
&r.own_pass_counters(),
support,
)
})
});
if self.debug && !detail_lines.is_empty() {
d.group("Horizon pass detail", detail_lines);
}
}
"tps" => self.debug_tps_line(d),
// DebugEntryChunkGeneration, where the world is generated
+132 -86
View File
@@ -21,6 +21,7 @@ use horizon_client::{
},
text::Text,
};
use horizon_perf_format::v1::{StopReason, Trigger};
use horizon_protocol::text::translatable;
use horizon_world::nbt::{JavaString, Tag};
use std::{
@@ -37,7 +38,7 @@ use std::{
pub(super) mod events;
mod management;
mod predicate_graph;
mod report;
pub(super) mod report;
mod status;
mod transfer;
@@ -658,9 +659,7 @@ const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
/// server.properties' line for `server.toml`'s `max_view_distance` (horizon-server writes it).
const FAR_PROPERTY: &str = "horizon-max-view-distance";
/// A dedicated server logs a tick slower than this, in milliseconds, with
/// its spans, unless `HORIZON_SPIKES` names another threshold: five ticks'
/// worth, a stall players feel as rubber-banding.
/// A tick slower than this, in milliseconds, enters the slow ring (five ticks' worth, a stall players feel) unless `HORIZON_SPIKES` names another.
pub(super) const SLOW_TICK_MS: f32 = 250.0;
/// `MinecraftServer.OVERLOADED_WARNING_INTERVAL_NANOS` plus 100 ticks: the
/// "Can't keep up!" warning comes at most this often.
@@ -806,9 +805,19 @@ struct ServerState {
op_level: i32,
/// `horizon-report`: the ticks being recorded.
report: Option<report::Recording>,
/// When the running capture takes its next memory snapshot, and how many it has taken.
memory_due: Option<(f64, u32)>,
/// The always-on health history and slow ring, and the `.hzperf` a report writes from them.
perf: horizon_client::server_perf::ServerPerf,
/// The perf clock's zero: the tick loop's start.
perf_epoch: Instant,
/// The Horizon switches a capture records.
horizon: horizon_perf_format::v1::HorizonOptions,
/// View and simulation distance, and the player limit, for the report.
view: (u32, u32),
max_players: usize,
/// `network-compression-threshold`, for the capture's settings.
compression: i32,
/// The mutable `gamemode` server property (`DedicatedServer.gameMode`).
game_mode: i32,
/// The mutable `force-gamemode` server property (`DedicatedServer.forceGameMode`).
@@ -1235,6 +1244,7 @@ impl Game<'_> {
}
self.sync_ops(&lists);
let anticheat = load_anticheat();
let anticheat_on = anticheat.enabled;
let chat_filter = load_chat_filter();
self.set_pvp_legacy_rules(load_combat());
// Horizon's far ring (docs/hosting.md): server.toml's `max_view_distance`.
@@ -1292,12 +1302,44 @@ impl Game<'_> {
game_mode: props.game_mode,
force_game_mode: props.force_game_mode,
report: None,
memory_due: None,
perf: horizon_client::server_perf::ServerPerf::new(
0.0,
self.options.spikes.unwrap_or(SLOW_TICK_MS),
),
perf_epoch: Instant::now(),
horizon: horizon_client::server_perf::horizon_options(
if self.lan.as_ref().is_some_and(|h| h.hub.far.is_some()) {
far as u32
} else {
0
},
horizon_client::lod_net::distance_property(
props.raw.get(horizon_client::lod_net::PROPERTY),
),
anticheat_on,
),
compression: props.network_compression_threshold,
view: (
props.view_distance.clamp(2, 32) as u32,
props.simulation_distance.clamp(2, 32) as u32,
),
max_players: props.max_players.max(0) as usize,
};
horizon_client::server_perf::warm_hardware();
if let Some(file) = std::env::var_os("HORIZON_TRACE").filter(|f| !f.is_empty()) {
self.record_report(
&mut state,
&report::Plan {
seconds: report::MAX_SECONDS,
samples: false,
timeline: true,
file: Some(file.into()),
trigger: Trigger::Env,
sampler: Default::default(),
},
);
}
let mut next = Instant::now();
// The panic that ended the tick loop, when one did.
let mut crashed: Option<String> = None;
@@ -1361,22 +1403,32 @@ impl Game<'_> {
// MinecraftServer.tickServer: while paused only the connections run.
// `MinecraftServer.runServer` catches what the tick throws, writes the crash
// report and still stops the server (saves). A panic here does the same.
let ticked =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
match &mut state.report {
Some(recording) => {
let sampled = recording.sample_next();
super::tick_profile::set_sampling(sampled);
let ticked =
self.tick_server_spans(paused, |wall, cpu, spans, recorded| {
recording.add(wall, cpu, spans, recorded, sampled)
});
super::tick_profile::set_sampling(false);
ticked
}
None => self.tick_server_timed(paused),
let slow_ms = state.perf.slow_threshold_ms();
let capturing = state.perf.capturing();
let mut tick_detail = None;
let ticked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut detail = |timing: super::levels::TickTiming| {
if capturing || timing.wall_ms > slow_ms {
tick_detail = Some(horizon_client::server_perf::TickDetail {
cpu_ms: timing.cpu_ms,
spans: timing.raw,
});
}
}));
};
match &mut state.report {
Some(recording) => {
let sampled = recording.sample_next();
super::tick_profile::set_sampling(sampled);
let ticked = self.tick_server_spans(paused, |timing| {
recording.add(sampled);
detail(timing)
});
super::tick_profile::set_sampling(false);
ticked
}
None => self.tick_server_spans(paused, detail),
}
}));
match ticked {
Ok(result) => result?,
Err(payload) => {
@@ -1384,19 +1436,12 @@ impl Game<'_> {
break;
}
}
if state.report.as_ref().is_some_and(report::Recording::done)
&& let Some(recording) = state.report.take()
{
self.finish_report(recording, state.view, state.max_players);
}
if horizon_client::spans::trace::due()
&& let Some(events) = horizon_client::spans::trace::stop()
{
log(
"INFO",
&format!("Trace written: {events} events (reports/)"),
);
if state.report.as_ref().is_some_and(report::Recording::done) {
self.finish_perf_capture(&mut state, StopReason::Normal);
}
self.serve_perf_requests(&mut state);
let done = report::log_saved(&mut state.perf, false);
self.perf_captures_done(done);
// DedicatedServer.sendLowDiskSpaceWarning: the operators hear of it
// (no window drains the toasts here).
if horizon_client::chunk_io::take().low_disk_space
@@ -1412,6 +1457,14 @@ impl Game<'_> {
}
// MinecraftServer.tickServer records each tick's time, for /tick query (and the status).
self.clock.record(tick_start.elapsed());
state.perf.tick(
state.perf_epoch.elapsed().as_secs_f64(),
tick_start.elapsed().as_secs_f32() * 1000.0,
tick_detail.take(),
|| self.perf_world_counts(),
|| self.wire_bytes(),
);
self.poll_server_memory_snapshot(&mut state);
// The far ring's brake, once a second on the average tick.
if braked.elapsed() >= Duration::from_secs(1)
&& let Some(host) = self.lan.as_mut()
@@ -1437,6 +1490,9 @@ impl Game<'_> {
if let Some(watchdog) = &watchdog {
watchdog.running.store(false, Ordering::Relaxed);
}
self.finish_perf_capture(&mut state, StopReason::Shutdown);
let done = report::log_saved(&mut state.perf, true);
self.perf_captures_done(done);
match crashed {
Some(message) => self.stop_after_crash(&message),
None => self.stop_dedicated(),
@@ -1671,73 +1727,63 @@ impl Game<'_> {
state.lists.lock().unwrap().stop = true;
}
// Horizon's own, on the console only: a performance report (report.rs).
"horizon-report" if rest.first() == Some(&"stop") => match state.report.take() {
Some(recording) => {
log("INFO", "Stopping the performance report: writing it now");
self.finish_report(recording, state.view, state.max_players);
}
None => log("INFO", "No performance report is being recorded"),
},
"horizon-report" if rest.first() == Some(&"status") => match &state.report {
Some(recording) => log("INFO", &recording.status()),
None => log("INFO", "No performance report is being recorded"),
},
"horizon-report" => {
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
None => Ok(60),
Some(Ok(s)) if (1..=report::MAX_SECONDS).contains(&s) => Ok(s),
Some(_) => Err(()),
"horizon-report" | "horizon-trace" => {
let trace = command == "horizon-trace";
let parsed = if trace {
report::parse_trace(&rest)
} else {
report::parse(&rest)
};
match (seconds, &state.report) {
(Err(()), _) => log(
match parsed {
Err(()) if trace => log(
"INFO",
&format!(
"horizon-report [SECONDS | stop | status]: 1 to {} seconds, 60 by default",
"horizon-trace [SECONDS | stop]: 1 to {} seconds, 30 by default",
report::MAX_SECONDS
),
),
(Ok(_), Some(_)) => {
log("INFO", "A performance report is already being recorded")
Err(()) => log(
"INFO",
&format!(
"horizon-report [SECONDS | stop | status] [--no-samples] [--timeline]: 1 to {} seconds, 60 by default",
report::MAX_SECONDS
),
),
Ok(report::Command::Stop) if state.report.is_some() => {
log("INFO", "Stopping the performance report: writing it now");
self.finish_perf_capture(state, StopReason::Normal);
}
(Ok(seconds), None) => {
state.report = Some(report::Recording::start(seconds, self.wire_bytes()));
Ok(report::Command::Stop) => {
log("INFO", "No performance report is being recorded")
}
Ok(report::Command::Status) => match &state.report {
Some(recording) => log("INFO", &recording.status()),
None => log("INFO", "No performance report is being recorded"),
},
Ok(report::Command::Record {
seconds,
samples,
timeline,
}) if state.report.is_none() => {
self.record_report(
state,
&report::Plan {
seconds,
samples,
timeline,
file: None,
trigger: Trigger::Console,
sampler: Default::default(),
},
);
log(
"INFO",
&format!("Recording a performance report for {seconds} s (reports/)"),
);
}
}
}
// Horizon's own, on the console only: every thread's spans as a timeline (report.rs).
"horizon-trace" if rest.first() == Some(&"stop") => {
match horizon_client::spans::trace::stop() {
Some(events) => log("INFO", &format!("Trace stopped: {events} events written")),
None => log("INFO", "No trace is being written"),
}
}
"horizon-trace" => {
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
None => Some(30),
Some(Ok(s)) if (1..=report::TRACE_MAX_SECONDS).contains(&s) => Some(s),
Some(_) => None,
};
match seconds.map(report::start_trace) {
None => log(
"INFO",
&format!(
"horizon-trace [SECONDS | stop]: 1 to {} seconds, 30 by default",
report::TRACE_MAX_SECONDS
),
),
Some(Ok(path)) => log(
"INFO",
&format!(
"Writing a trace for {} s to {} (open it in ui.perfetto.dev)",
seconds.unwrap_or_default(),
path.display()
),
),
Some(Err(e)) => log("ERROR", &format!("Could not start a trace: {e}")),
Ok(report::Command::Record { .. }) => {
log("INFO", "A performance report is already being recorded")
}
}
}
"save-all" => {
File diff suppressed because it is too large. Load diff
+43 -8
View File
@@ -37,6 +37,10 @@ pub(super) fn aim_deflect(
owner_uuid: Option<[i32; 4]>,
guest: Option<u32>,
) {
// `WindCharge.deflect`: refused while its `noDeflectTicks` last.
if !p.deflectable() {
return;
}
if let Some(look) = look {
p.velocity = look;
p.needs_sync = true;
@@ -183,6 +187,27 @@ impl Game<'_> {
let dir = view_vector(self.yaw, self.pitch);
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
let p = &mut self.creatures.projectiles[i];
// `WindCharge.deflect` refuses while its `noDeflectTicks` last: the
// blow goes on as an attack that hurts nothing.
if p.deflectable() {
Self::turn_by_player(p, dir, guest, owner_uuid);
}
self.reset_attack_strength();
self.swing_arm();
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
}
/// `AIM_DEFLECT` by a player's blow: along their view, theirs now.
fn turn_by_player(
p: &mut Projectile,
dir: [f64; 3],
guest: Option<u32>,
owner_uuid: Option<[i32; 4]>,
) {
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
@@ -196,14 +221,6 @@ impl Game<'_> {
// at once, not at the fireball's next update ten ticks later.
p.acceleration = 0.1;
p.needs_sync = true;
self.reset_attack_strength();
self.swing_arm();
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
}
}
@@ -245,6 +262,7 @@ mod tests {
assert_eq!(b.max_position().y, 66.0);
}
/// A wind charge past its first five ticks (`noDeflectTicks` run out).
fn wind_charge() -> Projectile {
let mut p = Projectile::new(
ProjectileKind::WindCharge,
@@ -252,9 +270,26 @@ mod tests {
Some(Who::Mob(7)),
);
p.velocity = Vec3::new(0.0, 1.5, 0.0);
p.no_deflect_ticks = 0;
p
}
#[test]
fn a_fresh_wind_charge_is_not_turned() {
let mut p = wind_charge();
p.no_deflect_ticks = 5;
aim_deflect(
&mut p,
Some(Vec3::new(0.0, -1.0, 0.0)),
Some(Who::Player),
Some([4; 4]),
Some(3),
);
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
assert_eq!(p.owner, Some(Who::Mob(7)));
assert!(!p.needs_sync);
}
#[test]
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
let mut p = wind_charge();
+1 -1
View File
@@ -937,7 +937,7 @@ impl Game<'_> {
Some(ClickEvent::CopyToClipboard(value)) => Click::CopyToClipboard(value),
Some(ClickEvent::ShowDialog(dialog)) => Click::ShowDialog(dialog),
Some(ClickEvent::Custom { id, payload }) => Click::Custom { id, payload },
Some(ClickEvent::OpenFile(_)) | None => return Ok(()),
Some(ClickEvent::OpenFile(_) | ClickEvent::Profiler(_)) | None => return Ok(()),
};
let after = open.dialog.after_action;
return self.run_dialog_action(Some(click), after);
@@ -319,7 +319,9 @@ impl Game<'_> {
);
let state = self.world.block(origin.x, origin.y, origin.z);
let mut used = Vec::new();
if let Some(op) = case.get("special_attack") {
if let Some(op) = case.get("shield_hit") {
used = self.fixture_shield_hit(op)?;
} else if let Some(op) = case.get("special_attack") {
used = self.fixture_special_attack(op)?;
} else if let Some(op) = case.get("wind_burst") {
let center = [
@@ -497,6 +499,68 @@ impl Game<'_> {
Ok(out)
}
/// Actual player combat and blocking-item side effects, without a window or connection.
fn fixture_shield_hit(&mut self, op: &Value) -> Result<Vec<String>> {
let stack = parse_stack(op["item"].as_str().ok_or("shield_hit item")?)?
.ok_or("shield_hit requires an item")?;
let item = stack.item.clone();
self.creative = op["creative"].as_bool().unwrap_or(false);
self.mode = if self.creative {
GameMode::Creative
} else {
GameMode::Survival
};
self.player = Some(PlayerMotion::new(
self.motion,
horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0),
)?);
self.yaw = 0.0;
self.armor = Default::default();
self.offhand = Some(stack);
self.using = Some((super::shield::OFFHAND_SLOT, 100));
self.combat = Default::default();
let before = self.stats.get("used", &item);
let from = horizon_core::geometry::Vec3::new(
0.0,
0.0,
if op["behind"].as_bool().unwrap_or(false) {
-1.0
} else {
1.0
},
);
let mut arrow = horizon_client::projectiles::Projectile::new(
ProjectileKind::Arrow {
base_damage: op["damage"].as_f64().unwrap_or(8.0),
critical: false,
effect: None,
},
from,
None,
);
arrow.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 1.0);
arrow.arrow.as_mut().unwrap().pierce = u8::from(op["pierce"].as_bool().unwrap_or(false));
self.projectile_hit(
&arrow,
horizon_client::projectiles::Hit::Entity {
who: horizon_client::mobs::Who::Player,
at: horizon_core::geometry::Vec3::ZERO,
},
)?;
let out = vec![format!(
"shield health={} used={} stack={}",
num(f64::from(self.player.as_ref().unwrap().health.health())),
self.stats.get("used", &item) - before,
self.offhand
.as_ref()
.map_or_else(|| "-".to_owned(), |s| format!("{}:{}", s.count, s.damage))
)];
self.player = None;
self.offhand = None;
self.using = None;
Ok(out)
}
/// A player holding the case's item right-clicks the block at `pos`
/// (`use_item_as_player`'s block steps): the statistics, the result
/// and the held stack, as `VanillaDispenserOracle.use` prints them.
+2 -1
View File
@@ -909,7 +909,7 @@ impl Game<'_> {
};
let (Some(slot), true) = (
info.equip_slot.as_deref().and_then(Slot::from_name),
info.equip_dispensable,
horizon_client::item_sync::dispensable(stack, info.equip_dispensable),
) else {
return false;
};
@@ -1240,6 +1240,7 @@ impl Game<'_> {
_ => ProjectileKind::Snowball,
};
let mut shot = Projectile::new(kind, at, None);
shot.set_egg_item(&one);
// ArrowItem/SpectralArrowItem.asProjectile: dispensed arrows
// may be picked up.
if let Some(a) = shot.arrow.as_mut() {
+3 -4
View File
@@ -381,7 +381,7 @@ pub(super) struct Dragon {
pub id: u32,
pub position: Vec3,
pub previous: Vec3,
velocity: Vec3,
pub(super) velocity: Vec3,
pub yaw: f32,
pub previous_yaw: f32,
yaw_accel: f32,
@@ -869,14 +869,14 @@ impl Dragon {
/// `ServerExplosion.hurtEntities` knockback on the dragon itself:
/// `pushFromExplosion` adds `(1 - dist) * exposure` along the blast to
/// its eye to the motion (`Entity.push`); the parts are not pushed.
fn whole_box(&self) -> Aabb {
pub(super) fn whole_box(&self) -> Aabb {
let p = self.position;
Aabb::new(
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
)
}
fn explosion_knockback(
pub(super) fn explosion_knockback(
&self,
center: [f64; 3],
radius: f32,
@@ -5831,7 +5831,6 @@ mod dragon_fireball_spawn_tests {
);
}
}
#[cfg(test)]
#[path = "thorns_tests.rs"]
mod thorns_tests;
+1
View File
@@ -2274,6 +2274,7 @@ mod set_rotation_tests {
#[test]
fn set_rotation_keeps_yaw_and_clamps_pitch_on_kinds_that_had_none() {
let mut orb = ExperienceOrb {
fall_distance: 0.0,
join: Default::default(),
no_gravity: false,
id: 0,
Loaded 100 of 489 files, more files were not shown because too many files have changed in this diff. Show more