Compare commits

..
27 Commits
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 3f3f294c52 Merge origin/main: game-bugs and launcher 0.1.2
Villager POI search: main's per-search state classification replaces this
branch's state table (same sections skipped, same positions in order).
status keeps main's mob and block-entity counts and this branch's pending
uploads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 18:41:36 +02:00
Ludovic MarieandClaude Opus 5.5 b6ee20d0e8 Graphics benchmark results: baseline against 9006566b on the RTX 4080 and lavapipe
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:50:18 +02:00
Ludovic MarieandClaude Sonnet 5.5 85851909d6 launcher CI: the game's Linux build failing its checks is a warning, not a launcher failure
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 17:38:41 +02:00
Ludovic MarieandClaude Sonnet 5.5 929fa4ec5a Launcher 0.1.2: Discord link, auto-install on by default, menu style setting, smoke tests
- links.json on main gives the Discord and sponsor links (built-in fallback offline,
  empty hides the button); 'links' command and MCP tool.
- Install new versions automatically is on unless explicitly turned off (new settings
  key, so 0.1.1's written default does not count as a choice).
- Settings: Horizon / Classic menu style, the horizon.txt menuStyle line the game reads;
  'menu-style' command and MCP tool.
- launcher workflow: install latest into a temp home, check imports (Windows, Linux client)
  and that the client starts, on all three platforms; also runs on launcher-* branches.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 17:27:31 +02:00
Ludovic MarieandClaude Opus 5.5 9006566bbd Graphics docs and screenshots for the Video section of the Horizon options
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:21:40 +02:00
Ludovic MarieandClaude Opus 5.5 73ca04b10d Merge origin/main: Horizon menus; graphics settings in the Video section
The Horizon options shell's Video section now holds the graphics settings:
a Simple / Advanced switch (remembered in horizon-graphics.txt), then the
level's rows, each with its frame-time cost and one-line description
(ui_theme::described_row). Horizon settings draw as switches, sliders or
value buttons like vanilla options. The Classic style keeps the Video
Settings buttons to the same screens. Main's menu blur and the world's
resolution-scale pass are separate (post.wgsl, world_post.wgsl).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:17:43 +02:00
Ludovic MarieandClaude Opus 5.5 9dc0803a24 Graphics benchmark: games run with HORIZON_NO_AUDIO=1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:12:30 +02:00
Ludovic Marie 87733155b3 Merge remote-tracking branch 'origin/game-bugs' into parity-base 2026-10-13 17:11:28 +02:00
Ludovic MarieandClaude Opus 5.5 7f1e197428 Section arena in pages; graphics docs and benchmark comparison
At render distance 32 the single section arena grew past the 4 GiB buffer
limit and the renderer panicked. Sections now live in pages of at most
256 MiB (or the device's buffer limit); draws keep their order and bind each
page once per run of consecutive draws in it, so translucent sections stay
back to front. docs/graphics.md describes the settings, the benchmark and
what changed; graphics_bench.py --compare prints a before/after table and
keeps each game's log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:10:07 +02:00
Ludovic MarieandClaude Opus 5.5 9bc1425177 Villagers: find POIs without reading every block of a 48-block sphere
AcquirePoi scanned every block within 48 of the villager (about 460,000),
naming each with string matches, every 20 to 40 ticks for each villager missing
a home, job site or meeting point. Villages gain such villagers over time
(babies, more villagers than beds), so the game thread's work grew with play
time: in a village, 18% of samples at the start and 30-42% eight minutes on,
frame rate dropping and staying low.

Block states are classified once per search, and 16^3 sections whose palette
holds no POI block are skipped whole (PoiManager only visits sections with
records). The same positions are visited in the same order, so the result,
ties in the distance sort included, is unchanged. Same session after: 18%
throughout, frames at the vsync cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:58:33 +02:00
Ludovic MarieandClaude Opus 5.5 ad9cd6c67e Resolution scale, FXAA and mipmap levels
With a resolution scale other than 100% or FXAA on, the world renders into
its own target at the scaled size and one full-screen pass copies it
(bilinear) or anti-aliases it (FXAA 3.11) onto the screen; the hand, screen
effects and HUD stay at full resolution. Off, the world renders straight to
the screen as before. Vanilla's Mipmap Levels option now limits the mip
levels block textures sample (the default 4 is the full chain, as before).
Both are in the Advanced screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:44:20 +02:00
Ludovic Marie ef4fc67f1b Merge remote-tracking branch 'origin/menus' into parity-base 2026-10-13 16:39:24 +02:00
Ludovic MarieandClaude Opus 5.5 e0283c70db Graphics settings, Simple and Advanced, with a performance overlay
Video Settings opens two Horizon screens (graphics_ui): Simple has the
Performance / Balanced / Quality / Ultra presets with render distance, FOV,
max framerate, VSync and GUI scale; Advanced groups every knob under
Rendering, Quality, Display, Performance and Overlay. Each row shows a
one-line description and its frame-time cost, and applies live. Layout lives
in graphics_ui, every colour, size and widget call in graphics_style, so a
menu theme can restyle them in one place.

Horizon-only knobs (horizon-graphics.txt, never options.txt): preset, cave
culling, chunk updates and upload budget per frame (nearest sections first),
sky, fog, weather, animated textures, present mode, frame pacing, worker
threads and the performance overlay (FPS, frame/CPU/GPU times and a
frame-time graph). Defaults keep vanilla's rendering; vanilla options keep
their options.txt keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:37:31 +02:00
Ludovic Marie c2c7614a2f Merge remote-tracking branch 'origin/feel' into parity-base 2026-10-13 16:28:56 +02:00
Ludovic MarieandClaude Opus 5.5 ad701d73d9 Fewer stalls: region writes on a writer thread, culled entities, villager POI scans skip empty sections
- Unloaded chunks and entity chunks are encoded and written by a background
  region writer, in order; loads see queued records first, and synchronous
  saves wait for the queue. Deleting entity chunks a region doesn't hold no
  longer rewrites the region file.
- Mobs render within Entity.shouldRenderAtSqrDistance (64 x bounding-box size
  x entity distance) and inside the view frustum (EntityRenderer.shouldRender);
  block entities outside the frustum are skipped, beams and structure blocks
  excepted.
- Minecarts and dropped items tick only in entity-ticking chunks, like mobs.
- Villager POI searches skip sections whose palette has no POI block.
- Streaming bookkeeping uses the Fx hash and checks a column's light
  readiness once per frame instead of once per dirty section.
- HORIZON_SPIKES=MS prints the timed spans of slow frames; frame summaries
  give the phase split of the slowest 1%.

RD 32 village flight on the 4080: 81 -> 230 fps; 1% low 5.8 -> 18.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:14:57 +02:00
Ludovic MarieandClaude Opus 5.5 1a9452d408 Memory: drop late chunks whole, bound the decoded sounds, count what stays resident
A column that finished loading or generating after the player moved out of
range was dropped, but its block entities and saved entities were added to the
world first. The entities spawned in a column that never became resident, so
nothing saved or removed them: travelling, mobs piled up (141 to 5,028 in an
8-minute flight), and when the column loaded later its entities spawned again,
so villages near the view edge doubled their villagers. Such a column is now
dropped with everything in it. Mesh results for a column unloaded meanwhile are
discarded too, rather than uploaded with nothing left to remove them.

Decoded static sounds were kept for the whole session: every sound heard, up to
1.4 GiB of samples, and a village plays hundreds. They now stay within 64 MiB,
least recently used out, and are decoded on a thread of their own instead of the
game loop (vanilla's buffers load asynchronously too). Stopping a source that
is still waiting for its sound stops it, as alSourceStop does, so a late decode
cannot start it.

status reports mobs, block entities, resident GPU sections and the sound
engine's tables, for soak tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:10:32 +02:00
Ludovic MarieandClaude Opus 5.5 66e41ed573 Satisfy clippy in the hand module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:40:09 +02:00
Ludovic MarieandClaude Opus 5.5 72542a89e2 Compare sprint-jumping with vanilla in the sprint scene
The --sprint-input scene drives the real LocalPlayer.aiStep sprint and
jump path from keyboard state. Add sprint-jump runs: sprint held or
started by double-tapping forward, jump tapped, held through landings or
pressed first, at five headings, with too little food to sprint, under a
two-block ceiling and up onto a slab. All 293,175 states match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:38:48 +02:00
Ludovic MarieandClaude Opus 5.5 45bd1a7be8 Renderer and tick performance: section arena with indirect multi-draw, dense occlusion graph, entity ticking within the simulation distance, fast chunk-map hashing
- Terrain sections live in one vertex arena (first-fit free list, grown on the
  GPU) and draw with one multi_draw_indexed_indirect per pass instead of a
  buffer bind and draw per section; per-frame scratch vectors are reused.
- The SectionOcclusionGraph search runs over dense arrays around the camera
  instead of hash sets, with per-column visibility.
- Mobs outside the entity-ticking chunks (Chebyshev distance of the
  simulation distance, DistanceManager.inEntityTickingRange) only check
  despawning, as in ServerLevel.tick; before, every loaded mob ticked.
- World chunks use the Fx hash; sky darkening is sampled once per time and
  weather instead of once per mob; creaking-heart scans compare state ids.
- Horizon-only graphics settings (horizon-graphics.txt): frame pacing without
  spinning, present mode, animated textures, worker threads.

RD 32 village flight on the 4080: 3.1 -> 81 fps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:34:48 +02:00
Ludovic MarieandClaude Opus 5.5 0734daa911 Swing the hand and raise it as vanilla does: SwingState, equip heights, attack clicks
The local player's clicks now go through a port of Minecraft.startAttack,
continueAttack and handleKeybinds' attack part with MultiPlayerGameMode's
block breaking (first_person.rs): attacking the air swings (and sets the
miss time), creative breaking swings every tick through its destroy delay,
survival mining keeps swinging after a block breaks, swords cannot break
blocks in creative, and attacking an entity in creative sets the delay.
The swing is LivingEntity.SwingState (restart rules, durations under haste
and mining fatigue, forward wrap on restart); the equip height follows
FirstPersonHandsAndItems with the item swap ticker, and placing or
starting to use an item drops the hand (itemUsed). Mining fatigue scales
by Math.pow(0.3, amplifier + 1) as in 26.3.

test_first_person_parity drives the real startAttack, continueAttack,
startUseItem, MultiPlayerGameMode breaking, SwingState, the tickers and
FirstPersonHandsAndItems over 4,192 scripted click sequences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:33:29 +02:00
Ludovic MarieandClaude Opus 5.5 d9351ec054 Menus: Horizon-style options screen, pause polish, screenshots
Options open as the launcher's Settings page: a sidebar with the sliding
selection (Video, Audio, Controls, Accessibility, Language, Chat, Interface)
and rows with a switch, slider or value button per option
(options_horizon.rs; new sections or rows slot into SECTIONS). Key binds,
the language list and resource packs open vanilla's pages. Interface holds
Menu style (Horizon / Classic). The pause menu hides the in-game HUD over the
blurred world. Scripted and frame-limited runs default to Classic, so the
harnesses see vanilla's screens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:33:15 +02:00
Ludovic MarieandClaude Opus 5.5 eb18bb42af Menus: Horizon style for the title, worlds, create, multiplayer and pause screens
One theme for the launcher and the game: crates/horizon-theme holds the
palette, type scale, spacing, radii, control sizes, motion and the bundled
fonts; launcher/src/theme.rs and the game's ui_theme both read it. ui_theme
ports the launcher's components (button with hover layer and press state,
sidebar list with the sliding selection, segmented control, switch, rows)
and draws Geist and Instrument Serif through the HUD atlas.

The title is laid out like the launcher (sidebar, page header, rows) over a
live backdrop: Horizon generates an ice-spikes landscape for a fixed seed,
orbits a camera over it with falling snow, then blurs and cold-grades the
frame (post.rs). The launcher's opening (wordmark, then the interface) plays
once per session and a click or key skips it. Menu style Horizon / Classic
lives in horizon.txt, never options.txt; Classic keeps vanilla's drawing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:19:14 +02:00
Ludovic MarieandClaude Opus 5.5 f2c7a2d359 Draw the first-person hand with its own projection at hudFov
GameRenderer.render3dHud sets up the hand's projection at Camera's hudFov
(70, changed only by death and fluids), never the FOV option or the
sprint, speed and bow modifier. The hand pass now has its own globals
with that projection (a port of JOML setPerspective, as Projection does).
The camera harness compares the world and hand projection matrices at FOV
options 30, 70, 110 and others with vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:07:39 +02:00
Ludovic MarieandClaude Opus 5.5 c982fadaf5 Graphics benchmark: scripted flights over four scenes at render distances 8/16/32, with CPU and GPU frame times
horizon-ctl gains 'fly' (a constant-speed camera path that replies with the
frame summary); frame summaries add CPU time per frame (waits for the GPU,
display and limiter excluded), per-phase averages and GPU time from timestamp
queries. --offscreen renders into a texture on the discrete GPU so the 4080
can be measured under the headless compositor. Baselines: lavapipe on vscreen
and the RTX 4080 offscreen, seed 1234.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:04:23 +02:00
Ludovic MarieandClaude Opus 5.5 1a10d4efe8 Windows: launcher without VCRUNTIME140.dll, a clear message without Vulkan, import checks in CI
The Windows launcher was built without crt-static and imported VCRUNTIME140.dll,
which only the Visual C++ redistributable installs: players without it got a
missing-DLL dialog before anything started. The game itself links only system
DLLs (vulkan-1.dll is loaded at run time).

- launcher.yml links the C runtime on Windows.
- tools/check_imports.py reads PE import and delay-import tables (and ELF
  loader, needed libraries and run paths); release.yml and launcher.yml fail
  when a Windows binary imports a non-system DLL.
- release.yml builds Linux on Ubuntu 22.04: the NixOS-made Linux release asked
  for a /nix/store loader, so it ran nowhere but NixOS.
- No adapter: the game says the Vulkan driver is missing and what to install,
  and on Windows keeps the console open until Enter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:02:15 +02:00
Ludovic MarieandClaude Opus 5.5 59fd98dea4 Sound: play under the launcher's own stream name, open any sample format, say what opened
PipeWire remembers mute per application name. A test run's stream, muted once
in the mixer, left "PipeWire ALSA [horizon-client]" muted for every later game,
the release played from the launcher included: no sound at all, with the device,
the stream and the mixer all working.

- The launcher starts the game as "Horizon" (media.role Game), its own entry.
- The game opens no output on a vscreen, where agents run it (HORIZON_AUDIO=1
  forces it, for a null sink), so nobody needs to mute it again.
- Any device sample format is accepted (the mixer converts from f32): a plain
  ALSA default reporting i16 used to fail.
- Other output devices are tried when the default does not open, and the log
  names the output opened or every reason none did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:02:15 +02:00
Ludovic MarieandClaude Opus 5.5 27a32cc30d README: parity is what the harnesses check, until the final audit
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:17:34 +02:00
105 changed files with 12768 additions and 736 deletions

No files matched your search

+42 -1
View File
@@ -6,7 +6,7 @@ name: launcher
on:
workflow_dispatch:
push:
branches: [launcher]
branches: [launcher, 'launcher-*']
tags: ['launcher-v*']
permissions:
@@ -27,7 +27,12 @@ jobs:
- os: windows-latest
asset: horizon-launcher-windows-x64.zip
binary: horizon-launcher.exe
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
# which only the Visual C++ redistributable installs.
rustflags: -C target-feature=+crt-static
runs-on: ${{ matrix.os }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
defaults:
run:
working-directory: launcher
@@ -48,6 +53,42 @@ jobs:
run: grep -qx "version = \"${GITHUB_REF_NAME#launcher-v}\"" Cargo.toml
- run: cargo test --release --locked --lib
- run: cargo build --release --locked
- name: Only system libraries
if: runner.os == 'Windows'
run: python ../tools/check_imports.py target/release/${{ matrix.binary }}
- name: Smoke test
env:
HORIZON_LAUNCHER_NO_SELF_UPDATE: '1'
run: |
set -euo pipefail
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
launcher="$PWD/target/release/${{ matrix.binary }}"
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
"$launcher" --version
retry "$launcher" versions
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
"$launcher" menu-style classic
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
"$launcher" menu-style horizon
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
client="${clients[0]}"
echo "installed client: $client"
# The game's Linux build is the game's to keep portable (release.yml checks it): a
# release linked to a Nix store is a warning here, not a broken launcher.
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
if [[ "$RUNNER_OS" == "Windows" ]]; then
python ../tools/check_imports.py "$client"
python ../tools/check_imports.py "$launcher"
elif [[ "$RUNNER_OS" == "Linux" ]]; then
soft python3 ../tools/check_imports.py "$client"
fi
# The client starts: --help prints before any window or GPU is needed.
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
soft help
- name: Package
run: |
mkdir -p dist
+26 -2
View File
@@ -1,7 +1,9 @@
name: release builds
# Started by hand: gh workflow run release.yml --ref <branch>
# Builds the Windows x64 client and uploads the playable folder as an artifact.
# Linux and macOS builds are made on the development machines.
# Builds the Windows x64 and Linux x86_64 clients and uploads the playable folders as artifacts.
# macOS builds are made on the development machines. Linux is built here, on an older Ubuntu,
# so the binary uses the standard loader and runs on any recent distribution (and on NixOS
# through nix-ld): a build made on NixOS asks for a /nix/store loader no other system has.
on:
workflow_dispatch:
permissions:
@@ -18,6 +20,10 @@ jobs:
env:
RUSTFLAGS: -C target-feature=+crt-static
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
- name: Only system DLLs
# Vulkan and the rest are loaded at run time; a linked non-system DLL is a
# "DLL not found" dialog on some players' machines.
run: python tools/check_imports.py target/release/horizon-client.exe
- name: Package
shell: bash
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
@@ -25,3 +31,21 @@ jobs:
with:
name: horizon-windows-x64
path: dist/horizon-windows-x64
linux:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- name: ALSA headers
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
- name: Build the client
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
- name: Standard loader and libraries only
run: python3 tools/check_imports.py target/release/horizon-client
- name: Package
run: packaging/package.sh target/release/horizon-client dist/horizon-linux-x86_64
- uses: actions/upload-artifact@v4
with:
name: horizon-linux-x86_64
path: dist/horizon-linux-x86_64
Generated
+6
View File
@@ -681,12 +681,14 @@ dependencies = [
name = "horizon-client"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"cpal",
"glam",
"horizon-assets",
"horizon-core",
"horizon-protocol",
"horizon-theme",
"horizon-world",
"miniz_oxide 0.8.9",
"png",
@@ -713,6 +715,10 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-theme"
version = "0.1.0"
[[package]]
name = "horizon-world"
version = "0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme"]
resolver = "2"
[workspace.package]
+1 -1
View File
@@ -20,7 +20,7 @@
# Horizon
A Rust rewrite of Minecraft: Java Edition 26.3 with one-to-one vanilla parity, checked against the game itself with differential tests.
A Rust rewrite of Minecraft: Java Edition 26.3 built for one-to-one vanilla parity, checked against the game itself with differential tests.
Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS: 100% of what the harnesses compare (see [Project status](#project-status)). The native client is playable on Linux, macOS (Apple Silicon) and Windows.
+546
View File
@@ -0,0 +1,546 @@
{
"label": "after-4080",
"date": "2026-10-13",
"commit": "9006566b",
"host": "ludodesktop",
"offscreen": true,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 133,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 941908352
},
"village": {
"avg_fps": 2409.179688612205,
"cpu": {
"avg_ms": 0.41004928978864236,
"max_ms": 18.15971565246582,
"p50_ms": 0.2718150019645691,
"p95_ms": 0.7112299799919128,
"p99_ms": 1.316559076309204
},
"frames": 28910,
"gpu": {
"avg_ms": 0.09866036293994015,
"max_ms": 0.25088000297546387,
"p50_ms": 0.08806400001049042,
"p95_ms": 0.1740799993276596,
"p99_ms": 0.19968000054359436
},
"low_1pct_fps": 160.80638022914025,
"max_ms": 18.175312042236328,
"p50_ms": 0.2760099768638611,
"p95_ms": 0.7161250114440918,
"p99_ms": 1.4078609943389893,
"phases_avg_ms": {
"prepare": 0.20953580908804606,
"render": 0.12439006986512069,
"tick": 0.05299293669455945,
"update": 0.023244019639293335
},
"seconds": 11.999935138359666,
"slow_phases_avg_ms": {
"prepare": 0.4052206484151298,
"render": 0.41893499056840766,
"tick": 5.252727109792939,
"update": 0.0936474929949076
}
},
"forest": {
"avg_fps": 1099.4635681039538,
"cpu": {
"avg_ms": 0.9003613499442573,
"max_ms": 23.31729507446289,
"p50_ms": 0.6657320261001587,
"p95_ms": 0.9898859858512878,
"p99_ms": 9.592857360839844
},
"frames": 13194,
"gpu": {
"avg_ms": 0.2639814413552015,
"max_ms": 3.0013439655303955,
"p50_ms": 0.2549760043621063,
"p95_ms": 0.3553279936313629,
"p99_ms": 0.37171199917793274
},
"low_1pct_fps": 82.87026130908886,
"max_ms": 27.990703582763672,
"p50_ms": 0.6705269813537598,
"p95_ms": 1.0082690715789795,
"p99_ms": 9.64776611328125,
"phases_avg_ms": {
"prepare": 0.571024256178027,
"render": 0.12442494783992657,
"tick": 0.1706307831885292,
"update": 0.03442592136820218
},
"seconds": 12.000397632777691,
"slow_phases_avg_ms": {
"prepare": 0.7136096191225629,
"render": 0.43175565880356415,
"tick": 10.713303016893791,
"update": 0.05390534066651581
}
},
"ocean": {
"avg_fps": 687.0806476294383,
"cpu": {
"avg_ms": 1.4490217101725757,
"max_ms": 29.75178337097168,
"p50_ms": 0.9425880312919617,
"p95_ms": 1.6511069536209106,
"p99_ms": 15.603099822998047
},
"frames": 8245,
"gpu": {
"avg_ms": 0.3711021953564546,
"max_ms": 1.5022079944610596,
"p50_ms": 0.3665919899940491,
"p95_ms": 0.43827199935913086,
"p99_ms": 0.4679679870605469
},
"low_1pct_fps": 47.486143092805435,
"max_ms": 29.770263671875,
"p50_ms": 0.947784960269928,
"p95_ms": 1.6628600358963013,
"p99_ms": 15.621638298034668,
"phases_avg_ms": {
"prepare": 0.8449565766073126,
"render": 0.1554101111230668,
"tick": 0.3840128544967872,
"update": 0.06481705411812297
},
"seconds": 12.000046906352043,
"slow_phases_avg_ms": {
"prepare": 1.2885668083845851,
"render": 0.45316039797771407,
"tick": 19.216593845781073,
"update": 0.08380924088947744
}
},
"mountain": {
"avg_fps": 343.77852072094186,
"cpu": {
"avg_ms": 1.5961282111390964,
"max_ms": 22.24184799194336,
"p50_ms": 0.8254669904708862,
"p95_ms": 11.660508155822754,
"p99_ms": 14.398658752441406
},
"frames": 4113,
"gpu": {
"avg_ms": 0.3449904050323872,
"max_ms": 2.9040639400482178,
"p50_ms": 0.3338240087032318,
"p95_ms": 0.43724799156188965,
"p99_ms": 0.5171200037002563
},
"low_1pct_fps": 29.95597087331344,
"max_ms": 33.422760009765625,
"p50_ms": 0.8299460411071777,
"p95_ms": 33.33942413330078,
"p99_ms": 33.37727355957031,
"phases_avg_ms": {
"prepare": 0.7147062004496516,
"render": 0.14707977256612884,
"tick": 0.6855548198381525,
"update": 0.04897363143349227
},
"seconds": 11.96409825539589,
"slow_phases_avg_ms": {
"prepare": 0.8949875491006034,
"render": 0.6183000469491595,
"tick": 14.63794419879005,
"update": 0.8699657806594457
}
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 0,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 16462208
},
"village": {
"avg_fps": 1041.3622747953375,
"cpu": {
"avg_ms": 0.9549695556642304,
"max_ms": 26.009796142578125,
"p50_ms": 0.4810290038585663,
"p95_ms": 2.5506999492645264,
"p99_ms": 9.879941940307617
},
"frames": 12496,
"gpu": {
"avg_ms": 0.22781361336253403,
"max_ms": 1.7469439506530762,
"p50_ms": 0.18534399569034576,
"p95_ms": 0.6113280057907104,
"p99_ms": 0.809984028339386
},
"low_1pct_fps": 64.04348583959742,
"max_ms": 26.032779693603516,
"p50_ms": 0.48582199215888977,
"p95_ms": 2.5584940910339355,
"p99_ms": 9.892412185668945,
"phases_avg_ms": {
"prepare": 0.37678885421859964,
"render": 0.3450905326050295,
"tick": 0.1742891420732452,
"update": 0.0590072420405441
},
"seconds": 11.999666496902703,
"slow_phases_avg_ms": {
"prepare": 1.4649118003845214,
"render": 1.1414484503269196,
"tick": 12.881806396484375,
"update": 0.11011978423595428
}
},
"forest": {
"avg_fps": 316.2068527731055,
"cpu": {
"avg_ms": 3.154168446387541,
"max_ms": 26.147607803344727,
"p50_ms": 2.2156569957733154,
"p95_ms": 12.254712104797363,
"p99_ms": 17.7707462310791
},
"frames": 3795,
"gpu": {
"avg_ms": 0.7502970771905776,
"max_ms": 2.599936008453369,
"p50_ms": 0.7239680290222168,
"p95_ms": 1.3158400058746338,
"p99_ms": 1.3772799968719482
},
"low_1pct_fps": 49.59272936233826,
"max_ms": 26.164094924926758,
"p50_ms": 2.222438097000122,
"p95_ms": 12.270298957824707,
"p99_ms": 17.786956787109375,
"phases_avg_ms": {
"prepare": 1.5666706890613822,
"render": 0.7056146899387927,
"tick": 0.7469899302897249,
"update": 0.13521884845002838
},
"seconds": 12.001637430429458,
"slow_phases_avg_ms": {
"prepare": 1.9408628783727948,
"render": 1.194917630208166,
"tick": 16.762235842253034,
"update": 0.24904731876756014
}
},
"ocean": {
"avg_fps": 385.09978163469907,
"cpu": {
"avg_ms": 2.589322079316407,
"max_ms": 18.984909057617188,
"p50_ms": 2.0551369190216064,
"p95_ms": 8.740593910217285,
"p99_ms": 11.559625625610352
},
"frames": 4622,
"gpu": {
"avg_ms": 0.6778284030938654,
"max_ms": 2.398207902908325,
"p50_ms": 0.7833600044250488,
"p95_ms": 0.9431040287017822,
"p99_ms": 1.511423945426941
},
"low_1pct_fps": 71.97424221133898,
"max_ms": 19.0070858001709,
"p50_ms": 2.061105966567993,
"p95_ms": 8.753417015075684,
"p99_ms": 11.577004432678223,
"phases_avg_ms": {
"prepare": 1.3568510900633204,
"render": 0.7359144338432817,
"tick": 0.41578289517198963,
"update": 0.0810434000275639
},
"seconds": 12.002084188103677,
"slow_phases_avg_ms": {
"prepare": 1.6523635336693296,
"render": 1.2426407299143203,
"tick": 10.818658037388579,
"update": 0.16372418847489864
}
},
"mountain": {
"avg_fps": 219.70895893367268,
"cpu": {
"avg_ms": 3.098228604710274,
"max_ms": 23.518693923950195,
"p50_ms": 2.0422279834747314,
"p95_ms": 10.511551856994629,
"p99_ms": 16.436803817749023
},
"frames": 2634,
"gpu": {
"avg_ms": 0.9377313036310374,
"max_ms": 9.588735580444336,
"p50_ms": 0.8488960266113281,
"p95_ms": 0.9728000164031982,
"p99_ms": 3.4201600551605225
},
"low_1pct_fps": 29.957383952355947,
"max_ms": 33.38852310180664,
"p50_ms": 2.0481808185577393,
"p95_ms": 33.341556549072266,
"p99_ms": 33.377445220947266,
"phases_avg_ms": {
"prepare": 1.3405477057013806,
"render": 0.8202416233113071,
"tick": 0.8190081106798682,
"update": 0.11872799601335143
},
"seconds": 11.988587141752243,
"slow_phases_avg_ms": {
"prepare": 1.648652986243919,
"render": 1.7585702317732352,
"tick": 14.372832368921351,
"update": 1.0560209235107456
}
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 212,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 255897088
},
"village": {
"avg_fps": 48.3055081533635,
"cpu": {
"avg_ms": 9.376506657416572,
"max_ms": 861.2249145507812,
"p50_ms": 3.0502548217773438,
"p95_ms": 32.94725036621094,
"p99_ms": 74.04153442382812
},
"frames": 1271,
"gpu": {
"avg_ms": 1.7700931664342303,
"max_ms": 20.69094467163086,
"p50_ms": 1.2410880327224731,
"p95_ms": 7.598080158233643,
"p99_ms": 15.925248146057129
},
"low_1pct_fps": 0.761712600806078,
"max_ms": 14330.3583984375,
"p50_ms": 3.071429967880249,
"p95_ms": 33.099002838134766,
"p99_ms": 75.7547378540039,
"phases_avg_ms": {
"prepare": 1.518610580008767,
"render": 1.6828305973582713,
"tick": 5.15484752775804,
"update": 1.020680267595335
},
"seconds": 26.31169919514656,
"slow_phases_avg_ms": {
"prepare": 0.9654910931220422,
"render": 3.5708756630237284,
"tick": 196.6683132465069,
"update": 14.971596607795128
}
},
"forest": {
"avg_fps": 27.53318156189366,
"cpu": {
"avg_ms": 40.13187835841161,
"max_ms": 1166.3994140625,
"p50_ms": 6.5535569190979,
"p95_ms": 63.877315521240234,
"p99_ms": 1108.11865234375
},
"frames": 271,
"gpu": {
"avg_ms": 2.297342118961785,
"max_ms": 22.554624557495117,
"p50_ms": 0.7065600156784058,
"p95_ms": 7.245823860168457,
"p99_ms": 9.055232048034668
},
"low_1pct_fps": 0.8848759980013957,
"max_ms": 1166.4168701171875,
"p50_ms": 6.675865173339844,
"p95_ms": 59.150550842285156,
"p99_ms": 1108.134521484375,
"phases_avg_ms": {
"prepare": 2.83869483858017,
"render": 2.5961379855321343,
"tick": 33.04425997872419,
"update": 1.6535338119387186
},
"seconds": 9.842669267654419,
"slow_phases_avg_ms": {
"prepare": 2.766425689061483,
"render": 4.919464270273845,
"tick": 1112.3431803385417,
"update": 10.057182629903158
}
},
"ocean": {
"avg_fps": 67.01857611699128,
"cpu": {
"avg_ms": 14.669945189483148,
"max_ms": 113.66764068603516,
"p50_ms": 4.473541736602783,
"p95_ms": 51.374263763427734,
"p99_ms": 94.75230407714844
},
"frames": 802,
"gpu": {
"avg_ms": 2.9074973299021734,
"max_ms": 22.526975631713867,
"p50_ms": 1.8206720352172852,
"p95_ms": 8.869888305664062,
"p99_ms": 11.596799850463867
},
"low_1pct_fps": 10.111656570136391,
"max_ms": 113.68443298339844,
"p50_ms": 4.488469123840332,
"p95_ms": 51.39033508300781,
"p99_ms": 94.76870727539062,
"phases_avg_ms": {
"prepare": 2.4291724538862556,
"render": 2.242118674918006,
"tick": 9.334139847333992,
"update": 0.6651962902814967
},
"seconds": 11.966831384181976,
"slow_phases_avg_ms": {
"prepare": 2.7512698968251548,
"render": 3.2687110900878906,
"tick": 90.47323608398438,
"update": 2.3865103986528187
}
},
"mountain": {
"avg_fps": 28.70327462003782,
"cpu": {
"avg_ms": 16.062305691622306,
"max_ms": 67.72319793701172,
"p50_ms": 10.215509414672852,
"p95_ms": 45.747154235839844,
"p99_ms": 63.64399337768555
},
"frames": 345,
"gpu": {
"avg_ms": 8.470281650363535,
"max_ms": 21.519359588623047,
"p50_ms": 8.845312118530273,
"p95_ms": 15.868927955627441,
"p99_ms": 17.557504653930664
},
"low_1pct_fps": 15.182693348952107,
"max_ms": 67.74352264404297,
"p50_ms": 33.36533737182617,
"p95_ms": 46.64082336425781,
"p99_ms": 63.65925979614258,
"phases_avg_ms": {
"prepare": 0.8729904708654984,
"render": 3.4296869785889337,
"tick": 8.941314909811826,
"update": 2.8190046198558116
},
"seconds": 12.01953451538086,
"slow_phases_avg_ms": {
"prepare": 0.8918330073356628,
"render": 4.432150959968567,
"tick": 50.667978286743164,
"update": 9.854933142662048
}
}
}
},
"load_before": [
1.86328125,
2.111328125,
3.9072265625
],
"load_after": [
8.4560546875,
5.08544921875,
4.68798828125
]
}
+546
View File
@@ -0,0 +1,546 @@
{
"label": "after-lavapipe",
"date": "2026-10-13",
"commit": "9006566b",
"host": "ludodesktop",
"offscreen": false,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 156,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 1278284288
},
"village": {
"avg_fps": 58.3354572261441,
"cpu": {
"avg_ms": 11.363309368576322,
"max_ms": 36.24496841430664,
"p50_ms": 13.213258743286133,
"p95_ms": 19.535802841186523,
"p99_ms": 24.83148956298828
},
"frames": 700,
"gpu": {
"avg_ms": 13.518205827985492,
"max_ms": 34.97639083862305,
"p50_ms": 13.195829391479492,
"p95_ms": 18.039182662963867,
"p99_ms": 23.378856658935547
},
"low_1pct_fps": 31.312386939419746,
"max_ms": 36.36259078979492,
"p50_ms": 16.02706527709961,
"p95_ms": 21.249738693237305,
"p99_ms": 29.130090713500977,
"phases_avg_ms": {
"prepare": 0.2306461141364915,
"render": 9.720663217795746,
"tick": 1.3112367039202113,
"update": 0.10169148096282567
},
"seconds": 11.99956310081482,
"slow_phases_avg_ms": {
"prepare": 0.35282185673713684,
"render": 25.05345916748047,
"tick": 3.1022580585309436,
"update": 0.38026727948869976
}
},
"forest": {
"avg_fps": 41.90001789962103,
"cpu": {
"avg_ms": 18.045572770611635,
"max_ms": 36.546627044677734,
"p50_ms": 20.543725967407227,
"p95_ms": 30.5231990814209,
"p99_ms": 34.017364501953125
},
"frames": 504,
"gpu": {
"avg_ms": 21.35815803588383,
"max_ms": 36.23066329956055,
"p50_ms": 19.825437545776367,
"p95_ms": 29.274858474731445,
"p99_ms": 33.32203674316406
},
"low_1pct_fps": 20.022920779459263,
"max_ms": 99.08029174804688,
"p50_ms": 22.576229095458984,
"p95_ms": 32.772239685058594,
"p99_ms": 36.67753982543945,
"phases_avg_ms": {
"prepare": 0.22351377572686898,
"render": 16.234788014183916,
"tick": 1.4387834655649459,
"update": 0.14943464463281017
},
"seconds": 12.028634479522704,
"slow_phases_avg_ms": {
"prepare": 0.2705736731489499,
"render": 32.015567461649574,
"tick": 2.741411516830946,
"update": 0.3076988315830628
}
},
"ocean": {
"avg_fps": 60.413862302111134,
"cpu": {
"avg_ms": 10.874024665643429,
"max_ms": 29.430055618286133,
"p50_ms": 12.944002151489258,
"p95_ms": 17.997587203979492,
"p99_ms": 25.568126678466797
},
"frames": 725,
"gpu": {
"avg_ms": 12.736994123787715,
"max_ms": 28.143625259399414,
"p50_ms": 12.234904289245605,
"p95_ms": 15.61594295501709,
"p99_ms": 24.476089477539062
},
"low_1pct_fps": 34.82401357005238,
"max_ms": 33.36631393432617,
"p50_ms": 16.01299476623535,
"p95_ms": 18.74709701538086,
"p99_ms": 26.6020565032959,
"phases_avg_ms": {
"prepare": 0.4042872669162421,
"render": 9.401332649341946,
"tick": 0.9126873913477977,
"update": 0.15663292961387798
},
"seconds": 12.000557030677795,
"slow_phases_avg_ms": {
"prepare": 0.3962315060198307,
"render": 26.506223678588867,
"tick": 0.0020812499860767275,
"update": 0.5454612672328949
}
},
"mountain": {
"avg_fps": 37.77761368324693,
"cpu": {
"avg_ms": 11.491443304826092,
"max_ms": 34.474151611328125,
"p50_ms": 13.871685981750488,
"p95_ms": 20.351163864135742,
"p99_ms": 23.77434539794922
},
"frames": 453,
"gpu": {
"avg_ms": 14.109076927302953,
"max_ms": 35.115882873535156,
"p50_ms": 13.555350303649902,
"p95_ms": 18.336809158325195,
"p99_ms": 30.865131378173828
},
"low_1pct_fps": 27.343844611059406,
"max_ms": 39.0676155090332,
"p50_ms": 33.33863830566406,
"p95_ms": 33.36062240600586,
"p99_ms": 34.297752380371094,
"phases_avg_ms": {
"prepare": 0.22927819613457778,
"render": 9.350584785242217,
"tick": 1.7383085893987211,
"update": 0.17420387428015405
},
"seconds": 11.991228556632995,
"slow_phases_avg_ms": {
"prepare": 0.2908208012580872,
"render": 25.648976135253907,
"tick": 1.9043863375205548,
"update": 0.7624145835638046
}
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 0,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 36807552
},
"village": {
"avg_fps": 20.701493975921895,
"cpu": {
"avg_ms": 31.808356682459515,
"max_ms": 74.47195434570312,
"p50_ms": 41.86561584472656,
"p95_ms": 56.75548553466797,
"p99_ms": 73.00084686279297
},
"frames": 249,
"gpu": {
"avg_ms": 40.500388481140135,
"max_ms": 67.22461700439453,
"p50_ms": 40.38219451904297,
"p95_ms": 50.01243209838867,
"p99_ms": 62.00723648071289
},
"low_1pct_fps": 8.83914293384798,
"max_ms": 133.2972869873047,
"p50_ms": 47.37559509277344,
"p95_ms": 61.3382682800293,
"p99_ms": 74.61792755126953,
"phases_avg_ms": {
"prepare": 0.30830518620081215,
"render": 25.584145257750667,
"tick": 5.116575629957185,
"update": 0.8002672145165592
},
"seconds": 12.028117404937744,
"slow_phases_avg_ms": {
"prepare": 0.3641819953918457,
"render": 44.77838750680288,
"tick": 6.285618623097737,
"update": 22.415442685286205
}
},
"forest": {
"avg_fps": 18.118065350089793,
"cpu": {
"avg_ms": 36.827518428137545,
"max_ms": 85.7597427368164,
"p50_ms": 49.536197662353516,
"p95_ms": 62.31198501586914,
"p99_ms": 66.591064453125
},
"frames": 218,
"gpu": {
"avg_ms": 47.82228247616269,
"max_ms": 76.9173355102539,
"p50_ms": 46.98957824707031,
"p95_ms": 57.49673843383789,
"p99_ms": 69.92066192626953
},
"low_1pct_fps": 11.528998473378772,
"max_ms": 93.62928009033203,
"p50_ms": 53.71598434448242,
"p95_ms": 66.69908905029297,
"p99_ms": 80.71000671386719,
"phases_avg_ms": {
"prepare": 0.29564340877423595,
"render": 30.678313442326466,
"tick": 4.647872053591402,
"update": 1.206680115073099
},
"seconds": 12.032189739227295,
"slow_phases_avg_ms": {
"prepare": 0.37793900569279987,
"render": 66.15962092081706,
"tick": 5.583541075388591,
"update": 1.5741689602533977
}
},
"ocean": {
"avg_fps": 8.272097120557788,
"cpu": {
"avg_ms": 96.70907358733974,
"max_ms": 208.84027099609375,
"p50_ms": 89.04510498046875,
"p95_ms": 169.3397979736328,
"p99_ms": 208.84027099609375
},
"frames": 98,
"gpu": {
"avg_ms": 57.298700176939676,
"max_ms": 66.62165832519531,
"p50_ms": 57.89077377319336,
"p95_ms": 65.73504638671875,
"p99_ms": 66.62165832519531
},
"low_1pct_fps": 4.785729156618201,
"max_ms": 208.95457458496094,
"p50_ms": 140.0601348876953,
"p95_ms": 178.57022094726562,
"p99_ms": 208.95457458496094,
"phases_avg_ms": {
"prepare": 3.3946731230434106,
"render": 34.994675042982,
"tick": 57.14290783599932,
"update": 1.177816742536973
},
"seconds": 11.847056262969971,
"slow_phases_avg_ms": {
"prepare": 7.7776899337768555,
"render": 70.36097717285156,
"tick": 128.46734619140625,
"update": 2.2351090908050537
}
},
"mountain": {
"avg_fps": 11.65883600782651,
"cpu": {
"avg_ms": 56.6912090659987,
"max_ms": 107.11215209960938,
"p50_ms": 77.04230499267578,
"p95_ms": 97.60908508300781,
"p99_ms": 104.08527374267578
},
"frames": 141,
"gpu": {
"avg_ms": 70.81221792731486,
"max_ms": 96.33728790283203,
"p50_ms": 69.16046905517578,
"p95_ms": 86.61634063720703,
"p99_ms": 92.56858825683594
},
"low_1pct_fps": 4.93309783391495,
"max_ms": 216.64273071289062,
"p50_ms": 81.59197235107422,
"p95_ms": 104.20377349853516,
"p99_ms": 188.7820281982422,
"phases_avg_ms": {
"prepare": 0.31791428133105554,
"render": 45.35119773817401,
"tick": 9.391353438086544,
"update": 1.6317692605104852
},
"seconds": 12.093831657409668,
"slow_phases_avg_ms": {
"prepare": 0.348672017455101,
"render": 86.63534545898438,
"tick": 13.065274715423584,
"update": 5.5503233671188354
}
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 119,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 222619264
},
"village": {
"avg_fps": 5.310476400246329,
"cpu": {
"avg_ms": 111.2176975607872,
"max_ms": 224.16696166992188,
"p50_ms": 76.4469985961914,
"p95_ms": 218.82273864746094,
"p99_ms": 224.16696166992188
},
"frames": 64,
"gpu": {
"avg_ms": 121.35315322875977,
"max_ms": 171.11207580566406,
"p50_ms": 125.45780944824219,
"p95_ms": 166.71322631835938,
"p99_ms": 171.11207580566406
},
"low_1pct_fps": 3.59762228435401,
"max_ms": 277.96136474609375,
"p50_ms": 192.1078338623047,
"p95_ms": 232.86318969726562,
"p99_ms": 277.96136474609375,
"phases_avg_ms": {
"prepare": 0.524940284434706,
"render": 56.89460512250662,
"tick": 38.0364211499691,
"update": 15.762759290635586
},
"seconds": 12.051649452209473,
"slow_phases_avg_ms": {
"prepare": 0.5129500031471252,
"render": 147.3928985595703,
"tick": 62.813438415527344,
"update": 13.448556900024414
}
},
"forest": {
"avg_fps": 3.899515654388116,
"cpu": {
"avg_ms": 155.46855975211935,
"max_ms": 270.7467041015625,
"p50_ms": 83.60679626464844,
"p95_ms": 268.6576843261719,
"p99_ms": 270.7467041015625
},
"frames": 47,
"gpu": {
"avg_ms": 192.77972445280656,
"max_ms": 225.69723510742188,
"p50_ms": 189.4962615966797,
"p95_ms": 217.36190795898438,
"p99_ms": 225.69723510742188
},
"low_1pct_fps": 3.3773198460417087,
"max_ms": 296.0927734375,
"p50_ms": 259.49017333984375,
"p95_ms": 283.4007568359375,
"p99_ms": 296.0927734375,
"phases_avg_ms": {
"prepare": 0.5114446409205173,
"render": 99.58217374821926,
"tick": 43.03971359577585,
"update": 12.336258025879555
},
"seconds": 12.052778900146484,
"slow_phases_avg_ms": {
"prepare": 0.5626839995384216,
"render": 208.71124267578125,
"tick": 53.47932052612305,
"update": 7.994467258453369
}
},
"ocean": {
"avg_fps": 2.9455219461811217,
"cpu": {
"avg_ms": 287.98993862376494,
"max_ms": 464.7646484375,
"p50_ms": 286.4447326660156,
"p95_ms": 444.6121520996094,
"p99_ms": 464.7646484375
},
"frames": 34,
"gpu": {
"avg_ms": 116.96576734270369,
"max_ms": 158.26394653320312,
"p50_ms": 106.45819854736328,
"p95_ms": 158.1454620361328,
"p99_ms": 158.26394653320312
},
"low_1pct_fps": 2.151113067092828,
"max_ms": 464.8756103515625,
"p50_ms": 315.1367492675781,
"p95_ms": 456.3304138183594,
"p99_ms": 464.8756103515625,
"phases_avg_ms": {
"prepare": 4.395756518139558,
"render": 69.52432030789993,
"tick": 206.13154108384077,
"update": 7.93927874284632
},
"seconds": 11.542945739746093,
"slow_phases_avg_ms": {
"prepare": 4.144052982330322,
"render": 166.14959716796875,
"tick": 285.5132751464844,
"update": 8.958683967590332
}
},
"mountain": {
"avg_fps": 3.983176689759444,
"cpu": {
"avg_ms": 171.41208145466257,
"max_ms": 413.8367919921875,
"p50_ms": 164.3318328857422,
"p95_ms": 360.7731628417969,
"p99_ms": 413.8367919921875
},
"frames": 47,
"gpu": {
"avg_ms": 166.80018520355225,
"max_ms": 304.8785400390625,
"p50_ms": 143.19728088378906,
"p95_ms": 275.3953552246094,
"p99_ms": 304.8785400390625
},
"low_1pct_fps": 2.418344167836982,
"max_ms": 413.5060729980469,
"p50_ms": 230.7030029296875,
"p95_ms": 365.4028625488281,
"p99_ms": 413.5060729980469,
"phases_avg_ms": {
"prepare": 0.496992789684458,
"render": 94.4349257743105,
"tick": 61.33542941478973,
"update": 15.14569672117842
},
"seconds": 11.799627197265625,
"slow_phases_avg_ms": {
"prepare": 0.4748440086841583,
"render": 312.3215637207031,
"tick": 86.37409973144531,
"update": 14.667168617248535
}
}
}
},
"load_before": [
8.4560546875,
5.08544921875,
4.68798828125
],
"load_after": [
10.98291015625,
7.201171875,
5.5615234375
]
}
+392
View File
@@ -0,0 +1,392 @@
{
"label": "baseline-4080",
"date": "2026-10-13",
"commit": "27a32cc3",
"host": "ludodesktop",
"offscreen": true,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 158,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 1137747200
},
"village": {
"avg_fps": 362.94072531330323,
"cpu": {
"avg_ms": 2.734311161203731,
"max_ms": 68.06377410888672,
"p50_ms": 1.4820960760116577,
"p95_ms": 7.922675609588623,
"p99_ms": 35.78989028930664
},
"frames": 4373,
"gpu": {
"avg_ms": 0.4188921034758625,
"max_ms": 2.5876479148864746,
"p50_ms": 0.4106239974498749,
"p95_ms": 0.5283839702606201,
"p99_ms": 0.5427200198173523
},
"low_1pct_fps": 21.64332698060583,
"max_ms": 68.08219146728516,
"p50_ms": 1.4894599914550781,
"p95_ms": 7.936410427093506,
"p99_ms": 36.54151153564453,
"seconds": 12.048799418210983
},
"forest": {
"avg_fps": 613.1553113846195,
"cpu": {
"avg_ms": 1.6186122214214547,
"max_ms": 216.98960876464844,
"p50_ms": 1.10076105594635,
"p95_ms": 3.0016868114471436,
"p99_ms": 10.144570350646973
},
"frames": 6746,
"gpu": {
"avg_ms": 0.395751640401594,
"max_ms": 1.99782395362854,
"p50_ms": 0.3983359932899475,
"p95_ms": 0.4833280146121979,
"p99_ms": 0.5375999808311462
},
"low_1pct_fps": 49.639170314777054,
"max_ms": 217.0211181640625,
"p50_ms": 1.109616994857788,
"p95_ms": 3.0161218643188477,
"p99_ms": 10.163111686706543,
"seconds": 11.002106439828873
},
"ocean": {
"avg_fps": 323.23791637473414,
"cpu": {
"avg_ms": 2.7230400277200597,
"max_ms": 4748.7744140625,
"p50_ms": 0.9233829975128174,
"p95_ms": 1.677724003791809,
"p99_ms": 7.7397589683532715
},
"frames": 3870,
"gpu": {
"avg_ms": 0.24116999250656182,
"max_ms": 0.954367995262146,
"p50_ms": 0.2754560112953186,
"p95_ms": 0.3389439880847931,
"p99_ms": 0.3532800078392029
},
"low_1pct_fps": 5.421154869716093,
"max_ms": 4748.80615234375,
"p50_ms": 0.9285120368003845,
"p95_ms": 1.7079449892044067,
"p99_ms": 33.33257293701172,
"seconds": 11.97260532861948
},
"mountain": {
"avg_fps": 28.19080143244723,
"cpu": {
"avg_ms": 12.358911560308298,
"max_ms": 187.3409881591797,
"p50_ms": 9.513923645019531,
"p95_ms": 25.783824920654297,
"p99_ms": 110.71585845947266
},
"frames": 338,
"gpu": {
"avg_ms": 0.46002139662497143,
"max_ms": 1.5626239776611328,
"p50_ms": 0.4546560049057007,
"p95_ms": 0.5140479803085327,
"p99_ms": 0.5959680080413818
},
"low_1pct_fps": 7.428141780247131,
"max_ms": 187.36033630371094,
"p50_ms": 33.334835052490234,
"p95_ms": 35.65903854370117,
"p99_ms": 110.73509216308594,
"seconds": 11.98972653579712
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 1,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 54284928
},
"village": {
"avg_fps": 7.569165639843409,
"cpu": {
"avg_ms": 138.25648041658624,
"max_ms": 877.6473999023438,
"p50_ms": 103.97663879394531,
"p95_ms": 318.5262145996094,
"p99_ms": 877.6473999023438
},
"frames": 86,
"gpu": {
"avg_ms": 3.585262137790059,
"max_ms": 8.415231704711914,
"p50_ms": 3.299328088760376,
"p95_ms": 7.171072006225586,
"p99_ms": 8.415231704711914
},
"low_1pct_fps": 2.44461512120682,
"max_ms": 409.0623474121094,
"p50_ms": 104.01687622070312,
"p95_ms": 318.5569763183594,
"p99_ms": 409.0623474121094,
"seconds": 11.361886381149292
},
"forest": {
"avg_fps": 27.65529078792617,
"cpu": {
"avg_ms": 37.926895780927815,
"max_ms": 3484.08349609375,
"p50_ms": 3.398620128631592,
"p95_ms": 44.56385040283203,
"p99_ms": 684.4393920898438
},
"frames": 314,
"gpu": {
"avg_ms": 2.683724634467986,
"max_ms": 9.881600379943848,
"p50_ms": 1.8923519849777222,
"p95_ms": 7.221248149871826,
"p99_ms": 9.04089641571045
},
"low_1pct_fps": 0.4753349245465373,
"max_ms": 3484.115234375,
"p50_ms": 3.5420219898223877,
"p95_ms": 44.591251373291016,
"p99_ms": 159.3817901611328,
"seconds": 11.354066113710404
},
"ocean": {
"avg_fps": 59.16125632179682,
"cpu": {
"avg_ms": 16.807428855239674,
"max_ms": 106.0348129272461,
"p50_ms": 9.335607528686523,
"p95_ms": 44.57380294799805,
"p99_ms": 84.97154998779297
},
"frames": 712,
"gpu": {
"avg_ms": 1.958949391112736,
"max_ms": 8.635392189025879,
"p50_ms": 1.1182080507278442,
"p95_ms": 5.493760108947754,
"p99_ms": 7.108607769012451
},
"low_1pct_fps": 10.542641239411056,
"max_ms": 106.06544494628906,
"p50_ms": 9.364562034606934,
"p95_ms": 45.14366912841797,
"p99_ms": 84.9927749633789,
"seconds": 12.034903317928315
},
"mountain": {
"avg_fps": 26.78175419456072,
"cpu": {
"avg_ms": 20.230309660251326,
"max_ms": 128.05508422851562,
"p50_ms": 15.369550704956055,
"p95_ms": 67.15973663330078,
"p99_ms": 104.6961898803711
},
"frames": 325,
"gpu": {
"avg_ms": 7.143625645270714,
"max_ms": 21.25823974609375,
"p50_ms": 7.187456130981445,
"p95_ms": 12.228608131408691,
"p99_ms": 19.310592651367188
},
"low_1pct_fps": 7.777799947825389,
"max_ms": 155.30685424804688,
"p50_ms": 33.335182189941406,
"p95_ms": 68.94293975830078,
"p99_ms": 109.09304809570312,
"seconds": 12.135127431869506
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 0,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 4829184
},
"village": {
"avg_fps": 4.1477100728464,
"cpu": {
"avg_ms": 244.17284844846142,
"max_ms": 386.7664489746094,
"p50_ms": 244.8783416748047,
"p95_ms": 363.0765380859375,
"p99_ms": 386.7664489746094
},
"frames": 49,
"gpu": {
"avg_ms": 10.357822744213806,
"max_ms": 23.010303497314453,
"p50_ms": 9.227264404296875,
"p95_ms": 19.309568405151367,
"p99_ms": 23.010303497314453
},
"low_1pct_fps": 2.5853636290905797,
"max_ms": 386.79278564453125,
"p50_ms": 230.8652801513672,
"p95_ms": 363.0941162109375,
"p99_ms": 386.79278564453125,
"seconds": 11.813747619628906
},
"forest": {
"avg_fps": 8.3624969613463,
"cpu": {
"avg_ms": 118.74922356747165,
"max_ms": 278.8187255859375,
"p50_ms": 123.03407287597656,
"p95_ms": 231.6608123779297,
"p99_ms": 260.8450622558594
},
"frames": 101,
"gpu": {
"avg_ms": 15.898816661669477,
"max_ms": 72.880126953125,
"p50_ms": 20.789247512817383,
"p95_ms": 28.441600799560547,
"p99_ms": 60.816383361816406
},
"low_1pct_fps": 3.7057290596141406,
"max_ms": 278.83917236328125,
"p50_ms": 123.19290161132812,
"p95_ms": 231.68026733398438,
"p99_ms": 260.8656921386719,
"seconds": 12.077732340812684
},
"ocean": {
"avg_fps": 2.3954947141714045,
"cpu": {
"avg_ms": 424.7621285574777,
"max_ms": 625.8532104492188,
"p50_ms": 435.64044189453125,
"p95_ms": 577.7593994140625,
"p99_ms": 625.8532104492188
},
"frames": 28,
"gpu": {
"avg_ms": 9.483117733682905,
"max_ms": 12.973055839538574,
"p50_ms": 9.90719985961914,
"p95_ms": 12.610560417175293,
"p99_ms": 12.973055839538574
},
"low_1pct_fps": 1.5977632874291703,
"max_ms": 625.8749389648438,
"p50_ms": 433.9859619140625,
"p95_ms": 577.7803955078125,
"p99_ms": 625.8749389648438,
"seconds": 11.688608551025391
},
"mountain": {
"avg_fps": 3.3756775332390268,
"cpu": {
"avg_ms": 300.512197249975,
"max_ms": 407.4363708496094,
"p50_ms": 292.483642578125,
"p95_ms": 394.8424377441406,
"p99_ms": 407.4363708496094
},
"frames": 39,
"gpu": {
"avg_ms": 9.728866460995796,
"max_ms": 18.930688858032227,
"p50_ms": 8.820735931396484,
"p95_ms": 17.399808883666992,
"p99_ms": 18.930688858032227
},
"low_1pct_fps": 2.4541792422631716,
"max_ms": 407.46820068359375,
"p50_ms": 292.4177551269531,
"p95_ms": 394.8649597167969,
"p99_ms": 407.46820068359375,
"seconds": 11.553236236572266
}
}
}
}
+392
View File
@@ -0,0 +1,392 @@
{
"label": "baseline-lavapipe",
"date": "2026-10-13",
"commit": "27a32cc3",
"host": "ludodesktop",
"offscreen": false,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 146,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 1288851072
},
"village": {
"avg_fps": 16.38225374384963,
"cpu": {
"avg_ms": 48.17248754105975,
"max_ms": 134.31028747558594,
"p50_ms": 44.57072067260742,
"p95_ms": 97.15985870361328,
"p99_ms": 127.31073760986328
},
"frames": 199,
"gpu": {
"avg_ms": 26.732369270324707,
"max_ms": 47.3107795715332,
"p50_ms": 26.284116744995117,
"p95_ms": 35.006874084472656,
"p99_ms": 39.38005828857422
},
"low_1pct_fps": 6.569673692138871,
"max_ms": 169.97352600097656,
"p50_ms": 56.791988372802734,
"p95_ms": 109.15220642089844,
"p99_ms": 134.45559692382812,
"seconds": 12.147290788650512
},
"forest": {
"avg_fps": 19.917172468484612,
"cpu": {
"avg_ms": 31.039839970568817,
"max_ms": 106.39506530761719,
"p50_ms": 33.26064682006836,
"p95_ms": 70.09876251220703,
"p99_ms": 86.21255493164062
},
"frames": 240,
"gpu": {
"avg_ms": 37.272586759195285,
"max_ms": 77.586669921875,
"p50_ms": 34.38500213623047,
"p95_ms": 59.649635314941406,
"p99_ms": 66.52396392822266
},
"low_1pct_fps": 9.909291086862112,
"max_ms": 106.57542419433594,
"p50_ms": 46.05862808227539,
"p95_ms": 80.38935852050781,
"p99_ms": 94.94140625,
"seconds": 12.049903186798096
},
"ocean": {
"avg_fps": 27.969763129786234,
"cpu": {
"avg_ms": 25.69895526429727,
"max_ms": 930.7354125976562,
"p50_ms": 18.981081008911133,
"p95_ms": 39.42117691040039,
"p99_ms": 168.07225036621094
},
"frames": 336,
"gpu": {
"avg_ms": 18.856842384451912,
"max_ms": 57.835872650146484,
"p50_ms": 18.172868728637695,
"p95_ms": 28.62451171875,
"p99_ms": 44.414398193359375
},
"low_1pct_fps": 1.527565223414601,
"max_ms": 930.8734741210938,
"p50_ms": 26.45800018310547,
"p95_ms": 50.053672790527344,
"p99_ms": 180.92478942871094,
"seconds": 12.012972667694092
},
"mountain": {
"avg_fps": 26.5942562164059,
"cpu": {
"avg_ms": 22.482411369495093,
"max_ms": 104.89199829101562,
"p50_ms": 22.033512115478516,
"p95_ms": 46.385555267333984,
"p99_ms": 61.15697479248047
},
"frames": 320,
"gpu": {
"avg_ms": 23.762589550018312,
"max_ms": 48.10239028930664,
"p50_ms": 22.580347061157227,
"p95_ms": 35.14606475830078,
"p99_ms": 42.47938919067383
},
"low_1pct_fps": 11.197907022053611,
"max_ms": 105.07585906982422,
"p50_ms": 33.362815856933594,
"p95_ms": 53.36695098876953,
"p99_ms": 74.80219268798828,
"seconds": 12.03267342376709
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 1,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 16106880
},
"village": {
"avg_fps": 10.754658718707336,
"cpu": {
"avg_ms": 69.22208800682655,
"max_ms": 193.21267700195312,
"p50_ms": 66.95130157470703,
"p95_ms": 154.44834899902344,
"p99_ms": 191.29208374023438
},
"frames": 130,
"gpu": {
"avg_ms": 49.041961078495945,
"max_ms": 73.83775329589844,
"p50_ms": 48.90928268432617,
"p95_ms": 60.60416793823242,
"p99_ms": 66.1044921875
},
"low_1pct_fps": 4.171866301284614,
"max_ms": 252.21458435058594,
"p50_ms": 79.8940200805664,
"p95_ms": 173.05947875976562,
"p99_ms": 227.18716430664062,
"seconds": 12.087784782409669
},
"forest": {
"avg_fps": 12.167278778001634,
"cpu": {
"avg_ms": 58.366991678873696,
"max_ms": 156.683349609375,
"p50_ms": 69.24014282226562,
"p95_ms": 110.83757019042969,
"p99_ms": 134.16554260253906
},
"frames": 147,
"gpu": {
"avg_ms": 56.45229888606716,
"max_ms": 78.26596069335938,
"p50_ms": 55.92947006225586,
"p95_ms": 63.54412841796875,
"p99_ms": 66.5202407836914
},
"low_1pct_fps": 5.722095670390665,
"max_ms": 212.99729919433594,
"p50_ms": 77.36986541748047,
"p95_ms": 122.84784698486328,
"p99_ms": 136.52499389648438,
"seconds": 12.081583950042724
},
"ocean": {
"avg_fps": 7.681804845753147,
"cpu": {
"avg_ms": 98.35419226740743,
"max_ms": 385.2493591308594,
"p50_ms": 78.85044860839844,
"p95_ms": 225.36231994628906,
"p99_ms": 385.2493591308594
},
"frames": 91,
"gpu": {
"avg_ms": 71.51263585297957,
"max_ms": 128.29212951660156,
"p50_ms": 67.92412567138672,
"p95_ms": 101.93219757080078,
"p99_ms": 128.29212951660156
},
"low_1pct_fps": 2.5948566279945817,
"max_ms": 385.37774658203125,
"p50_ms": 123.91693878173828,
"p95_ms": 225.49420166015625,
"p99_ms": 385.37774658203125,
"seconds": 11.846174411773681
},
"mountain": {
"avg_fps": 5.886844433101332,
"cpu": {
"avg_ms": 124.87309415575484,
"max_ms": 321.2157897949219,
"p50_ms": 117.2811050415039,
"p95_ms": 237.34304809570312,
"p99_ms": 321.2157897949219
},
"frames": 71,
"gpu": {
"avg_ms": 95.46965434816148,
"max_ms": 166.9488067626953,
"p50_ms": 83.54202270507812,
"p95_ms": 160.2403564453125,
"p99_ms": 166.9488067626953
},
"low_1pct_fps": 3.111272818975726,
"max_ms": 321.411865234375,
"p50_ms": 153.0963134765625,
"p95_ms": 275.37579345703125,
"p99_ms": 321.411865234375,
"seconds": 12.060790939331055
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 1,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 19154560
},
"village": {
"avg_fps": 2.5956714101148957,
"cpu": {
"avg_ms": 345.63314671670236,
"max_ms": 474.2013854980469,
"p50_ms": 347.07415771484375,
"p95_ms": 468.0090026855469,
"p99_ms": 474.2013854980469
},
"frames": 31,
"gpu": {
"avg_ms": 85.50063645839691,
"max_ms": 134.11460876464844,
"p50_ms": 82.40946960449219,
"p95_ms": 133.71929931640625,
"p99_ms": 134.11460876464844
},
"low_1pct_fps": 2.01406006678458,
"max_ms": 496.509521484375,
"p50_ms": 390.230224609375,
"p95_ms": 493.8070373535156,
"p99_ms": 496.509521484375,
"seconds": 11.942960067749024
},
"forest": {
"avg_fps": 2.2283930626963917,
"cpu": {
"avg_ms": 354.7813931978666,
"max_ms": 611.8099975585938,
"p50_ms": 293.61376953125,
"p95_ms": 601.3502197265625,
"p99_ms": 611.8099975585938
},
"frames": 26,
"gpu": {
"avg_ms": 201.54420654296874,
"max_ms": 357.0310363769531,
"p50_ms": 172.65980529785156,
"p95_ms": 341.8655700683594,
"p99_ms": 357.0310363769531
},
"low_1pct_fps": 1.52339962842959,
"max_ms": 656.4265747070312,
"p50_ms": 407.1051330566406,
"p95_ms": 611.97119140625,
"p99_ms": 656.4265747070312,
"seconds": 11.667600494384766
},
"ocean": {
"avg_fps": 2.6137193379326398,
"cpu": {
"avg_ms": 330.783615620931,
"max_ms": 730.7304077148438,
"p50_ms": 296.65692138671875,
"p95_ms": 680.8717041015625,
"p99_ms": 730.7304077148438
},
"frames": 30,
"gpu": {
"avg_ms": 113.80178985595703,
"max_ms": 216.57342529296875,
"p50_ms": 104.23703002929688,
"p95_ms": 193.67672729492188,
"p99_ms": 216.57342529296875
},
"low_1pct_fps": 1.1784160273210693,
"max_ms": 848.5967407226562,
"p50_ms": 346.120849609375,
"p95_ms": 681.0716552734375,
"p99_ms": 848.5967407226562,
"seconds": 11.477896484375
},
"mountain": {
"avg_fps": 2.72441042065798,
"cpu": {
"avg_ms": 325.24547207740045,
"max_ms": 578.7177124023438,
"p50_ms": 295.25457763671875,
"p95_ms": 569.8099975585938,
"p99_ms": 578.7177124023438
},
"frames": 31,
"gpu": {
"avg_ms": 104.99177145957947,
"max_ms": 163.95635986328125,
"p50_ms": 99.96784210205078,
"p95_ms": 160.04824829101562,
"p99_ms": 163.95635986328125
},
"low_1pct_fps": 1.7544071931551564,
"max_ms": 569.9931030273438,
"p50_ms": 378.246337890625,
"p95_ms": 555.5369873046875,
"p99_ms": 569.9931030273438,
"seconds": 11.378608657836914
}
}
}
}
+2
View File
@@ -22,6 +22,8 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
cpal = "0.16"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
horizon-theme = { path = "../horizon-theme" }
[features]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
+10 -1
View File
@@ -5,7 +5,7 @@ use horizon_client::{
Result,
camera::{
AvatarBob, BobState, Camera, CameraEntity, CameraFluid, CameraType, FovPlayer, HurtBob,
LivingCamera, UseItem, bob_matrix, field_of_view_modifier,
LivingCamera, UseItem, bob_matrix, field_of_view_modifier, perspective,
},
};
use horizon_core::{
@@ -258,6 +258,15 @@ fn camera(c: &Value, out: &mut impl Write) -> Result<()> {
fb(fov),
fb(hud)
)?;
let (w, h, far) = (fl(&op[4]), fl(&op[5]), fl(&op[6]));
let matrix =
|m: [f32; 16]| m.iter().map(|&x| format!(" {}", fb(x))).collect::<String>();
writeln!(
out,
"projection{}{}",
matrix(perspective(fov, w, h, far, 0.05, true)),
matrix(perspective(hud, w, h, far, 0.05, true))
)?;
}
other => panic!("{other}"),
}
@@ -0,0 +1,461 @@
//! Probe for test_first_person_parity.py: drives horizon_client::first_person
//! and attack_strength over the harness's scripted clicks, targets and use
//! results, printing what VanillaFirstPersonOracle prints.
use horizon_client::{
Result,
attack_strength::{AttackStrength, HeldStack},
first_person::{
Clicks, Hand, HandHeights, HandWorld, Hit, SwingAnimation, SwingState, UseResult,
swing_duration,
},
};
use serde_json::Value;
use std::{
collections::HashMap,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn fb(v: f32) -> String {
format!("{:08x}", v.to_bits())
}
fn fl(v: &Value) -> f32 {
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn dl(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
/// An `ItemStack` object: the hands hold indices into `Probe::stacks`, so
/// shrinking keeps its identity as vanilla's in-place `shrink` does.
#[derive(Clone)]
struct Stack {
item: String,
count: i32,
}
impl Stack {
fn empty(&self) -> bool {
self.item == "minecraft:air" || self.count <= 0
}
/// `getItem` and `getCount` of an empty stack are air and 0.
fn effective(&self) -> (&str, i32) {
if self.empty() {
("minecraft:air", 0)
} else {
(&self.item, self.count)
}
}
}
struct Probe<'a> {
case: &'a Value,
step: &'a Value,
creative: bool,
stacks: Vec<Stack>,
/// The stacks in the main and off hand.
hands: [usize; 2],
blocks: HashMap<[i32; 3], String>,
swing: SwingState,
events: Vec<String>,
heights: HandHeights,
}
fn hand_name(hand: Hand) -> &'static str {
match hand {
Hand::Main => "main",
Hand::Off => "off",
}
}
impl Probe<'_> {
fn stack(&self, hand: Hand) -> &Stack {
&self.stacks[self.hands[hand as usize]]
}
fn new_stack(&mut self, v: &Value) -> usize {
// ItemStack.EMPTY is index 0.
if v.is_null() {
return 0;
}
self.stacks.push(Stack {
item: v[0].as_str().unwrap().into(),
count: v[1].as_i64().unwrap() as i32,
});
self.stacks.len() - 1
}
fn table<'b>(&'b self, name: &str) -> &'b Value {
&self.case["rust"][name]
}
fn attack_speed(&self) -> f64 {
let item = self.stack(Hand::Main).effective().0;
self.table("attack_speed").get(item).map_or(4.0, dl)
}
fn scripted(&mut self, kind: &str, hand: Hand) -> UseResult {
let name = self.step["uses"][kind].as_str().unwrap().to_string();
self.events
.push(format!("use {kind} {} {name}", hand_name(hand)));
let result = match name.as_str() {
"success" => UseResult::Success { swing: true },
"server" | "consume" => UseResult::Success { swing: false },
"fail" => UseResult::Fail,
_ => UseResult::Pass,
};
if matches!(result, UseResult::Success { .. }) && self.step["shrink"].as_bool().unwrap() {
let i = self.hands[hand as usize];
if i != 0 {
self.stacks[i].count -= 1;
}
}
result
}
}
impl HandWorld for Probe<'_> {
fn hit(&mut self) -> Hit {
let h = &self.step["hit"];
match h[0].as_str().unwrap() {
"block" => Hit::Block {
pos: [0, 1, 2].map(|i| h[i + 1].as_i64().unwrap() as i32),
face: h[4].as_u64().unwrap() as u8,
},
"entity" => Hit::Entity,
_ => Hit::Miss,
}
}
fn is_air(&mut self, pos: [i32; 3]) -> bool {
self.blocks.get(&pos).is_none_or(|b| b == "minecraft:air")
}
fn creative(&self) -> bool {
self.creative
}
fn spectator(&self) -> bool {
false
}
fn instabuild(&self) -> bool {
self.creative
}
fn hands_busy(&self) -> bool {
self.step["busy"].as_bool().unwrap()
}
fn using_item(&self) -> bool {
self.step["using"].as_bool().unwrap()
}
fn item_enabled(&mut self, _: Hand) -> bool {
true
}
fn held_empty(&mut self, hand: Hand) -> bool {
self.stack(hand).empty()
}
fn held_count(&mut self, hand: Hand) -> i32 {
self.stack(hand).effective().1
}
fn infinite_materials(&self) -> bool {
self.creative
}
fn cannot_attack(&mut self) -> bool {
false
}
fn piercing_weapon(&mut self) -> bool {
false
}
fn attack_animation(&mut self) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn interact_animation(&mut self, _: Hand) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn piercing_attack(&mut self, _: SwingAnimation) {
unreachable!()
}
fn attack_entity(&mut self) -> bool {
self.events.push("attack".into());
true
}
fn block_action_restricted(&mut self, _: [i32; 3]) -> bool {
false
}
fn within_border(&mut self, _: [i32; 3]) -> bool {
true
}
fn attack_block(&mut self, _: [i32; 3]) {}
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed` for
/// a player on the ground, out of water, with no mining efficiency.
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
let block = self.blocks.get(&pos).cloned().unwrap_or_default();
let b = &self.table("blocks")[block.as_str()];
let hardness = fl(&b[0]);
if hardness == -1.0 {
return 0.0;
}
let item = self.stack(Hand::Main).effective().0.to_string();
let tool = &self.table("tools")[format!("{item} {block}").as_str()];
let (mut speed, correct) = if tool.is_null() {
(1.0f32, false)
} else {
(fl(&tool[0]), tool[1].as_bool().unwrap())
};
if let Some(amp) = self.case["haste"].as_i64() {
speed *= 1.0 + (amp as i32 + 1) as f32 * 0.2;
}
if let Some(amp) = self.case["fatigue"].as_i64() {
speed *= horizon_core::jmath::pow(0.3, f64::from(amp as i32 + 1)) as f32;
}
let harvest = !b[1].as_bool().unwrap() || correct;
speed / hardness / if harvest { 30.0 } else { 100.0 }
}
fn destroy_block(&mut self, pos: [i32; 3]) -> bool {
let item = self.stack(Hand::Main).effective().0;
// Item.canDestroyBlock: swords' tools cannot destroy in creative.
let sword = item.ends_with("_sword");
let ok = !(sword && self.creative) && !self.is_air(pos);
if ok {
self.blocks.insert(pos, "minecraft:air".into());
}
self.events
.push(format!("destroy {} {} {} {ok}", pos[0], pos[1], pos[2]));
ok
}
/// The stack object itself: `destroyingItem` is a reference.
fn main_hand_key(&mut self) -> String {
self.hands[0].to_string()
}
fn same_destroying_item(&mut self, key: &str) -> bool {
// ItemStack.EMPTY (index 0) before any break.
let then = &self.stacks[key.parse::<usize>().unwrap_or(0)];
then.effective().0 == self.stack(Hand::Main).effective().0
}
fn destroy_stage(&mut self, pos: [i32; 3], stage: Option<i32>) {
self.events.push(format!(
"stage {} {} {} {}",
pos[0],
pos[1],
pos[2],
stage.unwrap_or(-1)
));
}
fn swing(&mut self, hand: Hand, animation: SwingAnimation) {
let duration = swing_duration(
animation,
self.case["haste"].as_i64().map(|a| a as i32),
self.case["fatigue"].as_i64().map(|a| a as i32),
);
let started = self.swing.start_if_able(hand, animation, duration);
self.events.push(format!(
"swing {} {} {started}",
hand_name(hand),
animation.duration
));
}
fn reset_attack_strength(&mut self) {
unreachable!("ResetWorld resets the tickers")
}
fn entity_within_border(&mut self) -> bool {
true
}
fn use_on_entity(&mut self, hand: Hand) -> Option<UseResult> {
self.step["hit"][1]
.as_bool()
.unwrap()
.then(|| self.scripted("entity", hand))
}
fn use_on_block(&mut self, hand: Hand) -> UseResult {
self.scripted("block", hand)
}
fn use_item(&mut self, hand: Hand) -> UseResult {
self.scripted("item", hand)
}
fn item_used(&mut self, hand: Hand) {
self.events.push(format!("used {}", hand_name(hand)));
self.heights.item_used(hand);
}
fn release_using(&mut self) {
self.events.push("release".into());
}
}
fn held(stack: &Stack) -> Option<HeldStack> {
(!stack.empty()).then(|| HeldStack {
item: stack.item.clone(),
rest: stack.count.to_string(),
})
}
fn run(case: &Value, out: &mut impl Write) -> Result<()> {
let empty = Value::Null;
let mut probe = Probe {
case,
step: &empty,
creative: case["creative"].as_bool().unwrap(),
stacks: vec![Stack {
item: "minecraft:air".into(),
count: 0,
}],
hands: [0, 0],
blocks: case["blocks"]
.as_array()
.unwrap()
.iter()
.map(|b| {
(
[0, 1, 2].map(|i| b[i].as_i64().unwrap() as i32),
b[3].as_str().unwrap().to_string(),
)
})
.collect(),
swing: SwingState::default(),
events: Vec::new(),
heights: HandHeights::default(),
};
probe.hands[0] = probe.new_stack(&case["main"]);
probe.hands[1] = probe.new_stack(&case["off"]);
writeln!(out, "attack_speed {:x}", probe.attack_speed().to_bits())?;
let mut clicks = Clicks::default();
let mut strength = AttackStrength::default();
// FirstPersonHandsAndItems' visible items start as ItemStack.EMPTY.
let mut visible = [0usize, 0usize];
for (s, step) in case["steps"].as_array().unwrap().iter().enumerate() {
probe.step = step;
probe.events.clear();
if !step["set"].is_null() {
let i = probe.new_stack(&step["set"]["stack"]);
let hand = if step["set"]["hand"] == "main" { 0 } else { 1 };
probe.hands[hand] = i;
}
clicks.tick(
&mut ResetWorld {
probe: &mut probe,
strength: &mut strength,
},
step["ac"].as_u64().unwrap() as u32,
step["uc"].as_u64().unwrap() as u32,
step["ad"].as_bool().unwrap(),
step["ud"].as_bool().unwrap(),
);
probe.swing.tick();
let main = probe.stack(Hand::Main).clone();
strength.tick(held(&main));
// FirstPersonHandsAndItems.tick: shouldInstantlyReplaceVisibleItem
// (ItemStack.matchesIgnoringComponents; every item plays the swap
// animation), then the heights.
let mut changed = [false; 2];
for h in 0..2 {
let (a, b) = (visible[h], probe.hands[h]);
let same = a == b || probe.stacks[a].effective() == probe.stacks[b].effective();
if same {
visible[h] = b;
}
changed[h] = visible[h] != b;
}
let speed = probe.attack_speed();
let replace = probe.heights.tick(
probe.step["busy"].as_bool().unwrap(),
strength.swap_scale(1.0, speed),
changed,
);
for h in 0..2 {
if replace[h] {
visible[h] = probe.hands[h];
}
}
for e in &probe.events {
writeln!(out, "{e}")?;
}
let sw = &probe.swing;
let current = sw.current.map_or("- 0".to_string(), |c| {
format!("{} {}", hand_name(c.hand), c.duration_ticks)
});
let h = &probe.heights;
let d = &clicks.destroying;
writeln!(
out,
"tick {s} {current} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
sw.ticks,
fb(sw.animation),
fb(sw.old_animation),
fb(sw.animation(0.5)),
strength.ticker,
strength.swap_ticker,
fb(strength.swap_scale(1.0, speed)),
fb(h.main),
fb(h.old_main),
fb(h.off),
fb(h.old_off),
clicks.miss_time,
clicks.right_click_delay,
d.delay,
d.active,
fb(d.progress)
)?;
}
Ok(())
}
/// The probe with the attack-strength tickers it resets.
struct ResetWorld<'p, 'c> {
probe: &'p mut Probe<'c>,
strength: &'p mut AttackStrength,
}
macro_rules! forward {
($($name:ident($($arg:ident: $t:ty),*) -> $r:ty;)*) => {
$(fn $name(&mut self, $($arg: $t),*) -> $r { self.probe.$name($($arg),*) })*
};
}
impl HandWorld for ResetWorld<'_, '_> {
forward! {
hit() -> Hit;
is_air(pos: [i32; 3]) -> bool;
item_enabled(hand: Hand) -> bool;
held_empty(hand: Hand) -> bool;
held_count(hand: Hand) -> i32;
cannot_attack() -> bool;
piercing_weapon() -> bool;
attack_animation() -> SwingAnimation;
interact_animation(hand: Hand) -> SwingAnimation;
piercing_attack(a: SwingAnimation) -> ();
attack_entity() -> bool;
block_action_restricted(pos: [i32; 3]) -> bool;
within_border(pos: [i32; 3]) -> bool;
attack_block(pos: [i32; 3]) -> ();
destroy_progress(pos: [i32; 3]) -> f32;
destroy_block(pos: [i32; 3]) -> bool;
main_hand_key() -> String;
same_destroying_item(key: &str) -> bool;
destroy_stage(pos: [i32; 3], stage: Option<i32>) -> ();
swing(hand: Hand, a: SwingAnimation) -> ();
entity_within_border() -> bool;
use_on_entity(hand: Hand) -> Option<UseResult>;
use_on_block(hand: Hand) -> UseResult;
use_item(hand: Hand) -> UseResult;
item_used(hand: Hand) -> ();
release_using() -> ();
}
fn creative(&self) -> bool {
self.probe.creative()
}
fn spectator(&self) -> bool {
false
}
fn instabuild(&self) -> bool {
self.probe.instabuild()
}
fn hands_busy(&self) -> bool {
self.probe.hands_busy()
}
fn using_item(&self) -> bool {
self.probe.using_item()
}
fn infinite_materials(&self) -> bool {
self.probe.infinite_materials()
}
fn reset_attack_strength(&mut self) {
self.strength.reset();
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
run(&c, &mut out)?;
}
out.flush()?;
Ok(())
}
+263 -59
View File
@@ -61,10 +61,78 @@ pub struct Heard {
pub struct MixerBackend {
mixer: Arc<Mutex<Mixer>>,
root: PathBuf,
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
cache: Arc<Mutex<SoundCache>>,
/// The decoder thread for static sounds, started with the first one.
decoder: Option<std::sync::mpsc::Sender<Decode>>,
next: ChannelId,
live: bool,
}
/// Decoded samples and their rate (`None`: the file did not load).
type Samples = Option<(Arc<Vec<f32>>, u32)>;
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
#[derive(Default)]
struct SoundCache {
entries: HashMap<String, (Samples, u64)>,
bytes: usize,
clock: u64,
}
const CACHE_BYTES: usize = 64 << 20;
impl SoundCache {
fn get(&mut self, key: &str) -> Option<Samples> {
self.clock += 1;
let entry = self.entries.get_mut(key)?;
entry.1 = self.clock;
Some(entry.0.clone())
}
fn insert(&mut self, key: String, samples: Samples) {
self.clock += 1;
self.bytes += Self::size(&samples);
if let Some((old, _)) = self.entries.insert(key, (samples, self.clock)) {
self.bytes -= Self::size(&old);
}
while self.bytes > CACHE_BYTES && self.entries.len() > 1 {
let Some(oldest) = self
.entries
.iter()
.min_by_key(|(_, (_, used))| *used)
.map(|(k, _)| k.clone())
else {
break;
};
if let Some((gone, _)) = self.entries.remove(&oldest) {
self.bytes -= Self::size(&gone);
}
}
}
fn size(samples: &Samples) -> usize {
samples
.as_ref()
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
}
}
/// A static sound to decode for a channel waiting on it.
struct Decode {
key: String,
file: PathBuf,
channel: ChannelId,
}
/// Give a channel waiting on its sound the decoded samples and start it, as
/// the buffer future attaching and playing the source. A source stopped or
/// released meanwhile stays so.
fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: bool) {
if let Some(samples) = samples
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
&& c.state == State::Initial
{
c.samples = Some(samples);
c.position = 0.0;
c.state = State::Playing;
c.looping |= looping;
}
}
pub struct Audio {
_stream: Option<cpal::Stream>,
pub engine: SoundEngine,
@@ -176,53 +244,59 @@ impl Backend for MixerBackend {
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
self.with(channel, |c| c.relative = relative);
}
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
/// worker), then attach and `alSourcePlay`. A sound that cannot load
/// leaves its source initial, as a failed buffer future does in vanilla.
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
/// `getStream` (decoded on a thread of its own), then attach and
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
/// as a failed buffer future does in vanilla.
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
if !self.live {
return;
}
let (namespace, path) = sound.path();
let file = self.root.join("assets").join(namespace).join(&path);
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
if let Some(samples) = samples
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.samples = Some(samples);
c.position = 0.0;
c.state = State::Playing;
}
};
if sound.stream {
// A streamed sound loops by restarting its stream.
let mixer = self.mixer.clone();
std::thread::spawn(move || {
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
if looping
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.looping = true;
}
attach(&mixer, samples);
attach(&mixer, channel, samples, looping);
});
} else {
let samples = self
.cache
.entry(format!("{namespace}:{path}"))
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
.clone();
attach(&self.mixer, samples);
return;
}
}
fn stop(&mut self, channel: ChannelId) {
self.with(channel, |c| {
if c.state != State::Initial {
c.state = State::Stopped;
}
let key = format!("{namespace}:{path}");
let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
if let Some(samples) = cached {
attach(&self.mixer, channel, samples, false);
return;
}
let decoder = self.decoder.get_or_insert_with(|| {
let (send, receive) = std::sync::mpsc::channel::<Decode>();
let (mixer, cache) = (self.mixer.clone(), self.cache.clone());
std::thread::Builder::new()
.name("sound decoder".into())
.spawn(move || {
for job in receive {
// Queued twice before the first decode finished.
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
let samples = cached.unwrap_or_else(|| {
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
if let Ok(mut c) = cache.lock() {
c.insert(job.key, samples.clone());
}
samples
});
attach(&mixer, job.channel, samples, false);
}
})
.expect("sound decoder thread");
send
});
let _ = decoder.send(Decode { key, file, channel });
}
/// `alSourceStop`: any state becomes stopped, an initial source too (a
/// sound still decoding then never starts).
fn stop(&mut self, channel: ChannelId) {
self.with(channel, |c| c.state = State::Stopped);
}
fn pause(&mut self, channel: ChannelId) {
self.with(channel, |c| {
@@ -327,6 +401,71 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
*s = s.clamp(-1.0, 1.0);
}
}
/// Why the game should not open an output, if it should not:
/// `HORIZON_NO_AUDIO`, or a virtual screen (`vscreen`), where agents and
/// harnesses run the game and must stay off the user's speakers.
/// `HORIZON_AUDIO=1` opens the output anyway (testing into a null sink).
fn output_disabled(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<String> {
if var("HORIZON_NO_AUDIO").is_some() {
return Some("audio off: HORIZON_NO_AUDIO is set".into());
}
if var("HORIZON_AUDIO").is_some_and(|v| v == "1") {
return None;
}
let display = var("WAYLAND_DISPLAY")?;
Path::new(&display)
.components()
.any(|c| c.as_os_str() == "vscreen")
.then(|| "audio off on a virtual screen (vscreen); HORIZON_AUDIO=1 opens it".into())
}
/// An output stream on `device` in its default configuration, whatever its
/// sample format: the mixer works in f32 and converts.
fn open_device(
device: &cpal::Device,
mixer: Arc<Mutex<Mixer>>,
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
let stream = match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, mixer),
F::F64 => build_stream::<f64>(device, &config, mixer),
F::I16 => build_stream::<i16>(device, &config, mixer),
F::I32 => build_stream::<i32>(device, &config, mixer),
F::I8 => build_stream::<i8>(device, &config, mixer),
F::U16 => build_stream::<u16>(device, &config, mixer),
F::U32 => build_stream::<u32>(device, &config, mixer),
F::U8 => build_stream::<u8>(device, &config, mixer),
other => Err(format!("unsupported sample format {other}").into()),
}?;
stream.play()?;
Ok((stream, config))
}
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
mixer: Arc<Mutex<Mixer>>,
) -> Result<cpal::Stream> {
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let mut scratch = Vec::new();
let stream = device.build_output_stream(
&config.config(),
move |data: &mut [T], _| {
scratch.resize(data.len(), 0.0f32);
if let Ok(mut m) = mixer.lock() {
mix(&mut m, &mut scratch, channels, rate);
} else {
scratch.fill(0.0);
}
for (out, s) in data.iter_mut().zip(&scratch) {
*out = T::from_sample(*s);
}
},
|e| eprintln!("Horizon: audio stream error: {e}"),
None,
)?;
Ok(stream)
}
impl Audio {
pub fn new(root: &Path) -> Self {
Self::with_output(root, true)
@@ -347,7 +486,7 @@ impl Audio {
Ok(v) => (Some(v), true),
Err(e) => {
if output {
eprintln!("Horizon: audio unavailable: {e}");
eprintln!("Horizon: no sound: {e}");
}
(None, false)
}
@@ -360,7 +499,8 @@ impl Audio {
backend: MixerBackend {
mixer,
root: root.to_owned(),
cache: HashMap::new(),
cache: Arc::default(),
decoder: None,
next: 0,
live,
},
@@ -374,32 +514,40 @@ impl Audio {
}
}
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
// Test runs (harnesses, agents) launch the game on a virtual screen;
// keep them off the user's speakers.
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
}
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or("no audio output device")?;
let config = device.default_output_config()?;
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let stream = device.build_output_stream(
&config.into(),
move |data: &mut [f32], _| {
if let Ok(mut m) = mixer.lock() {
mix(&mut m, data, channels, rate);
} else {
data.fill(0.0);
let mut errors = Vec::new();
// The default device first, then any other output a host lists:
// a default that cannot open (busy, or a stale ALSA default) should
// not leave the game silent when another device works.
let default = host.default_output_device();
let others = host.output_devices().into_iter().flatten();
let mut tried = Vec::new();
for device in default.into_iter().chain(others) {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if tried.contains(&name) {
continue;
}
tried.push(name.clone());
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
eprintln!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok(stream);
}
},
|e| eprintln!("Horizon: audio stream error: {e}"),
None,
)?;
stream.play()?;
Ok(stream)
Err(e) => errors.push(format!("{name}: {e}")),
}
}
if errors.is_empty() {
return Err(format!("no audio output device on the {} host", host.id().name()).into());
}
Err(format!("no audio output device opened ({})", errors.join("; ")).into())
}
fn bound(&mut self) -> Bound<'_, MixerBackend> {
Bound {
@@ -525,6 +673,21 @@ impl Audio {
self.release(id);
}
/// Every sound event id (for command completion).
/// The engine's table sizes and the mixer's channels (`status`).
pub fn census(&self) -> serde_json::Value {
let mut census = self.engine.census();
census["channels"] = self
.backend
.mixer
.lock()
.map_or(0, |m| m.channels.len())
.into();
if let Ok(cache) = self.backend.cache.lock() {
census["decoded"] = cache.entries.len().into();
census["decoded_mib"] = (cache.bytes >> 20).into();
}
census
}
pub fn event_names(&self) -> Vec<String> {
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
v.sort();
@@ -590,6 +753,47 @@ mod tests {
}
}
#[test]
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
let mut cache = SoundCache::default();
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
for key in ["a", "b", "c", "d"] {
cache.insert(key.into(), sound());
}
assert!(cache.get("a").is_some());
cache.insert("e".into(), sound());
assert!(cache.bytes <= CACHE_BYTES);
assert!(
cache.get("b").is_none(),
"the least recently used goes first"
);
assert!(cache.get("a").is_some() && cache.get("e").is_some());
// A failed load is remembered without counting.
cache.insert("missing".into(), None);
assert_eq!(cache.get("missing"), Some(None));
}
#[test]
fn output_stays_off_on_a_virtual_screen_unless_asked() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |name: &str| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| std::ffi::OsString::from(*v))
}
};
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", "wayland-1")])).is_none());
assert!(output_disabled(env(&[])).is_none());
const VSCREEN: &str = "/run/user/1000/vscreen/main/wayland-1";
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN)])).is_some());
assert!(
output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN), ("HORIZON_AUDIO", "1")])).is_none()
);
assert!(
output_disabled(env(&[("HORIZON_NO_AUDIO", "1"), ("HORIZON_AUDIO", "1")])).is_some()
);
}
#[test]
fn linear_attenuation_reaches_zero_at_the_max_distance() {
assert_eq!(distance_gain(None, 100.0), 1.0);
+38
View File
@@ -583,6 +583,44 @@ impl Camera {
modify_fov_based_on_death_or_fluid(partial, 70.0, effect_scale, dead, fluid)
}
}
/// `Projection.getMatrix` for a perspective set up by `setupPerspective`:
/// JOML's `Matrix4f.setPerspective(fov * DEG_TO_RAD, width / height, near,
/// far, zZeroToOne)`, column-major. Vanilla passes its planes reversed
/// (`near` is `zFar`); the hand pass here keeps them in order.
pub fn perspective(
fov_degrees: f32,
width: f32,
height: f32,
near: f32,
far: f32,
zero_to_one: bool,
) -> [f32; 16] {
let h = horizon_core::jmath::tan(f64::from(fov_degrees * DEG_TO_RAD * 0.5)) as f32;
let aspect = width / height;
let mut m = [0.0f32; 16];
m[0] = 1.0 / (h * aspect);
m[5] = 1.0 / h;
let e = 1.0e-6f32;
if far > 0.0 && far.is_infinite() {
m[10] = e - 1.0;
m[14] = (e - if zero_to_one { 1.0 } else { 2.0 }) * near;
} else if near > 0.0 && near.is_infinite() {
m[10] = (if zero_to_one { 0.0 } else { 1.0 }) - e;
m[14] = ((if zero_to_one { 1.0 } else { 2.0 }) - e) * far;
} else {
m[10] = (if zero_to_one { far } else { far + near }) / (near - far);
m[14] = (if zero_to_one { far } else { far + far }) * near / (near - far);
}
m[11] = -1.0;
m
}
/// `GameRenderer.render3dHud`: the first-person hand and held items are
/// drawn with their own projection at `hudFov` (70 degrees, changed only by
/// death and fluids), never the FOV option or the sprint, speed and bow
/// modifier.
pub fn hand_projection(hud_fov: f32, width: f32, height: f32, far: f32) -> [f32; 16] {
perspective(hud_fov, width, height, 0.05, far, true)
}
/// `Camera.modifyFovBasedOnDeathOrFluid`. `dead` is the camera entity's
/// `deathTime` while it is a living entity that is dead or dying.
pub fn modify_fov_based_on_death_or_fluid(
+81 -1
View File
@@ -140,10 +140,37 @@ fn serve(stream: UnixStream, sender: &mpsc::Sender<Request>) {
}
}
/// Frame durations since the last reset, summarized like the Java recorder.
/// Frame durations since the last reset, summarized like the Java recorder,
/// with the CPU part of each frame and GPU times from timestamp queries.
#[derive(Default)]
pub struct FrameStats {
millis: Vec<f32>,
cpu: Vec<f32>,
gpu: Vec<f32>,
/// Milliseconds per frame phase (`PHASES`), one entry per rendered frame.
phases: Vec<[f32; PHASES.len()]>,
}
/// The parts of a frame's CPU time: world streaming and uploads, game ticks,
/// building the frame's geometry and HUD, and the renderer's own work.
pub const PHASES: [&str; 4] = ["update", "tick", "prepare", "render"];
fn percentiles(values: &[f32]) -> serde_json::Value {
let mut sorted = values.to_vec();
sorted.sort_by(f32::total_cmp);
let n = sorted.len();
if n == 0 {
return serde_json::Value::Null;
}
let total: f64 = sorted.iter().map(|&m| f64::from(m)).sum();
let percentile = |p: f64| sorted[((n as f64 * p).ceil() as usize).clamp(1, n) - 1];
serde_json::json!({
"avg_ms": total / n as f64,
"p50_ms": percentile(0.5),
"p95_ms": percentile(0.95),
"p99_ms": percentile(0.99),
"max_ms": sorted[n - 1],
})
}
impl FrameStats {
@@ -153,8 +180,48 @@ impl FrameStats {
self.millis.push(millis);
}
}
/// The frame's own work: its duration minus waits for the GPU, the
/// display and the frame limiter.
pub fn record_cpu(&mut self, millis: f32) {
if self.cpu.len() < Self::LIMIT {
self.cpu.push(millis);
}
}
pub fn record_phases(&mut self, millis: [f32; PHASES.len()]) {
if self.phases.len() < Self::LIMIT {
self.phases.push(millis);
}
}
/// Average milliseconds per phase over the frames whose CPU time is at
/// least `min_cpu` (all frames for 0): where the slow frames go.
fn phase_average(&self, min_cpu: f32) -> serde_json::Value {
let mut sums = [0.0f64; PHASES.len()];
let mut n = 0;
for (i, phases) in self.phases.iter().enumerate() {
if self.cpu.get(i).is_some_and(|&c| c >= min_cpu) {
for (sum, m) in sums.iter_mut().zip(phases) {
*sum += f64::from(*m);
}
n += 1;
}
}
PHASES
.iter()
.zip(sums)
.map(|(name, sum)| (name.to_string(), (sum / f64::from(n.max(1))).into()))
.collect::<serde_json::Map<_, _>>()
.into()
}
pub fn record_gpu(&mut self, millis: &[f32]) {
if self.gpu.len() < Self::LIMIT {
self.gpu.extend_from_slice(millis);
}
}
pub fn reset(&mut self) {
self.millis.clear();
self.cpu.clear();
self.gpu.clear();
self.phases.clear();
}
pub fn summary(&self) -> serde_json::Value {
let mut sorted = self.millis.clone();
@@ -172,6 +239,15 @@ impl FrameStats {
.map(|&m| f64::from(m))
.sum::<f64>()
/ slowest as f64;
// Where the slowest 1% of frames by CPU time go.
let slow_phases = {
let mut cpu = self.cpu.clone();
cpu.sort_by(f32::total_cmp);
let p99 = cpu
.get(cpu.len().saturating_sub(cpu.len().div_ceil(100)))
.copied();
p99.map_or(serde_json::Value::Null, |p| self.phase_average(p))
};
serde_json::json!({
"frames": n,
"seconds": total / 1000.0,
@@ -181,6 +257,10 @@ impl FrameStats {
"p95_ms": percentile(0.95),
"p99_ms": percentile(0.99),
"max_ms": sorted[n - 1],
"cpu": percentiles(&self.cpu),
"gpu": percentiles(&self.gpu),
"phases_avg_ms": self.phase_average(0.0),
"slow_phases_avg_ms": slow_phases,
})
}
}
+1 -6
View File
@@ -365,12 +365,7 @@ impl PlayerEffects {
f *= 1.0 + (a + 1) as f32 * 0.2;
}
if let Some(a) = self.amplifier("mining_fatigue") {
f *= match a {
0 => 0.3,
1 => 0.09,
2 => 0.0027,
_ => 8.1e-4,
};
f *= horizon_core::jmath::pow(0.3, f64::from(a + 1)) as f32;
}
f
}
+85
View File
@@ -0,0 +1,85 @@
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
//! keyed by small integers, such as chunk coordinates. Iteration order of
//! such maps carries no meaning: the default hasher's is random anyway.
use std::hash::{BuildHasherDefault, Hasher};
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
#[derive(Clone, Copy, Default)]
pub struct FxHasher {
hash: u64,
}
impl FxHasher {
#[inline]
fn add(&mut self, word: u64) {
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
}
}
impl Hasher for FxHasher {
#[inline]
fn write(&mut self, bytes: &[u8]) {
let (words, rest) = bytes.as_chunks::<8>();
for word in words {
self.add(u64::from_le_bytes(*word));
}
if !rest.is_empty() {
let mut word = [0u8; 8];
word[..rest.len()].copy_from_slice(rest);
self.add(u64::from_le_bytes(word));
}
}
#[inline]
fn write_u8(&mut self, i: u8) {
self.add(u64::from(i));
}
#[inline]
fn write_u16(&mut self, i: u16) {
self.add(u64::from(i));
}
#[inline]
fn write_u32(&mut self, i: u32) {
self.add(u64::from(i));
}
#[inline]
fn write_u64(&mut self, i: u64) {
self.add(i);
}
#[inline]
fn write_usize(&mut self, i: usize) {
self.add(i as u64);
}
#[inline]
fn write_i32(&mut self, i: i32) {
self.add(u64::from(i as u32));
}
#[inline]
fn finish(&self) -> u64 {
// The multiply leaves the low bits weakest; hash tables use them.
self.hash.rotate_left(26)
}
}
pub type FxBuild = BuildHasherDefault<FxHasher>;
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearby_chunks_spread_over_buckets() {
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
for x in -40..40 {
for z in -40..40 {
map.insert((x, z), x * z);
}
}
assert_eq!(map.len(), 6400);
assert_eq!(map[&(-7, 13)], -91);
let mut low = std::collections::HashSet::new();
for x in 0..64 {
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
}
assert!(low.len() > 32, "low bits vary: {}", low.len());
}
}
+601
View File
@@ -0,0 +1,601 @@
//! The local player's hand: the arm swing (`LivingEntity.SwingState`,
//! `getModifiedSwingDuration`), the first-person equip heights
//! (`FirstPersonHandsAndItems.tick` and `itemUsed`), and the click handling
//! that starts swings: `Minecraft.startAttack`, `continueAttack`,
//! `startUseItem` and `handleKeybinds`' attack and use part, with
//! `MultiPlayerGameMode`'s block breaking (`startDestroyBlock`,
//! `continueDestroyBlock`, `stopDestroyBlock`).
//!
//! What those read and do in the world is behind [`HandWorld`].
/// `InteractionHand`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hand {
Main,
Off,
}
impl Hand {
pub const BOTH: [Hand; 2] = [Hand::Main, Hand::Off];
}
/// `SwingAnimation`: its type (`SwingAnimationType` ordinal) and duration.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SwingAnimation {
pub kind: u8,
pub duration: i32,
}
impl SwingAnimation {
/// `SwingAnimation.DEFAULT`: WHACK for 6 ticks.
pub const DEFAULT: SwingAnimation = SwingAnimation {
kind: 1,
duration: 6,
};
}
/// `LivingEntity.SwingDescription`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SwingDescription {
pub hand: Hand,
pub animation: SwingAnimation,
pub duration_ticks: i32,
}
/// `LivingEntity.SwingState`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SwingState {
pub current: Option<SwingDescription>,
pub ticks: i32,
pub old_animation: f32,
pub animation: f32,
}
impl SwingState {
/// `startIfAble`: a swing restarts unless the current one is in its
/// first half (past its first tick).
pub fn start_if_able(&mut self, hand: Hand, animation: SwingAnimation, duration: i32) -> bool {
if let Some(c) = self.current
&& self.ticks <= c.duration_ticks / 2
&& self.ticks > 0
{
return false;
}
let swing = SwingDescription {
hand,
animation,
duration_ticks: duration,
};
if self.current != Some(swing) {
self.old_animation = 0.0;
self.animation = 0.0;
}
self.current = Some(swing);
self.ticks = 0;
true
}
/// `tick` (from `LivingEntity.baseTick`).
pub fn tick(&mut self) {
self.old_animation = self.animation;
if let Some(c) = self.current {
if c.duration_ticks > 0 {
self.animation = (self.ticks as f32 / c.duration_ticks as f32).min(1.0);
}
let ticks = self.ticks;
self.ticks += 1;
if ticks > c.duration_ticks {
self.current = None;
self.animation = 0.0;
}
} else {
self.animation = 0.0;
}
}
/// `getAnimation(partialTicks)`: a restarted swing keeps moving forward.
pub fn animation(&self, partial: f32) -> f32 {
if self.animation >= self.old_animation {
crate::camera::lerp(partial, self.old_animation, self.animation)
} else {
crate::camera::lerp(partial, self.old_animation, self.animation + 1.0)
}
}
pub fn swinging(&self) -> bool {
self.current.is_some()
}
/// The hand of the current swing (`state.attackHand`), main by default.
pub fn attack_hand(&self) -> Hand {
self.current.map_or(Hand::Main, |c| c.hand)
}
}
/// `LivingEntity.getModifiedSwingDuration`: `dig_speed` is
/// `MobEffectUtil.getDigSpeedAmplification` while haste or conduit power is
/// active, `fatigue` the mining fatigue amplifier.
pub fn swing_duration(
animation: SwingAnimation,
dig_speed: Option<i32>,
fatigue: Option<i32>,
) -> i32 {
let duration = animation.duration;
if let Some(amplification) = dig_speed {
duration - (1 + amplification)
} else if let Some(amplifier) = fatigue {
duration + (1 + amplifier) * 2
} else {
duration
}
}
/// `FirstPersonHandsAndItems`' equip heights.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HandHeights {
pub main: f32,
pub old_main: f32,
pub off: f32,
pub old_off: f32,
}
impl Default for HandHeights {
fn default() -> Self {
HandHeights {
main: 0.0,
old_main: 0.0,
off: 0.0,
old_off: 0.0,
}
}
}
/// What `FirstPersonHandsAndItems.tick` decides about a hand's visible item.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HandChange {
/// The visible item is not the hand's item after the instant replacement
/// (`mainHandItem != nextMainHand`): the hand lowers.
pub changed: bool,
/// The visible item becomes the hand's item this tick.
pub replace: bool,
}
impl HandHeights {
/// `FirstPersonHandsAndItems.tick`'s heights. `changed` says per hand
/// whether the visible item differs from the held one once
/// `shouldInstantlyReplaceVisibleItem` has run; `swap_scale` is
/// `getItemSwapScale(1.0F)`. Returns whether each hand's visible item
/// is replaced at the end.
pub fn tick(&mut self, hands_busy: bool, swap_scale: f32, changed: [bool; 2]) -> [bool; 2] {
self.old_main = self.main;
self.old_off = self.off;
if hands_busy {
self.main = (self.main - 0.4).clamp(0.0, 1.0);
self.off = (self.off - 0.4).clamp(0.0, 1.0);
} else {
let attack = swap_scale;
let main_target = if changed[0] {
0.0
} else {
attack * attack * attack
};
let off_target = if changed[1] { 0.0 } else { 1.0 };
self.main += (main_target - self.main).clamp(-0.4, 0.4);
self.off += (off_target - self.off).clamp(-0.4, 0.4);
}
[self.main < 0.1, self.off < 0.1]
}
/// `itemUsed`: the hand drops to the bottom and rises again.
pub fn item_used(&mut self, hand: Hand) {
match hand {
Hand::Main => self.main = 0.0,
Hand::Off => self.off = 0.0,
}
}
/// `Mth.lerp(partialTicks, oMainHandHeight, mainHandHeight)` and the
/// off hand's.
pub fn interpolated(&self, partial: f32) -> [f32; 2] {
[
crate::camera::lerp(partial, self.old_main, self.main),
crate::camera::lerp(partial, self.old_off, self.off),
]
}
}
/// `HitResult` at the crosshair (`Minecraft.hitResult`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Hit {
Miss,
Block { pos: [i32; 3], face: u8 },
Entity,
}
/// `InteractionResult` as `startUseItem` reads it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UseResult {
/// `Success` with its `SwingSource`: whether the client swings.
Success {
swing: bool,
},
Pass,
Fail,
}
/// What the click handling reads and does in the world.
pub trait HandWorld {
fn hit(&mut self) -> Hit;
fn is_air(&mut self, pos: [i32; 3]) -> bool;
/// `MultiPlayerGameMode.localPlayerMode`.
fn creative(&self) -> bool;
fn spectator(&self) -> bool;
/// `Abilities.instabuild`.
fn instabuild(&self) -> bool;
fn hands_busy(&self) -> bool;
fn using_item(&self) -> bool;
/// `ItemStack.isItemEnabled` for the item in a hand.
fn item_enabled(&mut self, hand: Hand) -> bool;
fn held_empty(&mut self, hand: Hand) -> bool;
fn held_count(&mut self, hand: Hand) -> i32;
/// `Player.hasInfiniteMaterials`.
fn infinite_materials(&self) -> bool;
/// `Player.cannotAttackWithItem(heldItem, 0)`.
fn cannot_attack(&mut self) -> bool;
/// The main-hand item is a `PIERCING_WEAPON`.
fn piercing_weapon(&mut self) -> bool;
fn attack_animation(&mut self) -> SwingAnimation;
fn interact_animation(&mut self, hand: Hand) -> SwingAnimation;
/// `MultiPlayerGameMode.piercingAttack`.
fn piercing_attack(&mut self, animation: SwingAnimation);
/// `Player.attack` on the targeted entity when the item's attack range
/// allows it; false when out of range.
fn attack_entity(&mut self) -> bool;
/// `Player.blockActionRestricted` for the game mode.
fn block_action_restricted(&mut self, pos: [i32; 3]) -> bool;
/// `WorldBorder.isWithinBounds`.
fn within_border(&mut self, pos: [i32; 3]) -> bool;
/// `BlockState.attack` on the first hit of a break.
fn attack_block(&mut self, pos: [i32; 3]);
/// `BlockState.getDestroyProgress` for the player.
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32;
/// `MultiPlayerGameMode.destroyBlock`.
fn destroy_block(&mut self, pos: [i32; 3]) -> bool;
/// A key for the main-hand stack, kept as `destroyingItem`.
fn main_hand_key(&mut self) -> String;
/// `ItemStack.isSameItemSameComponents(selected, destroyingItem)` for
/// the stack `main_hand_key` gave when the break started. Vanilla keeps
/// a reference to that stack, so it compares what it holds now.
fn same_destroying_item(&mut self, key: &str) -> bool {
self.main_hand_key() == key
}
/// Breaking progress shown (`destroyBlockProgress`), or cleared with `None`.
fn destroy_stage(&mut self, pos: [i32; 3], stage: Option<i32>);
/// `player.swing(hand, animation, false)`.
fn swing(&mut self, hand: Hand, animation: SwingAnimation);
/// `Player.resetAttackStrengthTicker`.
fn reset_attack_strength(&mut self);
/// The targeted entity's block position is inside the world border.
fn entity_within_border(&mut self) -> bool;
/// The use attempts of `startUseItem`: on the targeted entity (`None`
/// when it is out of interaction range), the targeted block, and the
/// item alone.
fn use_on_entity(&mut self, hand: Hand) -> Option<UseResult>;
fn use_on_block(&mut self, hand: Hand) -> UseResult;
fn use_item(&mut self, hand: Hand) -> UseResult;
/// `LocalPlayer.itemUsed`.
fn item_used(&mut self, hand: Hand);
/// `MultiPlayerGameMode.releaseUsingItem`.
fn release_using(&mut self);
}
/// `MultiPlayerGameMode`'s block breaking.
#[derive(Clone, Debug, PartialEq)]
pub struct Destroying {
pub delay: i32,
pub active: bool,
pub pos: [i32; 3],
pub face: u8,
pub item: String,
pub progress: f32,
pub ticks: f32,
}
impl Default for Destroying {
/// `destroyBlockPos` starts at (-1, -1, -1) and `destroyingItem` empty
/// (an empty key).
fn default() -> Self {
Destroying {
delay: 0,
active: false,
pos: [-1; 3],
face: 0,
item: String::new(),
progress: 0.0,
ticks: 0.0,
}
}
}
impl Destroying {
/// `getDestroyStage`.
pub fn stage(&self) -> i32 {
if self.progress > 0.0 {
(self.progress * 10.0) as i32
} else {
-1
}
}
fn same_target(&self, w: &mut impl HandWorld, pos: [i32; 3]) -> bool {
pos == self.pos && w.same_destroying_item(&self.item)
}
/// `startDestroyBlock`.
pub fn start(&mut self, w: &mut impl HandWorld, pos: [i32; 3], face: u8) -> bool {
if w.block_action_restricted(pos) || !w.within_border(pos) {
return false;
}
if w.instabuild() {
w.destroy_block(pos);
self.delay = 5;
} else if !self.active || !self.same_target(w, pos) {
let not_air = !w.is_air(pos);
if not_air && self.progress == 0.0 {
w.attack_block(pos);
}
if not_air && w.destroy_progress(pos) >= 1.0 {
w.destroy_block(pos);
} else {
self.active = true;
self.pos = pos;
self.face = face;
self.item = w.main_hand_key();
self.progress = 0.0;
self.ticks = 0.0;
w.destroy_stage(pos, Some(self.stage()));
}
}
true
}
/// `stopDestroyBlock`.
pub fn stop(&mut self, w: &mut impl HandWorld) {
if self.active {
self.active = false;
self.progress = 0.0;
w.destroy_stage(self.pos, None);
w.reset_attack_strength();
}
}
/// `continueDestroyBlock`: true while the hand keeps swinging.
pub fn continue_destroy(&mut self, w: &mut impl HandWorld, pos: [i32; 3], face: u8) -> bool {
if self.delay > 0 {
self.delay -= 1;
return true;
}
if w.instabuild() && w.within_border(pos) {
self.delay = 5;
w.destroy_block(pos);
return true;
}
if !self.same_target(w, pos) {
return self.start(w, pos, face);
}
if w.is_air(pos) {
self.active = false;
return false;
}
self.progress += w.destroy_progress(pos);
self.ticks += 1.0;
if self.progress >= 1.0 {
self.active = false;
w.destroy_block(pos);
self.progress = 0.0;
self.ticks = 0.0;
self.delay = 5;
} else if self.face != face {
self.face = face;
}
w.destroy_stage(self.pos, Some(self.stage()));
true
}
}
/// `Minecraft`'s attack and use handling.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Clicks {
pub miss_time: i32,
pub right_click_delay: i32,
pub destroying: Destroying,
}
impl Clicks {
/// `Minecraft.startAttack`: whether the click ended the attack (broke
/// the block outright).
pub fn start_attack(&mut self, w: &mut impl HandWorld) -> bool {
if self.miss_time > 0 || w.hands_busy() {
return false;
}
if w.spectator() {
return true;
}
if !w.item_enabled(Hand::Main) || w.cannot_attack() {
return false;
}
let animation = w.attack_animation();
if w.piercing_weapon() {
w.piercing_attack(animation);
return true;
}
let mut end = false;
let mut miss = false;
match w.hit() {
// MultiPlayerGameMode.attack.
Hit::Entity => {
if w.attack_entity() {
w.reset_attack_strength();
if w.instabuild() {
self.destroying.delay = 5;
}
}
}
Hit::Block { pos, face } if !w.is_air(pos) => {
self.destroying.start(w, pos, face);
end = w.is_air(pos);
}
Hit::Block { .. } | Hit::Miss => miss = true,
}
if miss {
if !w.creative() {
self.miss_time = 10;
}
w.reset_attack_strength();
}
w.swing(Hand::Main, animation);
end
}
/// `Minecraft.continueAttack`.
pub fn continue_attack(&mut self, w: &mut impl HandWorld, down: bool) {
if !down {
self.miss_time = 0;
}
if self.miss_time > 0 || w.using_item() || w.piercing_weapon() {
return;
}
let animation = w.attack_animation();
match w.hit() {
Hit::Block { pos, face } if down => {
if !w.is_air(pos) && self.destroying.continue_destroy(w, pos, face) {
w.swing(Hand::Main, animation);
}
}
_ => self.destroying.stop(w),
}
}
/// `Minecraft.startUseItem`.
pub fn start_use_item(&mut self, w: &mut impl HandWorld) {
if self.destroying.active {
return;
}
self.right_click_delay = 4;
if w.hands_busy() {
return;
}
for hand in Hand::BOTH {
if !w.item_enabled(hand) {
return;
}
let animation = w.interact_animation(hand);
match w.hit() {
Hit::Entity => {
if !w.entity_within_border() {
return;
}
if let Some(UseResult::Success { swing }) = w.use_on_entity(hand) {
if swing {
w.swing(hand, animation);
}
return;
}
}
Hit::Block { .. } => {
let count = w.held_count(hand);
match w.use_on_block(hand) {
UseResult::Success { swing } => {
if swing {
w.swing(hand, animation);
if !w.held_empty(hand)
&& (w.held_count(hand) != count || w.infinite_materials())
{
w.item_used(hand);
}
}
return;
}
UseResult::Fail => return,
UseResult::Pass => {}
}
}
Hit::Miss => {}
}
if !w.held_empty(hand)
&& let UseResult::Success { swing } = w.use_item(hand)
{
if swing {
w.swing(hand, animation);
}
w.item_used(hand);
return;
}
}
}
/// The attack and use part of `Minecraft.tick`: `rightClickDelay`, the
/// clicks `handleKeybinds` consumes, held use and attack, then
/// `missTime`. `attack_down` is `keyAttack.isDown()` with no screen and
/// the mouse grabbed.
pub fn tick(
&mut self,
w: &mut impl HandWorld,
attack_clicks: u32,
use_clicks: u32,
attack_down: bool,
use_down: bool,
) {
if self.right_click_delay > 0 {
self.right_click_delay -= 1;
}
let mut instant = false;
if w.using_item() {
if !use_down {
w.release_using();
}
} else {
for _ in 0..attack_clicks {
instant |= self.start_attack(w);
}
for _ in 0..use_clicks {
self.start_use_item(w);
}
}
if use_down && self.right_click_delay == 0 && !w.using_item() {
self.start_use_item(w);
}
self.continue_attack(w, !instant && attack_down);
if self.miss_time > 0 {
self.miss_time -= 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_swing_lasts_its_duration_and_restarts_after_half() {
let mut s = SwingState::default();
assert!(s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
let mut seen = Vec::new();
for _ in 0..9 {
s.tick();
seen.push(s.animation);
}
assert_eq!(
seen,
[
0.0,
1.0 / 6.0,
2.0 / 6.0,
0.5,
4.0 / 6.0,
5.0 / 6.0,
1.0,
0.0,
0.0
]
);
assert!(!s.swinging());
s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6);
s.tick();
s.tick();
assert!(!s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
s.tick();
s.tick();
assert!(s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
s.tick();
// Restarting the same swing wraps forward rather than snapping back.
assert_eq!(
s.animation(0.5),
(s.old_animation + s.animation + 1.0) / 2.0
);
}
#[test]
fn haste_shortens_and_fatigue_lengthens_swings() {
let d = SwingAnimation::DEFAULT;
assert_eq!(swing_duration(d, None, None), 6);
assert_eq!(swing_duration(d, Some(1), Some(0)), 4);
assert_eq!(swing_duration(d, None, Some(2)), 12);
}
}
+987
View File
@@ -0,0 +1,987 @@
//! The front-end screens in Horizon's menu style (`ui_theme`), laid out like
//! the launcher: a 200-wide sidebar with the sliding selection (Home,
//! Singleplayer, Multiplayer, Options), content pages with a page header,
//! settings rows and 1px dividers, and the launcher's opening (the wordmark,
//! then the interface). They drive the same state as the classic layouts in
//! `title` and `multiplayer`; only drawing and hit testing differ. The pause
//! menu's Horizon layout is `pause`, below.
use crate::{
hud::{HudAtlas, HudBatch},
multiplayer::{Outcome, Ping, Screen},
title::{Action, Mode, Title, list_worlds},
ui_theme::{
self as t, BODY, CONTROL, CONTROL_LARGE, Face, Kind, SIDEBAR, SMALL, Ui, color, id,
},
};
use std::time::Instant;
const NAV: u64 = id("title.nav", 0);
const QUIT: u64 = id("title.quit", 0);
const PLAY: u64 = id("home.play", 0);
const MULTIPLAYER: u64 = id("home.multiplayer", 0);
const RESUME: u64 = id("home.resume", 0);
const BACK: u64 = id("front.back", 0);
const PLAY_WORLD: u64 = id("worlds.play", 0);
const CREATE: u64 = id("worlds.create", 0);
const WORLD: u64 = id("worlds.row", 0);
const NAME: u64 = id("create.name", 0);
const SEED: u64 = id("create.seed", 0);
const MODE: u64 = id("create.mode", 0);
const MAKE: u64 = id("create.make", 0);
const SERVER: u64 = id("mp.row", 0);
const JOIN: u64 = id("mp.join", 0);
const DIRECT: u64 = id("mp.direct", 0);
const ADD: u64 = id("mp.add", 0);
const EDIT: u64 = id("mp.edit", 0);
const DELETE: u64 = id("mp.delete", 0);
const REFRESH: u64 = id("mp.refresh", 0);
const FIELD_A: u64 = id("mp.field", 0);
const FIELD_B: u64 = id("mp.field", 1);
const DONE: u64 = id("mp.done", 0);
/// The launcher's opening (`launcher/src/main.rs`): the wordmark holds over
/// the backdrop, then the interface comes in. A click or a key skips ahead.
#[derive(Clone, Copy, Debug)]
pub struct Opening {
started: Instant,
reveal: Option<Instant>,
}
impl Opening {
pub fn new() -> Self {
Self {
started: Instant::now(),
reveal: None,
}
}
/// The reveal's progress, 0 to 1; `ready` says the backdrop is showing.
fn phase(&mut self, ready: bool) -> f32 {
let held = self.started.elapsed();
if self.reveal.is_none()
&& ((held >= horizon_theme::OPENING_HOLD && ready)
|| held >= horizon_theme::OPENING_MAX)
{
self.reveal = Some(Instant::now());
}
self.reveal.map_or(0.0, |at| {
at.elapsed().as_secs_f32() / horizon_theme::OPENING_REVEAL.as_secs_f32()
})
}
pub fn skip(&mut self) {
self.reveal.get_or_insert_with(Instant::now);
}
}
impl Default for Opening {
fn default() -> Self {
Self::new()
}
}
/// Which sidebar item a mode belongs to.
fn page(mode: &Mode) -> usize {
match mode {
Mode::Main => 0,
Mode::Select { .. } | Mode::Create { .. } => 1,
Mode::Multiplayer(_) => 2,
}
}
const PAGES: [&str; 4] = ["Home", "Singleplayer", "Multiplayer", "Options"];
impl Title {
/// Horizon's layout for every title mode. `backdrop` is how much of the
/// live backdrop is visible (0 shows the plain cold background).
pub fn draw_horizon(&self, ui: &mut Ui, backdrop: f32) {
let reveal = self.opening_phase(backdrop >= 1.0);
if backdrop < 1.0 {
ui.cold_background(1.0 - backdrop);
}
let shade = t::ease_out_cubic(reveal);
let veil = if matches!(self.mode, Mode::Main) {
0.42
} else {
0.66
};
ui.atmosphere(veil * shade, shade);
let side = t::ease_out_cubic((reveal - 0.2) / 0.7);
let main = t::ease_out_cubic((reveal - 0.35) / 0.65);
// The sidebar slides in from the left.
ui.opacity = side;
ui.offset = (-16.0 * (1.0 - side), 0.0);
if side > 0.0 {
self.sidebar(ui);
}
// The content rises; each new page fades in and rises 8 over MOVE.
let entered = self.entered();
let p = t::ease_out_quint(entered.elapsed().as_secs_f32() / t::move_());
ui.opacity = main * p;
ui.offset = (0.0, 16.0 * (1.0 - main) + 8.0 * (1.0 - p));
if main > 0.0 {
let x = SIDEBAR + 48.0;
let w = (ui.w - x - 48.0).min(760.0);
match &self.mode {
Mode::Main => self.home(ui, x, w),
Mode::Select { worlds, selected } => worlds_page(ui, x, w, worlds, *selected),
Mode::Create {
name,
seed,
creative,
focus,
} => {
let caret = (self.started.elapsed().as_millis() / 500).is_multiple_of(2);
create_page(ui, x, w, name, seed, *creative, *focus, caret);
}
Mode::Multiplayer(screen) => {
let caret = (self.started.elapsed().as_millis() / 500).is_multiple_of(2);
multiplayer_page(ui, x, w, screen, caret);
}
}
}
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
if reveal < 1.0 {
// The wordmark rises and fades as the interface comes in.
let word = 1.0 - t::ease_out_cubic(reveal / 0.6);
ui.opacity = word;
let tw = ui.measure(Face::Display, t::WORDMARK, "Horizon");
let line = (t::WORDMARK * 1.2).round();
ui.text_line(
Face::Display,
t::WORDMARK,
line,
((ui.w - tw) / 2.0).round(),
(ui.h - line) / 2.0 - 28.0 * (1.0 - word),
color::WHITE,
"Horizon",
);
ui.opacity = 1.0;
}
}
fn opening_phase(&self, ready: bool) -> f32 {
let mut opening = self.opening.get();
let phase = opening.as_mut().map_or(1.0, |o| o.phase(ready));
self.opening.set(opening.filter(|_| phase < 1.0));
phase.min(1.0)
}
/// A click or key during the opening jumps to the reveal; true if it did.
pub fn skip_opening(&self) -> bool {
let mut opening = self.opening.get();
let skipped = opening.as_mut().is_some_and(|o| {
let holding = o.reveal.is_none();
o.skip();
holding
});
self.opening.set(opening);
skipped
}
fn sidebar(&self, ui: &mut Ui) {
let h = ui.h;
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Horizon",
);
ui.sidebar_list(NAV, 12.0, 66.0, SIDEBAR - 24.0, &PAGES, page(&self.mode));
let qy = h - 12.0 - 18.0 - 12.0 - CONTROL;
let hover = ui.interact(QUIT, 12.0, qy, SIDEBAR - 24.0, CONTROL, true);
ui.rounded(
12.0,
qy,
SIDEBAR - 24.0,
CONTROL,
t::RADIUS,
color::with_alpha(color::HOVER, 0.7 * hover),
);
ui.text_line(
Face::Regular,
BODY,
CONTROL,
22.0,
qy,
color::mix(color::MUTED, color::TEXT, hover),
"Quit game",
);
ui.text(
Face::Regular,
SMALL,
22.0,
h - 12.0 - 18.0,
color::FAINT,
"Horizon 26.3",
);
}
/// Home: the launcher's Play page over the backdrop.
fn home(&self, ui: &mut Ui, x: f32, w: f32) {
let mut y = 36.0 + 28.0;
ui.text(
Face::Medium,
SMALL,
x,
y,
color::FAINT,
"Minecraft 26.3 · Java Edition, rewritten in Rust",
);
y += 22.0;
ui.text_line(Face::Display, t::HERO, 58.0, x, y, color::TEXT, "Horizon");
y += 58.0 + 12.0;
ui.text(
Face::Regular,
t::LARGE,
x,
y,
color::MUTED,
"The world you know, rebuilt from the ground up.",
);
y += 22.0 + 24.0;
let pw = ui.button_width("Singleplayer", true).max(120.0);
ui.button(
PLAY,
[x, y, pw, CONTROL_LARGE],
"Singleplayer",
Kind::Primary,
true,
);
let mw = ui.button_width("Multiplayer", true);
ui.button(
MULTIPLAYER,
[x + pw + 8.0, y, mw, CONTROL_LARGE],
"Multiplayer",
Kind::Secondary,
true,
);
y += CONTROL_LARGE + 24.0;
ui.divider(x, y, w);
let worlds = self.recent_worlds();
let last = worlds.first();
let value = last.map_or("None yet".to_owned(), |l| {
let secs = l
.modified
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
format!("{} · {}", l.name, crate::title::date_label(secs))
});
let rh = ui.row(x, y, "Last played", &value, w - t::ROW_LABEL - 120.0);
if last.is_some() {
let bw = ui.button_width("Resume", false);
ui.button(
RESUME,
[x + w - bw, y + (rh - CONTROL) / 2.0, bw, CONTROL],
"Resume",
Kind::Secondary,
true,
);
}
y += rh;
ui.divider(x, y, w);
let count = match worlds.len() {
1 => "1 world".to_owned(),
n => format!("{n} worlds"),
};
y += ui.row(x, y, "Saves", &count, w - t::ROW_LABEL - 16.0);
ui.divider(x, y, w);
let note = "Not affiliated with Mojang or Microsoft";
ui.text(
Face::Regular,
SMALL,
x,
ui.h - 12.0 - 18.0,
color::FAINT,
note,
);
}
/// A click in Horizon's layout (`x`, `y` in GUI pixels).
pub fn click_horizon(
&mut self,
atlas: &HudAtlas,
batch: &mut HudBatch,
x: f32,
y: f32,
) -> Action {
if self.skip_opening() {
return Action::None;
}
let hit = {
let mut ui = Ui::dry(atlas, batch, (x, y));
self.draw_horizon(&mut ui, 1.0);
ui.hit((x, y))
};
let Some(hit) = hit else {
if let Mode::Create { focus, .. } = &mut self.mode {
*focus = 2;
}
return Action::None;
};
let saves = self.saves.clone();
let select = |saves: &std::path::Path| Mode::Select {
worlds: list_worlds(saves),
selected: None,
};
match hit {
QUIT => return Action::Quit,
h if h == NAV => {
self.mode = Mode::Main;
return Action::None;
}
h if h == NAV + 1 => {
if !matches!(self.mode, Mode::Select { .. }) {
self.mode = select(&saves);
}
return Action::None;
}
h if h == NAV + 2 => {
if !matches!(self.mode, Mode::Multiplayer(_)) {
self.mode = Mode::Multiplayer(Screen::list(&saves));
}
return Action::None;
}
h if h == NAV + 3 => return Action::Options,
_ => {}
}
match &mut self.mode {
Mode::Main => match hit {
PLAY => self.mode = select(&saves),
MULTIPLAYER => self.mode = Mode::Multiplayer(Screen::list(&saves)),
RESUME => {
if let Some(w) = list_worlds(&saves).into_iter().next() {
return Action::Play { folder: w.folder };
}
}
_ => {}
},
Mode::Select { worlds, selected } => match hit {
PLAY_WORLD => {
if let Some(w) = selected.and_then(|i| worlds.get(i)) {
return Action::Play {
folder: w.folder.clone(),
};
}
}
CREATE => {
self.mode = Mode::Create {
name: "New World".into(),
seed: String::new(),
creative: false,
focus: 0,
}
}
h if (WORLD..WORLD + 10_000).contains(&h) => {
let i = (h - WORLD) as usize;
if *selected == Some(i) {
return Action::Play {
folder: worlds[i].folder.clone(),
};
}
*selected = Some(i);
}
_ => {}
},
Mode::Create {
creative, focus, ..
} => match hit {
BACK => self.mode = select(&saves),
NAME => *focus = 0,
SEED => *focus = 1,
h if h == MODE || h == MODE + 1 => *creative = h == MODE + 1,
MAKE => return self.create_action(),
_ => {}
},
Mode::Multiplayer(screen) => {
let id = match hit {
JOIN => Some(40),
DIRECT => Some(41),
ADD => Some(42),
EDIT => Some(43),
DELETE => Some(44),
REFRESH => Some(45),
BACK => Some(match screen {
Screen::List { .. } => 46,
Screen::Edit { .. } => 51,
Screen::Direct { .. } => 53,
Screen::Disconnected { .. } => 54,
}),
DONE => Some(match screen {
Screen::Direct { .. } => 52,
_ => 50,
}),
FIELD_A | FIELD_B => {
if let Screen::Edit { focus, .. } = screen {
*focus = u8::from(hit == FIELD_B);
}
None
}
h if (SERVER..SERVER + 10_000).contains(&h) => {
let i = (h - SERVER) as usize;
let again =
matches!(screen, Screen::List { selected, .. } if *selected == Some(i));
if again {
if let Some(address) = screen.address_of(i) {
return Action::Connect { address };
}
} else if let Screen::List { selected, .. } = screen {
*selected = Some(i);
}
None
}
_ => None,
};
if let Some(id) = id {
match screen.click(Some(id), 0.0, 0.0, (0.0, 0.0), &saves) {
Outcome::Back => self.mode = Mode::Main,
Outcome::Connect(address) => return Action::Connect { address },
Outcome::None => {}
}
}
}
}
Action::None
}
}
/// The page header plus a row of actions under it; returns the y below.
fn header(ui: &mut Ui, x: f32, title: &str, description: &str) -> f32 {
let y = 36.0;
y + ui.page_header(x, y, title, description) + 24.0
}
/// Actions laid out right to left from `right`; returns the left edge.
fn actions_right(ui: &mut Ui, right: f32, y: f32, items: &[(u64, &str, Kind, bool)]) -> f32 {
let mut rx = right;
for &(bid, label, kind, on) in items {
let large = kind == Kind::Primary;
let (bw, bh) = if large {
(ui.button_width(label, true).max(120.0), CONTROL_LARGE)
} else {
(ui.button_width(label, false), CONTROL)
};
rx -= bw;
ui.button(
bid,
[rx, y + (CONTROL_LARGE - bh) / 2.0, bw, bh],
label,
kind,
on,
);
rx -= 8.0;
}
rx
}
/// A selectable list row: the launcher's row look with a hover layer and
/// the raised selection with its 1px border. Returns the row height.
#[allow(clippy::too_many_arguments)]
fn list_row(
ui: &mut Ui,
rid: u64,
x: f32,
y: f32,
w: f32,
selected: bool,
title: &str,
meta: &str,
right: &str,
dot: Option<u32>,
) -> f32 {
let h = 60.0;
let hover = ui.interact(rid, x - 12.0, y + 2.0, w + 24.0, h - 4.0, true);
let sel = ui.tween(rid ^ 0x5e1ec7, if selected { 1.0 } else { 0.0 }, t::quick());
ui.rounded(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::HOVER, 0.7 * hover),
);
ui.rounded(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::RAISED, sel),
);
ui.border(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::BORDER, sel),
);
let mut tx = x;
if let Some(dot) = dot {
ui.rounded(x, y + 17.0, 6.0, 6.0, 3.0, dot);
tx += 16.0;
}
let title = ui.fit(Face::Medium, BODY, title, w - 200.0);
ui.text(Face::Medium, BODY, tx, y + 10.0, color::TEXT, &title);
let meta = ui.fit(Face::Regular, SMALL, meta, w - 200.0);
ui.text(Face::Regular, SMALL, tx, y + 31.0, color::MUTED, &meta);
ui.text_right(Face::Regular, SMALL, x + w, y + 21.0, color::FAINT, right);
ui.divider(x, y + h, w);
h
}
/// An empty list: a serif line and a hint.
fn empty(ui: &mut Ui, x: f32, y: f32, title: &str, hint: &str) {
ui.text_line(Face::Display, 22.0, 30.0, x, y + 32.0, color::TEXT, title);
ui.text(Face::Regular, BODY, x, y + 66.0, color::MUTED, hint);
}
fn worlds_page(
ui: &mut Ui,
x: f32,
w: f32,
worlds: &[crate::title::WorldEntry],
selected: Option<usize>,
) {
let count = match worlds.len() {
0 => "No worlds yet. They live in the saves folder.".to_owned(),
1 => "1 world in the saves folder.".to_owned(),
n => format!("{n} worlds in the saves folder."),
};
let mut y = header(ui, x, "Singleplayer", &count);
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(PLAY_WORLD, "Play", Kind::Primary, selected.is_some()),
(CREATE, "Create world", Kind::Secondary, true),
],
);
ui.divider(x, y, w);
if worlds.is_empty() {
empty(ui, x, y, "A blank map.", "Create a world to begin.");
return;
}
let bottom = ui.h - 36.0;
for (i, world) in worlds.iter().enumerate() {
if y + 60.0 > bottom {
break;
}
let secs = world
.modified
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let meta = format!("{} · {}", crate::title::date_label(secs), world.folder);
let mode = if world.creative {
"Creative"
} else {
"Survival"
};
y += list_row(
ui,
WORLD + i as u64,
x,
y,
w,
selected == Some(i),
&world.name,
&meta,
mode,
None,
);
}
}
#[allow(clippy::too_many_arguments)]
fn create_page(
ui: &mut Ui,
x: f32,
w: f32,
name: &str,
seed: &str,
creative: bool,
focus: u8,
caret: bool,
) {
let mut y = header(
ui,
x,
"Create world",
"Generated by Horizon from the seed, as vanilla would.",
);
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(MAKE, "Create", Kind::Primary, true),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let fx = x + t::ROW_LABEL + 16.0;
let fw = (w - t::ROW_LABEL - 16.0).min(360.0);
for (i, label) in ["World name", "Seed", "Game mode"].iter().enumerate() {
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, label);
let r = [fx, y + 12.0, fw, CONTROL];
match i {
0 => ui.field(NAME, r, name, "New World", focus == 0, caret),
1 => ui.field(SEED, r, seed, "Random", focus == 1, caret),
_ => {
ui.segmented(
MODE,
fx,
y + 12.0,
&["Survival", "Creative"],
usize::from(creative),
);
}
}
y += rh;
ui.divider(x, y, w);
}
}
fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
match screen {
Screen::List {
servers,
selected,
pings,
lan,
} => {
let games = lan.games();
let sub = match servers.len() {
0 => "No saved servers. Worlds opened to LAN show up here too.".to_owned(),
1 => "1 saved server, and worlds opened to LAN.".to_owned(),
n => format!("{n} saved servers, and worlds opened to LAN."),
};
let mut y = header(ui, x, "Multiplayer", &sub);
let has = selected.is_some();
let saved = selected.is_some_and(|i| i < servers.len());
let mut items = vec![
(JOIN, "Join", Kind::Primary, has),
(ADD, "Add server", Kind::Secondary, true),
(DIRECT, "Direct connect", Kind::Secondary, true),
(REFRESH, "Refresh", Kind::Ghost, true),
];
if saved {
items.push((EDIT, "Edit", Kind::Ghost, true));
items.push((DELETE, "Delete", Kind::Danger, true));
}
actions_right(ui, x + w, y - 24.0 - 58.0, &items);
ui.divider(x, y, w);
let pings = pings.lock().map(|p| p.clone()).unwrap_or_default();
let mut rows: Vec<(String, String, String, u32)> = servers
.iter()
.map(|s| {
let (line, right, dot) = match pings.get(&s.ip) {
Some(Ping::Ok {
motd, players, ms, ..
}) => (
motd.lines().next().unwrap_or("").trim().to_owned(),
format!("{players} · {ms} ms"),
color::ACCENT,
),
Some(Ping::Failed(_)) => (
"Can't reach the server".to_owned(),
String::new(),
color::DANGER,
),
_ => ("Pinging…".to_owned(), String::new(), color::FAINT),
};
(s.name.clone(), format!("{line} · {}", s.ip), right, dot)
})
.collect();
rows.extend(games.iter().map(|g| {
(
"LAN world".to_owned(),
format!("{} · {}", g.motd, g.address),
"On your network".to_owned(),
color::ACCENT,
)
}));
if rows.is_empty() {
empty(
ui,
x,
y,
"Nobody here yet.",
"Add a server, connect directly, or open a world to LAN.",
);
return;
}
let bottom = ui.h - 36.0;
for (i, (name, meta, right, dot)) in rows.iter().enumerate() {
if y + 60.0 > bottom {
break;
}
y += list_row(
ui,
SERVER + i as u64,
x,
y,
w,
*selected == Some(i),
name,
meta,
right,
Some(*dot),
);
}
}
Screen::Edit {
index,
name,
address,
focus,
} => {
let title = if index.is_some() {
"Edit server"
} else {
"Add server"
};
let mut y = header(
ui,
x,
title,
"Saved to servers.dat, like vanilla's server list.",
);
let ok = horizon_protocol::servers::parse_address(address).is_some();
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(DONE, "Save", Kind::Primary, ok),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let fx = x + t::ROW_LABEL + 16.0;
let fw = (w - t::ROW_LABEL - 16.0).min(360.0);
for (i, (label, value, hint)) in [
("Name", name, "Minecraft Server"),
("Address", address, "play.example.net"),
]
.iter()
.enumerate()
{
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, label);
let fid = if i == 0 { FIELD_A } else { FIELD_B };
ui.field(
fid,
[fx, y + 12.0, fw, CONTROL],
value,
hint,
usize::from(*focus) == i,
caret,
);
y += rh;
ui.divider(x, y, w);
}
}
Screen::Direct { address } => {
let y = header(
ui,
x,
"Direct connect",
"Join a server once without saving it.",
);
let ok = horizon_protocol::servers::parse_address(address).is_some();
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(DONE, "Join", Kind::Primary, ok),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, "Address");
let fx = x + t::ROW_LABEL + 16.0;
ui.field(
FIELD_A,
[fx, y + 12.0, (w - t::ROW_LABEL - 16.0).min(360.0), CONTROL],
address,
"host:port",
true,
caret,
);
ui.divider(x, y + rh, w);
}
Screen::Disconnected { reason } => {
let mut y = header(
ui,
x,
"Connection lost",
"The server closed the connection.",
);
ui.divider(x, y, w);
y += 16.0;
// The launcher's error note: a danger dot beside the message.
let lines = wrap(ui, reason, w - 40.0);
let box_h = 24.0 + lines.len() as f32 * 19.0;
ui.rounded(x, y, w, box_h, t::RADIUS, color::RAISED);
ui.border(x, y, w, box_h, t::RADIUS, color::BORDER);
ui.rounded(x + 12.0, y + 18.0, 6.0, 6.0, 3.0, color::DANGER);
for (i, line) in lines.iter().enumerate() {
ui.text_line(
Face::Regular,
BODY,
19.0,
x + 28.0,
y + 12.0 + i as f32 * 19.0,
color::TEXT,
line,
);
}
y += box_h + 16.0;
let bw = ui.button_width("Back to servers", false);
ui.button(
BACK,
[x, y, bw, CONTROL],
"Back to servers",
Kind::Secondary,
true,
);
}
}
}
/// Greedy word wrap in dp.
pub fn wrap(ui: &Ui, text: &str, width: f32) -> Vec<String> {
let mut lines = Vec::new();
for para in text.lines() {
let mut line = String::new();
for word in para.split_whitespace() {
let next = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if !line.is_empty() && ui.measure(Face::Regular, BODY, &next) > width {
lines.push(std::mem::replace(&mut line, word.to_owned()));
} else {
line = next;
}
}
lines.push(line);
}
lines
}
/// The pause menu in Horizon's style: the launcher's sidebar over the
/// blurred world, with the game's actions as a page. Ids match
/// `game/screens.rs`'s PauseScreen actions: 0 back to game, 1 options,
/// 2 save and quit, 3 statistics, 4 advancements, 9 world options.
pub mod pause {
use super::*;
fn act(a: u8) -> u64 {
id("pause", u64::from(a))
}
pub fn draw(ui: &mut Ui, world: &str, quit_label: &str, opened: Instant) {
let (w, h) = (ui.w, ui.h);
let p = t::ease_out_quint(opened.elapsed().as_secs_f32() / t::move_());
ui.opacity = p;
ui.atmosphere(0.35, 1.0);
ui.offset = (-16.0 * (1.0 - p), 0.0);
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Horizon",
);
ui.sidebar_list(
id("pause.nav", 0),
12.0,
66.0,
SIDEBAR - 24.0,
&["Game", "Advancements", "Statistics", "Options"],
0,
);
ui.text(
Face::Regular,
SMALL,
22.0,
h - 12.0 - 18.0,
color::FAINT,
"Horizon 26.3",
);
ui.offset = (0.0, 8.0 * (1.0 - p));
let x = SIDEBAR + 48.0;
let cw = (w - x - 48.0).min(760.0);
let mut y = 36.0;
ui.text(Face::Medium, SMALL, x, y, color::FAINT, "Game paused");
y += 22.0;
let title = ui.fit(Face::Display, t::HERO, world, cw);
ui.text_line(Face::Display, t::HERO, 58.0, x, y, color::TEXT, &title);
y += 58.0 + 24.0;
let bw = ui.button_width("Back to game", true).max(120.0);
ui.button(
act(0),
[x, y, bw, CONTROL_LARGE],
"Back to game",
Kind::Primary,
true,
);
let lw = ui.button_width("Open to LAN", true);
ui.button(
act(9),
[x + bw + 8.0, y, lw, CONTROL_LARGE],
"Open to LAN",
Kind::Secondary,
true,
);
y += CONTROL_LARGE + 24.0;
ui.divider(x, y, cw);
let rh = ui.row(
x,
y,
"Leave",
"Saves the world, then returns to the title.",
cw - t::ROW_LABEL - 220.0,
);
let qw = ui.button_width(quit_label, false);
ui.button(
act(2),
[x + cw - qw, y + (rh - CONTROL) / 2.0, qw, CONTROL],
quit_label,
Kind::Danger,
true,
);
y += rh;
ui.divider(x, y, cw);
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
}
/// The PauseScreen action under a click, if any.
pub fn click(
atlas: &HudAtlas,
batch: &mut HudBatch,
x: f32,
y: f32,
world: &str,
quit_label: &str,
) -> Option<u8> {
let mut ui = Ui::dry(atlas, batch, (x, y));
draw(
&mut ui,
world,
quit_label,
Instant::now() - std::time::Duration::from_secs(5),
);
let hit = ui.hit((x, y))?;
let nav = id("pause.nav", 0);
match hit {
h if h == nav => Some(0),
h if h == nav + 1 => Some(4),
h if h == nav + 2 => Some(3),
h if h == nav + 3 => Some(1),
h => [0u8, 1, 2, 3, 4, 9].into_iter().find(|&a| act(a) == h),
}
}
}
+396 -156
View File
@@ -4,6 +4,7 @@ mod advancements;
mod advancements_screen;
mod allays;
mod armor_stands;
mod backdrop;
mod banners;
mod beacon;
mod block_entity_anims;
@@ -39,6 +40,7 @@ mod game_mode;
mod ghasts;
mod golems;
mod growth;
mod hand;
mod item_draw;
mod jukebox;
mod lan;
@@ -183,6 +185,11 @@ struct Options {
no_pause: bool,
/// Control socket path (`--control`), for `horizon-ctl`.
control: Option<PathBuf>,
/// `HORIZON_SPIKES=MS`: print the timed spans of frames slower than MS.
spikes: Option<f32>,
/// Render into a texture instead of the window (`--offscreen`), so a
/// GPU the display cannot present from can still be benchmarked.
offscreen: bool,
/// Multiplayer: `--server HOST[:PORT]` joins at start; `--name` is the offline player name.
server: Option<String>,
player_name: String,
@@ -218,6 +225,10 @@ impl Options {
dimension: None,
no_pause: false,
control: None,
offscreen: false,
spikes: std::env::var("HORIZON_SPIKES")
.ok()
.and_then(|v| v.parse().ok()),
server: None,
player_name: std::env::var("HORIZON_PLAYER_NAME").unwrap_or_else(|_| "Player".into()),
dedicated: None,
@@ -238,6 +249,7 @@ impl Options {
"--creative" => o.creative = true,
"--test-terrain" => o.test_terrain = true,
"--no-pause" => o.no_pause = true,
"--offscreen" => o.offscreen = true,
"--control" => o.control = Some(horizon_client::control::default_path()),
_ => {
let value = args
@@ -685,15 +697,25 @@ struct Game<'a> {
orbs: Vec<ExperienceOrb>,
xp_delay: u32,
last_level_up: u64,
/// Arm swing: ticks into the 6-tick swing (None when idle), previous/current progress.
swing: Option<u32>,
swing_anim: (f32, f32),
/// Main-hand equip height (previous, current) and the item it shows.
hand_height: (f32, f32),
hand_item: Option<ItemStack>,
/// The arm swing (`LivingEntity.SwingState`) and how many swings started.
swing: horizon_client::first_person::SwingState,
swings_started: u32,
/// The first-person equip heights and the items the hands show (with
/// the slot each came from).
heights: horizon_client::first_person::HandHeights,
visible_hands: [(usize, Option<ItemStack>); 2],
/// `Minecraft`'s attack handling, attack clicks since the last tick and
/// the attack strength tickers the hand reads.
clicks: horizon_client::first_person::Clicks,
attack_clicks: u32,
hand_strength: horizon_client::attack_strength::AttackStrength,
settings: Settings,
/// Where the options screen returns: true = title, false = pause menu.
options_from_title: bool,
/// The live title backdrop (game/backdrop.rs).
backdrop: backdrop::Backdrop,
/// When the pause menu opened (Horizon style's entrance).
pause_since: Option<Instant>,
dragging: bool,
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
@@ -776,6 +798,16 @@ struct Game<'a> {
/// Control replies waiting for a rendered frame or a benchmark deadline.
pending_screenshot: Option<std::sync::mpsc::Sender<String>>,
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
/// Recent frame times for the performance overlay.
frame_graph: horizon_client::perf_overlay::FrameGraph,
/// Meshed sections waiting for an upload budget (Horizon settings).
pending_uploads: Vec<((i32, i32, i32), horizon_client::mesher::SectionMesh)>,
/// This frame's view frustum, for culling entities and block entities.
view_frustum: Option<horizon_client::gfx::Frustum>,
/// A scripted flight from the control socket (`fly`).
flight: Option<control::Flight>,
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
sky_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
persist: persist::EntityMemory,
/// Connected to a server: the world comes from the network (game/remote.rs).
remote: Option<remote::Remote>,
@@ -1218,7 +1250,14 @@ impl Game<'_> {
}
fn tick_items(&mut self) {
let mut items = std::mem::take(&mut self.dropped);
items.retain_mut(|item| item.tick(&self.world, &self.res.facts, self.collision));
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
items.retain_mut(|item| {
!ticking(
item.position.x.floor() as i32,
item.position.z.floor() as i32,
) || item.tick(&self.world, &self.res.facts, self.collision)
});
let Some(p) = self.player.as_ref() else {
self.dropped = items;
return;
@@ -2781,67 +2820,69 @@ impl Game<'_> {
/// What the tick runs after the local player's own update: effects, the
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
self.tick_effects();
self.tick_burning();
self.tick_using();
self.tick_hud();
horizon_client::spans::time("tick_effects", || self.tick_effects());
horizon_client::spans::time("tick_burning", || self.tick_burning());
horizon_client::spans::time("tick_using", || self.tick_using());
horizon_client::spans::time("tick_hud", || self.tick_hud());
// The player's digging comes in with its packets, before the level
// ticks; ServerLevel.tick then runs the scheduled and random block
// ticks before any entity, so what they drop or spawn (dispensed
// items, popped crops) moves on the tick it appears, as in vanilla.
self.tick_mining()?;
self.postprocess_chunks()?;
horizon_client::spans::time("tick_local_hand", || self.tick_local_hand())?;
horizon_client::spans::time("postprocess_chunks", || self.postprocess_chunks())?;
if running {
self.tick_blocks()?;
horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
}
if running {
self.tick_paintings();
self.tick_item_frames();
self.tick_armor_stands();
self.tick_boats();
self.tick_carts();
self.tick_thunder();
self.tick_lecterns();
self.tick_maps();
self.tick_bolts();
self.tick_fishing();
self.tick_tridents();
self.tick_rockets();
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands());
horizon_client::spans::time("tick_boats", || self.tick_boats());
horizon_client::spans::time("tick_carts", || self.tick_carts());
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
horizon_client::spans::time("tick_lecterns", || self.tick_lecterns());
horizon_client::spans::time("tick_maps", || self.tick_maps());
horizon_client::spans::time("tick_bolts", || self.tick_bolts());
horizon_client::spans::time("tick_fishing", || self.tick_fishing());
horizon_client::spans::time("tick_tridents", || self.tick_tridents());
horizon_client::spans::time("tick_rockets", || self.tick_rockets());
}
if running {
self.tick_items();
horizon_client::spans::time("tick_items", || self.tick_items());
}
self.spawn_loaded_entities();
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
if running {
self.tick_mobs()?;
self.recheck_golem_openers();
self.tick_experience();
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
horizon_client::spans::time("tick_experience", || self.tick_experience());
}
self.tick_lan();
self.tick_hand();
self.tick_sleep();
self.tick_portal()?;
horizon_client::spans::time("tick_lan", || self.tick_lan());
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
horizon_client::spans::time("tick_portal", || self.tick_portal())?;
if running {
self.tick_end()?;
horizon_client::spans::time("tick_end", || self.tick_end())?;
}
if running {
self.tick_falling()?;
self.tick_tnt()?;
horizon_client::spans::time("tick_falling", || self.tick_falling())?;
horizon_client::spans::time("tick_tnt", || self.tick_tnt())?;
}
if running {
self.tick_block_entities()?;
self.tick_sculk_entities();
self.tick_sculk_blocks()?;
self.tick_brewing_stands()?;
horizon_client::spans::time("tick_block_entities", || self.tick_block_entities())?;
horizon_client::spans::time("tick_sculk_entities", || self.tick_sculk_entities());
horizon_client::spans::time("tick_sculk_blocks", || self.tick_sculk_blocks())?;
horizon_client::spans::time("tick_brewing_stands", || self.tick_brewing_stands())?;
}
if running {
// ClientLevel.tickBlockEntities (not while paused or frozen):
// chest lids, books, shulker boxes, bells, conduits, heads.
self.tick_block_entity_anims();
self.tick_particles();
horizon_client::spans::time("tick_block_entity_anims", || {
self.tick_block_entity_anims()
});
horizon_client::spans::time("tick_particles", || self.tick_particles());
}
if self.right && self.use_delay == 0 && self.screen == Screen::Playing {
self.use_item()?;
self.use_with_hand()?;
self.use_delay = 4;
}
Ok(())
@@ -2962,6 +3003,12 @@ impl Game<'_> {
hud_layout::paint(batch, &self.res.atlas, &draws);
}
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
let mut batch = self.hud_layers(size);
self.frame_graph
.draw(self.settings.horizon.overlay, &mut batch, &self.res.atlas);
batch
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
let mut batch = HudBatch::new(size);
let (w, h) = (batch.width, batch.height);
if self.screen == Screen::Options {
@@ -2969,7 +3016,15 @@ impl Game<'_> {
return batch;
}
if let Some(title) = &self.title {
title.draw(&self.res.atlas, &mut batch, self.cursor);
if horizon_client::ui_theme::horizon() {
let fade = self.backdrop_fade();
let mut ui =
horizon_client::ui_theme::Ui::new(&self.res.atlas, &mut batch, self.cursor);
title.draw_horizon(&mut ui, fade);
ui.finish();
} else {
title.draw(&self.res.atlas, &mut batch, self.cursor);
}
return batch;
}
let atlas_ptr: *const HudAtlas = &self.res.atlas;
@@ -2994,7 +3049,8 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
if !self.ui.hide_gui {
// Horizon's pause menu sits over the blurred world alone.
if !self.ui.hide_gui && !self.horizon_pause() {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
// extractVehicleHealth and the experience bar, laid out by hud_layout.
@@ -4365,9 +4421,34 @@ impl Game<'_> {
banner: &|p| self.banner_layers_at(p),
glyph: &|c| self.res.atlas.glyph_cell(c),
};
// Block entities outside the view frustum are skipped (with a
// block of margin for models larger than their block).
let view = self.view_frustum;
// Beams and structure outlines reach far outside their block
// (BlockEntityRenderer.shouldRenderOffScreen): never culled.
let off_screen = |state: u32| {
let name = &*self.res.facts.get(BlockStateId(state)).name;
matches!(
name,
"minecraft:beacon"
| "minecraft:end_gateway"
| "minecraft:structure_block"
| "minecraft:test_instance_block"
)
};
let visible = r.block_entities().filter(|(p, state)| {
off_screen(*state)
|| view.is_none_or(|v| {
let o =
(glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))
- camera)
.as_vec3();
v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
})
});
self.res
.block_entity_render
.render(&scene, r.block_entities(), &mut out);
.render(&scene, visible, &mut out);
}
// Experience orbs (ExperienceOrbRenderer): pulsing billboards from a 4x4 icon sheet.
if let Some(r) = self
@@ -4601,11 +4682,20 @@ impl Game<'_> {
.sample(self.world.day_time, None, None);
return (15.0 - 15.0 * sky.sky_light_level).round() as i32;
}
let mut sky = self.res.environment.sample(self.world.day_time, None, None);
let (rain, thunder) = self.world.weather.levels(1.0);
// Sampled once per (time, weather): every mob asks each tick.
let key = (self.world.day_time, rain.to_bits(), thunder.to_bits());
if let Some((k, darken)) = self.sky_darken_memo.get()
&& k == key
{
return darken;
}
let mut sky = self.res.environment.sample(self.world.day_time, None, None);
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
// Level.updateSkyBrightness: darkening from the sky light level.
(15.0 - 15.0 * sky.sky_light_level).round() as i32
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
self.sky_darken_memo.set(Some((key, darken)));
darken
}
fn mob_hit(&self) -> Option<(usize, f64)> {
let eye = self.eye_position()?;
@@ -6374,38 +6464,6 @@ impl Game<'_> {
}
self.damage_held(1);
}
/// LivingEntity.swing: restart the 6-tick arm swing.
fn swing_arm(&mut self) {
if self.swing.is_none_or(|t| t >= 3) {
self.swing = Some(0);
}
}
/// LivingEntity.updateSwingTime and ItemInHandRenderer.tick (equip height).
fn tick_hand(&mut self) {
self.swing_anim.0 = self.swing_anim.1;
if let Some(t) = self.swing {
let t = t + 1;
if t >= 6 {
self.swing = None;
self.swing_anim.1 = 0.0;
} else {
self.swing = Some(t);
self.swing_anim.1 = t as f32 / 6.0;
}
}
self.hand_height.0 = self.hand_height.1;
let held = self.inventory[self.selected].clone();
let same = match (&held, &self.hand_item) {
(Some(a), Some(b)) => a.item == b.item,
(None, None) => true,
_ => false,
};
let target = if same { 1.0 } else { 0.0 };
self.hand_height.1 += (target - self.hand_height.1).clamp(-0.4, 0.4);
if self.hand_height.1 < 0.1 {
self.hand_item = held;
}
}
/// FirstPersonHandsAndItemsRenderer: the right arm or held item in view
/// space, converted to camera-relative world space.
fn hand_geometry(&mut self, alpha: f64) -> Vec<Vertex> {
@@ -6423,14 +6481,15 @@ impl Game<'_> {
return out;
}
let a = alpha as f32;
let attack = self.swing_anim.0 + (self.swing_anim.1 - self.swing_anim.0) * a;
let attack = if self.swing.is_none() && self.swing_anim.1 == 0.0 {
0.0
// FirstPersonHandsAndItemsRenderer: the swing of the hand that
// swings, and its inverse arm height (the swap scale is 1 for every
// item but spears).
let attack = if self.swing.attack_hand() == horizon_client::first_person::Hand::Main {
self.swing.animation(a)
} else {
attack
0.0
};
let height = self.hand_height.0 + (self.hand_height.1 - self.hand_height.0) * a;
let inverse_height = 1.0 - height;
let inverse_height = 1.0 - self.heights.interpolated(a)[0];
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let forward = glam::Vec3::new(
-yaw.sin() * pitch.cos(),
@@ -6451,7 +6510,8 @@ impl Game<'_> {
if self.held_map_geometry(to_world, attack, inverse_height, packed, &mut out) {
return out;
}
match self.hand_item.clone() {
let [main_item, _] = self.visible_hand_items();
match main_item {
None => {
let Some(arm) = self.res.player_arm.clone() else {
return out;
@@ -7150,7 +7210,13 @@ impl Game<'_> {
return Ok(false);
};
let alpha = self.clock.fraction();
let (camera, underwater, view_yaw, view_pitch) = match self.camera_view(alpha as f32) {
let live = self.backdrop_live();
let view = if live {
self.backdrop_view()
} else {
self.camera_view(alpha as f32)
};
let (camera, underwater, view_yaw, view_pitch) = match view {
Some((c, yaw, pitch)) => {
let s =
self.world
@@ -7217,14 +7283,39 @@ impl Game<'_> {
} else {
Vec::new()
};
let mut hud = self.hud(size);
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
// Map textures changed since the last drawn frame (game/maps.rs).
hud.patches.append(&mut self.map_patches);
let title = self.title.is_some();
let (yaw, pitch) = if live {
(view_yaw, view_pitch)
} else if title {
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
(-(self.started.elapsed().as_secs_f32() * 2.0) % 360.0, 10.0)
} else {
(view_yaw, view_pitch)
};
let fov_degrees = if live {
72.0
} else if title {
85.0
} else {
self.fov_degrees(alpha as f32, camera.to_array())
};
let bob = if title {
glam::Mat4::IDENTITY
} else {
self.bob_matrix(alpha as f32)
};
let distance = (self.streaming.radius * 16) as f32;
self.view_frustum = self
.renderer
.as_ref()
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob, distance));
let mut entities = if title {
Vec::new()
} else {
self.entity_geometry(camera, alpha)
horizon_client::spans::time("entity_geometry", || self.entity_geometry(camera, alpha))
};
let mut additive = Vec::new();
if !title {
@@ -7236,7 +7327,7 @@ impl Game<'_> {
entities.extend(dragon);
}
}
let celestial = if title {
let celestial = if title && !live {
self.panorama_geometry()
} else if self.dim == Dim::Overworld
&& self.potions.effects.get("darkness").is_none()
@@ -7262,16 +7353,16 @@ impl Game<'_> {
if !title {
self.end_geometry(camera, &mut entities);
}
if !title && self.dim == Dim::Overworld {
self.cloud_geometry(&sky, camera, alpha, &mut entities);
self.weather_geometry(camera, alpha, &mut entities);
if (!title || live) && self.dim == Dim::Overworld {
horizon_client::spans::time("clouds", || {
self.cloud_geometry(&sky, camera, alpha, &mut entities)
});
if self.settings.horizon.weather {
horizon_client::spans::time("weather", || {
self.weather_geometry(camera, alpha, &mut entities)
});
}
}
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
let (yaw, pitch) = if title {
(-(self.started.elapsed().as_secs_f32() * 2.0) % 360.0, 10.0)
} else {
(view_yaw, view_pitch)
};
let water_fog = {
let c = info.water_fog;
[
@@ -7287,38 +7378,44 @@ impl Game<'_> {
camera,
yaw,
pitch,
fov_degrees: if title {
85.0
} else {
self.fov_degrees(alpha as f32, camera.to_array())
},
bob: if title {
glam::Mat4::IDENTITY
} else {
self.bob_matrix(alpha as f32)
},
fov_degrees,
bob,
sky,
distance: (self.streaming.radius * 16) as f32,
distance,
underwater,
title,
skybox: self.dim == Dim::Overworld,
end_sky: self.dim == Dim::End,
title: title && !live,
skybox: self.dim == Dim::Overworld && self.settings.horizon.sky,
end_sky: self.dim == Dim::End && self.settings.horizon.sky,
environment_fog: (self.dim == Dim::Nether).then_some((sky.fog_start, sky.fog_end)),
// The camera inside an opaque block sees nothing through it: disable.
cave_culling: !self
.res
.facts
.get(self.world.block(block[0], block[1], block[2]))
.solid_render,
cave_culling: self.settings.horizon.cave_culling
&& !self
.res
.facts
.get(self.world.block(block[0], block[1], block[2]))
.solid_render,
water_fog,
fog: Some(self.frame_fog(camera, &sky, biome)),
fog: Some({
let mut fog = self.frame_fog(camera, &sky, biome);
// Horizon's fog setting: no distance fog (fluids keep theirs).
if !self.settings.horizon.fog && !underwater && fog.environmental_end >= distance {
(fog.render_distance_start, fog.render_distance_end) = (1e9, 2e9);
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
}
fog
}),
dark_disc: self.dark_disc(camera),
outline,
hud: &hud,
entities: &entities,
additive: &additive,
celestial: &celestial,
celestial: if self.settings.horizon.sky || title {
&celestial
} else {
&[]
},
hand: &hand,
hand_fov_degrees: self.hud_fov_degrees(alpha as f32, camera.to_array()),
screen: &screen,
border,
game_time: self.world.game_time as f32,
@@ -7334,8 +7431,16 @@ impl Game<'_> {
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(),
block_flicker: self.res.fog.state.block_light_flicker,
post: if live {
Some(horizon_client::post::PostGrade::FROST)
} else if self.horizon_pause() {
Some(horizon_client::post::PostGrade::PAUSE)
} else {
None
},
};
self.renderer.as_mut().ok_or("renderer")?.render(&frame)
let renderer = self.renderer.as_mut().ok_or("renderer")?;
horizon_client::spans::time("renderer", || renderer.render(&frame))
}
/// `LevelExtractor` / `LevelRenderer`: the world border wall this frame.
fn border_draw(
@@ -7491,22 +7596,20 @@ impl Game<'_> {
}
match button {
MouseButton::Left => {
if pressed && self.attack() {
return Ok(());
// KeyMapping.click: Minecraft.tick starts the attack.
if pressed {
self.attack_clicks += 1;
}
self.toggles
.button(2, pressed, false, self.settings.toggle_attack);
self.left = self.toggles.is_down(2);
if pressed {
self.destroy_delay = 0;
}
}
MouseButton::Right => {
self.toggles
.button(3, pressed, false, self.settings.toggle_use);
self.right = self.toggles.is_down(3);
if pressed {
self.use_item()?;
self.use_with_hand()?;
self.use_delay = 4;
}
}
@@ -7538,32 +7641,79 @@ impl Game<'_> {
(p.position.z.floor() as i32) >> 4,
);
}
let (uploads, unloaded) = self.streaming.update(&mut self.world);
let (uploads, unloaded) =
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
let dirty: Vec<_> = unloaded
.iter()
.filter(|c| self.world.modified.remove(&(c.x, c.z)))
.cloned()
.collect();
let modified: Vec<_> = dirty.iter().map(|c| self.with_entities(c)).collect();
self.unload_entities(&unloaded);
for c in &unloaded {
let modified: Vec<_> = horizon_client::spans::time("with_entities", || {
dirty.iter().map(|c| self.with_entities(c)).collect()
});
horizon_client::spans::time("unload_entities", || self.unload_entities(&unloaded));
if !unloaded.is_empty() {
let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> =
unloaded.iter().map(|c| (c.x, c.z)).collect();
self.world
.block_entities
.retain(|p, _| !(p.0 >> 4 == c.x && p.2 >> 4 == c.z));
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
self.world
.block_names
.retain(|p, _| !(p.0 >> 4 == c.x && p.2 >> 4 == c.z));
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
}
if !modified.is_empty()
&& let Err(e) = self
.save
.save(&modified, self.res.assets.registry, &self.res.biomes)
&& let Err(e) = horizon_client::spans::time("save_chunks", || {
self.save
.save_later(&modified, self.res.assets.registry, &self.res.biomes)
})
{
eprintln!("Horizon: failed to save unloaded chunks: {e}");
}
if let Some(renderer) = self.renderer.as_mut() {
for (key, mesh) in uploads {
renderer.upload(key, &mesh);
let g = &self.settings.horizon;
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
if !budgeted && self.pending_uploads.is_empty() {
horizon_client::spans::time("upload", || {
for (key, mesh) in uploads {
renderer.upload(key, &mesh);
}
});
} else {
// Horizon's chunk-update and upload budgets: the nearest
// sections go first, the rest wait for later frames; a newer
// mesh of a waiting section replaces it.
let pending = &mut self.pending_uploads;
let fresh: horizon_client::fast_hash::FxHashSet<(i32, i32, i32)> =
uploads.iter().map(|(k, _)| *k).collect();
let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> =
unloaded.iter().map(|c| (c.x, c.z)).collect();
pending.retain(|(k, _)| !fresh.contains(k) && !gone.contains(&(k.0, k.2)));
pending.extend(uploads);
let centre = self.streaming.center;
let far = |k: &(i32, i32, i32)| (k.0 - centre.0).pow(2) + (k.2 - centre.1).pow(2);
pending.sort_unstable_by_key(|(k, _)| std::cmp::Reverse(far(k)));
let (limit, budget) = if budgeted {
(
g.chunk_updates as usize,
u64::from(g.upload_budget_kib) * 1024,
)
} else {
(0, 0)
};
let (mut count, mut bytes) = (0, 0u64);
while let Some((_, mesh)) = pending.last() {
let size = ((mesh.solid.len() + mesh.translucent.len()) * 32) as u64;
if (limit > 0 && count >= limit)
|| (budget > 0 && bytes > 0 && bytes + size > budget)
{
break;
}
let (key, mesh) = pending.pop().expect("checked above");
renderer.upload(key, &mesh);
count += 1;
bytes += size;
}
}
for c in unloaded {
for sy in 0..(HEIGHT / 16) {
@@ -7593,6 +7743,7 @@ impl ApplicationHandler for Game<'_> {
let assets = &self.res.assets;
match pollster::block_on(Renderer::new(
window.clone(),
self.options.offscreen,
&assets.pixels,
assets.sprite_size,
assets.texture_layers,
@@ -7627,6 +7778,15 @@ impl ApplicationHandler for Game<'_> {
) {
self.last_input = Instant::now();
}
if let WindowEvent::MouseInput {
state,
button: MouseButton::Left,
..
} = &event
{
// The press state of Horizon-style buttons.
horizon_client::ui_theme::set_pressed(*state == ElementState::Pressed);
}
let result: Result<()> = (|| {
match event {
WindowEvent::CloseRequested => {
@@ -7752,9 +7912,14 @@ impl ApplicationHandler for Game<'_> {
.as_ref()
.map(|r| [r.config.width, r.config.height])
.unwrap_or([1, 1]);
let b = HudBatch::new(size);
let mut b = HudBatch::new(size);
let atlas = &self.res.atlas;
let action = self.title.as_mut().map_or(Action::None, |t| {
t.click(self.cursor.0, self.cursor.1, (b.width, b.height))
if horizon_client::ui_theme::horizon() {
t.click_horizon(atlas, &mut b, self.cursor.0, self.cursor.1)
} else {
t.click(self.cursor.0, self.cursor.1, (b.width, b.height))
}
});
match action {
Action::Quit => event_loop.exit(),
@@ -7920,6 +8085,7 @@ impl ApplicationHandler for Game<'_> {
WindowEvent::KeyboardInput { event, .. } if self.title.is_some() => {
if event.state == ElementState::Pressed
&& let Some(title) = self.title.as_mut()
&& !title.skip_opening()
{
match event.physical_key {
PhysicalKey::Code(KeyCode::Backspace) => title.backspace(),
@@ -8001,18 +8167,26 @@ impl ApplicationHandler for Game<'_> {
self.pitch = (self.pitch
+ (turn(KeyCode::ArrowDown) - turn(KeyCode::ArrowUp)) * 90.0 * secs)
.clamp(-90.0, 90.0);
if self.title.is_none() {
self.update_world();
self.spawn_player()?;
self.update_backdrop()?;
horizon_client::ui_theme::set_backdrop(self.backdrop_fade());
if self.screen != Screen::Paused {
self.pause_since = None;
}
if self.title.is_none() {
horizon_client::spans::time("update_world", || self.update_world());
horizon_client::spans::time("spawn_player", || self.spawn_player())?;
}
let updated = Instant::now();
self.track_toggle_screens();
let steps = self.clock.advance(elapsed);
for _ in 0..steps {
let started = Instant::now();
if self.title.is_none() && !self.pause {
self.tick()?;
horizon_client::spans::time("tick", || self.tick())?;
self.tick_ambience();
self.tick_fov();
} else if self.title.is_some() {
self.tick_backdrop();
}
self.tick_music();
self.tick_sound_engine();
@@ -8022,7 +8196,48 @@ impl ApplicationHandler for Game<'_> {
}
}
self.apply_mouse_look();
self.advance_flight();
let ticked = Instant::now();
let rendered = self.render()?;
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;
let spans = horizon_client::spans::take();
if cpu > threshold {
let list: Vec<String> = spans
.iter()
.filter(|(_, ms)| *ms >= 0.5)
.map(|(n, ms)| format!("{n} {ms:.1}"))
.collect();
eprintln!(
"Horizon: slow frame {cpu:.1} ms ({} ticks): {}",
steps,
list.join(", ")
);
}
}
if let Some(r) = self.renderer.as_mut() {
let cpu = now.elapsed().saturating_sub(r.last_wait);
if rendered {
let ms = |d: Duration| d.as_secs_f32() * 1000.0;
self.frame_graph.record(
ms(elapsed),
ms(cpu),
r.gpu_times.last().copied(),
);
self.frame_stats.record_cpu(ms(cpu));
let render = r.last_encode;
self.frame_stats.record_phases([
ms(updated - now),
ms(ticked - updated),
ms(ticked.elapsed().saturating_sub(r.last_wait + render)),
ms(render),
]);
}
self.frame_stats.record_gpu(&r.gpu_times);
r.gpu_times.clear();
}
self.limit_frame();
self.pause = self.pauses_game();
self.clock.end_frame(self.pause);
@@ -8103,6 +8318,7 @@ pub fn run() -> Result<()> {
return Ok(());
}
let started = Instant::now();
horizon_client::spans::enable(options.spikes.is_some());
let vanilla_root = options.resources.clone();
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs);
@@ -8151,10 +8367,7 @@ pub fn run() -> Result<()> {
biomes: biomes.clone(),
light,
});
let threads = std::thread::available_parallelism()
.map_or(2, |n| n.get())
.saturating_sub(1)
.max(1);
let threads = early_settings.horizon.worker_threads();
eprintln!(
"Horizon: resources loaded in {:.1}s; {} block states, {} items, {} biomes; {} worker threads",
started.elapsed().as_secs_f32(),
@@ -8246,6 +8459,14 @@ pub fn run() -> Result<()> {
let now = Instant::now();
let game_root = options.resources.clone();
let mut settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
horizon_client::ui_theme::load_preferences(&std::env::current_dir()?);
// Scripted and frame-limited runs (the parity harnesses) see vanilla's
// screens unless HORIZON_MENU_STYLE asks otherwise.
if (options.frames.is_some() || options.control.is_some())
&& std::env::var_os("HORIZON_MENU_STYLE").is_none()
{
horizon_client::ui_theme::set_style(horizon_client::ui_theme::MenuStyle::Classic);
}
if let Some(d) = options.distance_override {
settings.render_distance = d;
}
@@ -8357,12 +8578,17 @@ pub fn run() -> Result<()> {
orbs: Vec::new(),
xp_delay: 0,
last_level_up: 0,
swing: None,
swing_anim: (0.0, 0.0),
hand_height: (1.0, 1.0),
hand_item: None,
swing: Default::default(),
swings_started: 0,
heights: Default::default(),
visible_hands: [(0, None), (0, None)],
clicks: Default::default(),
attack_clicks: 0,
hand_strength: Default::default(),
settings,
options_from_title: false,
backdrop: Default::default(),
pause_since: None,
dragging: false,
avatar: Default::default(),
flash: 0,
@@ -8428,6 +8654,11 @@ pub fn run() -> Result<()> {
frame_stats: Default::default(),
pending_screenshot: None,
pending_bench: None,
flight: None,
view_frustum: None,
pending_uploads: Vec::new(),
frame_graph: Default::default(),
sky_darken_memo: Default::default(),
persist: Default::default(),
remote: None,
lan: None,
@@ -8493,7 +8724,16 @@ pub fn run() -> Result<()> {
let (seed, creative) = (game.options.seed, game.options.creative);
game.open_world(&folder, None, Some(seed), creative)?;
}
None => game.title = Some(Title::new(std::env::current_dir()?.join("saves"))),
None => {
let title = Title::new(std::env::current_dir()?.join("saves"));
// The launcher's opening, once per session and only in Horizon's style.
if horizon_client::ui_theme::horizon() && game.options.frames.is_none() {
title
.opening
.set(Some(horizon_client::front::Opening::new()));
}
game.title = Some(title);
}
}
if let (Some(script), Some(output)) = (
std::env::var_os("HORIZON_COMMAND_SCRIPT"),
@@ -183,7 +183,7 @@ impl Game<'_> {
true
}
/// The armor stand under the crosshair, its distance and the hit point.
fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
pub(super) fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
+245
View File
@@ -0,0 +1,245 @@
//! The live title backdrop (Menu style: Horizon): a frozen landscape that
//! Horizon generates and renders itself behind the title screens, seen
//! through a slow orbiting camera, falling snow, an overcast sky and the cold
//! blur and grade of `gfx::PostGrade`. Nothing is bundled: the chunks come
//! from the vanilla generator for a fixed seed and are cached in
//! `saves/.horizon-title/backdrop` (no level.dat, so no world list entry).
//! The ice-spikes spot is found once with `/locate biome`'s search and kept
//! in `spot.txt` there.
use super::Game;
use horizon_client::{
Result,
save::{Persistent, SaveDir},
streaming::Streaming,
vanillagen::VanillaTerrain,
world::{Weather, World},
};
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
use std::{
sync::{Arc, Mutex},
time::Instant,
};
/// The world the backdrop is cut from (HORIZON_BACKDROP_SEED overrides it).
const SEED: i64 = 2_026_031_337;
/// The biome the camera looks for.
const BIOME: &str = "minecraft:ice_spikes";
/// Late morning: the snow reads clearly; the grade (`PostGrade::FROST`) makes it cold.
const DAY_TIME: i64 = 4_000;
#[derive(Default)]
pub(super) struct Backdrop {
pub active: bool,
/// The search for the spot, then the spot (block x, z).
spot: Arc<Mutex<Option<Option<(i32, i32)>>>>,
centred: bool,
/// Camera position once the ground under it has loaded.
pub camera: Option<glam::DVec3>,
pub ready: Option<Instant>,
pub started: Option<Instant>,
}
fn seed() -> i64 {
std::env::var("HORIZON_BACKDROP_SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(SEED)
}
impl Game<'_> {
/// How visible the live backdrop is (0 until its first chunks are drawn).
pub(super) fn backdrop_fade(&self) -> f32 {
let Some(at) = self.backdrop.ready else {
return 0.0;
};
let x = (at.elapsed().as_secs_f32() / 1.6).clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
/// Whether the frame should draw the backdrop world instead of the panorama.
pub(super) fn backdrop_live(&self) -> bool {
self.title.is_some() && self.backdrop.active && self.backdrop.camera.is_some()
}
/// The orbiting camera: position, yaw and pitch.
pub(super) fn backdrop_view(&self) -> Option<(glam::DVec3, f32, f32)> {
let c = self.backdrop.camera?;
let t = self
.backdrop
.started
.map_or(0.0, |s| s.elapsed().as_secs_f32());
// A full turn in about six minutes.
let yaw = (200.0 + t * 1.0) % 360.0;
let pitch = 6.0 + (t * 0.05).sin() * 2.0;
Some((c, yaw, pitch))
}
/// Called every frame while the title is up.
pub(super) fn update_backdrop(&mut self) -> Result<()> {
let wanted = self.title.is_some()
&& horizon_client::ui_theme::horizon()
&& self.renderer.is_some()
&& self.options.frames.is_none();
if !wanted {
if self.backdrop.active && self.title.is_some() {
self.stop_backdrop();
}
if self.title.is_none() && self.backdrop.active {
// open_world / connect_server replaced the world and streaming.
self.backdrop = Backdrop::default();
}
return Ok(());
}
if !self.backdrop.active {
self.start_backdrop()?;
}
let found = *self.backdrop.spot.lock().unwrap_or_else(|e| e.into_inner());
let Some(Some((x, z))) = found else {
return Ok(());
};
if !self.backdrop.centred {
self.backdrop.centred = true;
self.streaming.center = (x >> 4, z >> 4);
self.streaming.paused = false;
}
self.update_world();
if self.backdrop.camera.is_none()
&& (-2..=2)
.all(|dz| (-2..=2).all(|dx| self.streaming.is_lit((x >> 4) + dx, (z >> 4) + dz)))
{
// Above the tallest block nearby, so no spike pokes through the lens.
let mut top = 64;
for dz in (-12..=12).step_by(3) {
for dx in (-12..=12).step_by(3) {
if let Some(y) = self.surface_y(x + dx, z + dz) {
top = top.max(y);
}
}
}
let ground = self.surface_y(x, z).unwrap_or(top);
let y = (ground + 22).max(top + 4);
self.backdrop.camera = Some(glam::DVec3::new(
f64::from(x) + 0.5,
f64::from(y),
f64::from(z) + 0.5,
));
}
if self.backdrop.camera.is_some()
&& self.backdrop.ready.is_none()
&& self
.renderer
.as_ref()
.is_some_and(|r| r.section_count() > 40)
{
self.backdrop.ready = Some(Instant::now());
}
Ok(())
}
/// The world tick of the backdrop: time passes for clouds and snow only.
pub(super) fn tick_backdrop(&mut self) {
if self.backdrop.active {
self.world.game_time += 1;
// The snow's fall follows the client tick counter.
self.ticks += 1;
}
}
fn surface_y(&self, x: i32, z: i32) -> Option<i32> {
let air = self.res.facts.air;
(-60..320).rev().find(|&y| self.world.block(x, y, z) != air)
}
fn start_backdrop(&mut self) -> Result<()> {
let seed = seed();
let root = std::env::current_dir()?
.join("saves")
.join(".horizon-title")
.join("backdrop");
std::fs::create_dir_all(&root)?;
let save = Arc::new(SaveDir::new(root.join(format!("{seed}"))));
let res = &*self.res;
let inner = Arc::new(
VanillaTerrain::new(
&self.options.resources.join("worldgen"),
res.assets.registry,
&res.biomes,
seed,
)
.map_err(|e| format!("title backdrop: {e}"))?,
);
let mut streaming = Streaming::new(
Arc::new(Persistent {
inner: inner.clone(),
save: save.clone(),
registry: res.assets.clone(),
biomes: res.biomes.clone(),
}),
res.context.clone(),
res.threads.min(6),
);
streaming.radius = self.settings.render_distance.clamp(4, 10);
streaming.biome_blend = self.settings.biome_blend;
streaming.smooth_lighting = self.settings.smooth_lighting;
streaming.cutout_leaves = self.settings.cutout_leaves;
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
streaming.paused = true;
self.streaming = streaming;
self.save = save;
let mut world = World::new(self.res.facts.air, seed);
world.day_time = DAY_TIME;
world.game_time = 6000;
// Light snow: the weather renders as snow in frozen biomes.
world.weather = Weather {
clear_time: 0,
rain_time: i32::MAX / 2,
thunder_time: i32::MAX / 2,
raining: true,
thundering: false,
rain_level: (0.35, 0.35),
thunder_level: (0.0, 0.0),
};
self.world = world;
self.dim = super::Dim::Overworld;
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop {
active: true,
started: Some(Instant::now()),
..Default::default()
};
// Find (or recall) the spot off the main thread.
let spot = self.backdrop.spot.clone();
std::fs::create_dir_all(root.join(format!("{seed}")))?;
let cache = root.join(format!("{seed}")).join("spot.txt");
std::thread::Builder::new()
.name("horizon-backdrop".into())
.spawn(move || {
let known = std::fs::read_to_string(&cache).ok().and_then(|t| {
let mut it = t.split_whitespace().map(|v| v.parse::<i32>().ok());
Some((it.next()??, it.next()??))
});
let found = known.or_else(|| {
let allowed = |name: &str| name == BIOME;
let search = horizon_world::worldgen::generator::BiomeSearch {
allowed: &allowed,
origin: [0, 64, 0],
radius: 6400,
horizontal: 32,
vertical: 64,
min_y: -64,
max_y: 320,
};
let found = inner.find_closest_biome(&search);
eprintln!("Horizon: title backdrop: {BIOME} at {found:?}");
let (_, p) = found?;
let _ = std::fs::write(&cache, format!("{} {}\n", p[0], p[2]));
Some((p[0], p[2]))
});
*spot.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Some(found.unwrap_or((0, 0))));
})?;
Ok(())
}
fn stop_backdrop(&mut self) {
self.streaming.paused = true;
self.world = World::new(self.res.facts.air, 0);
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop::default();
}
}
+1 -1
View File
@@ -597,7 +597,7 @@ impl Game<'_> {
self.swing_arm();
true
}
fn boat_hit(&self) -> Option<(usize, f64)> {
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
+62 -2
View File
@@ -29,9 +29,10 @@ const HELP: &[&str] = &[
"chat TEXT send a chat line or /command as if typed",
"type TEXT type into the open chat box (key enter tap sends)",
"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)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs graphics graphics-advanced)",
"stats [reset] frame-time summary since the last reset",
"bench SECONDS reset stats, wait, then reply with the summary",
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH] fly the camera along a line at constant speed, then reply with the frame summary",
"open WORLD [SEED] [creative] open or create a world from the title screen",
"save save the world",
"save-all save the world, rewriting every resident chunk",
@@ -42,7 +43,42 @@ const HELP: &[&str] = &[
"quit save and exit",
];
/// A straight camera path at constant speed, for repeatable benchmarks: the
/// player is placed on it every frame, facing along it.
pub struct Flight {
from: Vec3,
to: Vec3,
pitch: f32,
started: Instant,
seconds: f64,
reply: Sender<String>,
}
impl Game<'_> {
/// Move the camera along the running flight; reply when it ends.
pub(super) fn advance_flight(&mut self) {
let Some(flight) = &self.flight else { return };
let t = (flight.started.elapsed().as_secs_f64() / flight.seconds).min(1.0);
let (a, b) = (flight.from, flight.to);
let d = [b.x - a.x, b.y - a.y, b.z - a.z];
let at = Vec3::new(a.x + d[0] * t, a.y + d[1] * t, a.z + d[2] * t);
// Minecraft yaw: 0 faces +Z, 90 faces -X.
self.yaw = (-d[0]).atan2(d[2]).to_degrees() as f32;
self.pitch = flight.pitch;
if let Some(player) = self.player.as_mut() {
player.position = at;
player.velocity = Vec3::new(0.0, 0.0, 0.0);
player.fall_distance = 0.0;
// Stay where the flight ends (creative flight).
player.flying = true;
self.previous_position = at;
}
if t >= 1.0
&& let Some(flight) = self.flight.take()
{
let _ = flight.reply.send(self.frame_stats.summary().to_string());
}
}
pub(super) fn poll_control(&mut self, event_loop: &ActiveEventLoop) {
if let Some((deadline, reply)) = self.pending_bench.take() {
if Instant::now() >= deadline {
@@ -325,6 +361,8 @@ impl Game<'_> {
"language" => Page::Language,
"font" => Page::Font,
"packs" => Page::Packs,
"graphics" => Page::Graphics,
"graphics-advanced" => Page::GraphicsAdvanced,
other => return Err(format!("unknown options page {other}").into()),
};
if self.screen == Screen::Playing && self.title.is_none() {
@@ -357,6 +395,24 @@ impl Game<'_> {
));
Ok(None)
}
"fly" => {
self.require_world()?;
if self.flight.is_some() || self.pending_bench.is_some() {
return Err("a benchmark is already running".into());
}
let n = |i: usize| -> Result<f64> { Ok(arg(i)?.parse()?) };
let pitch = words.get(8).map_or(Ok(10.0), |p| p.parse::<f32>())?;
self.frame_stats.reset();
self.flight = Some(Flight {
from: Vec3::new(n(1)?, n(2)?, n(3)?),
to: Vec3::new(n(4)?, n(5)?, n(6)?),
pitch,
started: Instant::now(),
seconds: n(7)?.max(0.1),
reply: reply.clone(),
});
Ok(None)
}
"open" => {
if self.title.is_none() {
return Err("a world is already open".into());
@@ -491,6 +547,7 @@ impl Game<'_> {
"present_mode": format!("{:?}", r.config.present_mode),
"drawn_sections": r.drawn_sections,
"uploaded_bytes": r.uploaded_bytes,
"resident_sections": r.resident_sections(),
})
});
json!({
@@ -510,6 +567,7 @@ impl Game<'_> {
"ticks": self.ticks,
"day_time": self.world.day_time,
"music": self.music.current(&self.audio.engine),
"sounds": self.audio.census(),
"fishing": self.hook_status(),
"tridents": self.tridents.len(),
"rockets": self.rockets.len(),
@@ -519,7 +577,9 @@ impl Game<'_> {
"using": self.using,
"render_distance": self.streaming.radius,
"chunks": self.world.chunks.len(),
"pending_chunks": self.streaming.pending(),
"mobs": self.mobs.len(),
"block_entities": self.world.block_entities.len(),
"pending_chunks": self.streaming.pending() + self.pending_uploads.len(),
"generated_chunks": self.streaming.generated_total,
"meshed_sections": self.streaming.meshed_total,
"renderer": renderer,
+17 -9
View File
@@ -17,10 +17,7 @@ use horizon_core::{
random::{LegacyRandom, RandomSource},
};
use horizon_world::registry::BlockStateId;
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use std::{collections::HashMap, sync::Arc};
type Pos = (i32, i32, i32);
@@ -319,7 +316,16 @@ impl Game<'_> {
let Some(chunk) = self.world.chunks.get(&key).cloned() else {
continue;
};
let name = |id: u16| &*self.res.facts.get(BlockStateId(u32::from(id))).name;
if self.creaking.hearts.is_empty() {
self.creaking.hearts = self
.res
.facts
.states
.iter()
.map(|f| &*f.name == "minecraft:creaking_heart")
.collect();
}
let hearts = &self.creaking.hearts;
let _ = heart;
for (si, section) in chunk.sections.iter().enumerate() {
if section.is_uniform(0) {
@@ -327,7 +333,7 @@ impl Game<'_> {
}
for i in 0..4096 {
let id = section.get(i);
if name(id) != "minecraft:creaking_heart" {
if !hearts.get(usize::from(id)).copied().unwrap_or(false) {
continue;
}
let (lx, lz, ly) = ((i & 15) as i32, ((i >> 4) & 15) as i32, (i >> 8) as i32);
@@ -343,8 +349,8 @@ impl Game<'_> {
}
}
}
let loaded: HashSet<(i32, i32)> = self.world.chunks.keys().copied().collect();
self.creaking.scanned.retain(|k| loaded.contains(k));
let chunks = &self.world.chunks;
self.creaking.scanned.retain(|k| chunks.contains_key(k));
}
}
@@ -352,5 +358,7 @@ impl Game<'_> {
#[derive(Default)]
pub(super) struct CreakingState {
/// Chunks already scanned for heart blocks.
scanned: HashSet<(i32, i32)>,
scanned: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
/// Which block states are creaking hearts, by state id.
hearts: Vec<bool>,
}
+51 -1
View File
@@ -403,6 +403,25 @@ impl Game<'_> {
self.tick_raids();
self.tick_creature_entities()
}
/// `DistanceManager.inEntityTickingRange`: chunks within the simulation
/// distance (Chebyshev, in chunks) of the player or of a guest.
pub(super) fn entity_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
let d = self.settings.simulation_distance.max(2);
let mut centres: Vec<(i32, i32)> = self.streaming.extra.iter().map(|&(c, _)| c).collect();
if let Some(p) = &self.player {
centres.push((
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
));
}
let everywhere = centres.is_empty();
move |x, z| {
everywhere
|| centres
.iter()
.any(|&(cx, cz)| ((x >> 4) - cx).abs().max(((z >> 4) - cz).abs()) <= d)
}
}
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
/// projectiles and effects that follow from them.
pub(super) fn tick_creature_entities(&mut self) -> Result<()> {
@@ -424,6 +443,7 @@ impl Game<'_> {
let mut mobs = std::mem::take(&mut self.mobs);
let mut dead = Vec::new();
let mut died = std::mem::take(&mut self.died);
let ticking = self.entity_ticking_area();
let level_random;
{
// The level random, lent to the mobs (brain behaviours draw from
@@ -441,6 +461,18 @@ impl Game<'_> {
keep.push(!m.hostile() || m.persistent);
continue;
}
// ServerLevel.tick: every loaded entity checks despawning, but
// only those in entity-ticking chunks (within the simulation
// distance) tick.
if !ticking(m.position.x.floor() as i32, m.position.z.floor() as i32) {
let kept = horizon_client::mobs::ai::despawn_check(m, &w);
if !kept {
died.remove(&m.id);
snapshot[i].alive = false;
}
keep.push(kept);
continue;
}
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
let from = events.len();
let kept = m.tick(&w, &mut scratch, &mut events);
@@ -2248,11 +2280,29 @@ impl Game<'_> {
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let atlas = &self.res.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = f64::from(self.settings.entity_distance);
for mob in &self.mobs {
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
if d.length_squared() > 128.0 * 128.0 {
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the entity distance option.
let (w, h, _) = mob.dims();
let range = (w + h + w) / 3.0 * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
// EntityRenderer.shouldRender: the culling box, grown by 0.5
// (GuardianRenderer also draws while its beam is out).
if let Some(view) = &self.view_frustum
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
let lo =
d.as_vec3() - glam::Vec3::new(w as f32 / 2.0 + 0.5, 0.5, w as f32 / 2.0 + 0.5);
let hi = d.as_vec3()
+ glam::Vec3::new(w as f32 / 2.0 + 0.5, h as f32 + 0.5, w as f32 / 2.0 + 0.5);
if !view.intersects(lo, hi) {
continue;
}
}
let light = self.world.light(
mob.position.x.floor() as i32,
(mob.position.y + mob.eye_height()).floor() as i32,
@@ -376,6 +376,7 @@ impl Game<'_> {
self.set_default_game_mode(game_mode);
self.ui.difficulty = props.difficulty as u8;
self.settings.render_distance = props.view_distance.clamp(2, 32);
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
self.streaming.radius = self.settings.render_distance;
self.settle_spawn()?;
let gate_lists = lists.clone();
+9 -5
View File
@@ -722,10 +722,9 @@ impl Game<'_> {
.play("minecraft:entity.ender_dragon.hurt", Some(at), 5.0, pitch);
}
/// Player melee on the dragon's parts or a crystal under the crosshair.
pub(super) fn attack_dragon(&mut self) -> bool {
let Some(eye) = self.eye_position() else {
return false;
};
/// The dragon part under the crosshair within reach: its id and distance.
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(u32, f64)> = None;
@@ -750,7 +749,12 @@ impl Game<'_> {
best = Some((id, t0));
}
}
let Some((id, d)) = best else { return false };
best
}
pub(super) fn attack_dragon(&mut self) -> bool {
let Some((id, d)) = self.dragon_hit() else {
return false;
};
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
+9
View File
@@ -86,6 +86,15 @@ impl Game<'_> {
false,
)
}
/// `Camera.calculateHudFov`: the first-person hand's field of view.
pub(super) fn hud_fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
self.view.calculate_hud_fov(
partial,
self.fov_effect_scale(),
self.death_time(),
self.camera_fluid(camera),
)
}
/// The pose `GameRenderer.renderLevel` multiplies into the projection:
/// `bobHurt`, then `bobView` with the View Bobbing option.
pub(super) fn bob_matrix(&self, partial: f32) -> glam::Mat4 {
+372
View File
@@ -0,0 +1,372 @@
//! The local player's hand: clicks and held attack through
//! `horizon_client::first_person` (`Minecraft.startAttack` /
//! `continueAttack`, `MultiPlayerGameMode` block breaking), the arm swing
//! (`LivingEntity.SwingState`) and the first-person equip heights
//! (`FirstPersonHandsAndItems`) with the item swap ticker they read.
use super::{Game, Mining, Screen};
use horizon_client::{
Result,
attack_strength::HeldStack,
first_person::{Hand, HandWorld, Hit, SwingAnimation, UseResult, swing_duration},
items::ItemStack,
};
/// The off hand's place in `visible_hands` (the main hand uses its hotbar slot).
const OFFHAND: usize = usize::MAX;
impl Game<'_> {
/// `LivingEntity.swing` with `getModifiedSwingDuration`.
pub(super) fn swing_hand(&mut self, hand: Hand, animation: SwingAnimation) {
let effects = &self.potions.effects;
let dig_speed = effects
.amplifier("haste")
.max(effects.amplifier("conduit_power"));
let fatigue = effects.amplifier("mining_fatigue");
let duration = swing_duration(animation, dig_speed, fatigue);
if self.swing.start_if_able(hand, animation, duration) {
self.swings_started += 1;
}
}
/// `swing(InteractionHand.MAIN_HAND)` with the default animation.
pub(super) fn swing_arm(&mut self) {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
/// `Player.resetAttackStrengthTicker`.
pub(super) fn reset_attack_strength(&mut self) {
self.attack_ticks = 0;
self.hand_strength.reset();
}
/// The attack and use part of `Minecraft.tick` for the local player:
/// attack clicks since the last tick and the held attack button.
pub(super) fn tick_local_hand(&mut self) -> Result<()> {
let attack_clicks = std::mem::take(&mut self.attack_clicks);
if self.screen == Screen::Playing && self.title.is_none() {
let mut clicks = std::mem::take(&mut self.clicks);
// keyAttack.isDown(); playing implies the grabbed mouse here
// (scripts under --control hold buttons without grabbing it).
let down = self.left;
let ticks = self.clicks.destroying.ticks;
let mut world = GameHands {
game: self,
error: None,
};
clicks.tick(&mut world, attack_clicks, 0, down, false);
let error = world.error.take();
self.clicks = clicks;
if let Some(e) = error {
return Err(e);
}
// A tick of breaking: the hit sound every fourth tick and the
// cracking particles.
let d = &self.clicks.destroying;
if d.active && d.ticks > ticks {
let (pos, first) = (d.pos, (d.ticks as u32 - 1).is_multiple_of(4));
let state = self.world.block(pos[0], pos[1], pos[2]);
if first {
let sounds = self.res.facts.get(state).sounds.clone();
self.audio.play(
&sounds.hit,
Some(pos.map(|v| f64::from(v) + 0.5)),
(sounds.volume + 1.0) / 8.0,
sounds.pitch * 0.5,
);
}
if let Some(hit) = self.pick() {
self.crack_particle(pos, hit.face, state);
}
}
} else {
// Minecraft.tick: a screen keeps the attack from starting.
self.clicks.miss_time = 10000;
}
let d = &self.clicks.destroying;
self.mining = d.active.then_some(Mining {
pos: d.pos,
progress: d.progress,
ticks: d.ticks as u32,
});
Ok(())
}
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
pub(super) fn destroy_progress(&self, pos: [i32; 3]) -> f32 {
let state = self.world.block(pos[0], pos[1], pos[2]);
let facts = self.res.facts.get(state);
if facts.hardness == -1.0 {
return 0.0;
}
let held = self.held();
let mut speed = self.res.items.mining_speed(held, &facts.name);
speed = self.modify_destroy_speed(speed);
if let Some(eye) = self.eye_position()
&& self
.res
.facts
.get(self.world.block(
eye[0].floor() as i32,
eye[1].floor() as i32,
eye[2].floor() as i32,
))
.fluid
== horizon_client::blocks::Fluid::Water
{
speed *= self.submerged_mining_speed();
}
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
speed /= 5.0;
}
let harvest = !facts.requires_tool || self.res.items.correct_for_drops(held, &facts.name);
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
}
/// Whether an attack would hit an entity rather than the block behind
/// it: the crosshair's `EntityHitResult`.
fn entity_targeted(&self) -> bool {
let before = |d: Option<f64>| d.is_some_and(|d| self.before_block(d));
before(self.painting_hit().map(|h| h.1))
|| before(self.frame_hit().map(|h| h.1))
|| before(self.armor_stand_hit().map(|h| h.1))
|| before(self.boat_hit().map(|h| h.1))
|| before(self.cart_hit().map(|h| h.1))
|| before(self.dragon_hit().map(|h| h.1))
|| before(self.mob_hit().map(|h| h.1))
}
/// `LocalPlayer.isHandsBusy`: paddling a boat.
fn hands_busy(&self) -> bool {
self.riding
.is_some_and(|id| self.boats.iter().any(|b| b.id == id))
&& [
winit::keyboard::KeyCode::KeyW,
winit::keyboard::KeyCode::KeyS,
winit::keyboard::KeyCode::KeyA,
winit::keyboard::KeyCode::KeyD,
]
.iter()
.any(|k| self.keys.contains(k))
}
/// `MultiPlayerGameMode.startUseItem`'s bookkeeping around the game's
/// use: nothing starts while a block is being broken or the hands are
/// busy, and `LocalPlayer.itemUsed` drops the hand after a use that
/// swung and spent (or, with infinite materials, kept) the item, or that
/// started using it.
pub(super) fn use_with_hand(&mut self) -> Result<()> {
if self.clicks.destroying.active || self.hands_busy() {
return Ok(());
}
let before = self.held().cloned();
let swings = self.swings_started;
let using = self.using.is_some();
self.use_item()?;
let swung = self.swings_started != swings;
let after = self.held().cloned();
let spent = match (&before, &after) {
(Some(a), Some(b)) => a.count != b.count || a.item != b.item || self.creative,
_ => false,
};
if (swung && spent) || (!using && self.using.is_some() && after.is_some()) {
self.heights.item_used(Hand::Main);
}
Ok(())
}
/// `LocalPlayer.tick`: the swing (`LivingEntity.baseTick`), the attack
/// strength and item swap tickers (`Player.tick`), then
/// `FirstPersonHandsAndItems.tick`.
pub(super) fn tick_hand(&mut self) {
self.swing.tick();
let main = self.inventory[self.selected].clone();
self.hand_strength.tick(main.as_ref().map(|s| HeldStack {
item: s.item.to_string(),
rest: format!("{} {} {:?}", s.count, s.damage, s.extra),
}));
let next = [(self.selected, main), (OFFHAND, self.offhand.clone())];
// shouldInstantlyReplaceVisibleItem: the same stack, or an equal one
// (matchesIgnoringComponents; every item plays the swap animation).
// A stack that stays in its slot with the same item is the same
// stack, whatever its count (vanilla shrinks and grows it in place).
let mut changed = [false; 2];
for h in 0..2 {
let same = match (&self.visible_hands[h], &next[h]) {
((_, None), (_, None)) => true,
((slot, Some(a)), (next_slot, Some(b))) => {
a.item == b.item
&& (slot == next_slot || (a.count == b.count && a.extra == b.extra))
}
_ => false,
};
if same {
self.visible_hands[h] = next[h].clone();
}
changed[h] = !same;
}
let speed = self.main_hand_attack_speed();
let replace = self.heights.tick(
self.hands_busy(),
self.hand_strength.swap_scale(1.0, speed),
changed,
);
for h in 0..2 {
if replace[h] {
self.visible_hands[h] = next[h].clone();
}
}
}
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier.
fn main_hand_attack_speed(&self) -> f64 {
horizon_client::attack_strength::BASE_ATTACK_SPEED
+ self
.held()
.and_then(|h| self.res.items.get(&h.item))
.map_or(0.0, |i| f64::from(i.attack_speed))
}
/// The visible main- and off-hand items.
pub(super) fn visible_hand_items(&self) -> [Option<ItemStack>; 2] {
[
self.visible_hands[0].1.clone(),
self.visible_hands[1].1.clone(),
]
}
}
/// The game as `first_person`'s world: the crosshair, breaking and attacking.
struct GameHands<'g, 'a> {
game: &'g mut Game<'a>,
error: Option<Box<dyn std::error::Error>>,
}
impl GameHands<'_, '_> {
fn keep(&mut self, r: Result<()>) {
if let Err(e) = r
&& self.error.is_none()
{
self.error = Some(e);
}
}
}
impl HandWorld for GameHands<'_, '_> {
fn hit(&mut self) -> Hit {
if self.game.entity_targeted() {
return Hit::Entity;
}
match self.game.pick() {
Some(h) => Hit::Block {
pos: h.pos,
face: match h.face {
[0, -1, 0] => 0,
[0, 1, 0] => 1,
[0, 0, -1] => 2,
[0, 0, 1] => 3,
[-1, 0, 0] => 4,
_ => 5,
},
},
None => Hit::Miss,
}
}
fn is_air(&mut self, pos: [i32; 3]) -> bool {
let state = self.game.world.block(pos[0], pos[1], pos[2]);
self.game.res.facts.get(state).name.ends_with("air")
}
fn creative(&self) -> bool {
self.game.creative
}
fn spectator(&self) -> bool {
self.game.spectator()
}
fn instabuild(&self) -> bool {
self.game.creative
}
fn hands_busy(&self) -> bool {
self.game.hands_busy()
}
fn using_item(&self) -> bool {
self.game.using.is_some()
}
fn item_enabled(&mut self, _: Hand) -> bool {
true
}
fn held_empty(&mut self, hand: Hand) -> bool {
match hand {
Hand::Main => self.game.held().is_none(),
Hand::Off => self.game.offhand.is_none(),
}
}
fn held_count(&mut self, hand: Hand) -> i32 {
let stack = match hand {
Hand::Main => self.game.held(),
Hand::Off => self.game.offhand.as_ref(),
};
stack.map_or(0, |s| s.count as i32)
}
fn infinite_materials(&self) -> bool {
self.game.creative
}
fn cannot_attack(&mut self) -> bool {
false
}
fn piercing_weapon(&mut self) -> bool {
false
}
fn attack_animation(&mut self) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn interact_animation(&mut self, _: Hand) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn piercing_attack(&mut self, _: SwingAnimation) {}
fn attack_entity(&mut self) -> bool {
self.game.attack()
}
fn block_action_restricted(&mut self, _: [i32; 3]) -> bool {
!self.game.may_build()
}
fn within_border(&mut self, _: [i32; 3]) -> bool {
true
}
fn attack_block(&mut self, _: [i32; 3]) {}
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
self.game.destroy_progress(pos)
}
fn destroy_block(&mut self, pos: [i32; 3]) -> bool {
if self.is_air(pos) {
return false;
}
// Item.canDestroyBlock: swords, tridents and maces cannot break
// blocks in creative.
let held = self.game.held().map(|s| s.item.to_string());
if self.game.creative
&& held.is_some_and(|i| {
i.ends_with("_sword") || i == "minecraft:trident" || i == "minecraft:mace"
})
{
return false;
}
let r = self.game.break_block(pos);
self.keep(r);
true
}
fn main_hand_key(&mut self) -> String {
self.game
.held()
.map_or(String::new(), |s| format!("{} {:?}", s.item, s.extra))
}
fn destroy_stage(&mut self, _: [i32; 3], _: Option<i32>) {}
fn swing(&mut self, hand: Hand, animation: SwingAnimation) {
self.game.swing_hand(hand, animation);
}
fn reset_attack_strength(&mut self) {
self.game.reset_attack_strength();
}
fn entity_within_border(&mut self) -> bool {
true
}
fn use_on_entity(&mut self, _: Hand) -> Option<UseResult> {
None
}
fn use_on_block(&mut self, _: Hand) -> UseResult {
UseResult::Pass
}
fn use_item(&mut self, _: Hand) -> UseResult {
UseResult::Pass
}
fn item_used(&mut self, hand: Hand) {
self.game.heights.item_used(hand);
}
fn release_using(&mut self) {}
}
@@ -29,8 +29,7 @@ pub(super) struct GuestPlay {
using: Option<(usize, u32)>,
use_delay: u32,
attack_ticks: u32,
swing: Option<u32>,
swing_anim: (f32, f32),
swing: horizon_client::first_person::SwingState,
/// The block being dug and the face the client aimed at.
digging: Option<([i32; 3], [i32; 3])>,
last_progress: i32,
@@ -131,8 +130,7 @@ impl Game<'_> {
let using = std::mem::replace(&mut self.using, play.using.take());
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
let swing = std::mem::replace(&mut self.swing, play.swing.take());
let swing_anim = std::mem::replace(&mut self.swing_anim, play.swing_anim);
let swing = std::mem::replace(&mut self.swing, play.swing);
let (left, right) = (self.left, self.right);
self.left = play.digging.is_some();
self.right = play.using.is_some() || self.using.is_some();
@@ -153,7 +151,6 @@ impl Game<'_> {
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
play.swing = std::mem::replace(&mut self.swing, swing);
play.swing_anim = std::mem::replace(&mut self.swing_anim, swing_anim);
self.container = container;
self.screen = screen;
if sneak {
@@ -689,7 +686,7 @@ impl Game<'_> {
.mining
.as_ref()
.map_or(-1, |m| (m.progress.clamp(0.0, 1.0) * 10.0) as i32 - 1);
let swinging = play.swing.is_some();
let swinging = play.swing.swinging();
if progress != play.last_progress && progress >= 0 {
play.last_progress = progress;
host.hub.broadcast_near(
@@ -779,7 +776,7 @@ impl Game<'_> {
};
let (id, at) = (g.entity_id, g.position);
let play = host.play.get_mut(&conn).expect("play");
let swinging = play.swing == Some(0);
let swinging = play.swing.swinging() && play.swing.ticks == 0;
if swinging && !play.swung {
host.hub.swing(id, 0, at, Some(conn));
}
+7 -2
View File
@@ -762,9 +762,14 @@ impl Game<'_> {
/// Every cart but the ridden one (which ticks with its rider).
pub(super) fn tick_carts(&mut self) {
self.push_carts();
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
let mut i = 0;
while i < self.carts.len() {
if Some(self.carts[i].id) == self.riding {
let p = self.carts[i].position;
if Some(self.carts[i].id) == self.riding
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
i += 1;
continue;
}
@@ -890,7 +895,7 @@ impl Game<'_> {
self.swing_arm();
true
}
fn cart_hit(&self) -> Option<(usize, f64)> {
pub(super) fn cart_hit(&self) -> Option<(usize, f64)> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
+13 -2
View File
@@ -142,7 +142,10 @@ impl Game<'_> {
let s = &self.settings;
if let Some(r) = self.renderer.as_mut() {
r.gamma = s.gamma;
r.set_vsync(s.vsync);
r.set_present_mode(s.vsync, s.horizon.present_mode);
r.animated_textures = s.horizon.animated_textures;
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
r.set_world_post(s.horizon.resolution_scale as f32 / 100.0, s.horizon.fxaa);
}
if let Some(w) = &self.window {
let want = s.fullscreen;
@@ -207,6 +210,14 @@ impl Game<'_> {
return;
}
let limit = self.framerate_limit();
horizon_client::window::end_frame(&mut self.limiter, limit, &mut self.frame_clock);
match self.settings.horizon.pacing {
horizon_client::graphics::Pacing::Precise => {
horizon_client::window::end_frame(&mut self.limiter, limit, &mut self.frame_clock)
}
horizon_client::graphics::Pacing::Efficient => {
let mut clock = horizon_client::window::SleepingClock(self.frame_clock);
horizon_client::window::end_frame(&mut self.limiter, limit, &mut clock);
}
}
}
}
+1 -1
View File
@@ -831,7 +831,7 @@ impl Game<'_> {
self.spawn_loaded_entities();
let keys: HashSet<(i32, i32)> = unloaded.iter().map(|c| (c.x, c.z)).collect();
let chunks = self.entity_chunks(&keys, true);
if let Err(e) = self.save.save_entities(chunks) {
if let Err(e) = self.save.save_entities_later(chunks) {
eprintln!("Horizon: failed to save entities: {e}");
}
}
@@ -297,8 +297,8 @@ impl Game<'_> {
}
let crouching = p.pose == Pose::Crouching;
let (position, flying) = (p.position, p.flying || p.fall_flying);
let attack = self.swing_anim;
let swinging = self.swing.is_some();
let attack = (self.swing.old_animation, self.swing.animation);
let swinging = self.swing.swinging();
let (yaw, pitch) = (self.yaw, self.pitch);
let main = self.inventory[self.selected]
.as_ref()
+46 -4
View File
@@ -52,7 +52,32 @@ fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
]
}
impl Game<'_> {
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
pub(super) fn horizon_pause(&self) -> bool {
self.screen == Screen::Paused && self.title.is_none() && horizon_client::ui_theme::horizon()
}
fn pause_labels(&self) -> (String, &'static str) {
let world = if self.remote.is_some() {
"Multiplayer".to_owned()
} else {
self.level.name.clone()
};
let quit = if self.remote.is_some() {
"Disconnect"
} else {
"Save and quit"
};
(world, quit)
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
if horizon_client::ui_theme::horizon() {
let opened = *self.pause_since.get_or_insert_with(std::time::Instant::now);
let (world, quit) = self.pause_labels();
let mut ui = horizon_client::ui_theme::Ui::new(&self.res.atlas, batch, self.cursor);
horizon_client::front::pause::draw(&mut ui, &world, quit, opened);
ui.finish();
return;
}
let atlas = &self.res.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -102,10 +127,27 @@ impl Game<'_> {
let b = HudBatch::new(size);
(b.width, b.height)
};
let hit = pause_buttons(w, h)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4);
let hit = if horizon_client::ui_theme::horizon() {
let size = self
.renderer
.as_ref()
.map_or([1, 1], |r| [r.config.width, r.config.height]);
let mut b = HudBatch::new(size);
let (world, quit) = self.pause_labels();
horizon_client::front::pause::click(
&self.res.atlas,
&mut b,
self.cursor.0,
self.cursor.1,
&world,
quit,
)
} else {
pause_buttons(w, h)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
};
match hit {
Some(0) => {
self.screen = Screen::Playing;
File diff suppressed because it is too large. Load diff
+180
View File
@@ -0,0 +1,180 @@
//! One vertex buffer for every terrain section: sections own ranges of it, so
//! a frame binds it once and draws every section with indirect multi-draw.
use std::collections::BTreeMap;
/// Ranges are rounded up to this many vertices to limit fragmentation.
const GRAIN: u64 = 256;
/// Free vertex ranges: start -> length, never adjacent.
#[derive(Debug, PartialEq)]
struct FreeList(BTreeMap<u64, u64>);
impl FreeList {
fn new(capacity: u64) -> Self {
Self(BTreeMap::from([(0, capacity)]))
}
/// First fit.
fn take(&mut self, len: u64) -> Option<u64> {
let (&start, &free) = self.0.iter().find(|(_, l)| **l >= len)?;
self.0.remove(&start);
if free > len {
self.0.insert(start + len, free - len);
}
Some(start)
}
fn give(&mut self, start: u64, len: u64) {
let (mut start, mut len) = (start, len);
if let Some((&prev, &prev_len)) = self.0.range(..start).next_back()
&& prev + prev_len == start
{
self.0.remove(&prev);
start = prev;
len += prev_len;
}
if let Some(next_len) = self.0.remove(&(start + len)) {
len += next_len;
}
self.0.insert(start, len);
}
}
/// One vertex buffer of the arena; only the newest page grows.
struct Page {
buffer: wgpu::Buffer,
/// Capacity in vertices.
capacity: u64,
free: FreeList,
}
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
/// buffer sizes): a section's vertices sit in one page.
pub struct Arena {
pages: Vec<Page>,
stride: u64,
page_limit: u64,
}
impl Arena {
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
// 256 MiB pages, or less where the device's buffers are smaller.
let bytes = device.limits().max_buffer_size.min(256 << 20);
let page_limit = (bytes / stride) / GRAIN * GRAIN;
let capacity = capacity.next_multiple_of(GRAIN).min(page_limit);
Self {
pages: vec![Page {
buffer: Self::create(device, stride, capacity),
capacity,
free: FreeList::new(capacity),
}],
stride,
page_limit,
}
}
fn create(device: &wgpu::Device, stride: u64, capacity: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Section vertex arena"),
size: capacity * stride,
usage: wgpu::BufferUsages::VERTEX
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
})
}
/// The rounded length a request of `vertices` occupies.
fn span(vertices: u64) -> u64 {
vertices.max(1).next_multiple_of(GRAIN)
}
pub fn buffer(&self, page: u32) -> &wgpu::Buffer {
&self.pages[page as usize].buffer
}
/// A range of `vertices`: (page, first vertex). Grows the newest page
/// (copying its contents on the GPU) or starts a new one when nothing
/// free is large enough.
pub fn alloc(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
vertices: u64,
) -> (u32, u64) {
let len = Self::span(vertices).min(self.page_limit);
for (i, page) in self.pages.iter_mut().enumerate() {
if let Some(start) = page.free.take(len) {
return (i as u32, start);
}
}
let last = self.pages.len() - 1;
let current = self.pages[last].capacity;
if current < self.page_limit {
let grown = (current * 2).max(current + len).min(self.page_limit);
if grown - current >= len || grown == self.page_limit {
self.grow(device, queue, last, grown);
if let Some(start) = self.pages[last].free.take(len) {
return (last as u32, start);
}
}
}
let capacity = (1 << 20u64).max(len).min(self.page_limit);
let mut free = FreeList::new(capacity);
let start = free.take(len).expect("a new page fits one section");
self.pages.push(Page {
buffer: Self::create(device, self.stride, capacity),
capacity,
free,
});
((self.pages.len() - 1) as u32, start)
}
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
let len = Self::span(vertices).min(self.page_limit);
self.pages[page as usize].free.give(start, len);
}
fn grow(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, page: usize, capacity: u64) {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let buffer = Self::create(device, self.stride, capacity);
let old = &mut self.pages[page];
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Grow section arena"),
});
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
// Writes queued for the old buffer run before this copy.
queue.submit([encoder.finish()]);
let previous = old.capacity;
old.buffer = buffer;
old.capacity = capacity;
old.free.give(previous, capacity - previous);
}
pub fn clear(&mut self) {
for page in &mut self.pages {
page.free = FreeList::new(page.capacity);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_ranges_coalesce() {
let mut free = FreeList::new(4 * GRAIN);
let a = free.take(GRAIN).unwrap();
let b = free.take(2 * GRAIN).unwrap();
let c = free.take(GRAIN).unwrap();
assert_eq!((a, b, c), (0, GRAIN, 3 * GRAIN));
assert_eq!(free.take(1), None);
free.give(a, GRAIN);
free.give(c, GRAIN);
// Two holes of one grain: a two-grain request does not fit.
assert_eq!(free.take(2 * GRAIN), None);
free.give(b, 2 * GRAIN);
assert_eq!(free, FreeList::new(4 * GRAIN));
}
#[test]
fn first_fit_reuses_the_lowest_hole() {
let mut free = FreeList::new(8 * GRAIN);
let a = free.take(GRAIN).unwrap();
let _b = free.take(GRAIN).unwrap();
free.give(a, GRAIN);
assert_eq!(free.take(GRAIN), Some(a));
}
}
+196
View File
@@ -0,0 +1,196 @@
//! `SectionOcclusionGraph`: which sections the camera can see through open
//! faces. Breadth-first from the camera section through the faces each
//! section's VisGraph connects, never stepping back towards the camera.
//!
//! The search runs over dense arrays around the camera rather than hash sets,
//! so it stays cheap enough to redo whenever a section is meshed.
use super::SectionKey;
use std::collections::{HashMap, VecDeque};
const STEPS: [(i32, i32, i32); 6] = [
(0, -1, 0),
(0, 1, 0),
(0, 0, -1),
(0, 0, 1),
(-1, 0, 0),
(1, 0, 0),
];
const NONE: u8 = 6;
pub struct Occlusion {
/// VisGraph connectivity per column, one entry per section (bottom first).
columns: HashMap<(i32, i32), Box<[u64]>>,
sections: i32,
dirty: bool,
from: Option<SectionKey>,
radius: i32,
/// The searched box: lowest section y and its height in sections.
y0: i32,
height: i32,
reached: Vec<bool>,
vis: Vec<u64>,
queue: VecDeque<(u32, u8, u8)>,
}
impl Occlusion {
pub fn new(sections: i32) -> Self {
Self {
columns: HashMap::new(),
sections,
dirty: true,
from: None,
radius: 0,
y0: 0,
height: 0,
reached: Vec::new(),
vis: Vec::new(),
queue: VecDeque::new(),
}
}
pub fn set(&mut self, key: SectionKey, vis: u64) {
if !(0..self.sections).contains(&key.1) {
return;
}
let sections = self.sections as usize;
let column = self
.columns
.entry((key.0, key.2))
.or_insert_with(|| vec![crate::mesher::ALL_VISIBLE; sections].into());
let slot = &mut column[key.1 as usize];
if *slot != vis {
*slot = vis;
self.dirty = true;
}
}
pub fn remove(&mut self, key: SectionKey) {
let Some(column) = self.columns.get_mut(&(key.0, key.2)) else {
return;
};
if let Some(slot) = column.get_mut(key.1 as usize)
&& *slot != crate::mesher::ALL_VISIBLE
{
*slot = crate::mesher::ALL_VISIBLE;
self.dirty = true;
}
if column.iter().all(|&v| v == crate::mesher::ALL_VISIBLE) {
self.columns.remove(&(key.0, key.2));
}
}
pub fn clear(&mut self) {
self.columns.clear();
self.dirty = true;
}
fn index(&self, key: SectionKey) -> Option<usize> {
let from = self.from?;
let (dx, dz, dy) = (key.0 - from.0, key.2 - from.2, key.1 - self.y0);
let side = 2 * self.radius + 1;
if dx.abs() > self.radius || dz.abs() > self.radius || !(0..self.height).contains(&dy) {
return None;
}
Some((((dz + self.radius) * side + dx + self.radius) * self.height + dy) as usize)
}
fn key(&self, index: usize) -> SectionKey {
let from = self.from.expect("searched");
let side = 2 * self.radius + 1;
let i = index as i32;
let dy = i % self.height;
let column = i / self.height;
(
from.0 + column % side - self.radius,
self.y0 + dy,
from.2 + column / side - self.radius,
)
}
pub fn contains(&self, key: SectionKey) -> bool {
self.index(key).is_some_and(|i| self.reached[i])
}
/// Search again when sections changed or the camera moved to another section.
pub fn update(&mut self, from: SectionKey, radius: i32) {
if !self.dirty && self.from == Some(from) && self.radius == radius {
return;
}
self.from = Some(from);
self.radius = radius;
self.y0 = from.1.min(-1);
self.height = from.1.max(self.sections) - self.y0 + 1;
let side = (2 * radius + 1) as usize;
let size = side * side * self.height as usize;
self.reached.clear();
self.reached.resize(size, false);
self.vis.clear();
self.vis.resize(size, crate::mesher::ALL_VISIBLE);
for dz in -radius..=radius {
for dx in -radius..=radius {
let Some(column) = self.columns.get(&(from.0 + dx, from.2 + dz)) else {
continue;
};
let base = self
.index((from.0 + dx, 0, from.2 + dz))
.expect("column inside the searched box");
self.vis[base..base + column.len()].copy_from_slice(column);
}
}
let start = self.index(from).expect("camera inside the searched box");
self.reached[start] = true;
self.queue.clear();
self.queue.push_back((start as u32, NONE, 0));
while let Some((index, entered, travelled)) = self.queue.pop_front() {
let vis = self.vis[index as usize];
let key = self.key(index as usize);
for (d, step) in STEPS.iter().enumerate() {
let opposite = d ^ 1;
if travelled & (1 << opposite) != 0 {
continue;
}
if entered != NONE && vis & (1 << (usize::from(entered) * 6 + d)) == 0 {
continue;
}
let next = (key.0 + step.0, key.1 + step.1, key.2 + step.2);
if next.1 < -1 || next.1 > self.sections {
continue;
}
let Some(n) = self.index(next) else { continue };
if !self.reached[n] {
self.reached[n] = true;
self.queue
.push_back((n as u32, opposite as u8, travelled | (1 << d)));
}
}
}
self.dirty = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::ALL_VISIBLE;
#[test]
fn open_space_reaches_the_whole_box() {
let mut o = Occlusion::new(4);
o.update((0, 1, 0), 2);
for x in -2..=2 {
for z in -2..=2 {
for y in -1..=4 {
assert!(o.contains((x, y, z)), "{x} {y} {z}");
}
}
}
assert!(!o.contains((3, 1, 0)));
}
#[test]
fn closed_neighbours_hide_what_is_behind_them() {
let mut o = Occlusion::new(4);
for (dx, dy, dz) in STEPS {
o.set((dx, 1 + dy, dz), 0);
}
o.update((0, 1, 0), 3);
assert!(o.contains((1, 1, 0)), "a neighbour is seen");
assert!(!o.contains((2, 1, 0)), "nothing shows through it");
o.set((1, 1, 0), ALL_VISIBLE);
o.update((0, 1, 0), 3);
assert!(o.contains((2, 1, 0)));
}
}
+125
View File
@@ -0,0 +1,125 @@
//! GPU frame time from timestamp queries, read back a few frames late so the
//! CPU never waits for it. Frames whose readback slot is still busy go untimed.
use std::sync::{
Arc,
atomic::{AtomicU8, Ordering},
};
const SLOTS: usize = 4;
const FREE: u8 = 0;
const PENDING: u8 = 1;
const MAPPED: u8 = 2;
struct Slot {
buffer: wgpu::Buffer,
state: Arc<AtomicU8>,
}
pub struct GpuTimer {
queries: wgpu::QuerySet,
resolve: wgpu::Buffer,
slots: Vec<Slot>,
/// The slot this frame writes into, when one was free at `begin`.
current: Option<usize>,
next: usize,
period_ns: f32,
}
impl GpuTimer {
/// The features timing needs; `None` when the adapter lacks them.
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
let wanted =
wgpu::Features::TIMESTAMP_QUERY | wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS;
if adapter.features().contains(wanted) {
wanted
} else {
wgpu::Features::empty()
}
}
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Option<Self> {
if !device
.features()
.contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS)
{
return None;
}
let queries = device.create_query_set(&wgpu::QuerySetDescriptor {
label: Some("Frame timestamps"),
ty: wgpu::QueryType::Timestamp,
count: 2,
});
let resolve = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame timestamp resolve"),
size: 16,
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let slots = (0..SLOTS)
.map(|_| Slot {
buffer: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame timestamp readback"),
size: 16,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
state: Arc::new(AtomicU8::new(FREE)),
})
.collect();
Some(Self {
queries,
resolve,
slots,
current: None,
next: 0,
period_ns: queue.get_timestamp_period(),
})
}
pub fn begin(&mut self, encoder: &mut wgpu::CommandEncoder) {
let slot = self.next;
self.current = (self.slots[slot].state.load(Ordering::Acquire) == FREE).then_some(slot);
if self.current.is_some() {
encoder.write_timestamp(&self.queries, 0);
}
}
pub fn end(&mut self, encoder: &mut wgpu::CommandEncoder) {
let Some(slot) = self.current else { return };
encoder.write_timestamp(&self.queries, 1);
encoder.resolve_query_set(&self.queries, 0..2, &self.resolve, 0);
encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.slots[slot].buffer, 0, 16);
}
/// After the frame's submission: start reading its timestamps back.
pub fn submitted(&mut self) {
let Some(slot) = self.current.take() else {
return;
};
let state = self.slots[slot].state.clone();
state.store(PENDING, Ordering::Release);
self.slots[slot]
.buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
self.next = (slot + 1) % SLOTS;
}
/// GPU milliseconds of frames whose timestamps have arrived, oldest first.
pub fn collect(&mut self, device: &wgpu::Device, out: &mut Vec<f32>) {
let _ = device.poll(wgpu::PollType::Poll);
for i in 0..SLOTS {
let slot = &self.slots[(self.next + i) % SLOTS];
if slot.state.load(Ordering::Acquire) != MAPPED {
continue;
}
if let Ok(data) = slot.buffer.slice(..).get_mapped_range() {
let stamps: &[u64] = bytemuck::cast_slice(&data);
let ticks = stamps[1].saturating_sub(stamps[0]);
out.push((ticks as f64 * f64::from(self.period_ns) / 1e6) as f32);
}
slot.buffer.unmap();
slot.state.store(FREE, Ordering::Release);
}
}
}
+193
View File
@@ -0,0 +1,193 @@
//! Resolution scale and FXAA (Horizon settings): the world renders into its
//! own target at the scaled size, then one full-screen pass copies or
//! anti-aliases it onto the screen, under the hand and HUD. With neither
//! setting on, the world renders straight to the screen as before.
struct WorldTarget {
size: [u32; 2],
color: wgpu::TextureView,
depth: wgpu::TextureView,
group: wgpu::BindGroup,
}
pub struct Post {
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
copy: wgpu::RenderPipeline,
fxaa: wgpu::RenderPipeline,
format: wgpu::TextureFormat,
/// World resolution relative to the window (0.5..=2.0).
pub scale: f32,
pub antialias: bool,
target: Option<WorldTarget>,
}
impl Post {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon post"),
source: wgpu::ShaderSource::Wgsl(include_str!("../world_post.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon post bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon post"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = |entry: &str| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon post"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_post"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Horizon post"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
copy: pipeline("fs_copy"),
fxaa: pipeline("fs_fxaa"),
layout,
sampler,
format,
scale: 1.0,
antialias: false,
target: None,
}
}
/// The world's colour and depth targets this frame, or `None` to draw
/// it straight to the screen.
pub fn prepare(
&mut self,
device: &wgpu::Device,
window: [u32; 2],
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
if self.scale == 1.0 && !self.antialias {
self.target = None;
return None;
}
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
if self.target.as_ref().is_none_or(|t| t.size != size) {
let extent = wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
};
let texture = |label, format, usage| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
.create_view(&Default::default())
};
let color = texture(
"Scaled world",
self.format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
);
let depth = texture(
"Scaled world depth",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon post"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&color),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.target = Some(WorldTarget {
size,
color,
depth,
group,
});
}
self.target
.as_ref()
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// Draw the scaled world onto `screen`.
pub fn finish(&self, encoder: &mut wgpu::CommandEncoder, screen: &wgpu::TextureView) {
let Some(target) = &self.target else { return };
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: screen,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon post"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(if self.antialias {
&self.fxaa
} else {
&self.copy
});
pass.set_bind_group(0, &target.group, &[]);
pass.draw(0..3, 0..1);
}
}
+320
View File
@@ -0,0 +1,320 @@
//! Horizon's own graphics settings, beyond vanilla's options: kept in
//! `horizon-graphics.txt` next to `options.txt` so that file stays exactly
//! vanilla's. Every default reproduces vanilla's rendering; only choices the
//! player makes here depart from it.
use std::{fs, path::Path};
pub const FILE: &str = "horizon-graphics.txt";
/// Simple-level presets. `Custom` leaves every option as the player set it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Preset {
Performance,
Balanced,
Quality,
Ultra,
#[default]
Custom,
}
impl Preset {
pub const ALL: [Preset; 5] = [
Preset::Performance,
Preset::Balanced,
Preset::Quality,
Preset::Ultra,
Preset::Custom,
];
pub fn name(self) -> &'static str {
match self {
Preset::Performance => "performance",
Preset::Balanced => "balanced",
Preset::Quality => "quality",
Preset::Ultra => "ultra",
Preset::Custom => "custom",
}
}
pub fn label(self) -> &'static str {
match self {
Preset::Performance => "Performance",
Preset::Balanced => "Balanced",
Preset::Quality => "Quality",
Preset::Ultra => "Ultra",
Preset::Custom => "Custom",
}
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|p| p.name() == s)
.unwrap_or_default()
}
}
/// How the swapchain presents frames.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PresentMode {
/// Follow vanilla's VSync option (Mailbox or Fifo on, Immediate off).
#[default]
Auto,
Fifo,
Mailbox,
Immediate,
}
impl PresentMode {
pub const ALL: [PresentMode; 4] = [
PresentMode::Auto,
PresentMode::Fifo,
PresentMode::Mailbox,
PresentMode::Immediate,
];
pub fn name(self) -> &'static str {
match self {
PresentMode::Auto => "auto",
PresentMode::Fifo => "fifo",
PresentMode::Mailbox => "mailbox",
PresentMode::Immediate => "immediate",
}
}
pub fn label(self) -> &'static str {
match self {
PresentMode::Auto => "Auto (VSync)",
PresentMode::Fifo => "FIFO",
PresentMode::Mailbox => "Mailbox",
PresentMode::Immediate => "Immediate",
}
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|p| p.name() == s)
.unwrap_or_default()
}
}
/// What the frame limiter does in the last half millisecond before a frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Pacing {
/// Sleep the whole wait: no CPU spent spinning, a little less precise.
#[default]
Efficient,
/// Vanilla's `FramerateLimiter`: sleep, then spin until the deadline.
Precise,
}
/// The performance overlay drawn over the HUD.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Overlay {
#[default]
Off,
/// Frames per second and frame time.
Fps,
/// The numbers and a graph of recent frame times.
Graph,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Graphics {
pub preset: Preset,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
pub upload_budget_kib: u32,
pub sky: bool,
pub fog: bool,
pub weather: bool,
pub animated_textures: bool,
pub present_mode: PresentMode,
pub pacing: Pacing,
/// Chunk worker threads; 0 uses every core but one. Applies at the next start.
pub workers: u32,
pub overlay: Overlay,
/// World resolution as a percentage of the window's (50..=200).
pub resolution_scale: u32,
/// FXAA over the world.
pub fxaa: bool,
/// The settings level last shown: Advanced, or Simple.
pub advanced: bool,
}
impl Default for Graphics {
fn default() -> Self {
Self {
preset: Preset::Custom,
cave_culling: true,
chunk_updates: 0,
upload_budget_kib: 0,
sky: true,
fog: true,
weather: true,
animated_textures: true,
present_mode: PresentMode::Auto,
pacing: Pacing::Efficient,
workers: 0,
overlay: Overlay::Off,
resolution_scale: 100,
fxaa: false,
advanced: false,
}
}
}
fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl Graphics {
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
.unwrap_or_default()
}
pub fn parse(text: &str) -> Self {
let mut g = Self::default();
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = value.trim();
let flag = |d: bool| match value {
"true" => true,
"false" => false,
_ => d,
};
let number = |d: u32| value.parse().unwrap_or(d);
match key.trim() {
"preset" => g.preset = Preset::parse(value),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"sky" => g.sky = flag(g.sky),
"fog" => g.fog = flag(g.fog),
"weather" => g.weather = flag(g.weather),
"animatedTextures" => g.animated_textures = flag(g.animated_textures),
"presentMode" => g.present_mode = PresentMode::parse(value),
"framePacing" => {
g.pacing = if value == "precise" {
Pacing::Precise
} else {
Pacing::Efficient
}
}
"workers" => g.workers = number(g.workers).min(64),
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
"fxaa" => g.fxaa = flag(g.fxaa),
"level" => g.advanced = value == "advanced",
"overlay" => {
g.overlay = match value {
"fps" => Overlay::Fps,
"graph" => Overlay::Graph,
_ => Overlay::Off,
}
}
_ => {}
}
}
g
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\ncaveCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nlevel:{}\n",
self.preset.name(),
bool_text(self.cave_culling),
self.chunk_updates,
self.upload_budget_kib,
bool_text(self.sky),
bool_text(self.fog),
bool_text(self.weather),
bool_text(self.animated_textures),
self.present_mode.name(),
match self.pacing {
Pacing::Efficient => "efficient",
Pacing::Precise => "precise",
},
self.workers,
match self.overlay {
Overlay::Off => "off",
Overlay::Fps => "fps",
Overlay::Graph => "graph",
},
self.resolution_scale,
bool_text(self.fxaa),
if self.advanced { "advanced" } else { "simple" },
)
}
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
/// Worker threads for chunk generation and meshing.
pub fn worker_threads(&self) -> usize {
if self.workers > 0 {
return self.workers as usize;
}
std::thread::available_parallelism()
.map_or(2, |n| n.get())
.saturating_sub(1)
.max(1)
}
}
/// A preset's values for vanilla's options and Horizon's own.
pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
let (rd, sim, clouds, particles, shadows, blend, mipmaps, entity, leaves, weather_r) =
match preset {
Preset::Performance => (8, 6, "false", 2, false, 0, 0, 0.5, false, 3),
Preset::Balanced => (12, 8, "fast", 1, true, 1, 2, 0.75, true, 5),
Preset::Quality => (16, 12, "true", 0, true, 2, 4, 1.0, true, 10),
Preset::Ultra => (24, 12, "true", 0, true, 3, 4, 1.5, true, 10),
Preset::Custom => return,
};
s.render_distance = rd;
s.simulation_distance = sim;
s.clouds = clouds.into();
s.particles = particles;
s.entity_shadows = shadows;
s.biome_blend = blend;
s.mipmap_levels = mipmaps;
s.entity_distance = entity;
s.cutout_leaves = leaves;
s.weather_radius = weather_r;
s.graphics_preset = "custom".into();
let g = &mut s.horizon;
g.preset = preset;
g.cave_culling = true;
g.animated_textures = preset != Preset::Performance;
g.chunk_updates = 0;
g.upload_budget_kib = 0;
g.sky = true;
g.fog = true;
g.weather = true;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips() {
let g = Graphics {
preset: Preset::Balanced,
cave_culling: false,
chunk_updates: 12,
upload_budget_kib: 4096,
sky: false,
fog: false,
weather: false,
animated_textures: false,
present_mode: PresentMode::Mailbox,
pacing: Pacing::Precise,
workers: 6,
overlay: Overlay::Graph,
resolution_scale: 75,
fxaa: true,
advanced: true,
};
assert_eq!(Graphics::parse(&g.serialize()), g);
assert_eq!(Graphics::parse(""), Graphics::default());
assert_eq!(
Graphics::parse("workers:abc\nsky:maybe\n"),
Graphics::default()
);
}
}
+181
View File
@@ -0,0 +1,181 @@
//! The look of Horizon's graphics settings screens, in one place: sizes,
//! colours and the widgets they draw with. The screens (`graphics_ui`) only
//! lay out rows and handle input; a menu theme replaces this module.
use crate::{
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
/// What a setting costs in frame time, shown beside its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cost {
None,
Low,
Medium,
High,
}
impl Cost {
pub fn label(self) -> &'static str {
match self {
Cost::None => "No cost",
Cost::Low => "Low cost",
Cost::Medium => "Medium cost",
Cost::High => "High cost",
}
}
}
pub struct Style {
/// Width of a setting's button or slider.
pub control_width: f32,
/// Space between the control and its description.
pub gap: f32,
/// Widest description column; narrower on small screens.
pub max_text_width: f32,
/// Vertical distance between setting rows.
pub row_height: f32,
pub header_height: f32,
/// Top and bottom bands holding the title and the buttons.
pub band: f32,
pub title_color: u32,
pub header_color: u32,
pub description_color: u32,
/// Colour of each `Cost`, in its order.
pub cost_colors: [u32; 4],
}
/// `hud::rgba` with full alpha, usable in constants.
const fn opaque(r: u8, g: u8, b: u8) -> u32 {
0xFF00_0000 | (b as u32) << 16 | (g as u32) << 8 | r as u32
}
pub const STYLE: Style = Style {
control_width: 150.0,
gap: 8.0,
max_text_width: 250.0,
row_height: 25.0,
header_height: 16.0,
band: 33.0,
title_color: u32::MAX,
header_color: opaque(255, 255, 85),
description_color: opaque(170, 170, 170),
cost_colors: [
opaque(120, 200, 120),
opaque(170, 220, 120),
opaque(240, 200, 90),
opaque(240, 110, 90),
],
};
impl Style {
/// Width of the description column on a screen `screen_width` wide.
pub fn text_width(&self, screen_width: f32) -> f32 {
(screen_width - self.control_width - self.gap - 24.0).clamp(120.0, self.max_text_width)
}
/// Left edge of a row's control, centring control and description.
pub fn row_x(&self, screen_width: f32) -> f32 {
let total = self.control_width + self.gap + self.text_width(screen_width);
((screen_width - total) / 2.0).floor()
}
pub fn background(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
top: f32,
bottom: f32,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
ui::list_background(batch, atlas, top, bottom, in_world);
}
pub fn frame(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
top: f32,
bottom: f32,
in_world: bool,
) {
ui::separators(batch, atlas, top, bottom, in_world);
}
pub fn title(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
let w = batch.width;
ui::centered_text(batch, atlas, text, 0.0, 12.0, w, self.title_color);
}
pub fn header(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text, x: f32, y: f32) {
batch.text_styled(atlas, text, x, y + 4.0, self.header_color, true);
}
pub fn button(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
hover: bool,
) {
ui::button(batch, atlas, x, y, w, 20.0, label, true, hover);
}
pub fn slider(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
) {
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
x: f32,
y: f32,
cost: Cost,
text: &str,
) {
let tag = Text::literal(cost.label());
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
let indent = atlas.font.width_text(&tag) + 4.0;
let lines = wrap(atlas, text, self.text_width(batch.width), indent);
for (i, line) in lines.iter().take(2).enumerate() {
let lx = if i == 0 { x + indent } else { x };
batch.text_styled(
atlas,
&Text::literal(line.clone()),
lx,
y + i as f32 * 10.0,
self.description_color,
true,
);
}
}
}
/// Greedy word wrap; the first line is `indent` narrower.
fn wrap(atlas: &HudAtlas, text: &str, width: f32, indent: f32) -> Vec<String> {
let mut lines = vec![String::new()];
for word in text.split_whitespace() {
let room = if lines.len() == 1 {
width - indent
} else {
width
};
let line = lines.last().expect("one line");
let candidate = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if atlas.font.width(&candidate) <= room || line.is_empty() {
*lines.last_mut().expect("one line") = candidate;
} else {
lines.push(word.to_owned());
}
}
lines
}
+733
View File
@@ -0,0 +1,733 @@
//! Horizon's graphics settings screens, Simple and Advanced: which settings
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
graphics::{Overlay, Pacing, PresentMode, Preset},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
options_ui::Opt,
settings::Settings,
text::Text,
ui,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
Simple,
Advanced,
}
/// Horizon's own settings (`graphics::Graphics`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum H {
Preset,
CaveCulling,
ChunkUpdates,
UploadBudget,
Sky,
Fog,
Weather,
AnimatedTextures,
PresentMode,
Pacing,
Workers,
Overlay,
ResolutionScale,
Antialiasing,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Item {
Vanilla(Opt),
Horizon(H),
}
pub struct Setting {
pub item: Item,
pub cost: Cost,
pub text: &'static str,
}
pub enum Row {
Header(&'static str),
Setting(Setting),
}
fn v(opt: Opt, cost: Cost, text: &'static str) -> Row {
Row::Setting(Setting {
item: Item::Vanilla(opt),
cost,
text,
})
}
fn h(opt: H, cost: Cost, text: &'static str) -> Row {
Row::Setting(Setting {
item: Item::Horizon(opt),
cost,
text,
})
}
/// The settings of a level, grouped under headers (Simple has no headers).
pub fn rows(level: Level) -> Vec<Row> {
use Cost::*;
let render = v(
Opt::RenderDistance,
High,
"How far terrain is drawn, in chunks. The biggest lever on frame time.",
);
let fov = v(
Opt::Fov,
Low,
"Field of view. Wider shows more terrain at once.",
);
let fps = v(
Opt::MaxFps,
None,
"Caps frames per second; a cap saves power and heat.",
);
let vsync = v(
Opt::Vsync,
None,
"Waits for the display's refresh: no tearing, a little latency.",
);
let gui = v(Opt::GuiScale, None, "Size of menus and the HUD.");
let preset = h(
H::Preset,
None,
"Sets the options below together. Changing one makes it Custom.",
);
match level {
Level::Simple => vec![preset, render, fov, fps, vsync, gui],
Level::Advanced => vec![
Row::Header("Rendering"),
render,
v(
Opt::SimulationDistance,
Medium,
"How far mobs, crops and machines keep working. CPU time.",
),
v(
Opt::EntityDistance,
Medium,
"How far away mobs and items are drawn, as a share of normal.",
),
h(
H::CaveCulling,
None,
"Skips terrain hidden behind closed sections. Off costs a lot.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
),
Row::Header("Quality"),
v(
Opt::SmoothLighting,
Low,
"Soft shadows in corners. Costs chunk building time only.",
),
v(
Opt::Mipmaps,
Low,
"Smaller copies of block textures for distant terrain: less shimmer.",
),
v(
Opt::BiomeBlend,
Low,
"Blends grass and water colours across biomes when building chunks.",
),
v(
Opt::Clouds,
Medium,
"Cloud layer: off, flat (fast) or 3D (fancy).",
),
v(Opt::CloudRange, Low, "How far clouds reach, in chunks."),
h(H::Sky, Low, "Draws the sky, sun, moon and stars."),
h(
H::Fog,
None,
"Distance fog. Off shows the edge of the loaded world.",
),
h(H::Weather, Low, "Draws rain and snow."),
v(
Opt::WeatherRadius,
Low,
"How far rain and snow fall around you.",
),
v(Opt::Particles, Medium, "How many particles effects spawn."),
v(
Opt::EntityShadows,
Low,
"Round shadows under mobs and items.",
),
v(
Opt::CutoutLeaves,
Medium,
"Leaves with see-through gaps. Off draws them solid.",
),
v(
Opt::ImprovedTransparency,
Medium,
"Sorts water and glass more exactly behind each other.",
),
v(
Opt::ChunkBuilder,
Low,
"Which chunk rebuilds wait for the frame: none, nearby or yours.",
),
h(
H::AnimatedTextures,
Low,
"Animates water, lava, fire and other moving textures.",
),
Row::Header("Display"),
fov,
fps,
vsync,
h(
H::ResolutionScale,
High,
"World pixels per screen pixel. Below 100% is faster on the GPU.",
),
h(
H::Antialiasing,
Low,
"FXAA smooths jagged edges of the world; slightly softer image.",
),
h(
H::PresentMode,
None,
"How frames reach the screen. Auto follows VSync.",
),
h(
H::Pacing,
None,
"Efficient sleeps until the next frame; Precise spins, like vanilla.",
),
v(Opt::Fullscreen, None, "Fills the screen (borderless)."),
gui,
v(Opt::Gamma, None, "Brightens dark places."),
v(
Opt::InactivityFps,
None,
"Lowers the frame rate when minimised, or idle too.",
),
Row::Header("Preferences"),
v(Opt::ViewBobbing, None, "Sways the view while walking."),
v(Opt::ChunkFade, None, "Fades newly loaded chunks in."),
v(Opt::Vignette, None, "Darkens the screen's edges."),
v(Opt::Autosave, None, "Shows when the world saves."),
v(Opt::AttackIndicator, None, "Shows attack cooldown."),
Row::Header("Performance"),
h(
H::Workers,
None,
"Threads that generate, light and mesh chunks. Next start.",
),
h(
H::UploadBudget,
Low,
"Mesh data sent to the GPU per frame. Lower is smoother while loading.",
),
Row::Header("Overlay"),
h(
H::Overlay,
None,
"Frame rate and frame times over the game, with a graph.",
),
],
}
}
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
fn on_off(on: bool) -> &'static str {
if on { "On" } else { "Off" }
}
fn labelled(caption: &str, value: impl std::fmt::Display) -> Text {
Text::literal(format!("{caption}: {value}"))
}
impl H {
fn slider(self, s: &Settings) -> Option<f32> {
let g = &s.horizon;
match self {
H::ChunkUpdates => Some(g.chunk_updates as f32 / 64.0),
H::UploadBudget => {
let i = UPLOAD_STEPS
.iter()
.position(|&b| b >= g.upload_budget_kib)
.unwrap_or(UPLOAD_STEPS.len() - 1);
Some(i as f32 / (UPLOAD_STEPS.len() - 1) as f32)
}
H::Workers => Some(g.workers as f32 / max_workers() as f32),
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
_ => None,
}
}
fn label(self, s: &Settings) -> Text {
let g = &s.horizon;
match self {
H::Preset => labelled("Preset", g.preset.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", format!("{n} per frame")),
},
H::UploadBudget => match g.upload_budget_kib {
0 => labelled("Upload Budget", "Unlimited"),
k if k >= 1024 => labelled("Upload Budget", format!("{} MiB/frame", k / 1024)),
k => labelled("Upload Budget", format!("{k} KiB/frame")),
},
H::Sky => labelled("Sky", on_off(g.sky)),
H::Fog => labelled("Fog", on_off(g.fog)),
H::Weather => labelled("Weather", on_off(g.weather)),
H::AnimatedTextures => labelled("Animated Textures", on_off(g.animated_textures)),
H::PresentMode => labelled("Present Mode", g.present_mode.label()),
H::Pacing => labelled(
"Frame Pacing",
match g.pacing {
Pacing::Efficient => "Efficient",
Pacing::Precise => "Precise",
},
),
H::Workers => match g.workers {
0 => labelled("Worker Threads", format!("Auto ({})", g.worker_threads())),
n => labelled("Worker Threads", n),
},
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
H::Overlay => labelled(
"Performance Overlay",
match g.overlay {
Overlay::Off => "Off",
Overlay::Fps => "FPS",
Overlay::Graph => "FPS and Graph",
},
),
}
}
fn set_slider(self, s: &mut Settings, v: f32) {
let g = &mut s.horizon;
match self {
H::ChunkUpdates => g.chunk_updates = (v * 64.0).round() as u32,
H::UploadBudget => {
g.upload_budget_kib =
UPLOAD_STEPS[(v * (UPLOAD_STEPS.len() - 1) as f32).round() as usize]
}
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
_ => {}
}
}
fn cycle(self, s: &mut Settings, back: bool) {
fn step<T: Copy + PartialEq>(all: &[T], now: T, back: bool) -> T {
let i = all.iter().position(|x| *x == now).unwrap_or(0);
let n = all.len();
all[if back { (i + n - 1) % n } else { (i + 1) % n }]
}
if self == H::Preset {
let preset = step(&Preset::ALL, s.horizon.preset, back);
crate::graphics::apply_preset(preset, s);
s.horizon.preset = preset;
return;
}
let g = &mut s.horizon;
match self {
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::Sky => g.sky = !g.sky,
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
H::Antialiasing => g.fxaa = !g.fxaa,
H::Pacing => {
g.pacing = match g.pacing {
Pacing::Efficient => Pacing::Precise,
Pacing::Precise => Pacing::Efficient,
}
}
H::Overlay => {
g.overlay = step(
&[Overlay::Off, Overlay::Fps, Overlay::Graph],
g.overlay,
back,
)
}
_ => {}
}
}
}
fn max_workers() -> u32 {
std::thread::available_parallelism().map_or(8, |n| n.get() as u32)
}
impl Item {
/// On/off settings, drawn as switches by the Horizon menus.
pub fn switch(self, s: &Settings) -> Option<bool> {
let g = &s.horizon;
Some(match self {
Item::Horizon(H::CaveCulling) => g.cave_culling,
Item::Horizon(H::Sky) => g.sky,
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::Antialiasing) => g.fxaa,
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
Item::Vanilla(Opt::Vsync) => s.vsync,
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
Item::Vanilla(Opt::SmoothLighting) => s.smooth_lighting,
Item::Vanilla(Opt::EntityShadows) => s.entity_shadows,
Item::Vanilla(Opt::Vignette) => s.vignette,
Item::Vanilla(Opt::Autosave) => s.autosave_indicator,
Item::Vanilla(Opt::CutoutLeaves) => s.cutout_leaves,
Item::Vanilla(Opt::ImprovedTransparency) => s.improved_transparency,
_ => return None,
})
}
/// Caption and value text, split from the label ("Caption: Value").
pub fn caption_value(self, s: &Settings) -> (String, String) {
let label = self.label(s).plain();
match label.split_once(": ") {
Some((c, v)) => (c.to_owned(), v.to_owned()),
None => (label, String::new()),
}
}
pub fn slider(self, s: &Settings) -> Option<f32> {
match self {
Item::Vanilla(o) => o.slider_position(s),
Item::Horizon(h) => h.slider(s),
}
}
pub fn label(self, s: &Settings) -> Text {
match self {
// The vanilla button shows the scale alone, under its own caption.
Item::Vanilla(Opt::GuiScale) => Text::translate(
"options.generic_value",
&[
Text::translate("options.guiScale", &[]),
Opt::GuiScale.label(s),
],
),
Item::Vanilla(o) => o.label(s),
Item::Horizon(h) => h.label(s),
}
}
pub fn set_slider(self, s: &mut Settings, v: f32) {
match self {
Item::Vanilla(o) => o.set_slider(s, v),
Item::Horizon(h) => h.set_slider(s, v),
}
self.touched(s);
}
pub fn cycle(self, s: &mut Settings, back: bool) {
match self {
Item::Vanilla(o) => o.cycle(s, back),
Item::Horizon(h) => h.cycle(s, back),
}
self.touched(s);
}
/// A preset no longer describes the settings once one of them changes.
fn touched(self, s: &mut Settings) {
let presetless = matches!(
self,
Item::Horizon(H::Preset | H::Overlay | H::Pacing | H::PresentMode | H::Workers)
| Item::Vanilla(
Opt::GuiScale
| Opt::Fullscreen
| Opt::Vsync
| Opt::MaxFps
| Opt::Fov
| Opt::Gamma
| Opt::InactivityFps
| Opt::ViewBobbing
| Opt::ChunkFade
| Opt::Vignette
| Opt::Autosave
| Opt::AttackIndicator
)
);
if !presetless {
s.horizon.preset = Preset::Custom;
}
}
}
/// What the options screens must do after an interaction here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Changed,
OpenAdvanced,
OpenSimple,
Done,
}
#[derive(Clone, Copy)]
enum Target {
Item(Item),
Button(Action),
}
struct Widget {
x: f32,
y: f32,
w: f32,
target: Target,
/// In the scrolling list (clipped to it).
list: bool,
}
#[derive(Default)]
pub struct GraphicsUi {
scroll: f32,
dragging: Option<Item>,
}
impl GraphicsUi {
pub fn reset(&mut self) {
self.scroll = 0.0;
self.dragging = None;
}
fn bounds(h: f32) -> (f32, f32) {
(STYLE.band, h - STYLE.band)
}
/// Widgets and the list's content height.
fn layout(&self, level: Level, w: f32, h: f32) -> (Vec<Widget>, Vec<(f32, &'static str)>, f32) {
let (top, _) = Self::bounds(h);
let x = STYLE.row_x(w);
let mut y = top + 4.0 - self.scroll;
let start = y;
let mut widgets = Vec::new();
let mut headers = Vec::new();
for row in rows(level) {
match row {
Row::Header(name) => {
headers.push((y, name));
y += STYLE.header_height;
}
Row::Setting(s) => {
widgets.push(Widget {
x,
y,
w: STYLE.control_width,
target: Target::Item(s.item),
list: true,
});
y += STYLE.row_height;
}
}
}
let content = y - start + 4.0;
let cx = (w / 2.0).floor();
let by = h - 27.0;
let other = match level {
Level::Simple => Action::OpenAdvanced,
Level::Advanced => Action::OpenSimple,
};
widgets.push(Widget {
x: cx - 155.0,
y: by,
w: 150.0,
target: Target::Button(other),
list: false,
});
widgets.push(Widget {
x: cx + 5.0,
y: by,
w: 150.0,
target: Target::Button(Action::Done),
list: false,
});
(widgets, headers, content)
}
fn clamp_scroll(&mut self, h: f32, content: f32) {
let (top, bottom) = Self::bounds(h);
self.scroll = self.scroll.clamp(0.0, (content - (bottom - top)).max(0.0));
}
pub fn draw(
&mut self,
level: Level,
s: &Settings,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
let (_, _, content) = self.layout(level, w, h);
self.clamp_scroll(h, content);
let (widgets, headers, _) = self.layout(level, w, h);
let (top, bottom) = Self::bounds(h);
STYLE.background(batch, atlas, top, bottom, in_world);
let shown = |y: f32, height: f32| y >= top && y + height <= bottom;
let x = STYLE.row_x(w);
for (y, name) in headers {
if shown(y, STYLE.header_height) {
STYLE.header(batch, atlas, &Text::literal(name), x, y);
}
}
let descriptions: Vec<(Cost, &'static str)> = rows(level)
.into_iter()
.filter_map(|r| match r {
Row::Setting(s) => Some((s.cost, s.text)),
Row::Header(_) => None,
})
.collect();
let mut index = 0;
for wd in &widgets {
let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
match wd.target {
Target::Item(item) => {
let (cost, text) = descriptions[index];
index += 1;
if !shown(wd.y, 20.0) {
continue;
}
let label = item.label(s);
match item.slider(s) {
Some(value) => STYLE.slider(
batch,
atlas,
(wd.x, wd.y, wd.w),
&label,
value,
hover || self.dragging == Some(item),
),
None => STYLE.button(batch, atlas, (wd.x, wd.y, wd.w), &label, hover),
}
STYLE.description(
batch,
atlas,
wd.x + wd.w + STYLE.gap,
wd.y + 1.0,
cost,
text,
);
}
Target::Button(action) => {
let label = match action {
Action::OpenAdvanced => "Advanced...",
Action::OpenSimple => "Simple...",
_ => "Done",
};
STYLE.button(
batch,
atlas,
(wd.x, wd.y, wd.w),
&Text::literal(label),
hover,
);
}
}
}
STYLE.frame(batch, atlas, top, bottom, in_world);
ui::scrollbar(
batch,
atlas,
x + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0,
top,
bottom,
content,
self.scroll,
);
let title = match level {
Level::Simple => "Graphics",
Level::Advanced => "Graphics: Advanced",
};
STYLE.title(batch, atlas, &Text::literal(title));
}
/// A press (or a drag with `drag`) at `(x, y)`.
pub fn click(
&mut self,
level: Level,
s: &mut Settings,
(x, y): (f32, f32),
(w, h): (f32, f32),
drag: bool,
right: bool,
) -> Vec<Action> {
let (widgets, _, _) = self.layout(level, w, h);
let (top, bottom) = Self::bounds(h);
if drag {
if let Some(item) = self.dragging
&& let Some(wd) = widgets
.iter()
.find(|wd| matches!(wd.target, Target::Item(i) if i == item))
{
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
return vec![Action::Changed];
}
return Vec::new();
}
for wd in widgets {
if !ui::hovered((x, y), wd.x, wd.y, wd.w, 20.0) {
continue;
}
if wd.list && (y < top || y >= bottom || wd.y < top || wd.y + 20.0 > bottom) {
continue;
}
return match wd.target {
Target::Button(action) => vec![action],
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
} else {
item.cycle(s, right);
}
vec![Action::Changed]
}
};
}
Vec::new()
}
pub fn release(&mut self) {
self.dragging = None;
}
pub fn scroll(&mut self, amount: f32) {
self.scroll -= amount * 18.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_advanced_setting_has_a_description() {
for level in [Level::Simple, Level::Advanced] {
for row in rows(level) {
if let Row::Setting(s) = row {
assert!(!s.text.is_empty(), "{:?}", s.item);
assert!(s.text.len() < 80, "one line: {:?}", s.item);
}
}
}
}
#[test]
fn presets_set_vanilla_and_horizon_options() {
let mut s = Settings::default();
Item::Horizon(H::Preset).cycle(&mut s, false);
assert_eq!(s.horizon.preset, Preset::Performance);
assert_eq!(s.render_distance, 8);
assert_eq!(s.clouds, "false");
// Any quality change leaves the preset.
Item::Vanilla(Opt::Clouds).cycle(&mut s, false);
assert_eq!(s.horizon.preset, Preset::Custom);
// Display preferences keep it.
Item::Horizon(H::Preset).cycle(&mut s, true);
Item::Vanilla(Opt::Vsync).cycle(&mut s, false);
assert_eq!(s.horizon.preset, Preset::Ultra);
}
#[test]
fn upload_budget_slider_steps() {
let mut s = Settings::default();
H::UploadBudget.set_slider(&mut s, 1.0);
assert_eq!(s.horizon.upload_budget_kib, 16384);
assert_eq!(H::UploadBudget.slider(&s), Some(1.0));
H::UploadBudget.set_slider(&mut s, 0.0);
assert_eq!(s.horizon.upload_budget_kib, 0);
}
}
+7
View File
@@ -204,6 +204,13 @@ impl HudAtlas {
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
}
// Horizon-style menu text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
2048,
1024,
vec![0; 2048 * 1024 * 4],
));
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let width = 4096u32;
let mut placements = Vec::with_capacity(images.len());
+11
View File
@@ -40,13 +40,19 @@ pub mod enchant;
pub mod entities;
pub mod entity_nbt;
pub mod environment;
pub mod fast_hash;
pub mod fastutil_order;
pub mod first_person;
pub mod flatgen;
pub mod fog;
pub mod font;
pub mod front;
pub mod gamerules;
pub mod gfx;
pub mod gossip;
pub mod graphics;
pub mod graphics_style;
pub mod graphics_ui;
pub mod health;
pub mod hotbar;
pub mod hud;
@@ -79,12 +85,15 @@ pub mod nbt_path;
pub mod net;
pub mod options_file;
pub mod options_fixes;
pub mod options_horizon;
pub mod options_ui;
pub mod pack_screen;
pub mod packs;
pub mod particles;
pub mod patrol;
pub mod perf_overlay;
pub mod pose;
pub mod post;
pub mod projectiles;
pub mod raid;
pub mod random_sequences;
@@ -105,6 +114,7 @@ pub mod skins;
pub mod snbt;
pub mod sound_engine;
pub mod sound_events;
pub mod spans;
pub mod sprint;
pub mod sprites;
pub mod stations;
@@ -116,6 +126,7 @@ pub mod testgen;
pub mod text;
pub mod title;
pub mod ui;
pub mod ui_theme;
pub mod vanillagen;
pub mod window;
pub mod world;
+1 -1
View File
@@ -359,7 +359,7 @@ pub trait LightWorld {
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8>;
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8);
}
impl LightWorld for HashMap<(i32, i32), Arc<Chunk>> {
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
fn state(&self, x: i32, y: i32, z: i32) -> Option<u16> {
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
return None;
+7
View File
@@ -5,6 +5,13 @@ mod game;
fn main() {
if let Err(error) = game::run() {
eprintln!("Horizon: {error}");
// Started from Explorer, the console window closes with the game:
// keep the message on screen until it is read.
#[cfg(windows)]
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!("Press Enter to close.");
let _ = std::io::stdin().read_line(&mut String::new());
}
std::process::exit(1);
}
}
+12
View File
@@ -899,6 +899,18 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
_ => {}
}
}
/// `Entity.checkDespawn` alone, for a mob outside the entity-ticking
/// chunks. Returns false when the mob is gone.
pub fn despawn_check(m: &mut Mob, w: &MobWorld) -> bool {
if m.removed {
return false;
}
if !check_despawn(m, w) {
m.removed = true;
return false;
}
true
}
/// Mob.checkDespawn, before the tick (players are the only despawn
/// reference). Returns false when the mob is discarded.
fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool {
@@ -17,6 +17,7 @@ use horizon_core::{
geometry::Vec3,
random::{LegacyRandom, RandomSource},
};
use std::collections::HashMap;
/// `MemorySlot.NEVER_EXPIRE`.
pub(super) const NEVER: i64 = i64::MAX;
@@ -843,12 +844,20 @@ impl Poi {
}
/// The POI a block state is (`PoiTypes`): job site blocks, bed heads, bells.
pub fn poi_at(w: &MobWorld, x: i32, y: i32, z: i32) -> Option<Poi> {
let name = w.block_name(x, y, z);
poi_of_state(w, w.world.block(x, y, z))
}
/// The POI a block state is, if any (`PoiTypes.forState`).
fn poi_of_state(w: &MobWorld, state: horizon_world::registry::BlockStateId) -> Option<Poi> {
let name = &*w.facts.get(state).name;
if name == "minecraft:bell" {
return Some(Poi::Meeting);
}
if name.ends_with("_bed") {
return (w.prop(x, y, z, "part") == Some("head")).then_some(Poi::Home);
let part = w
.registry
.state(state)
.and_then(|s| s.properties.get("part"));
return (part.map(String::as_str) == Some("head")).then_some(Poi::Home);
}
super::villager::job_site_profession(name).map(Poi::Job)
}
@@ -920,12 +929,30 @@ fn pois_in_range(
let chunks = radius.div_euclid(16) + 1;
let y0 = (c[1] - radius).max(crate::chunk::MIN_Y);
let y1 = (c[1] + radius).min(crate::chunk::MIN_Y + crate::chunk::HEIGHT - 1);
// The POI each block state is, worked out once per search, and the
// sections whose palette holds none skipped whole, as PoiManager only
// visits sections with records: the same positions in the same order,
// without reading and naming every block of the sphere.
let mut kinds: HashMap<u32, Option<Poi>> = HashMap::new();
let mut kind = |state: u32| {
*kinds
.entry(state)
.or_insert_with(|| poi_of_state(w, horizon_world::registry::BlockStateId(state)))
};
for qz in cz - chunks..=cz + chunks {
for qx in cx - chunks..=cx + chunks {
if !w.world.chunks.contains_key(&(qx, qz)) {
let Some(chunk) = w.world.chunks.get(&(qx, qz)) else {
continue;
}
};
let holds: Vec<bool> = chunk
.sections
.iter()
.map(|s| s.blocks.maybe_has(|v| kind(v).is_some()))
.collect();
for y in y0..=y1 {
if !holds[((y - crate::chunk::MIN_Y) >> 4) as usize] {
continue;
}
for z in qz * 16..qz * 16 + 16 {
for x in qx * 16..qx * 16 + 16 {
if (x - c[0]).abs() > radius || (z - c[2]).abs() > radius {
@@ -937,11 +964,11 @@ fn pois_in_range(
if d > r2 {
continue;
}
let state = w.world.block(x, y, z);
let state = chunk.block(x & 15, y, z & 15);
if w.facts.is_air(state) {
continue;
}
let Some(poi) = poi_at(w, x, y, z) else {
let Some(poi) = kind(state.0) else {
continue;
};
if !poi_matches(m, f, poi) {
+7
View File
@@ -108,6 +108,13 @@ fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option
}
impl Screen {
/// The address of list row `row` (saved servers, then LAN games).
pub fn address_of(&self, row: usize) -> Option<String> {
match self {
Self::List { servers, lan, .. } => entry_address(servers, lan, row),
_ => None,
}
}
pub fn list(saves: &Path) -> Self {
let servers = servers::load(&servers_file(saves));
let pings: Pings = Arc::default();
@@ -0,0 +1,557 @@
//! The options screen in Horizon's menu style, laid out like the launcher's
//! Settings page: a sidebar with the sliding selection (one item per
//! section), then the section's page header and settings rows (label column,
//! value, control on the right) between 1px dividers.
//!
//! Adding settings (for example the Simple/Advanced graphics menu): add rows
//! to a section in [`SECTIONS`], or a new section. A row is an [`HRow`]: an
//! existing option (`Opt`, drawn as a switch, a slider or a value button from
//! its kind), a header, or a button opening one of vanilla's pages. New kinds
//! of control belong in `ui_theme` so the launcher-matching look stays in one
//! place. Everything else (sliders dragging, scrolling, Done) is handled here.
use crate::{
graphics_style::Cost,
graphics_ui::{self, Item, Level},
hud::{HudAtlas, HudBatch},
options_ui::{Event, Kind, Opt, OptionsUi, Page, apply_preset},
settings::{SOUND_SOURCES, Settings},
ui_theme::{
self as t, CONTROL, Face, Kind as Button, MenuStyle, ROW_LABEL, SIDEBAR, SMALL, Ui, color,
id,
},
};
/// One row of a section.
#[derive(Clone, Copy, Debug)]
pub enum HRow {
Header(&'static str),
Opt(Opt),
/// A label, a value and a button opening one of vanilla's pages.
Page(&'static str, &'static str, Page),
/// Menu style: Horizon / Classic.
Style,
/// Graphics settings level: Simple / Advanced.
Level,
/// A graphics setting with its frame-time cost and description.
Graphics(Item, Cost, &'static str),
}
/// A sidebar section: name, description, rows.
pub struct Section {
pub name: &'static str,
pub blurb: &'static str,
pub rows: fn(&Settings) -> Vec<HRow>,
}
pub const SECTIONS: &[Section] = &[
Section {
name: "Video",
blurb: "Graphics, Simple or Advanced: each setting says what it costs.",
rows: |s| {
let level = if s.horizon.advanced {
Level::Advanced
} else {
Level::Simple
};
let mut r = vec![HRow::Level];
r.extend(graphics_ui::rows(level).into_iter().map(|row| match row {
graphics_ui::Row::Header(name) => HRow::Header(name),
graphics_ui::Row::Setting(g) => HRow::Graphics(g.item, g.cost, g.text),
}));
r
},
},
Section {
name: "Audio",
blurb: "Volume for every kind of sound, and subtitles.",
rows: |_| {
let mut r: Vec<HRow> = (0..SOUND_SOURCES.len())
.map(|i| HRow::Opt(Opt::Volume(i)))
.collect();
r.push(HRow::Opt(Opt::ShowSubtitles));
r
},
},
Section {
name: "Controls",
blurb: "Keys, the mouse and how movement keys behave.",
rows: |_| {
let mut r = vec![HRow::Page(
"Key binds",
"Every action and its key",
Page::KeyBinds,
)];
r.push(HRow::Header("Mouse"));
r.extend(
[
Opt::Sensitivity,
Opt::WheelSensitivity,
Opt::InvertY,
Opt::DiscreteScroll,
]
.map(HRow::Opt),
);
r.push(HRow::Header("Movement"));
r.extend([Opt::ToggleCrouch, Opt::ToggleSprint, Opt::AutoJump].map(HRow::Opt));
r
},
},
Section {
name: "Accessibility",
blurb: "Contrast, motion, text backgrounds and notifications.",
rows: |_| {
[
Opt::HighContrast,
Opt::MenuBlur,
Opt::TextBackgroundOpacity,
Opt::NotificationDisplayTime,
Opt::ShowSubtitles,
Opt::ViewBobbing,
Opt::ChatLineSpacing,
]
.map(HRow::Opt)
.to_vec()
},
},
Section {
name: "Language",
blurb: "The game's language and its font.",
rows: |_| {
vec![
HRow::Page("Language", "", Page::Language),
HRow::Opt(Opt::ForceUnicode),
HRow::Opt(Opt::JapaneseGlyphs),
]
},
},
Section {
name: "Chat",
blurb: "What chat shows and how it looks.",
rows: |_| {
[
Opt::ChatVisibility,
Opt::ChatColors,
Opt::ChatLinks,
Opt::ChatOpacity,
Opt::ChatScale,
Opt::ChatWidth,
Opt::ChatHeightFocused,
Opt::ChatHeightUnfocused,
Opt::AutoSuggestions,
Opt::ReducedDebugInfo,
]
.map(HRow::Opt)
.to_vec()
},
},
Section {
name: "Interface",
blurb: "Menus and resource packs.",
rows: |_| {
vec![
HRow::Style,
HRow::Page("Resource packs", "Textures, sounds and fonts", Page::Packs),
]
},
},
];
const NAV: u64 = id("options.nav", 0);
const DONE: u64 = id("options.done", 0);
const ROW: u64 = id("options.row", 0);
const STYLE: u64 = id("options.style", 0);
const LEVEL: u64 = id("options.level", 0);
const ROW_H: f32 = 12.0 + CONTROL + 12.0;
const HEADER_H: f32 = 40.0;
/// Whether an option is an on/off switch, and its value.
fn switch_value(opt: Opt, s: &Settings) -> Option<bool> {
Some(match opt {
Opt::InvertY => s.invert_y,
Opt::DiscreteScroll => s.discrete_scroll,
Opt::ViewBobbing => s.view_bobbing,
Opt::Vsync => s.vsync,
Opt::Fullscreen => s.fullscreen,
Opt::SmoothLighting => s.smooth_lighting,
Opt::EntityShadows => s.entity_shadows,
Opt::Vignette => s.vignette,
Opt::Autosave => s.autosave_indicator,
Opt::CutoutLeaves => s.cutout_leaves,
Opt::ImprovedTransparency => s.improved_transparency,
Opt::AutoJump => s.auto_jump,
Opt::ChatColors => s.chat_colors,
Opt::ChatLinks => s.chat_links,
Opt::AutoSuggestions => s.auto_suggestions,
Opt::ReducedDebugInfo => s.reduced_debug_info,
Opt::HighContrast => s.high_contrast,
Opt::ShowSubtitles => s.show_subtitles,
Opt::ForceUnicode => s.force_unicode,
Opt::JapaneseGlyphs => s.japanese_glyphs,
_ => return None,
})
}
/// The option's caption and value text, from its vanilla label
/// ("Caption: Value") and its caption key.
fn caption_value(opt: Opt, s: &Settings) -> (String, String) {
let label = opt.label(s).plain();
let caption = crate::text::Text::translate(opt.caption(), &[]).plain();
if let Some(rest) = label.strip_prefix(&caption) {
return (caption, rest.trim_start_matches([':', ' ']).to_owned());
}
match label.split_once(": ") {
Some((c, v)) => (c.to_owned(), v.to_owned()),
None => (caption, label),
}
}
/// What a click hit.
enum Target {
Nav(usize),
Done,
Row(usize),
Slider(Item, f32),
Style(usize),
Level(usize),
}
impl OptionsUi {
/// Lay out (and draw, on a real frame) the Horizon options shell.
/// Returns the sliders' tracks (option, x, width) for dragging.
fn horizon_layout(
&mut self,
s: &Settings,
ui: &mut Ui,
backdrop: f32,
) -> Vec<(Item, f32, f32)> {
let (w, h) = (ui.w, ui.h);
if self.in_world {
ui.atmosphere(0.45, 1.0);
} else {
if backdrop < 1.0 {
ui.cold_background(1.0 - backdrop);
}
ui.atmosphere(0.66, 1.0);
}
let section = self.section.min(SECTIONS.len() - 1);
// The sidebar.
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Options",
);
let names: Vec<&str> = SECTIONS.iter().map(|s| s.name).collect();
ui.sidebar_list(NAV, 12.0, 66.0, SIDEBAR - 24.0, &names, section);
let dy = h - 12.0 - CONTROL;
ui.button(
DONE,
[12.0, dy, SIDEBAR - 24.0, CONTROL],
"Done",
Button::Secondary,
true,
);
// The page fades in and rises 8 when the section changes.
let p = ui.tween(id("options.page", section as u64), 1.0, t::move_());
if self.section_shown != Some(section) {
self.section_shown = Some(section);
ui.restart(id("options.page", section as u64), 0.0, 1.0, t::move_());
}
let x = SIDEBAR + 48.0;
let cw = (w - x - 48.0).min(760.0);
ui.opacity = p;
ui.offset = (0.0, 8.0 * (1.0 - p));
let sec = &SECTIONS[section];
let mut y = 36.0;
y += ui.page_header(x, y, sec.name, sec.blurb) + 24.0;
let top = y;
let rows = (sec.rows)(s);
let content: f32 = rows
.iter()
.map(|r| {
if matches!(r, HRow::Header(_)) {
HEADER_H
} else {
ROW_H
}
})
.sum();
let bottom = h - 24.0;
let max = (content - (bottom - top)).max(0.0);
self.hscroll = self.hscroll.clamp(0.0, max);
y -= self.hscroll;
let vstart = ui.batch.vertices.len();
let hstart = ui.hits.len();
let mut sliders = Vec::new();
ui.divider(x, y, cw);
let control_x = x + cw;
for (i, row) in rows.iter().enumerate() {
match *row {
HRow::Header(label) => {
ui.text(Face::Medium, SMALL, x, y + 18.0, color::FAINT, label);
y += HEADER_H;
ui.divider(x, y, cw);
continue;
}
HRow::Opt(opt) => {
let (caption, value) = caption_value(opt, s);
let cy = y + ROW_H / 2.0;
if let Some(on) = switch_value(opt, s) {
ui.row(x, y, &caption, "", 0.0);
ui.switch(ROW + i as u64, control_x, cy, on);
} else if let Kind::Slider(v) = opt.kind(s) {
let sw = 220.0_f32.min(cw - ROW_LABEL - 200.0).max(80.0);
ui.row(x, y, &caption, &value, cw - ROW_LABEL - 16.0 - sw - 24.0);
let sx = control_x - sw - 8.0;
ui.slider(ROW + i as u64, sx, cy, sw, v);
sliders.push((Item::Vanilla(opt), sx, sw));
} else {
ui.row(x, y, &caption, "", 0.0);
let bw = ui.button_width(&value, false).max(96.0);
ui.button(
ROW + i as u64,
[control_x - bw, y + 12.0, bw, CONTROL],
&value,
Button::Secondary,
true,
);
}
}
HRow::Page(label, value, _) => {
let value = if value.is_empty() {
self.languages
.iter()
.find(|l| l.code == s.language)
.map_or_else(|| s.language.clone(), |l| l.label())
} else {
value.to_owned()
};
ui.row(x, y, label, &value, cw - ROW_LABEL - 140.0);
let bw = ui.button_width("Change…", false);
let open = if label == "Language" {
"Change…"
} else {
"Open…"
};
let bw = bw.max(ui.button_width(open, false));
ui.button(
ROW + i as u64,
[control_x - bw, y + 12.0, bw, CONTROL],
open,
Button::Secondary,
true,
);
}
HRow::Level => {
ui.row(x, y, "Settings", "", 0.0);
let labels = ["Simple", "Advanced"];
let probe = ui.segmented(LEVEL, -10_000.0, -10_000.0, &labels, 0);
let selected = usize::from(s.horizon.advanced);
ui.segmented(LEVEL, control_x - probe, y + 12.0 - 3.0, &labels, selected);
}
HRow::Graphics(item, cost, text) => {
let (caption, value) = item.caption_value(s);
let cost = (cost as u8, cost.label());
let cy = y + ROW_H / 2.0;
let id = ROW + i as u64;
if let Some(on) = item.switch(s) {
ui.described_row(x, y, &caption, "", 0.0, cost, text, cw - 60.0);
ui.switch(id, control_x, cy, on);
} else if let Some(v) = item.slider(s) {
let sw = 220.0_f32.min(cw - ROW_LABEL - 200.0).max(80.0);
let value_w = cw - ROW_LABEL - 16.0 - sw - 24.0;
let text_w = cw - sw - 24.0;
ui.described_row(x, y, &caption, &value, value_w, cost, text, text_w);
let sx = control_x - sw - 8.0;
ui.slider(id, sx, cy, sw, v);
sliders.push((item, sx, sw));
} else {
let bw = ui.button_width(&value, false).max(96.0);
let text_w = cw - bw - 24.0;
ui.described_row(x, y, &caption, "", 0.0, cost, text, text_w);
ui.button(
id,
[control_x - bw, y + 12.0, bw, CONTROL],
&value,
Button::Secondary,
true,
);
}
}
HRow::Style => {
ui.row(x, y, "Menu style", "", 0.0);
let labels = ["Horizon", "Classic"];
let probe = ui.segmented(STYLE, -10_000.0, -10_000.0, &labels, 0);
let selected = usize::from(t::style() == MenuStyle::Classic);
ui.segmented(STYLE, control_x - probe, y + 12.0 - 3.0, &labels, selected);
}
}
y += ROW_H;
ui.divider(x, y, cw);
}
// Clip the rows to the page below the header, like the launcher's scroll view.
let (ox, oy) = ui.offset;
let scale = ui.batch.scale;
let k = ui.density / scale;
let (y0, y1) = ((top - 1.0 + oy) * k, (bottom + oy) * k);
ui.batch.clip_since(vstart, 0.0, y0, ui.batch.width, y1);
let (cy0, cy1) = (top - 1.0 + oy, bottom + oy);
let kept: Vec<_> = ui
.hits
.drain(hstart..)
.filter(|h| h.rect[1] >= cy0 && h.rect[3] <= cy1)
.collect();
ui.hits.extend(kept);
if max > 0.0 {
// A thin scroll indicator.
let track = bottom - top;
let bar = (track * track / (track + max)).max(24.0);
let by = top + (track - bar) * (self.hscroll / max);
ui.rounded(
x + cw + 16.0,
by,
3.0,
bar,
1.5,
color::with_alpha(color::MUTED, 0.35),
);
}
let _ = ox;
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
sliders
}
pub(crate) fn draw_horizon(
&mut self,
s: &Settings,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
) {
let mut ui = Ui::new(atlas, batch, cursor);
let backdrop = t::backdrop();
self.horizon_layout(s, &mut ui, backdrop);
ui.finish();
}
fn horizon_target(&mut self, s: &Settings, atlas: &HudAtlas, x: f32, y: f32) -> Option<Target> {
let mut batch = t::scratch_batch();
let mut ui = Ui::dry(atlas, &mut batch, (x, y));
let sliders = self.horizon_layout(s, &mut ui, 1.0);
let hit = ui.hit((x, y))?;
let section = self.section.min(SECTIONS.len() - 1);
if (NAV..NAV + SECTIONS.len() as u64).contains(&hit) {
return Some(Target::Nav((hit - NAV) as usize));
}
if hit == DONE {
return Some(Target::Done);
}
if hit == STYLE || hit == STYLE + 1 {
return Some(Target::Style((hit - STYLE) as usize));
}
if hit == LEVEL || hit == LEVEL + 1 {
return Some(Target::Level((hit - LEVEL) as usize));
}
let i = hit.checked_sub(ROW)? as usize;
let rows = (SECTIONS[section].rows)(s);
let item = match rows.get(i) {
Some(HRow::Opt(opt)) => Some(Item::Vanilla(*opt)),
Some(HRow::Graphics(item, ..)) => Some(*item),
_ => None,
};
if let Some(item) = item
&& let Some(&(_, sx, sw)) = sliders.iter().find(|(o, ..)| *o == item)
{
let v = t::slider_value(&ui, sx, sw, x);
return Some(Target::Slider(item, v));
}
(i < rows.len()).then_some(Target::Row(i))
}
/// A click (or a drag with the button held) on the Horizon shell.
pub(crate) fn click_horizon(
&mut self,
s: &mut Settings,
atlas: &HudAtlas,
x: f32,
y: f32,
drag: bool,
right: bool,
) -> Vec<Event> {
let mut events = Vec::new();
if drag {
if let Some(item) = self.hdragging {
let mut batch = t::scratch_batch();
let mut ui = Ui::dry(atlas, &mut batch, (x, y));
let sliders = self.horizon_layout(s, &mut ui, 1.0);
if let Some(&(_, sx, sw)) = sliders.iter().find(|(o, ..)| *o == item) {
set_item_slider(item, s, t::slider_value(&ui, sx, sw, x));
events.push(Event::Changed);
}
}
return events;
}
match self.horizon_target(s, atlas, x, y) {
Some(Target::Nav(i)) => {
self.section = i;
self.hscroll = 0.0;
}
Some(Target::Done) => events.extend(self.back(s)),
Some(Target::Style(i)) => {
t::set_style(if i == 1 {
MenuStyle::Classic
} else {
MenuStyle::Horizon
});
if let Ok(dir) = std::env::current_dir() {
t::save_preferences(&dir);
}
}
Some(Target::Level(i)) => {
s.horizon.advanced = i == 1;
self.hscroll = 0.0;
events.push(Event::Changed);
}
Some(Target::Slider(item, v)) => {
self.hdragging = Some(item);
set_item_slider(item, s, v);
events.push(Event::Changed);
}
Some(Target::Row(i)) => {
let section = self.section.min(SECTIONS.len() - 1);
match (SECTIONS[section].rows)(s).get(i) {
Some(HRow::Opt(opt)) => {
opt.cycle(s, right);
if *opt == Opt::Preset {
apply_preset(s);
}
events.push(Event::Changed);
}
Some(HRow::Graphics(item, ..)) => {
item.cycle(s, right);
events.push(Event::Changed);
}
Some(HRow::Page(_, _, page)) => self.open_page(*page),
_ => {}
}
}
None => {}
}
events
}
pub(crate) fn scroll_horizon(&mut self, amount: f32) {
self.hscroll -= amount * 40.0;
}
}
/// A slider moved: vanilla options keep their preset handling, Horizon's own
/// settings go through `graphics_ui`.
fn set_item_slider(item: Item, s: &mut Settings, v: f32) {
match item {
Item::Vanilla(opt) => {
opt.set_slider(s, v);
if opt == Opt::Preset {
apply_preset(s);
}
}
Item::Horizon(_) => item.set_slider(s, v),
}
}
+105 -5
View File
@@ -92,13 +92,13 @@ fn chat_px(caption: &str, px: f32) -> Text {
&[tr(caption), Text::literal((px as i32).to_string())],
)
}
enum Kind {
pub(crate) enum Kind {
/// Slider position 0..1.
Slider(f32),
Cycle,
}
impl Opt {
fn caption(self) -> &'static str {
pub(crate) fn caption(self) -> &'static str {
match self {
Opt::Fov => "options.fov",
Opt::RenderDistance => "options.renderDistance",
@@ -155,7 +155,7 @@ impl Opt {
Opt::JapaneseGlyphs => "options.japaneseGlyphVariants",
}
}
fn kind(self, s: &Settings) -> Kind {
pub(crate) fn kind(self, s: &Settings) -> Kind {
let unit = |v: f32, lo: f32, hi: f32| Kind::Slider(((v - lo) / (hi - lo)).clamp(0.0, 1.0));
match self {
Opt::Fov => unit(s.fov, 30.0, 110.0),
@@ -190,6 +190,13 @@ impl Opt {
_ => Kind::Cycle,
}
}
/// A slider's position (0..1), or `None` for a cycle button.
pub fn slider_position(self, s: &Settings) -> Option<f32> {
match self.kind(s) {
Kind::Slider(v) => Some(v),
Kind::Cycle => None,
}
}
/// The widget message (OptionInstance.toString / CycleButton full name).
pub fn label(self, s: &Settings) -> Text {
let c = self.caption();
@@ -532,6 +539,9 @@ pub enum Page {
Language,
Font,
Packs,
/// Horizon's graphics settings (`graphics_ui`).
Graphics,
GraphicsAdvanced,
}
#[derive(Clone, Debug, PartialEq)]
enum Act {
@@ -590,7 +600,14 @@ pub enum Event {
pub struct OptionsUi {
pub stack: Vec<Page>,
scroll: f32,
dragging: Option<Opt>,
pub(crate) dragging: Option<Opt>,
/// Horizon style: the selected sidebar section, the one last drawn and
/// the rows' scroll in dp (`options_horizon`).
pub section: usize,
pub(crate) section_shown: Option<usize>,
pub(crate) hscroll: f32,
/// The slider being dragged on the Horizon options shell.
pub(crate) hdragging: Option<crate::graphics_ui::Item>,
/// Key Binds: the mapping waiting for a key.
pub selected_key: Option<&'static str>,
pub languages: Vec<LanguageInfo>,
@@ -606,6 +623,7 @@ pub struct OptionsUi {
pub in_world: bool,
/// Scroll the selected language into the middle on the next layout.
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
}
impl Default for OptionsUi {
fn default() -> Self {
@@ -615,6 +633,10 @@ impl Default for OptionsUi {
stack: vec![Page::Main],
scroll: 0.0,
dragging: None,
section: 0,
section_shown: None,
hscroll: 0.0,
hdragging: None,
selected_key: None,
languages: Vec::new(),
language_choice: None,
@@ -625,6 +647,7 @@ impl Default for OptionsUi {
pack_focus: std::time::Instant::now(),
in_world: false,
center_language: false,
graphics: Default::default(),
}
}
}
@@ -693,6 +716,10 @@ impl OptionsUi {
self.in_world = in_world;
self.selected_key = None;
}
/// Open one of vanilla's pages over the current one.
pub(crate) fn open_page(&mut self, page: Page) {
self.push(page);
}
fn push(&mut self, page: Page) {
self.stack.push(page);
self.scroll = 0.0;
@@ -704,6 +731,17 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if matches!(page, Page::Graphics | Page::GraphicsAdvanced) {
self.graphics.reset();
}
}
/// The graphics screen level of `page`, if it is one.
fn graphics_level(page: Page) -> Option<crate::graphics_ui::Level> {
match page {
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
_ => None,
}
}
fn title(page: Page) -> &'static str {
match page {
@@ -718,12 +756,30 @@ impl OptionsUi {
Page::Language => "options.language.title",
Page::Font => "options.font.title",
Page::Packs => "resourcePack.title",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
}
}
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::Video => {
let mut rows = vec![Row::Header("options.video.display.header")];
// Horizon's graphics screens, Simple and Advanced.
let mut rows = vec![Row::Buttons(
button(
0.0,
0.0,
150.0,
Text::literal("Horizon Graphics..."),
Act::Push(Page::Graphics),
),
Some(button(
0.0,
0.0,
150.0,
Text::literal("Advanced Graphics..."),
Act::Push(Page::GraphicsAdvanced),
)),
)];
rows.push(Row::Header("options.video.display.header"));
rows.extend(pairs(&[
Opt::MaxFps,
Opt::Vsync,
@@ -1175,6 +1231,15 @@ impl OptionsUi {
cursor: (f32, f32),
) {
let (w, h) = (batch.width, batch.height);
if crate::ui_theme::horizon() && self.page() == Page::Main {
self.draw_horizon(s, batch, atlas, cursor);
return;
}
if let Some(level) = Self::graphics_level(self.page()) {
self.graphics
.draw(level, s, batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::Packs {
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
let blink = self.pack_focus.elapsed().as_millis() as u64;
@@ -1324,6 +1389,31 @@ impl OptionsUi {
drag: bool,
right: bool,
) -> Vec<Event> {
if crate::ui_theme::horizon() && self.page() == Page::Main {
return self.click_horizon(s, atlas, x, y, drag, right);
}
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
let mut events = Vec::new();
for action in self.graphics.click(level, s, (x, y), size, drag, right) {
match action {
Action::Changed => events.push(Event::Changed),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
{
events.extend(self.back(s));
} else {
self.stack.pop();
self.push(Page::Graphics);
}
}
Action::Done => events.extend(self.back(s)),
}
}
return events;
}
if self.page() == Page::Packs {
let mut events = Vec::new();
if drag || right {
@@ -1421,12 +1511,22 @@ impl OptionsUi {
}
pub fn release(&mut self) {
self.dragging = None;
self.hdragging = None;
self.graphics.release();
}
pub fn scroll(&mut self, amount: f32) {
self.scroll -= amount * 18.0;
}
/// The mouse wheel at the cursor.
pub fn scroll_at(&mut self, cursor: (f32, f32), amount: f32) {
if crate::ui_theme::horizon() && self.page() == Page::Main {
self.scroll_horizon(amount);
return;
}
if Self::graphics_level(self.page()).is_some() {
self.graphics.scroll(amount);
return;
}
match &mut self.pack_screen {
Some(p) if self.stack.last() == Some(&Page::Packs) => {
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
+110
View File
@@ -0,0 +1,110 @@
//! The performance overlay (Horizon's Overlay setting): frames per second,
//! frame, CPU and GPU times, and a graph of recent frame times.
use crate::{
graphics::Overlay,
hud::{HudAtlas, HudBatch, rgba},
text::Text,
};
use std::collections::VecDeque;
const HISTORY: usize = 160;
#[derive(Default)]
pub struct FrameGraph {
/// Frame, CPU and GPU milliseconds of recent frames, oldest first.
frames: VecDeque<(f32, f32, f32)>,
}
impl FrameGraph {
pub fn record(&mut self, frame_ms: f32, cpu_ms: f32, gpu_ms: Option<f32>) {
let gpu = gpu_ms.unwrap_or_else(|| self.frames.back().map_or(0.0, |f| f.2));
if self.frames.len() == HISTORY {
self.frames.pop_front();
}
self.frames.push_back((frame_ms, cpu_ms, gpu));
}
pub fn draw(&self, mode: Overlay, batch: &mut HudBatch, atlas: &HudAtlas) {
if mode == Overlay::Off || self.frames.is_empty() {
return;
}
let n = self.frames.len() as f32;
let avg = |f: fn(&(f32, f32, f32)) -> f32| self.frames.iter().map(f).sum::<f32>() / n;
let (frame, cpu, gpu) = (avg(|f| f.0), avg(|f| f.1), avg(|f| f.2));
let worst = self.frames.iter().map(|f| f.0).fold(0.0f32, f32::max);
let lines = [
format!("{:.0} fps", 1000.0 / frame.max(0.001)),
format!("frame {frame:.1} ms max {worst:.1}"),
format!("cpu {cpu:.1} gpu {gpu:.1} ms"),
];
let width = 120.0f32.max(
lines
.iter()
.map(|l| atlas.font.width(l))
.fold(0.0, f32::max)
+ 6.0,
);
let x = batch.width - width - 2.0;
let graph = if mode == Overlay::Graph { 42.0 } else { 0.0 };
batch.fill(
atlas,
x,
2.0,
width,
4.0 + lines.len() as f32 * 10.0 + graph,
rgba(0, 0, 0, 144),
);
for (i, line) in lines.iter().enumerate() {
let color = if i == 0 {
rgba(255, 255, 255, 255)
} else {
rgba(200, 200, 200, 255)
};
batch.text_styled(
atlas,
&Text::literal(line.clone()),
x + 3.0,
4.0 + i as f32 * 10.0,
color,
true,
);
}
if mode != Overlay::Graph {
return;
}
// Bars of frame time on a 0..33 ms scale; lines at 60 and 30 fps.
let (gy, gh) = (4.0 + lines.len() as f32 * 10.0 + 2.0, 36.0);
let scale = gh / 33.3;
let bar = (width - 6.0) / HISTORY as f32;
for (i, &(ms, cpu, _)) in self.frames.iter().enumerate() {
let height = (ms * scale).min(gh);
let bx = x + 3.0 + i as f32 * bar;
let color = if ms > 33.3 {
rgba(240, 90, 80, 255)
} else if ms > 16.7 {
rgba(240, 200, 90, 255)
} else {
rgba(110, 210, 120, 255)
};
batch.fill(atlas, bx, gy + gh - height, bar.max(1.0), height, color);
let cpu_height = (cpu * scale).min(height);
batch.fill(
atlas,
bx,
gy + gh - cpu_height,
bar.max(1.0),
cpu_height,
rgba(80, 140, 230, 200),
);
}
for target in [16.7, 33.3] {
let ly = gy + gh - target * scale;
batch.fill(
atlas,
x + 3.0,
ly,
width - 6.0,
1.0,
rgba(255, 255, 255, 90),
);
}
}
}
+273
View File
@@ -0,0 +1,273 @@
//! The menus' backdrop treatment (`ui_theme`'s Horizon style): the finished
//! world frame is blurred (dual Kawase: three halvings down, back up) and
//! colour graded before the HUD is drawn over it. Needs a swapchain that can
//! be copied from; without one the frame is left as it is.
/// How the frame under a Horizon-style menu is treated.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PostGrade {
/// Colour multiplier and lift (`c * tint + lift`).
pub tint: [f32; 3],
pub lift: [f32; 3],
/// Toward grey, 0..1.
pub desaturate: f32,
/// Darkening toward the corners, 0..1.
pub vignette: f32,
}
impl PostGrade {
/// The title backdrop: icy, slightly lifted shadows.
pub const FROST: Self = Self {
tint: [0.62, 0.78, 1.0],
lift: [0.0, 0.01, 0.035],
desaturate: 0.45,
vignette: 0.5,
};
/// Behind the pause menu: the world stays recognisable.
pub const PAUSE: Self = Self {
tint: [0.8, 0.86, 0.98],
lift: [0.0, 0.004, 0.012],
desaturate: 0.25,
vignette: 0.3,
};
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
texel: [f32; 4],
tint: [f32; 4],
lift: [f32; 4],
params: [f32; 4],
}
const LEVELS: usize = 3;
pub struct Post {
layout: wgpu::BindGroupLayout,
down: wgpu::RenderPipeline,
up: wgpu::RenderPipeline,
finish: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
/// The copied frame, then the halvings.
levels: Vec<(wgpu::Texture, wgpu::TextureView, [u32; 2])>,
uniforms: Vec<wgpu::Buffer>,
size: [u32; 2],
format: wgpu::TextureFormat,
}
impl Post {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Menu backdrop"),
source: wgpu::ShaderSource::Wgsl(include_str!("post.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Menu backdrop bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Menu backdrop"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = |entry: &str| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Menu backdrop pass"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
})
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Menu backdrop"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let uniforms = (0..LEVELS * 2 + 1)
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Menu backdrop pass"),
size: std::mem::size_of::<Uniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
})
.collect();
Self {
down: pipeline("fs_down"),
up: pipeline("fs_up"),
finish: pipeline("fs_final"),
layout,
sampler,
levels: Vec::new(),
uniforms,
size: [0, 0],
format,
}
}
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if self.size == size && !self.levels.is_empty() {
return;
}
self.size = size;
self.levels = (0..=LEVELS)
.map(|i| {
let s = [(size[0] >> i).max(1), (size[1] >> i).max(1)];
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Menu backdrop level"),
size: wgpu::Extent3d {
width: s[0],
height: s[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.format,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
(texture, view, s)
})
.collect();
}
fn pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
pipeline: &wgpu::RenderPipeline,
(from, uniform): (usize, usize),
target: &wgpu::TextureView,
data: Uniform,
) {
let (_, view, s) = &self.levels[from];
let mut data = data;
data.texel = [0.5 / s[0] as f32, 0.5 / s[1] as f32, 0.0, 0.0];
queue.write_buffer(&self.uniforms[uniform], 0, bytemuck::bytes_of(&data));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu backdrop pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[uniform].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu backdrop pass"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
/// Blur and grade `frame` (the swapchain texture) into `target`.
#[allow(clippy::too_many_arguments)]
pub fn apply(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::Texture,
target: &wgpu::TextureView,
size: [u32; 2],
grade: &PostGrade,
) {
self.ensure(device, size);
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.levels[0].0.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
let data = Uniform {
texel: [0.0; 4],
tint: [grade.tint[0], grade.tint[1], grade.tint[2], 1.0],
lift: [grade.lift[0], grade.lift[1], grade.lift[2], 0.0],
params: [grade.desaturate, grade.vignette, 0.0, 0.0],
};
let mut u = 0;
for i in 1..=LEVELS {
let target = &self.levels[i].1;
self.pass(device, queue, encoder, &self.down, (i - 1, u), target, data);
u += 1;
}
for i in (1..LEVELS).rev() {
let target = &self.levels[i].1;
self.pass(device, queue, encoder, &self.up, (i + 1, u), target, data);
u += 1;
}
self.pass(device, queue, encoder, &self.finish, (1, u), target, data);
}
}
+51
View File
@@ -0,0 +1,51 @@
// Menu backdrop: dual-Kawase blur (down and up passes) and a cold colour grade.
struct Post {
// Half a source texel (u, v), unused, unused.
texel: vec4<f32>,
// Multiplier and lift of the grade.
tint: vec4<f32>,
lift: vec4<f32>,
// Desaturation, vignette, unused, unused.
params: vec4<f32>,
}
@group(0) @binding(0) var src: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;
@group(0) @binding(2) var<uniform> post: Post;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn vs_main(@builtin(vertex_index) i: u32) -> Out {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: Out;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2(p.x, 1.0 - p.y);
return out;
}
fn s(uv: vec2<f32>) -> vec3<f32> {
return textureSampleLevel(src, samp, uv, 0.0).rgb;
}
@fragment fn fs_down(in: Out) -> @location(0) vec4<f32> {
let h = post.texel.xy;
let c = s(in.uv) * 4.0 + s(in.uv - h) + s(in.uv + h) + s(in.uv + vec2(h.x, -h.y)) + s(in.uv - vec2(h.x, -h.y));
return vec4(c / 8.0, 1.0);
}
fn up(uv: vec2<f32>) -> vec3<f32> {
let h = post.texel.xy;
var c = s(uv + vec2(-h.x * 2.0, 0.0)) + s(uv + vec2(h.x * 2.0, 0.0));
c += s(uv + vec2(0.0, -h.y * 2.0)) + s(uv + vec2(0.0, h.y * 2.0));
c += (s(uv + vec2(-h.x, h.y)) + s(uv + vec2(h.x, h.y)) + s(uv + vec2(-h.x, -h.y)) + s(uv + vec2(h.x, -h.y))) * 2.0;
return c / 12.0;
}
@fragment fn fs_up(in: Out) -> @location(0) vec4<f32> {
return vec4(up(in.uv), 1.0);
}
@fragment fn fs_final(in: Out) -> @location(0) vec4<f32> {
var c = up(in.uv);
let l = dot(c, vec3(0.2126, 0.7152, 0.0722));
c = mix(c, vec3(l), post.params.x);
c = c * post.tint.rgb + post.lift.rgb;
let d = length((in.uv - 0.5) * vec2(1.25, 1.0));
c *= 1.0 - post.params.y * smoothstep(0.35, 0.95, d);
return vec4(clamp(c, vec3(0.0), vec3(1.0)), 1.0);
}
+291 -74
View File
@@ -38,7 +38,11 @@ use std::{
collections::{BTreeMap, HashMap},
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex, OnceLock},
sync::{
Arc, Condvar, Mutex, OnceLock,
atomic::{AtomicU64, Ordering},
mpsc,
},
};
pub const VERSION_NAME: &str = "26.3";
@@ -1108,12 +1112,159 @@ pub fn load_light(tag: &Tag, chunk: &mut Chunk, sky_light: bool) {
}
type RegionCache = Mutex<HashMap<(i32, i32), Option<Arc<Mutex<RegionFile>>>>>;
/// Rewrite every region file in `folder` touched by `records`, keeping its
/// other chunks. `None` deletes a chunk.
fn write_region_records(
root: &Path,
cache: &RegionCache,
folder: &str,
records: Vec<((i32, i32), Option<Arc<NamedTag>>)>,
) -> Result<()> {
let mut by_region: BTreeMap<(i32, i32), Vec<((i32, i32), Option<Arc<NamedTag>>)>> =
BTreeMap::new();
for (key, tag) in records {
by_region
.entry((key.0 >> 5, key.1 >> 5))
.or_default()
.push((key, tag));
}
if by_region.is_empty() {
return Ok(());
}
let staging = root.join(folder).join(".staging");
fs::create_dir_all(&staging)?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as u32);
for ((rx, rz), list) in by_region {
let path = region_path(root, folder, rx, rz);
let mut records: BTreeMap<(i32, i32), ChunkRecord> = BTreeMap::new();
let only_deletes = list.iter().all(|(_, tag)| tag.is_none());
if let Ok(mut existing) = RegionFile::open(&path) {
let positions: Vec<ChunkPos> = existing.chunks().map(|(p, _)| p).collect();
if only_deletes
&& !positions
.iter()
.any(|p| list.iter().any(|(k, _)| *k == (p.x, p.z)))
{
continue;
}
for p in positions {
if let Ok(Some(r)) = existing.read(p) {
records.insert((p.x, p.z), r);
}
}
} else if only_deletes {
continue;
}
// Deleting chunks the region doesn't hold changes nothing: leave
// the file alone (unloading empty entity chunks asks for that).
let mut changed = false;
for (key, tag) in list {
match tag {
Some(tag) => {
changed = true;
records.insert(
key,
ChunkRecord::from_nbt(
&tag,
Compression::Zlib,
timestamp,
Limits::default(),
)?,
);
}
None => {
changed |= records.remove(&key).is_some();
}
}
}
if !changed {
continue;
}
if records.is_empty() {
let _ = fs::remove_file(&path);
} else {
let temporary = staging.join(format!("r.{rx}.{rz}.mca"));
let _ = fs::remove_file(&temporary);
let mut writer = RegionWriter::create(&temporary)?;
for ((x, z), r) in &records {
writer.write(ChunkPos::new(*x, *z), r)?;
}
writer.finish()?;
fs::rename(&temporary, &path)?;
}
cache
.lock()
.map_err(|_| "region cache poisoned")?
.remove(&(rx, rz));
}
let _ = fs::remove_dir(&staging);
Ok(())
}
fn region_path(root: &Path, folder: &str, rx: i32, rz: i32) -> PathBuf {
root.join(format!("{folder}/r.{rx}.{rz}.mca"))
}
/// A record waiting for the writer thread.
#[derive(Clone)]
enum Record {
Tag(Arc<NamedTag>),
/// A chunk to encode on the writer thread.
Chunk(Arc<Chunk>, Arc<Encoding>),
Delete,
}
/// What encoding a chunk needs, captured when it is queued.
struct Encoding {
registry: &'static BlockRegistry,
biomes: Arc<Biomes>,
tables: Option<Arc<SaveTables>>,
sky_light: bool,
}
impl Record {
fn tag(&self) -> Option<Arc<NamedTag>> {
match self {
Record::Tag(t) => Some(t.clone()),
Record::Chunk(c, e) => Some(Arc::new(encode_with(
c,
e.registry,
&e.biomes,
e.tables.as_deref(),
e.sky_light,
))),
Record::Delete => None,
}
}
}
/// Chunk and entity records waiting for the writer thread, by (entities?,
/// x, z): reads see them before the region files do.
type PendingWrites = Mutex<HashMap<(bool, i32, i32), (u64, Record)>>;
type WriteBatch = (bool, Vec<((i32, i32), Record, u64)>);
/// The background thread that encodes chunks and rewrites region files, in
/// submission order.
struct Writer {
send: mpsc::Sender<WriteBatch>,
thread: std::thread::JoinHandle<()>,
}
/// Writes still queued or running, with a condition to wait for none.
#[derive(Default)]
struct Outstanding {
count: Mutex<usize>,
done: Condvar,
}
/// One dimension folder (`dimensions/minecraft/<name>`). Region files are
/// read lazily by worker threads.
pub struct SaveDir {
pub root: PathBuf,
regions: RegionCache,
entity_regions: RegionCache,
regions: Arc<RegionCache>,
entity_regions: Arc<RegionCache>,
pending: Arc<PendingWrites>,
sequence: AtomicU64,
writer: Mutex<Option<Writer>>,
outstanding: Arc<Outstanding>,
/// Set by `Persistent` before the first load.
pub tables: OnceLock<Arc<SaveTables>>,
/// False in dimensions without skylight (no SkyLight layers are written).
@@ -1124,16 +1275,29 @@ impl SaveDir {
let sky_light = !root.ends_with("the_nether") && !root.ends_with("DIM-1");
Self {
root,
regions: Mutex::new(HashMap::new()),
entity_regions: Mutex::new(HashMap::new()),
regions: Default::default(),
entity_regions: Default::default(),
pending: Default::default(),
sequence: AtomicU64::new(0),
writer: Mutex::new(None),
outstanding: Default::default(),
tables: OnceLock::new(),
sky_light,
}
}
fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf {
self.root.join(format!("{folder}/r.{rx}.{rz}.mca"))
region_path(&self.root, folder, rx, rz)
}
fn read_record(&self, folder: &str, x: i32, z: i32) -> Option<NamedTag> {
let pending = self
.pending
.lock()
.ok()?
.get(&(folder != "region", x, z))
.map(|(_, r)| r.clone());
if let Some(record) = pending {
return record.tag().map(|t| (*t).clone());
}
let key = (x >> 5, z >> 5);
let cache = if folder == "region" {
&self.regions
@@ -1172,83 +1336,98 @@ impl SaveDir {
}
out
}
/// Rewrite every region file in `folder` touched by `records`, keeping
/// its other chunks. `None` deletes a chunk.
/// Queue `records` for the writer thread; with `wait`, return once
/// they and every earlier write are on disk.
fn write_records(
&self,
folder: &str,
records: Vec<((i32, i32), Option<NamedTag>)>,
wait: bool,
) -> Result<()> {
let mut by_region: BTreeMap<(i32, i32), Vec<((i32, i32), Option<NamedTag>)>> =
BTreeMap::new();
for (key, tag) in records {
by_region
.entry((key.0 >> 5, key.1 >> 5))
.or_default()
.push((key, tag));
}
if by_region.is_empty() {
return Ok(());
}
let staging = self.root.join(folder).join(".staging");
fs::create_dir_all(&staging)?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as u32);
let cache = if folder == "region" {
&self.regions
} else {
&self.entity_regions
};
for ((rx, rz), list) in by_region {
let path = self.region_path(folder, rx, rz);
let mut records: BTreeMap<(i32, i32), ChunkRecord> = BTreeMap::new();
if let Ok(mut existing) = RegionFile::open(&path) {
let positions: Vec<ChunkPos> = existing.chunks().map(|(p, _)| p).collect();
for p in positions {
if let Ok(Some(r)) = existing.read(p) {
records.insert((p.x, p.z), r);
}
let records = records
.into_iter()
.map(|(k, t)| (k, t.map_or(Record::Delete, |t| Record::Tag(Arc::new(t)))))
.collect();
self.queue(folder != "region", records, wait)
}
fn queue(&self, entities: bool, records: Vec<((i32, i32), Record)>, wait: bool) -> Result<()> {
if !records.is_empty() {
let mut batch = Vec::with_capacity(records.len());
{
let mut pending = self.pending.lock().map_err(|_| "pending writes poisoned")?;
for (key, record) in records {
let seq = self.sequence.fetch_add(1, Ordering::Relaxed);
pending.insert((entities, key.0, key.1), (seq, record.clone()));
batch.push((key, record, seq));
}
}
for (key, tag) in list {
match tag {
Some(tag) => {
records.insert(
key,
ChunkRecord::from_nbt(
&tag,
Compression::Zlib,
timestamp,
Limits::default(),
)?,
);
}
None => {
records.remove(&key);
}
}
}
if records.is_empty() {
let _ = fs::remove_file(&path);
} else {
let temporary = staging.join(format!("r.{rx}.{rz}.mca"));
let _ = fs::remove_file(&temporary);
let mut writer = RegionWriter::create(&temporary)?;
for ((x, z), r) in &records {
writer.write(ChunkPos::new(*x, *z), r)?;
}
writer.finish()?;
fs::rename(&temporary, &path)?;
}
cache
*self
.outstanding
.count
.lock()
.map_err(|_| "region cache poisoned")?
.remove(&(rx, rz));
.map_err(|_| "writer poisoned")? += 1;
let mut writer = self.writer.lock().map_err(|_| "writer poisoned")?;
let writer = writer.get_or_insert_with(|| self.start_writer());
if writer.send.send((entities, batch)).is_err() {
return Err("the region writer stopped".into());
}
}
if wait {
self.flush();
}
let _ = fs::remove_dir(&staging);
Ok(())
}
fn start_writer(&self) -> Writer {
let (send, receive) = mpsc::channel::<WriteBatch>();
let root = self.root.clone();
let (regions, entity_regions) = (self.regions.clone(), self.entity_regions.clone());
let pending = self.pending.clone();
let outstanding = self.outstanding.clone();
let thread = std::thread::Builder::new()
.name("horizon-region-writer".into())
.spawn(move || {
for (entities, batch) in receive {
let (folder, cache) = if entities {
("entities", &entity_regions)
} else {
("region", &regions)
};
let records = batch.iter().map(|(k, r, _)| (*k, r.tag())).collect();
if let Err(e) = write_region_records(&root, cache, folder, records) {
eprintln!("Horizon: failed to write {folder} files: {e}");
}
// Readers find the files (and a fresh region cache) from here on.
if let Ok(mut pending) = pending.lock() {
for (k, _, seq) in &batch {
if pending
.get(&(entities, k.0, k.1))
.is_some_and(|p| p.0 == *seq)
{
pending.remove(&(entities, k.0, k.1));
}
}
}
if let Ok(mut count) = outstanding.count.lock() {
*count -= 1;
outstanding.done.notify_all();
}
}
})
.expect("spawn region writer");
Writer { send, thread }
}
/// Wait until every queued write is on disk.
pub fn flush(&self) {
let Ok(mut count) = self.outstanding.count.lock() else {
return;
};
while *count > 0 {
count = match self.outstanding.done.wait(count) {
Ok(c) => c,
Err(_) => return,
};
}
}
/// Rewrite every region touched by `chunks`, keeping their other chunks.
pub fn save(
&self,
@@ -1266,11 +1445,39 @@ impl SaveDir {
)
})
.collect();
self.write_records("region", records)?;
self.write_records("region", records, true)?;
Ok(chunks.len())
}
/// `save` on the writer thread: returns before the files are written;
/// loads see the chunks meanwhile.
pub fn save_later(
&self,
chunks: &[Arc<Chunk>],
registry: &'static BlockRegistry,
biomes: &Arc<Biomes>,
) -> Result<usize> {
let encoding = Arc::new(Encoding {
registry,
biomes: biomes.clone(),
tables: self.tables.get().cloned(),
sky_light: self.sky_light,
});
let records = chunks
.iter()
.map(|c| ((c.x, c.z), Record::Chunk(c.clone(), encoding.clone())))
.collect();
self.queue(false, records, false)?;
Ok(chunks.len())
}
/// Write the entity chunks (`EntityStorage`): an empty list deletes it.
pub fn save_entities(&self, chunks: Vec<((i32, i32), Vec<Tag>)>) -> Result<()> {
self.write_entities(chunks, true)
}
/// `save_entities` on the writer thread.
pub fn save_entities_later(&self, chunks: Vec<((i32, i32), Vec<Tag>)>) -> Result<()> {
self.write_entities(chunks, false)
}
fn write_entities(&self, chunks: Vec<((i32, i32), Vec<Tag>)>, wait: bool) -> Result<()> {
let records = chunks
.into_iter()
.map(|((x, z), entities)| {
@@ -1285,7 +1492,17 @@ impl SaveDir {
((x, z), tag)
})
.collect();
self.write_records("entities", records)
self.write_records("entities", records, wait)
}
}
impl Drop for SaveDir {
/// Finish queued writes before the world is let go.
fn drop(&mut self) {
if let Some(writer) = self.writer.get_mut().ok().and_then(Option::take) {
drop(writer.send);
let _ = writer.thread.join();
}
}
}
+19 -2
View File
@@ -175,6 +175,8 @@ pub struct Settings {
pub toggle_use: bool,
/// Taps of the sprint key counted as double taps (sprintWindow).
pub sprint_window: i32,
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
pub horizon: crate::graphics::Graphics,
}
impl Default for Settings {
fn default() -> Self {
@@ -243,6 +245,7 @@ impl Default for Settings {
toggle_attack: false,
toggle_use: false,
sprint_window: 7,
horizon: Default::default(),
}
}
}
@@ -273,10 +276,20 @@ fn put_s(f: &mut OptionsFile, key: &str, v: &str) {
}
impl Settings {
pub fn load(path: &Path) -> Self {
let horizon = path
.parent()
.map(crate::graphics::Graphics::load)
.unwrap_or_default();
let Ok(bytes) = fs::read(path) else {
return Self::default();
return Self {
horizon,
..Self::default()
};
};
Self::parse(&String::from_utf8_lossy(&bytes))
Self {
horizon,
..Self::parse(&String::from_utf8_lossy(&bytes))
}
}
pub fn parse(text: &str) -> Self {
Self::from_file(OptionsFile::parse(text))
@@ -332,6 +345,7 @@ impl Settings {
toggle_attack: f.bool("toggleAttack"),
toggle_use: f.bool("toggleUse"),
sprint_window: f.int("sprintWindow"),
horizon: Default::default(),
auto_jump: f.bool("autoJump"),
language: f.string("lang"),
force_unicode: f.bool("forceUnicodeFont"),
@@ -461,6 +475,9 @@ impl Settings {
}
pub fn save(&self, path: &Path) {
let _ = fs::write(path, self.serialize());
if let Some(dir) = path.parent() {
self.horizon.save(dir);
}
}
/// Volume of a sound source (master excluded).
pub fn volume(&self, source: &str) -> f32 {
+11
View File
@@ -445,6 +445,17 @@ impl SoundEngine {
self.instances.remove(&id);
}
}
/// Table sizes, for soak tests watching what the engine holds.
pub fn census(&self) -> serde_json::Value {
serde_json::json!({
"instances": self.instances.len(),
"playing": self.instance_to_channel.len(),
"handles": self.handles.len(),
"ticking": self.ticking.len(),
"queued": self.queued.len(),
"used": self.used,
})
}
pub fn instance(&self, id: InstanceId) -> Option<&SoundInstance> {
self.instances.get(&id)
}
+43
View File
@@ -0,0 +1,43 @@
//! 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. Costs one clock read per span when enabled and
//! a relaxed load otherwise.
use std::{
cell::RefCell,
sync::atomic::{AtomicBool, Ordering},
time::Instant,
};
static ENABLED: AtomicBool = AtomicBool::new(false);
thread_local! {
static SPANS: RefCell<Vec<(&'static str, f32)>> = const { RefCell::new(Vec::new()) };
}
pub fn enable(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
/// Run `f`, recording its duration under `name` when spans are enabled.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
if !ENABLED.load(Ordering::Relaxed) {
return f();
}
let started = Instant::now();
let out = f();
let ms = started.elapsed().as_secs_f32() * 1000.0;
SPANS.with(|s| s.borrow_mut().push((name, ms)));
out
}
/// The frame's spans, summed by name, slowest first; clears them.
pub fn take() -> Vec<(&'static str, f32)> {
let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()));
let mut sums: Vec<(&'static str, f32)> = Vec::new();
for (name, ms) in spans {
match sums.iter_mut().find(|(n, _)| *n == name) {
Some(entry) => entry.1 += ms,
None => sums.push((name, ms)),
}
}
sums.sort_by(|a, b| b.1.total_cmp(&a.1));
sums
}
+55 -31
View File
@@ -2,6 +2,7 @@
//! around the player, closest first. The game thread owns the world and only
//! hands immutable `Arc` snapshots to workers; results carry a sequence number
//! so stale work is dropped.
use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::{
chunk::{Chunk, SECTIONS},
light::{LightTables, light_chunk},
@@ -10,7 +11,7 @@ use crate::{
};
use horizon_core::biome::BiomeZoom;
use std::{
collections::{BinaryHeap, HashMap, HashSet},
collections::{BinaryHeap, HashSet},
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicBool, Ordering},
@@ -98,19 +99,19 @@ pub struct Streaming {
results: mpsc::Receiver<Done>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
generating: HashSet<(i32, i32)>,
generating: FxHashSet<(i32, i32)>,
/// Proto chunks: `None` while checked out to a decoration job.
protos: HashMap<(i32, i32), Option<Proto>>,
decorated: HashSet<(i32, i32)>,
protos: FxHashMap<(i32, i32), Option<Proto>>,
decorated: FxHashSet<(i32, i32)>,
decorating: usize,
finishing: HashSet<(i32, i32)>,
finishing: FxHashSet<(i32, i32)>,
decorates: bool,
lighting: HashMap<(i32, i32), u64>,
meshing: HashMap<(i32, i32, i32), u64>,
lighting: FxHashMap<(i32, i32), u64>,
meshing: FxHashMap<(i32, i32, i32), u64>,
/// Chunks whose light is current.
lit: HashSet<(i32, i32)>,
lit: FxHashSet<(i32, i32)>,
/// Columns whose sections have been meshed at least once.
meshed: HashSet<(i32, i32)>,
meshed: FxHashSet<(i32, i32)>,
sequence: u64,
pub center: (i32, i32),
pub radius: i32,
@@ -133,7 +134,7 @@ pub struct Streaming {
light_log: Vec<(u64, (i32, i32, i32))>,
light_edit_sequence: u64,
/// Edit sequence at each in-flight light job's snapshot.
light_snapshots: HashMap<(i32, i32), u64>,
light_snapshots: FxHashMap<(i32, i32), u64>,
pub generated_total: u64,
pub meshed_total: u64,
/// Chunks come from a server (`insert_remote`/`forget`): nothing is
@@ -234,16 +235,16 @@ impl Streaming {
results,
threads,
order: 0,
generating: HashSet::new(),
protos: HashMap::new(),
decorated: HashSet::new(),
generating: Default::default(),
protos: Default::default(),
decorated: Default::default(),
decorating: 0,
finishing: HashSet::new(),
finishing: Default::default(),
decorates: generator.decorates(),
lighting: HashMap::new(),
meshing: HashMap::new(),
lit: HashSet::new(),
meshed: HashSet::new(),
lighting: Default::default(),
meshing: Default::default(),
lit: Default::default(),
meshed: Default::default(),
sequence: 0,
center: (0, 0),
radius: 8,
@@ -258,7 +259,7 @@ impl Streaming {
light_tables,
light_log: Vec::new(),
light_edit_sequence: 0,
light_snapshots: HashMap::new(),
light_snapshots: Default::default(),
generated_total: 0,
meshed_total: 0,
remote: false,
@@ -358,20 +359,24 @@ impl Streaming {
}
Done::Generated(mut chunk) => {
let key = (chunk.x, chunk.z);
world
.block_entities
.extend(std::mem::take(&mut chunk.block_entities));
world
.block_names
.extend(std::mem::take(&mut chunk.block_names));
let entities = std::mem::take(&mut chunk.entities);
if !entities.is_empty() {
world.loaded_entities.push(((chunk.x, chunk.z), entities));
}
self.generating.remove(&key);
self.finishing.remove(&key);
self.decorated.insert(key);
// A column that finished after the player moved away is
// dropped whole: its block entities and saved entities
// too, or they would stay in the world (and the entities
// spawn again, duplicated, when the column loads later).
if self.in_range(key.0, key.1, 2) || self.near_finished(key.0, key.1, 1) {
world
.block_entities
.extend(std::mem::take(&mut chunk.block_entities));
world
.block_names
.extend(std::mem::take(&mut chunk.block_names));
let entities = std::mem::take(&mut chunk.entities);
if !entities.is_empty() {
world.loaded_entities.push(((chunk.x, chunk.z), entities));
}
self.generated_total += 1;
// A chunk saved with its light needs no light job; its
// unlit neighbours still light themselves from it.
@@ -424,6 +429,12 @@ impl Streaming {
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
continue;
}
// Its column unloaded while it was meshed: nothing would
// ever remove the uploaded section.
if !world.chunks.contains_key(&(x, z)) {
self.meshing.remove(&(x, sy, z));
continue;
}
self.meshing.remove(&(x, sy, z));
self.meshed_total += 1;
uploads.push(((x, sy, z), mesh));
@@ -449,6 +460,13 @@ impl Streaming {
}
self.lit.remove(key);
self.meshed.remove(key);
self.lighting.remove(key);
self.light_snapshots.remove(key);
}
if !far.is_empty() {
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
world.dirty_sections.retain(|k| !far.contains(&(k.0, k.2)));
}
// Drop far proto chunks that are not checked out.
let areas: Vec<((i32, i32), i64)> =
@@ -614,13 +632,19 @@ impl Streaming {
}
// Meshing once the column and its neighbours are lit.
let dirty: Vec<_> = world.dirty_sections.iter().copied().collect();
let mut columns: HashMap<(i32, i32), Option<Box<Region>>> = HashMap::new();
let mut columns: FxHashMap<(i32, i32), Option<Box<Region>>> = FxHashMap::default();
let mut ready_columns: FxHashMap<(i32, i32), bool> = FxHashMap::default();
for (x, sy, z) in dirty {
if !world.chunks.contains_key(&(x, z)) || !self.in_main_range(x, z, 0) {
world.dirty_sections.remove(&(x, sy, z));
continue;
}
let ready = (-1..=1).all(|dz| (-1..=1).all(|dx| self.lit.contains(&(x + dx, z + dz))));
// Once per column: the 3x3 lit check would otherwise repeat for
// each of its sections every frame.
let lit = &self.lit;
let ready = *ready_columns.entry((x, z)).or_insert_with(|| {
(-1..=1).all(|dz| (-1..=1).all(|dx| lit.contains(&(x + dx, z + dz))))
});
if !ready || self.lighting.contains_key(&(x, z)) {
continue;
}
+83 -40
View File
@@ -50,6 +50,12 @@ pub struct Title {
pub mode: Mode,
pub saves: PathBuf,
pub started: std::time::Instant,
/// The mode last drawn and when it appeared (Horizon's screen fades).
shown: std::cell::Cell<(std::mem::Discriminant<Mode>, std::time::Instant)>,
/// The launcher-style opening (Horizon style, first title of the session).
pub opening: std::cell::Cell<Option<crate::front::Opening>>,
/// The saves listed for Home, re-read every couple of seconds.
recent: std::cell::RefCell<Option<(std::time::Instant, Vec<WorldEntry>)>>,
}
struct Button {
x: f32,
@@ -96,10 +102,78 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
}
impl Title {
pub fn new(saves: PathBuf) -> Self {
let mode = Mode::Main;
let now = std::time::Instant::now();
Self {
mode: Mode::Main,
shown: std::cell::Cell::new((std::mem::discriminant(&mode), now)),
mode,
saves,
started: std::time::Instant::now(),
started: now,
opening: std::cell::Cell::new(None),
recent: std::cell::RefCell::new(None),
}
}
/// The worlds, newest first, cached for two seconds (drawn every frame).
pub fn recent_worlds(&self) -> Vec<WorldEntry> {
let mut recent = self.recent.borrow_mut();
match &*recent {
Some((at, worlds)) if at.elapsed().as_secs() < 2 => worlds.clone(),
_ => {
let worlds = list_worlds(&self.saves);
*recent = Some((std::time::Instant::now(), worlds.clone()));
worlds
}
}
}
/// When the current mode first appeared.
pub fn entered(&self) -> std::time::Instant {
let now = std::mem::discriminant(&self.mode);
let (mode, at) = self.shown.get();
if mode == now {
return at;
}
let at = std::time::Instant::now();
self.shown.set((now, at));
at
}
/// Create New World: the folder name and options (FileUtil.findAvailableName).
pub fn create_action(&self) -> Action {
let Mode::Create {
name,
seed,
creative,
..
} = &self.mode
else {
return Action::None;
};
let base: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let base = if base.trim().is_empty() {
"World".to_owned()
} else {
base.trim().to_owned()
};
// FileUtil.findAvailableName: append (n) until unused.
let mut folder = base.clone();
let mut n = 1;
while self.saves.join(&folder).exists() {
folder = format!("{base} ({n})");
n += 1;
}
Action::Create {
folder,
name: name.clone(),
seed: parse_seed(seed),
creative: *creative,
}
}
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
@@ -446,44 +520,7 @@ impl Title {
*creative = !*creative;
Action::None
}
(
Mode::Create {
name,
seed,
creative,
..
},
Some(21),
) => {
let base: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let base = if base.trim().is_empty() {
"World".to_owned()
} else {
base.trim().to_owned()
};
// FileUtil.findAvailableName: append (n) until unused.
let mut folder = base.clone();
let mut n = 1;
while self.saves.join(&folder).exists() {
folder = format!("{base} ({n})");
n += 1;
}
Action::Create {
folder,
name: name.clone(),
seed: parse_seed(seed),
creative: *creative,
}
}
(Mode::Create { .. }, Some(21)) => self.create_action(),
(Mode::Create { .. }, Some(22)) => {
self.mode = Mode::Select {
worlds: list_worlds(&self.saves),
@@ -510,6 +547,7 @@ impl Title {
if let Mode::Create {
name, seed, focus, ..
} = &mut self.mode
&& *focus < 2
{
let field = if *focus == 0 { name } else { seed };
for c in text.chars().filter(|c| !c.is_control()) {
@@ -526,6 +564,7 @@ impl Title {
if let Mode::Create {
name, seed, focus, ..
} = &mut self.mode
&& *focus < 2
{
let field = if *focus == 0 { name } else { seed };
field.pop();
@@ -554,6 +593,10 @@ impl Title {
};
}
}
/// `yyyy/MM/dd HH:mm` (UTC) for a modification time in Unix seconds.
pub fn date_label(secs: u64) -> String {
date_string(secs)
}
fn date_string(secs: u64) -> String {
// Civil date from Unix time (days-from-civil inverse), UTC.
let days = (secs / 86400) as i64;
File diff suppressed because it is too large. Load diff
+17
View File
@@ -174,6 +174,23 @@ impl LimiterClock for SystemClock {
}
}
/// The real clock without busy waiting: where vanilla spins on
/// `Thread.onSpinWait` for the last half millisecond, this sleeps in short
/// steps, so a capped frame rate leaves the core idle.
#[derive(Clone, Copy, Debug, Default)]
pub struct SleepingClock(pub SystemClock);
impl LimiterClock for SleepingClock {
fn nanos(&mut self) -> i64 {
self.0.nanos()
}
fn park(&mut self, nanos: i64) {
self.0.park(nanos);
}
fn spin(&mut self) {
std::thread::sleep(std::time::Duration::from_micros(50));
}
}
/// `Minecraft.runTick`'s frame-limiter step: only limits below 260
/// (the "unlimited" setting) pace frames.
pub fn end_frame(
+6 -6
View File
@@ -9,15 +9,15 @@ use std::{
};
pub struct World {
pub chunks: HashMap<(i32, i32), Arc<Chunk>>,
pub chunks: crate::fast_hash::FxHashMap<(i32, i32), Arc<Chunk>>,
pub random: LegacyRandom,
pub revision: u64,
pub health_rules: crate::health::HealthRules,
pub air: BlockStateId,
/// Sections whose geometry changed and need remeshing.
pub dirty_sections: HashSet<(i32, i32, i32)>,
pub dirty_sections: crate::fast_hash::FxHashSet<(i32, i32, i32)>,
/// Chunks whose light must be recomputed.
pub dirty_light: HashSet<(i32, i32)>,
pub dirty_light: crate::fast_hash::FxHashSet<(i32, i32)>,
/// Edits on lit chunks (position, old state, new state), relit
/// incrementally by streaming.
pub light_edits: Vec<((i32, i32, i32), u16, u16)>,
@@ -108,13 +108,13 @@ impl Weather {
impl World {
pub fn new(air: BlockStateId, seed: i64) -> Self {
Self {
chunks: HashMap::new(),
chunks: Default::default(),
random: LegacyRandom::new(seed),
revision: 0,
health_rules: crate::health::HealthRules::default(),
air,
dirty_sections: HashSet::new(),
dirty_light: HashSet::new(),
dirty_sections: Default::default(),
dirty_light: Default::default(),
light_edits: Vec::new(),
loaded_entities: Vec::new(),
modified: HashSet::new(),
+55
View File
@@ -0,0 +1,55 @@
// Horizon's world post pass: the world, rendered at the resolution scale,
// copied (bilinear) or anti-aliased with FXAA onto the screen before the
// hand and HUD. Only used when a non-vanilla setting asks for it.
@group(0) @binding(0) var world: texture_2d<f32>;
@group(0) @binding(1) var world_sampler: sampler;
struct PostOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex fn vs_post(@builtin(vertex_index) i: u32) -> PostOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: PostOut;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2(p.x, 1.0 - p.y);
return out;
}
@fragment fn fs_copy(in: PostOut) -> @location(0) vec4<f32> {
return vec4(textureSampleLevel(world, world_sampler, in.uv, 0.0).rgb, 1.0);
}
fn luma(c: vec3<f32>) -> f32 {
return dot(c, vec3(0.299, 0.587, 0.114));
}
// FXAA 3.11 (console quality): blend along the local edge direction.
@fragment fn fs_fxaa(in: PostOut) -> @location(0) vec4<f32> {
let texel = 1.0 / vec2<f32>(textureDimensions(world));
let rgb_m = textureSampleLevel(world, world_sampler, in.uv, 0.0).rgb;
let nw = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(-0.5, -0.5) * texel, 0.0).rgb);
let ne = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(0.5, -0.5) * texel, 0.0).rgb);
let sw = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(-0.5, 0.5) * texel, 0.0).rgb);
let se = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(0.5, 0.5) * texel, 0.0).rgb);
let m = luma(rgb_m);
let lo = min(m, min(min(nw, ne), min(sw, se)));
let hi = max(m, max(max(nw, ne), max(sw, se)));
if hi - lo < max(0.0312, hi * 0.125) {
return vec4(rgb_m, 1.0);
}
var dir = vec2(-((nw + ne) - (sw + se)), (nw + sw) - (ne + se));
let reduce = max((nw + ne + sw + se) * 0.25 * 0.125, 1.0 / 128.0);
let scale = 1.0 / (min(abs(dir.x), abs(dir.y)) + reduce);
dir = clamp(dir * scale, vec2(-8.0), vec2(8.0)) * texel;
let a = 0.5 * (textureSampleLevel(world, world_sampler, in.uv + dir * (1.0 / 3.0 - 0.5), 0.0).rgb
+ textureSampleLevel(world, world_sampler, in.uv + dir * (2.0 / 3.0 - 0.5), 0.0).rgb);
let b = a * 0.5 + 0.25 * (textureSampleLevel(world, world_sampler, in.uv - dir * 0.5, 0.0).rgb
+ textureSampleLevel(world, world_sampler, in.uv + dir * 0.5, 0.0).rgb);
let lb = luma(b);
if lb < lo || lb > hi {
return vec4(a, 1.0);
}
return vec4(b, 1.0);
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "horizon-theme"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Horizon's design tokens and fonts, shared by the launcher and the game's menus"
[dependencies]
File renamed without changes.
+120
View File
@@ -0,0 +1,120 @@
//! Horizon's design tokens, shared by the launcher (`launcher/src/theme.rs`)
//! and the game's menus (`horizon-client`'s `ui_theme`), so the two read as
//! one product: change a value here and both follow.
//!
//! One neutral palette in two appearances and one accent. Spacing follows
//! 4/8/12/16/24/32. Type: Geist for the interface, Instrument Serif for
//! titles, Geist Mono for consoles and commands, all bundled here under the
//! SIL Open Font License (`fonts/OFL-*.txt`). Five sizes: 12, 13, 15, 28 and 56.
//! Colours are `0xRRGGBB`; sizes are in logical pixels.
use std::time::Duration;
pub const FONT: &str = "Geist";
pub const DISPLAY: &str = "Instrument Serif";
pub const MONO: &str = "Geist Mono";
/// The bundled font files.
pub mod fonts {
pub const GEIST_REGULAR: &[u8] = include_bytes!("../fonts/Geist-Regular.ttf");
pub const GEIST_MEDIUM: &[u8] = include_bytes!("../fonts/Geist-Medium.ttf");
pub const GEIST_SEMIBOLD: &[u8] = include_bytes!("../fonts/Geist-SemiBold.ttf");
pub const GEIST_MONO: &[u8] = include_bytes!("../fonts/GeistMono-Regular.ttf");
pub const INSTRUMENT_SERIF: &[u8] = include_bytes!("../fonts/InstrumentSerif-Regular.ttf");
}
pub const SMALL: f32 = 12.;
pub const BODY: f32 = 13.;
pub const LARGE: f32 = 15.;
pub const TITLE: f32 = 28.;
pub const HERO: f32 = 56.;
/// The opening's wordmark.
pub const WORDMARK: f32 = 96.;
/// The spacing scale.
pub const SPACE: [f32; 6] = [4., 8., 12., 16., 24., 32.];
/// Corner radii: buttons, fields, nav items and rows; segments inside a
/// segmented control; the segmented control's frame; menus and cards.
pub const RADIUS: f32 = 6.;
pub const RADIUS_SEGMENT: f32 = 5.;
pub const RADIUS_GROUP: f32 = 7.;
pub const RADIUS_CARD: f32 = 8.;
/// Dividers and borders are one pixel.
pub const HAIRLINE: f32 = 1.;
/// Control heights: buttons and nav items, segments, the large Play button.
pub const CONTROL: f32 = 30.;
pub const SEGMENT: f32 = 26.;
pub const CONTROL_LARGE: f32 = 40.;
/// A pressed primary button's opacity (secondary and ghost: `PRESSED_SOFT`).
pub const PRESSED: f32 = 0.82;
pub const PRESSED_SOFT: f32 = 0.8;
/// Disabled controls' opacity.
pub const DISABLED: f32 = 0.4;
/// The hover layer of a primary button: white at this opacity.
pub const PRIMARY_HOVER: f32 = 0.10;
/// Sidebar width and the label column of settings rows.
pub const SIDEBAR: f32 = 200.;
pub const ROW_LABEL: f32 = 150.;
/// Motion: short and eased out, never bouncy.
pub const QUICK: Duration = Duration::from_millis(160);
pub const MOVE: Duration = Duration::from_millis(240);
/// The opening: the wordmark holds, then the interface appears.
pub const OPENING_HOLD: Duration = Duration::from_millis(1400);
pub const OPENING_MAX: Duration = Duration::from_millis(2500);
pub const OPENING_REVEAL: Duration = Duration::from_millis(800);
/// GPUI's `ease_out_quint`, used for hovers, the sliding selection and page changes.
pub fn ease_out_quint(t: f32) -> f32 {
1. - (1. - t.clamp(0., 1.)).powi(5)
}
pub fn ease_out_cubic(t: f32) -> f32 {
1. - (1. - t.clamp(0., 1.)).powi(3)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Palette {
pub bg: u32,
pub sidebar: u32,
pub raised: u32,
pub hover: u32,
pub border: u32,
pub text: u32,
pub muted: u32,
pub faint: u32,
pub accent: u32,
pub on_accent: u32,
pub danger: u32,
pub dark: bool,
}
pub const DARK: Palette = Palette {
bg: 0x111113,
sidebar: 0x0c0c0d,
raised: 0x1a1a1d,
hover: 0x202024,
border: 0x26262a,
text: 0xe8e8ea,
muted: 0x9a9aa1,
faint: 0x626269,
accent: 0x7c5cc4,
on_accent: 0xffffff,
danger: 0xd9705f,
dark: true,
};
pub const LIGHT: Palette = Palette {
bg: 0xfcfcfb,
sidebar: 0xf4f4f2,
raised: 0xf2f2f0,
hover: 0xebebe8,
border: 0xe3e3df,
text: 0x19191b,
muted: 0x63636a,
faint: 0x9a9a9f,
accent: 0x6a4bb3,
on_accent: 0xffffff,
danger: 0xb84a3a,
dark: false,
};
+2
View File
@@ -6,7 +6,9 @@ To play, use [the launcher](../launcher/README.md). These pages are for develope
- [Build from source](../README.md#build-from-source): your own copy of the game, resource preparation, `cargo run`.
- [Native client](native-client.md): options, controls, the dedicated server, `horizon-ctl` scripting, packaging, architecture.
- [Graphics](graphics.md): the Simple and Advanced graphics settings, the graphics benchmark and what it measured.
- [Launcher](../launcher/README.md): installs, updates and starts builds; its CLI and MCP server; releasing it.
- Menus: the title, world list, create world, multiplayer, options and pause screens share the launcher's design tokens and fonts (`crates/horizon-theme`), drawn by `ui_theme` over a live frozen backdrop (`game/backdrop.rs`, blurred and graded by `post.rs`). Options → Interface → Menu style switches to vanilla's screens (Classic, kept in `horizon.txt`; `HORIZON_MENU_STYLE=horizon|classic` overrides it, and scripted runs default to Classic). [Title](media/menu-title.png) ([webm](media/menu-title.webm)), [worlds](media/menu-worlds.png), [options](media/menu-options.png), [pause](media/menu-pause.png).
## Parity
+96
View File
@@ -0,0 +1,96 @@
# Graphics: settings and performance
## Settings
In the Horizon menu style, the Options screen's **Video** section holds the graphics settings, with a Simple / Advanced switch at the top (remembered between sessions):
- **Simple**: a preset (Performance, Balanced, Quality, Ultra or Custom), render distance, FOV, max framerate, VSync and GUI scale.
- **Advanced**: every setting, grouped under Rendering, Quality, Display, Preferences, Performance and Overlay.
In the Classic style, Video Settings starts with two buttons opening the same settings as vanilla-looking screens.
Every row has a one-line description and its cost in frame time (none, low, medium, high), and applies at once, except worker threads, which apply at the next start. A preset sets the quality settings together; changing one of them turns it into Custom. Display preferences (FOV, framerate, VSync, GUI scale, fullscreen, present mode, pacing) leave the preset as it is.
![Simple](media/settings-simple.png)
![Advanced](media/settings-advanced.png)
| Group | Setting | Stored in |
| --- | --- | --- |
| Rendering | render distance, simulation distance, entity distance | `options.txt` (vanilla keys) |
| | cave culling, chunk updates per frame | `horizon-graphics.txt` |
| Quality | smooth lighting, mipmap levels, biome blend, clouds, cloud distance, weather radius, particles, entity shadows, cutout leaves | `options.txt` |
| | sky, fog, weather, animated textures | `horizon-graphics.txt` |
| Display | FOV, max framerate, VSync, fullscreen, GUI scale | `options.txt` |
| | resolution scale, anti-aliasing (FXAA), present mode, frame pacing | `horizon-graphics.txt` |
| Performance | worker threads, upload budget per frame | `horizon-graphics.txt` |
| Overlay | performance overlay: FPS, frame/CPU/GPU times, frame-time graph | `horizon-graphics.txt` |
`options.txt` keeps exactly vanilla's keys and values, so the options harness still compares it with vanilla; Horizon's settings live in `horizon-graphics.txt` beside it. Their defaults render as vanilla does: cave culling on, no upload budgets, sky, fog and weather on, 100% resolution, no FXAA, the present mode following VSync. Frame pacing defaults to Efficient: the limiter sleeps through the last half millisecond where vanilla spins, which changes timing, not pixels.
Which settings each level shows, their descriptions and costs are in `crates/horizon-client/src/graphics_ui.rs` (`rows`); the Horizon shell draws them in `options_horizon.rs` with `ui_theme::described_row`, and the Classic screens use `graphics_style.rs`.
Not implemented: distant-terrain LOD, anisotropic filtering (wgpu needs linear magnification for it, which blurs close blocks), MSAA (FXAA instead) and exclusive fullscreen (fullscreen is borderless).
## Benchmark
`tools/graphics_bench.py` opens a fresh creative world (seed 1234) on a virtual screen and flies the camera along four fixed 192-block paths, over a village, a forest, the ocean and a mountain, at render distances 8, 16 and 32, one game per distance. Each flight takes 12 seconds and reports average FPS, 1% lows, frame-time percentiles, CPU time per frame (frame time minus waits for the GPU, the display and the limiter), the split of that CPU time (world update, ticks, frame preparation, renderer) for all frames and for the slowest 1%, and GPU time per frame from timestamp queries.
```sh
cargo build --release -p horizon-client --bins
python3 tools/graphics_bench.py --label mine # lavapipe on vscreen: CPU-side numbers
python3 tools/graphics_bench.py --label mine-gpu --offscreen # the desktop's RTX 4080
python3 tools/graphics_bench.py --compare baseline-4080 mine-gpu # markdown table
```
vscreen is a headless compositor whose frames are software-rendered (lavapipe), so its frame times measure the CPU, rasterisation included. `--offscreen` (`horizon-client --offscreen`) renders into a texture on the most powerful GPU instead of the window, which measures the RTX 4080 under the same compositor. `--distances`, `--scenes` and `--build` pick a subset or another build. Results go to `benchmarks/graphics/<label>.json`, with the load average before and after: the desktop is shared, and other sessions' builds move the numbers.
Two tools find where a slow frame goes: `HORIZON_SPIKES=MS` prints the timed spans (ticks by system, streaming, uploads, saving, renderer) of every frame slower than MS milliseconds, and a frame-pointer build (`RUSTFLAGS="-C force-frame-pointers=yes"`) profiles with `perf record --call-graph fp`.
## Results
Baseline `27a32cc3` (main) against `9006566b`, the same full run (render distances 8, 16 then 32 in one fresh world), on ludodesktop.
RTX 4080, `--offscreen` (`benchmarks/graphics/baseline-4080.json`, `after-4080.json`):
| RD | scene | avg FPS | 1% low FPS | p99 ms | CPU ms/frame | GPU ms/frame |
| --- | --- | --- | --- | --- | --- | --- |
| 8 | village | 363 → 2409 | 21.6 → 160.8 | 37 → 1 | 2.7 → 0.4 | 0.42 → 0.10 |
| 8 | forest | 613 → 1099 | 49.6 → 82.9 | 10 → 10 | 1.6 → 0.9 | 0.40 → 0.26 |
| 8 | ocean | 323 → 687 | 5.4 → 47.5 | 33 → 16 | 2.7 → 1.4 | 0.24 → 0.37 |
| 8 | mountain | 28 → 344 | 7.4 → 30.0 | 111 → 33 | 12.4 → 1.6 | 0.46 → 0.34 |
| 16 | village | 8 → 1041 | 2.4 → 64.0 | 409 → 10 | 138.3 → 1.0 | 3.59 → 0.23 |
| 16 | forest | 28 → 316 | 0.5 → 49.6 | 159 → 18 | 37.9 → 3.2 | 2.68 → 0.75 |
| 16 | ocean | 59 → 385 | 10.5 → 72.0 | 85 → 12 | 16.8 → 2.6 | 1.96 → 0.68 |
| 16 | mountain | 27 → 220 | 7.8 → 30.0 | 109 → 33 | 20.2 → 3.1 | 7.14 → 0.94 |
| 32 | village | 4 → 48 | 2.6 → 0.8 | 387 → 76 | 244.2 → 9.4 | 10.36 → 1.77 |
| 32 | forest | 8 → 28 | 3.7 → 0.9 | 261 → 1108 | 118.7 → 40.1 | 15.90 → 2.30 |
| 32 | ocean | 2 → 67 | 1.6 → 10.1 | 626 → 95 | 424.8 → 14.7 | 9.48 → 2.91 |
| 32 | mountain | 3 → 29 | 2.5 → 15.2 | 407 → 64 | 300.5 → 16.1 | 9.73 → 8.47 |
lavapipe on vscreen, CPU-rendered (`baseline-lavapipe.json`, `after-lavapipe.json`); at 16 and 32 chunks most of the frame is software rasterisation, which no change here touches:
| RD | scene | avg FPS | 1% low FPS | p99 ms | CPU ms/frame | GPU ms/frame |
| --- | --- | --- | --- | --- | --- | --- |
| 8 | village | 16 → 58 | 6.6 → 31.3 | 134 → 29 | 48.2 → 11.4 | 26.73 → 13.52 |
| 8 | forest | 20 → 42 | 9.9 → 20.0 | 95 → 37 | 31.0 → 18.0 | 37.27 → 21.36 |
| 8 | ocean | 28 → 60 | 1.5 → 34.8 | 181 → 27 | 25.7 → 10.9 | 18.86 → 12.74 |
| 8 | mountain | 27 → 38 | 11.2 → 27.3 | 75 → 34 | 22.5 → 11.5 | 23.76 → 14.11 |
| 16 | village | 11 → 21 | 4.2 → 8.8 | 227 → 75 | 69.2 → 31.8 | 49.04 → 40.50 |
| 16 | forest | 12 → 18 | 5.7 → 11.5 | 137 → 81 | 58.4 → 36.8 | 56.45 → 47.82 |
| 16 | ocean | 8 → 8 | 2.6 → 4.8 | 385 → 209 | 98.4 → 96.7 | 71.51 → 57.30 |
| 16 | mountain | 6 → 12 | 3.1 → 4.9 | 321 → 189 | 124.9 → 56.7 | 95.47 → 70.81 |
| 32 | village | 3 → 5 | 2.0 → 3.6 | 497 → 278 | 345.6 → 111.2 | 85.50 → 121.35 |
| 32 | forest | 2 → 4 | 1.5 → 3.4 | 656 → 296 | 354.8 → 155.5 | 201.54 → 192.78 |
| 32 | ocean | 3 → 3 | 1.2 → 2.2 | 849 → 465 | 330.8 → 288.0 | 113.80 → 116.97 |
| 32 | mountain | 3 → 4 | 1.8 → 2.4 | 570 → 414 | 325.2 → 171.4 | 104.99 → 166.80 |
Render distance 32 still has long stalls in the forest and village flights: the slowest frames are game ticks (mob AI bursts as chunks load, and thousands of dropped items piling up in some chunks, which the sequential run accumulates), not rendering. Results swing between runs on the shared desktop; the probe runs during the work saw RD 32 forest from 15 to 335 FPS depending on what the world had accumulated.
## What changed
The baseline was CPU-bound everywhere: on the RTX 4080 the GPU spent 0.2 to 16 ms per frame, the CPU 2 to 425.
- **Ticking**: mobs, minecarts and dropped items outside the entity-ticking chunks (the simulation distance, as `DistanceManager.inEntityTickingRange`) only check despawning, as in `ServerLevel.tick`; every loaded one used to tick, so render distance 32 ticked seven times as many as vanilla. Villager POI searches skip sections whose palette holds no POI block. Sky darkening is sampled once per time and weather instead of once per mob, and the world's chunk map uses a fast hash.
- **Stalls**: chunks and entity chunks unloaded while moving are encoded and written by a background region writer (loads see queued records first, saves wait for the queue); deleting entity chunks a region doesn't hold no longer rewrites the region file; unloading drops block entities in one pass instead of one per chunk.
- **Rendering**: terrain sections share one vertex arena drawn with one indirect multi-draw per pass, instead of a buffer and draw call each; the cave-culling search runs on dense arrays; mobs are culled by the view frustum and vanilla's per-entity render distance, block entities by the frustum; per-frame scratch buffers are reused.
- **Streaming**: its bookkeeping uses the fast hash, and a column's readiness for meshing is checked once per frame rather than once per dirty section.
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 KiB

+13
View File
@@ -55,6 +55,15 @@ Worlds autosave every 6000 ticks (5 minutes), when chunks unload and on quit.
On headless Linux use `vscreen start ./target/release/horizon-client` and
`vscreen shot`.
Sound: the game prints the output it opened (`Horizon: audio output ...`) or
why it has none (`Horizon: no sound: ...`). It opens none with
`HORIZON_NO_AUDIO` set, nor on a `vscreen`, where tests run beside someone
listening; `HORIZON_AUDIO=1` opens it there anyway, so send it to a null sink
(`PIPEWIRE_PROPS='{ target.object=<sink> node.dont-fallback=true }'`). Don't
mute a test run's stream in the mixer: PipeWire remembers the mute for every
later `horizon-client`. The launcher starts the game as "Horizon" for that
reason.
### Drive the game from scripts (`horizon-ctl`)
`--control` opens a local Unix socket (`$XDG_RUNTIME_DIR/horizon-control.sock`,
@@ -74,6 +83,10 @@ $C bench 10 # avg/1%-low FPS and p50/p95/p99/max frame tim
$C quit # save and exit
```
`fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH]` moves the camera along a line at
constant speed and replies with the same summary as `bench`, plus CPU and GPU
time per frame; [the graphics benchmark](graphics.md#benchmark) is built on it.
Other commands: `status`, `key KEY down|up|tap`, `mouse left|right|middle
down|up|click`, `turn`, `tp X Y Z`, `hotbar N`, `give ITEM [COUNT]`,
`render-distance N`, `time TICKS`, `resume`, `stats [reset]`, `open WORLD [SEED]
+6 -1
View File
@@ -2565,12 +2565,13 @@ dependencies = [
[[package]]
name = "horizon-launcher"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"clap",
"dirs 5.0.1",
"flate2",
"gpui",
"horizon-theme",
"image",
"pulldown-cmark",
"self-replace",
@@ -2581,6 +2582,10 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-theme"
version = "0.1.0"
[[package]]
name = "http"
version = "1.5.0"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-launcher"
version = "0.1.1"
version = "0.1.2"
edition = "2021"
license = "MIT"
description = "Downloads, prepares and starts Horizon builds"
@@ -20,6 +20,7 @@ dirs = "5"
self-replace = "1"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
clap = { version = "4", features = ["derive"] }
horizon-theme = { path = "../crates/horizon-theme" }
[profile.release]
strip = true
+21 -9
View File
@@ -17,16 +17,23 @@ The opening, then Play ([clip](docs/opening.webm)):
- **Play**: prepares resources as `PLAY.txt` describes (your launcher's 26.3 install, the
26.3 server jar from that version's own json, Python 3.10+ and Java 25), then starts the
client, or a server (`--dedicated --nogui`) in a folder you choose, with a live console.
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors.
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors. A quiet "Join Discord" link sits in the sidebar footer.
- **What's next**: the README's roadmap, from main.
Data lives in the platform's data directory (`~/.local/share/horizon-launcher` on Linux,
`~/Library/Application Support/horizon-launcher` on macOS, `%APPDATA%\horizon-launcher` on
Windows): `versions/<tag>/`, `server-jars/`, `server/` and `settings.json`.
- **Settings**: install new Horizon versions automatically (off by default; the selected
version only moves to the new one if you were on the newest), the opening animation, and the
launcher's own version and update state.
- **Settings**: install new Horizon versions automatically (on unless you turn it off; the
selected version only moves to the new one if you were on the newest), the opening animation,
the game's menu style, and the launcher's own version and update state.
- **Menu style**: Horizon's own menus or Classic (vanilla's screens). Classic has no way back
inside the game, so the launcher is where you return to Horizon's. It is the `menuStyle:` line
of `horizon.txt` in the game's home folder (`HORIZON_HOME`, else `horizon` in the data
directory), which the game reads when it starts.
- **Links**: the Discord invite and the Sponsors page come from `launcher/links.json` on main
(`{"discord": "...", "sponsor": "..."}`), so they change without a launcher release. Offline,
the launcher uses the same values built in; an empty address hides its button.
## Updates
@@ -56,10 +63,12 @@ The same binary is a command-line tool; without a subcommand it opens the window
horizon-launcher server [--dir <dir>] [--version <tag>] [--accept-eula]
horizon-launcher notes [<tag>] patch notes as plain text
horizon-launcher next the roadmap
horizon-launcher links the Discord and sponsor links
horizon-launcher menu-style [horizon|classic] show or set the game's menu style
horizon-launcher update [--check] Horizon and the launcher
`--json` prints JSON where there is something to report (`versions`, `install`, `notes`,
`next`, `update --check`). Errors go to stderr with exit code 1. `server` runs in the terminal,
`next`, `links`, `menu-style`, `update --check`). Errors go to stderr with exit code 1. `server` runs in the terminal,
so lines you type reach its console. `HORIZON_LAUNCHER_HOME` replaces the data folder (for
scripts and tests).
@@ -71,7 +80,7 @@ scripts and tests).
(with the full path to the binary if it is not on your `PATH`). Its tools: `list_versions`,
`install_version`, `remove_version`, `prepare_resources`, `launch_client`, `start_server`,
`server_command`, `server_log`, `stop_server`, `patch_notes`, `roadmap` and `check_updates`.
`server_command`, `server_log`, `stop_server`, `patch_notes`, `roadmap`, `links`, `menu_style` and `check_updates`.
A server started through MCP stops (cleanly, with `stop`) when the session ends. Starting one
needs `accept_eula`, which an agent should only pass with the player's consent.
@@ -83,11 +92,14 @@ The window, the command line and the MCP server all call the same core, `src/bac
On NixOS, `nix-shell` in this folder provides the libraries GPUI links to (Wayland, X11,
xkbcommon, Vulkan, fontconfig). macOS needs Xcode's command line tools. The `launcher`
workflow builds all three platforms on every push to the `launcher` branch.
workflow builds all three platforms on every push to the `launcher` branch (or a
`launcher-*` one), then installs the latest Horizon into a temporary folder and checks that the
client starts (`--help`) and that the binaries import only system libraries (Windows).
cargo test --lib # tag separation, version order, update selection, notes as text
cargo test --lib # tag separation, version order, update selection, notes as text,
# the auto-install default, the links fallback, the menu style file
Geist, Geist Mono and Instrument Serif (SIL Open Font License, `assets/fonts/OFL-*.txt`) are
Geist, Geist Mono and Instrument Serif (SIL Open Font License, `crates/horizon-theme/fonts/OFL-*.txt`, shared with the game's menus) are
bundled so the type is the same on every platform.
Background image (`assets/background.jpg`): backgrounds.supply (free preview), licence to confirm before release.
Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

+1
View File
@@ -0,0 +1 @@
{"discord": "https://discord.gg/HcTeMZxe5E", "sponsor": "https://github.com/sponsors/ludovic111"}
+167 -6
View File
@@ -9,7 +9,6 @@ use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
pub const REPO: &str = "ludovic111/horizon";
pub const SPONSOR_URL: &str = "https://github.com/sponsors/ludovic111";
pub const README_URL: &str = "https://github.com/ludovic111/horizon/blob/main/launcher/README.md";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Launcher releases are tagged `launcher-vX.Y.Z`; Horizon's own are `vYYYY.MM.DD`.
@@ -163,6 +162,43 @@ pub fn roadmap() -> Result<String, String> {
Err("The README has no roadmap section yet.".into())
}
/// The community and sponsor links, from `launcher/links.json` on main, so they can change
/// without a launcher release. An empty address hides its button.
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct Links {
pub discord: String,
pub sponsor: String,
}
impl Default for Links {
/// What the launcher uses until (or unless) it can read links.json.
fn default() -> Self {
Self { discord: "https://discord.gg/HcTeMZxe5E".into(), sponsor: "https://github.com/sponsors/ludovic111".into() }
}
}
impl Links {
/// Reads links.json. A key that is missing keeps its default, an empty one hides the link,
/// and text that is not a JSON object gives the defaults.
pub fn parse(text: &str) -> Self {
let mut links = Self::default();
if let Ok(serde_json::Value::Object(map)) = serde_json::from_str(text) {
for (key, slot) in [("discord", &mut links.discord), ("sponsor", &mut links.sponsor)] {
if let Some(url) = map.get(key).and_then(|v| v.as_str()) {
*slot = url.trim().to_string();
}
}
}
links
}
}
/// The links from main, or the built-in ones when offline.
pub fn links() -> Links {
let url = format!("https://raw.githubusercontent.com/{REPO}/main/launcher/links.json");
get(&url).ok().and_then(|r| r.into_string().ok()).map_or_else(Links::default, |text| Links::parse(&text))
}
/// The launcher's data folder; `HORIZON_LAUNCHER_HOME` overrides it (for tests and scripts).
pub fn data_dir() -> PathBuf {
if let Some(home) = std::env::var_os("HORIZON_LAUNCHER_HOME").filter(|h| !h.is_empty()) {
@@ -346,11 +382,72 @@ pub fn game_home() -> PathBuf {
dirs::data_dir().unwrap_or_else(std::env::temp_dir).join("horizon")
}
/// Which drawing the game's menus use. Classic is vanilla's own screens, which have no way to
/// switch back, so the launcher is where the player returns to Horizon's.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuStyle {
Horizon,
Classic,
}
impl MenuStyle {
pub fn name(self) -> &'static str {
match self {
MenuStyle::Horizon => "horizon",
MenuStyle::Classic => "classic",
}
}
pub fn parse(name: &str) -> Option<Self> {
match name {
"horizon" => Some(MenuStyle::Horizon),
"classic" => Some(MenuStyle::Classic),
_ => None,
}
}
/// The style `horizon.txt` holds: Horizon unless it has the line `menuStyle:classic`, as the
/// game reads it (crates/horizon-client/src/ui_theme.rs).
fn from_file(text: &str) -> Self {
if text.lines().any(|l| l.trim() == "menuStyle:classic") {
MenuStyle::Classic
} else {
MenuStyle::Horizon
}
}
/// `text` with its `menuStyle:` line set to this style and every other line kept.
fn into_file(self, text: &str) -> String {
let mut out: Vec<String> = text.lines().filter(|l| !l.trim().starts_with("menuStyle:")).map(String::from).collect();
out.insert(0, format!("menuStyle:{}", self.name()));
out.join("\n") + "\n"
}
}
/// The menu style saved in the game home's `horizon.txt` (Horizon when there is none).
pub fn menu_style() -> MenuStyle {
MenuStyle::from_file(&fs::read_to_string(game_home().join("horizon.txt")).unwrap_or_default())
}
pub fn set_menu_style(style: MenuStyle) -> Result<(), String> {
let home = game_home();
fs::create_dir_all(&home).map_err(|e| e.to_string())?;
let file = home.join("horizon.txt");
let text = style.into_file(&fs::read_to_string(&file).unwrap_or_default());
fs::write(file, text).map_err(|e| e.to_string())
}
pub fn client_command(tag: &str) -> Command {
let home = game_home();
let _ = fs::create_dir_all(&home);
let mut cmd = Command::new(version_dir(tag).join(CLIENT));
cmd.current_dir(home).arg("--resources").arg(resources(tag));
// PipeWire remembers volume and mute per application name. The game's own name is
// "PipeWire ALSA [horizon-client]", shared with development and test runs, where
// muting it once left every later game silent: played from here it is "Horizon".
if std::env::var_os("PIPEWIRE_PROPS").is_none() {
cmd.env("PIPEWIRE_PROPS", "{ application.name = \"Horizon\" media.role = \"Game\" }");
}
cmd
}
@@ -701,6 +798,7 @@ pub enum Job {
pub enum Msg {
Releases(Result<Vec<Release>, String>),
Roadmap(Result<String, String>),
Links(Links),
Progress(String, f32),
Installed(String, Result<(), String>),
Line(Job, String),
@@ -774,8 +872,10 @@ pub struct Settings {
pub selected: Option<String>,
pub minecraft_dir: Option<PathBuf>,
pub server_dir: Option<PathBuf>,
/// Unset means on: only a player who turned it off has `false` here. 0.1.1 wrote its
/// `auto_install` (off by default) for everyone, so this is a new key and that one is ignored.
#[serde(default)]
pub auto_install: bool,
pub install_new_versions: Option<bool>,
/// Off by default, so the opening animation shows unless the player turns it off.
#[serde(default)]
pub skip_opening: bool,
@@ -783,10 +883,14 @@ pub struct Settings {
impl Settings {
pub fn load() -> Self {
fs::read_to_string(data_dir().join("settings.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
Self::parse(&fs::read_to_string(data_dir().join("settings.json")).unwrap_or_default())
}
pub fn parse(text: &str) -> Self {
serde_json::from_str(text).unwrap_or_default()
}
/// Whether new Horizon versions are installed automatically.
pub fn auto_install(&self) -> bool {
self.install_new_versions.unwrap_or(true)
}
pub fn save(&self) {
let _ = fs::create_dir_all(data_dir());
@@ -799,6 +903,16 @@ mod tests {
use super::*;
use std::cmp::Ordering::*;
#[test]
fn the_game_plays_under_its_own_audio_stream_name() {
if std::env::var_os("PIPEWIRE_PROPS").is_some() {
return;
}
let cmd = client_command("v2026.10.13");
let props = cmd.get_envs().find(|(k, _)| *k == "PIPEWIRE_PROPS").and_then(|(_, v)| v);
assert!(props.is_some_and(|v| v.to_string_lossy().contains("application.name = \"Horizon\"")));
}
#[test]
fn tags_never_mix() {
assert!(is_game_tag("v2026.10.13"));
@@ -843,6 +957,53 @@ mod tests {
assert!(select_after_update(None, &installed));
}
#[test]
fn new_versions_install_automatically_unless_turned_off() {
// No settings file, an old one that never mentioned it, and 0.1.1's default `false`.
assert!(Settings::parse("").auto_install());
assert!(Settings::parse("{}").auto_install());
assert!(Settings::parse(r#"{"selected": "v2026.10.13", "auto_install": false}"#).auto_install());
assert!(Settings::parse(r#"{"install_new_versions": true}"#).auto_install());
assert!(!Settings::parse(r#"{"install_new_versions": false}"#).auto_install());
// Saving after the player turned it off keeps it off.
let off = Settings { install_new_versions: Some(false), ..Settings::default() };
assert!(!Settings::parse(&serde_json::to_string(&off).unwrap()).auto_install());
// The selection rule is unchanged: the new version is selected only from the newest.
let installed = ["v2026.10.12".to_string(), "v2026.10.1".to_string()];
assert!(select_after_update(Some("v2026.10.12"), &installed));
assert!(!select_after_update(Some("v2026.10.1"), &installed));
}
#[test]
fn links_fall_back_to_the_built_in_ones() {
let defaults = Links::default();
assert_eq!(defaults.discord, "https://discord.gg/HcTeMZxe5E");
assert_eq!(defaults.sponsor, "https://github.com/sponsors/ludovic111");
// The file on main says the same thing as the defaults.
let file = Links::parse(r#"{"discord": "https://discord.gg/HcTeMZxe5E", "sponsor": "https://github.com/sponsors/ludovic111"}"#);
assert_eq!(file, defaults);
// Unreadable or missing, offline included: the defaults.
assert_eq!(Links::parse(""), defaults);
assert_eq!(Links::parse("<html>404</html>"), defaults);
assert_eq!(Links::parse("[1]"), defaults);
assert_eq!(Links::parse(r#"{"discord": 5}"#), defaults);
// A key that is missing keeps its default; a new address replaces it; empty hides it.
assert_eq!(Links::parse(r#"{"discord": "https://x.y/z"}"#), Links { discord: "https://x.y/z".into(), ..defaults.clone() });
assert_eq!(Links::parse(r#"{"discord": "", "sponsor": " "}"#), Links { discord: String::new(), sponsor: String::new() });
}
#[test]
fn menu_style_file_matches_the_games() {
assert_eq!(MenuStyle::from_file(""), MenuStyle::Horizon);
assert_eq!(MenuStyle::from_file("menuStyle:horizon\n"), MenuStyle::Horizon);
assert_eq!(MenuStyle::from_file("menuStyle:classic\n"), MenuStyle::Classic);
assert_eq!(MenuStyle::from_file("other:1\nmenuStyle:classic\n"), MenuStyle::Classic);
assert_eq!(MenuStyle::Classic.into_file(""), "menuStyle:classic\n");
assert_eq!(MenuStyle::Horizon.into_file("menuStyle:classic\nother:1\n"), "menuStyle:horizon\nother:1\n");
assert_eq!(MenuStyle::parse("classic"), Some(MenuStyle::Classic));
assert_eq!(MenuStyle::parse("vanilla"), None);
}
#[test]
fn plain_notes() {
let text = markdown_to_text("# Title\n\nSome *words* and [a link](https://x.y).\n\n- one\n- two\n");
+29
View File
@@ -57,6 +57,14 @@ enum Cmd {
Notes { tag: Option<String> },
/// Show the roadmap
Next,
/// Show the Discord and sponsor links
Links,
/// Show or set the game's menu style (classic is vanilla's own screens)
MenuStyle {
/// Omit to show the current style
#[arg(value_parser = ["horizon", "classic"])]
style: Option<String>,
},
/// Look for a newer Horizon release and launcher, and install them
Update {
/// Only report what is available
@@ -198,6 +206,27 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
print!("{}", backend::markdown_to_text(&roadmap));
}
}
Cmd::Links => {
let links = backend::links();
if as_json {
json(serde_json::to_value(&links).unwrap());
} else {
for (name, url) in [("Discord", &links.discord), ("Sponsor", &links.sponsor)] {
println!("{name:<8} {}", if url.is_empty() { "(none)" } else { url });
}
}
}
Cmd::MenuStyle { style } => {
if let Some(style) = style.as_deref().and_then(backend::MenuStyle::parse) {
backend::set_menu_style(style)?;
}
let style = backend::menu_style().name();
if as_json {
json(serde_json::json!({ "menu_style": style }));
} else {
println!("Menu style: {style}");
}
}
Cmd::Update { check } => update(check, as_json)?,
Cmd::Mcp => unreachable!(),
}
+105 -39
View File
@@ -53,9 +53,9 @@ enum SelfUpdate {
/// The opening: the wordmark over the image holds at least `OPENING_HOLD` (until the release
/// list is in, at most `OPENING_MAX`), then the launcher appears over `OPENING_REVEAL`.
const OPENING_HOLD: Duration = Duration::from_millis(1400);
const OPENING_MAX: Duration = Duration::from_millis(2500);
const OPENING_REVEAL: Duration = Duration::from_millis(800);
const OPENING_HOLD: Duration = horizon_theme::OPENING_HOLD;
const OPENING_MAX: Duration = horizon_theme::OPENING_MAX;
const OPENING_REVEAL: Duration = horizon_theme::OPENING_REVEAL;
/// The phase of the opening at one moment: how far the reveal has gone, 0 to 1.
struct Opening {
@@ -78,7 +78,7 @@ fn background_image() -> Arc<RenderImage> {
}
fn ease_out_cubic(x: f32) -> f32 {
1. - (1. - x.clamp(0., 1.)).powi(3)
horizon_theme::ease_out_cubic(x)
}
/// How often the launcher looks for new releases while it is open.
@@ -124,6 +124,9 @@ struct Launcher {
confirm_remove: Option<String>,
picker_open: bool,
settings: Settings,
/// The Discord and sponsor links: built in until links.json on main has been read.
links: backend::Links,
menu_style: backend::MenuStyle,
preparing: bool,
prepare_log: VecDeque<String>,
prepare_error: Option<String>,
@@ -192,6 +195,8 @@ impl Launcher {
confirm_remove: None,
picker_open: false,
settings,
links: backend::Links::default(),
menu_style: backend::menu_style(),
preparing: false,
prepare_log: VecDeque::new(),
prepare_error: None,
@@ -210,6 +215,7 @@ impl Launcher {
};
this.fetch_releases();
this.fetch_roadmap();
this.fetch_links();
this.check_updates();
this
}
@@ -262,6 +268,11 @@ impl Launcher {
std::thread::spawn(move || tx.send(Msg::Releases(backend::releases())));
}
fn fetch_links(&self) {
let tx = self.tx.clone();
std::thread::spawn(move || tx.send(Msg::Links(backend::links())));
}
fn fetch_roadmap(&mut self) {
self.roadmap = Load::Loading;
let tx = self.tx.clone();
@@ -275,6 +286,7 @@ impl Launcher {
match msg {
Msg::Releases(r) => self.releases = r.map_or_else(Load::Failed, Load::Ready),
Msg::Roadmap(r) => self.roadmap = r.map_or_else(Load::Failed, Load::Ready),
Msg::Links(links) => self.links = links,
Msg::Progress(tag, p) => {
self.bar = (self.bar.1, p);
self.installing = Some((tag, p));
@@ -300,7 +312,7 @@ impl Launcher {
Msg::Updates(Ok(updates)) => {
self.update_error = None;
self.updates = updates;
if self.settings.auto_install {
if self.settings.auto_install() {
self.install_update();
}
if let Some(release) = self.updates.launcher.clone() {
@@ -560,7 +572,7 @@ fn button(id: impl Into<SharedString>, label: impl Into<SharedString>, kind: Kin
let id: SharedString = id.into();
let t = *t;
let hover = match kind {
Kind::Primary => gpui::white().opacity(0.10),
Kind::Primary => gpui::white().opacity(theme::PRIMARY_HOVER),
_ => t.hover,
};
let base = div()
@@ -570,19 +582,19 @@ fn button(id: impl Into<SharedString>, label: impl Into<SharedString>, kind: Kin
.flex_none()
.items_center()
.justify_center()
.h(px(30.))
.h(px(theme::CONTROL))
.px(px(12.))
.rounded(px(6.))
.rounded(px(theme::RADIUS))
.text_size(px(theme::BODY))
.font_weight(FontWeight::MEDIUM)
.cursor_pointer()
.child(hover_layer(&id, hover, 6.))
.child(hover_layer(&id, hover, theme::RADIUS))
.child(div().relative().child(label.into()));
let base = track_hover(base, id);
match kind {
Kind::Primary => base.bg(t.accent).text_color(t.on_accent).active(|s| s.opacity(0.82)),
Kind::Secondary => base.border_1().border_color(t.border).bg(t.raised).text_color(t.text).active(|s| s.opacity(0.8)),
Kind::Ghost => base.text_color(t.muted).hover(move |s| s.text_color(t.text)).active(|s| s.opacity(0.8)),
Kind::Primary => base.bg(t.accent).text_color(t.on_accent).active(|s| s.opacity(theme::PRESSED)),
Kind::Secondary => base.border_1().border_color(t.border).bg(t.raised).text_color(t.text).active(|s| s.opacity(theme::PRESSED_SOFT)),
Kind::Ghost => base.text_color(t.muted).hover(move |s| s.text_color(t.text)).active(|s| s.opacity(theme::PRESSED_SOFT)),
}
}
@@ -607,7 +619,7 @@ fn switch(id: &'static str, on: bool, t: &Theme) -> Stateful<Div> {
}
fn disabled(b: Stateful<Div>) -> Stateful<Div> {
b.opacity(0.4).cursor_default()
b.opacity(theme::DISABLED).cursor_default()
}
fn page_header(title: &str, description: &str, t: &Theme) -> Div {
@@ -637,7 +649,7 @@ fn row(label: &str, value: impl IntoElement, action: Option<Stateful<Div>>, t: &
.items_center()
.gap(px(16.))
.py(px(12.))
.child(div().w(px(150.)).flex_none().text_size(px(13.)).text_color(t.muted).child(label.to_string()))
.child(div().w(px(theme::ROW_LABEL)).flex_none().text_size(px(13.)).text_color(t.muted).child(label.to_string()))
.child(div().flex_1().min_w_0().text_size(px(13.)).text_color(t.text).child(value))
.children(action)
}
@@ -687,7 +699,7 @@ fn megabytes(bytes: u64) -> String {
impl Launcher {
fn sidebar(&self, cx: &mut Context<Self>) -> Div {
let t = self.theme;
const STEP: f32 = 32.;
const STEP: f32 = theme::CONTROL + 2.;
let mut nav = div().relative().flex().flex_col().gap(px(2.));
// The selection is one pill that slides to the chosen item.
let (from, to) = (self.previous_page.index() as f32 * STEP, self.page.index() as f32 * STEP);
@@ -696,8 +708,8 @@ impl Launcher {
.absolute()
.left_0()
.w_full()
.h(px(30.))
.rounded(px(6.))
.h(px(theme::CONTROL))
.rounded(px(theme::RADIUS))
.bg(t.raised)
.border_1()
.border_color(t.border)
@@ -715,12 +727,12 @@ impl Launcher {
.relative()
.flex()
.items_center()
.h(px(30.))
.h(px(theme::CONTROL))
.px(px(10.))
.rounded(px(6.))
.rounded(px(theme::RADIUS))
.text_size(px(theme::BODY))
.cursor_pointer()
.when(!active, |d| d.child(hover_layer(&id, t.hover.opacity(0.7), 6.)))
.when(!active, |d| d.child(hover_layer(&id, t.hover.opacity(0.7), theme::RADIUS)))
.child(
div()
.relative()
@@ -752,11 +764,22 @@ impl Launcher {
),
_ => div().child(format!("Launcher {}", backend::VERSION)),
};
let discord = (!self.links.discord.is_empty()).then(|| {
let url = self.links.discord.clone();
div()
.id("discord")
.mb(px(4.))
.text_color(t.muted)
.cursor_pointer()
.hover(move |s| s.text_color(t.text))
.child("Join Discord")
.on_click(move |_, _, cx| cx.open_url(&url))
});
div()
.flex()
.flex_col()
.flex_none()
.w(px(200.))
.w(px(theme::SIDEBAR))
.h_full()
.bg(t.sidebar.opacity(0.8))
.border_r_1()
@@ -774,7 +797,7 @@ impl Launcher {
)
.child(nav)
.child(div().flex_1())
.child(div().px(px(10.)).text_size(px(theme::SMALL)).text_color(t.faint).child(footer))
.child(div().px(px(10.)).text_size(px(theme::SMALL)).text_color(t.faint).children(discord).child(footer))
}
fn segmented(&self, cx: &mut Context<Self>) -> Div {
@@ -785,9 +808,9 @@ impl Launcher {
.id(label)
.flex()
.items_center()
.h(px(26.))
.h(px(theme::SEGMENT))
.px(px(14.))
.rounded(px(5.))
.rounded(px(theme::RADIUS_SEGMENT))
.text_size(px(13.))
.cursor_pointer()
.when(active, |d| {
@@ -804,7 +827,7 @@ impl Launcher {
.flex()
.p(px(2.))
.gap(px(2.))
.rounded(px(7.))
.rounded(px(theme::RADIUS_GROUP))
.bg(t.raised)
.border_1()
.border_color(t.border)
@@ -1134,11 +1157,11 @@ impl Launcher {
fn settings_page(&mut self, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let toggle = switch("auto-install", self.settings.auto_install, &t).on_click(cx.listener(
let toggle = switch("auto-install", self.settings.auto_install(), &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.settings.auto_install = !this.settings.auto_install;
this.settings.install_new_versions = Some(!this.settings.auto_install());
this.settings.save();
if this.settings.auto_install {
if this.settings.auto_install() {
this.install_update();
}
cx.notify();
@@ -1151,6 +1174,46 @@ impl Launcher {
cx.notify();
},
));
let menus = div()
.id("menu-style")
.flex()
.flex_none()
.p(px(2.))
.gap(px(2.))
.rounded(px(theme::RADIUS_GROUP))
.bg(t.raised)
.border_1()
.border_color(t.border)
.children([(backend::MenuStyle::Horizon, "Horizon"), (backend::MenuStyle::Classic, "Classic")].map(
|(style, label)| {
let active = self.menu_style == style;
div()
.id(label)
.flex()
.items_center()
.h(px(theme::SEGMENT))
.px(px(14.))
.rounded(px(theme::RADIUS_SEGMENT))
.text_size(px(13.))
.cursor_pointer()
.when(active, |d| {
d.bg(t.bg).border_1().border_color(t.border).text_color(t.text).font_weight(FontWeight::MEDIUM)
})
.when(!active, |d| d.text_color(t.muted).hover(move |s| s.text_color(t.text)))
.child(label)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
if backend::set_menu_style(style).is_ok() {
this.menu_style = style;
}
cx.notify();
}))
},
));
let menu_note = div().flex().flex_col().gap(px(2.)).child("Menu style").child(
div().text_size(px(theme::SMALL)).text_color(t.muted).child(
"Classic is vanilla's own screens, with no way back inside the game: switch back here. Applies the next time the game starts.",
),
);
let auto = div()
.flex()
.flex_col()
@@ -1225,7 +1288,7 @@ impl Launcher {
.flex()
.flex_col()
.gap(px(24.))
.child(page_header("Settings", "Updates, and the launcher itself.", &t))
.child(page_header("Settings", "Updates, menus, and the launcher itself.", &t))
.child(
div()
.flex()
@@ -1235,6 +1298,8 @@ impl Launcher {
.child(divider(&t))
.child(row("Opening", div().child("Show opening animation"), Some(opening), &t))
.child(divider(&t))
.child(row("Game menus", menu_note, Some(menus), &t))
.child(divider(&t))
.child(row("Horizon", horizon, horizon_action, &t))
.child(divider(&t))
.child(row("Launcher", div().child(launcher), launcher_action, &t))
@@ -1484,7 +1549,7 @@ impl Launcher {
fn notes_page(&mut self, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let sponsor = div()
let sponsor = (!self.links.sponsor.is_empty()).then(|| div()
.flex()
.items_center()
.justify_between()
@@ -1506,15 +1571,16 @@ impl Launcher {
.child("Horizon is built in the open. Sponsors fund the next release."),
),
)
.child(button("sponsor", "Sponsor on GitHub", Kind::Secondary, &t).on_click(cx.listener(
|_, _: &ClickEvent, _, cx| cx.open_url(backend::SPONSOR_URL),
)));
.child(button("sponsor", "Sponsor on GitHub", Kind::Secondary, &t).on_click({
let url = self.links.sponsor.clone();
move |_: &ClickEvent, _, cx| cx.open_url(&url)
})));
let page = div()
.flex()
.flex_col()
.gap(px(24.))
.child(page_header("Patch notes", "What changed in each release, newest first.", &t))
.child(sponsor);
.children(sponsor);
let releases = match &self.releases {
Load::Loading => return page.child(centered("Loading patch notes…", &t)),
Load::Failed(e) => {
@@ -1769,11 +1835,11 @@ fn main() {
Application::new().run(|cx: &mut App| {
cx.text_system()
.add_fonts(vec![
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-Regular.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-Medium.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-SemiBold.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/GeistMono-Regular.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/InstrumentSerif-Regular.ttf").as_slice()),
Cow::Borrowed(horizon_theme::fonts::GEIST_REGULAR),
Cow::Borrowed(horizon_theme::fonts::GEIST_MEDIUM),
Cow::Borrowed(horizon_theme::fonts::GEIST_SEMIBOLD),
Cow::Borrowed(horizon_theme::fonts::GEIST_MONO),
Cow::Borrowed(horizon_theme::fonts::INSTRUMENT_SERIF),
])
.expect("bundled fonts");
let bounds = Bounds::centered(None, size(px(1040.), px(700.)), cx);
+11
View File
@@ -38,6 +38,9 @@ fn tools() -> Value {
{ "name": "patch_notes", "description": "A release's patch notes as markdown.",
"inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "description": "Default: the newest release." } } } },
{ "name": "roadmap", "description": "What's planned next for Horizon, from the README's roadmap.", "inputSchema": none },
{ "name": "links", "description": "The Horizon community links: the Discord invite and the GitHub Sponsors page (an empty one is not offered).", "inputSchema": none },
{ "name": "menu_style", "description": "Show or set the game's menu style. \"horizon\" is Horizon's own menus, \"classic\" is vanilla's screens, which have no way back inside the game. The game reads it when it starts.",
"inputSchema": { "type": "object", "properties": { "style": { "type": "string", "enum": ["horizon", "classic"], "description": "Omit to show the current style." } } } },
{ "name": "check_updates", "description": "Check GitHub for a Horizon release newer than every installed version, and for a newer launcher.", "inputSchema": none },
])
}
@@ -136,6 +139,14 @@ impl Session {
Ok(format!("# {} ({}, {})\n\n{}", if r.name.is_empty() { &r.tag } else { &r.name }, r.tag, r.date, r.body))
}
"roadmap" => backend::roadmap(),
"links" => Ok(serde_json::to_string_pretty(&backend::links()).unwrap()),
"menu_style" => {
if let Some(name) = arg_str(args, "style") {
let style = backend::MenuStyle::parse(name).ok_or("style is \"horizon\" or \"classic\"")?;
backend::set_menu_style(style)?;
}
Ok(format!("Menu style: {}", backend::menu_style().name()))
}
"check_updates" => {
let u = backend::check_updates()?;
Ok(serde_json::to_string_pretty(&json!({
+37 -39
View File
@@ -1,22 +1,29 @@
//! One neutral palette in two appearances and one accent. Spacing follows 4/8/12/16/24/32.
//! Type: Geist for the interface, Instrument Serif for titles, Geist Mono for consoles and
//! commands, all bundled (SIL Open Font License). Five sizes: 12, 13, 15, 28 and 56.
//! The launcher's view of Horizon's design tokens (`crates/horizon-theme`), which the game's
//! menus share: one neutral palette in two appearances and one accent, spacing 4/8/12/16/24/32,
//! Geist / Instrument Serif / Geist Mono (bundled, SIL Open Font License), five sizes.
use gpui::{rgb, Hsla, WindowAppearance};
use horizon_theme as tokens;
use std::time::Duration;
pub const FONT: &str = "Geist";
pub const DISPLAY: &str = "Instrument Serif";
pub const MONO: &str = "Geist Mono";
pub const FONT: &str = tokens::FONT;
pub const DISPLAY: &str = tokens::DISPLAY;
pub const MONO: &str = tokens::MONO;
pub const SMALL: f32 = 12.;
pub const BODY: f32 = 13.;
pub const LARGE: f32 = 15.;
pub const TITLE: f32 = 28.;
pub const HERO: f32 = 56.;
pub const SMALL: f32 = tokens::SMALL;
pub const BODY: f32 = tokens::BODY;
pub const LARGE: f32 = tokens::LARGE;
pub const TITLE: f32 = tokens::TITLE;
pub const HERO: f32 = tokens::HERO;
/// Shapes and controls, shared with the game's menus.
pub use tokens::{
CONTROL, CONTROL_LARGE, DISABLED, PRESSED, PRESSED_SOFT, PRIMARY_HOVER, RADIUS, RADIUS_CARD, RADIUS_GROUP,
RADIUS_SEGMENT, ROW_LABEL, SEGMENT, SIDEBAR,
};
/// Motion: short and eased out, never bouncy.
pub const QUICK: Duration = Duration::from_millis(160);
pub const MOVE: Duration = Duration::from_millis(240);
pub const QUICK: Duration = tokens::QUICK;
pub const MOVE: Duration = tokens::MOVE;
#[derive(Clone, Copy)]
pub struct Theme {
@@ -47,36 +54,27 @@ impl Theme {
}
pub fn dark() -> Self {
Self {
bg: c(0x111113),
sidebar: c(0x0c0c0d),
raised: c(0x1a1a1d),
hover: c(0x202024),
border: c(0x26262a),
text: c(0xe8e8ea),
muted: c(0x9a9aa1),
faint: c(0x626269),
accent: c(0x7c5cc4),
on_accent: c(0xffffff),
danger: c(0xd9705f),
dark: true,
}
Self::from(tokens::DARK)
}
pub fn light() -> Self {
Self::from(tokens::LIGHT)
}
fn from(p: tokens::Palette) -> Self {
Self {
bg: c(0xfcfcfb),
sidebar: c(0xf4f4f2),
raised: c(0xf2f2f0),
hover: c(0xebebe8),
border: c(0xe3e3df),
text: c(0x19191b),
muted: c(0x63636a),
faint: c(0x9a9a9f),
accent: c(0x6a4bb3),
on_accent: c(0xffffff),
danger: c(0xb84a3a),
dark: false,
bg: c(p.bg),
sidebar: c(p.sidebar),
raised: c(p.raised),
hover: c(p.hover),
border: c(p.border),
text: c(p.text),
muted: c(p.muted),
faint: c(p.faint),
accent: c(p.accent),
on_accent: c(p.on_accent),
danger: c(p.danger),
dark: p.dark,
}
}
}
@@ -299,6 +299,11 @@ public final class VanillaCameraOracle {
+ " " + fb(up.x()) + " " + fb(up.y()) + " " + fb(up.z())
+ " " + fb(left.x()) + " " + fb(left.y()) + " " + fb(left.z())
+ " " + camera.isDetached() + " " + fb(fv) + " " + fb(hv));
// Camera.setupPerspective and GameRenderer.render3dHud's hudProjection:
// Projection.getMatrix's setPerspective (planes reversed, zero-to-one depth).
float w = f(op.get(4)), h = f(op.get(5)), far = f(op.get(6));
out.println("projection" + matrix(new Matrix4f().setPerspective(fv * (float) (Math.PI / 180.0), w / h, far, 0.05F, true))
+ matrix(new Matrix4f().setPerspective(hv * (float) (Math.PI / 180.0), w / h, far, 0.05F, true)));
}
default -> throw new IllegalArgumentException(op.toString());
}
@@ -388,6 +393,16 @@ public final class VanillaCameraOracle {
out.println(sb);
}
static String matrix(Matrix4f m) {
var sb = new StringBuilder();
for (int col = 0; col < 4; col++) {
for (int row = 0; row < 4; row++) {
sb.append(' ').append(fb(m.get(col, row)));
}
}
return sb.toString();
}
static void avatar(JsonObject c, PrintWriter out) throws Exception {
StubPlayer p = make(StubPlayer.class);
var state = new ClientAvatarState();
@@ -0,0 +1,385 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import java.io.*;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.client.multiplayer.MultiPlayerGameMode;
import net.minecraft.client.multiplayer.prediction.BlockStatePredictionHandler;
import net.minecraft.client.player.FirstPersonHandsAndItems;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.renderer.item.ItemModelResolver;
import net.minecraft.client.tutorial.Tutorial;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.protocol.Packet;
import net.minecraft.resources.Identifier;
import net.minecraft.tags.TagKey;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.effect.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.flag.FeatureFlagSet;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.SwingAnimation;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.border.WorldBorder;
import net.minecraft.world.level.gameevent.GameEvent;
import net.minecraft.world.level.material.*;
import net.minecraft.world.phys.*;
/**
* Drives the real Minecraft.startAttack, continueAttack and startUseItem,
* MultiPlayerGameMode's startDestroyBlock, continueDestroyBlock,
* stopDestroyBlock, destroyBlock and attack, LivingEntity.swing with its
* SwingState and getModifiedSwingDuration, Player's attack strength and item
* swap tickers and getItemSwapScale, and FirstPersonHandsAndItems.tick and
* itemUsed, tick by tick over scripted clicks and crosshair targets. The
* player is a LocalPlayer shell with a real Inventory and attributes; the
* level, connection and item model resolver are shells answering what these
* read, and the results of using items on blocks, entities and the air are
* scripted. The attack and use part of Minecraft.handleKeybinds and the
* ticker lines of Player.tick are mirrored here in vanilla's order.
*/
public final class VanillaFirstPersonOracle {
static Object unsafe;
static Method allocate;
@SuppressWarnings("unchecked")
static <T> T make(Class<T> type) throws Exception {
return (T) allocate.invoke(unsafe, type);
}
static Field field(Class<?> type, String name) throws Exception {
for (Class<?> c = type; c != null; c = c.getSuperclass()) {
try {
Field f = c.getDeclaredField(name);
f.setAccessible(true);
return f;
} catch (NoSuchFieldException e) {
// try the superclass
}
}
throw new NoSuchFieldException(type + "." + name);
}
static Method method(Class<?> type, String name, Class<?>... args) throws Exception {
Method m = type.getDeclaredMethod(name, args);
m.setAccessible(true);
return m;
}
static String fb(float v) {
return String.format("%08x", Float.floatToRawIntBits(v));
}
static List<String> events = new ArrayList<>();
static Map<BlockPos, BlockState> blocks = new HashMap<>();
static JsonObject step;
static String hand(InteractionHand h) {
return h == InteractionHand.MAIN_HAND ? "main" : "off";
}
static InteractionResult result(String name) {
return switch (name) {
case "success" -> InteractionResult.SUCCESS;
case "server" -> InteractionResult.SUCCESS_SERVER;
case "consume" -> InteractionResult.CONSUME;
case "fail" -> InteractionResult.FAIL;
default -> InteractionResult.PASS;
};
}
static final class Level0 extends ClientLevel {
Level0() {
super(null, null, null, null, 0, 0, null, false, 0L, 0);
}
@Override public BlockState getBlockState(BlockPos pos) { return blocks.getOrDefault(pos, Blocks.AIR.defaultBlockState()); }
@Override public FluidState getFluidState(BlockPos pos) { return Fluids.EMPTY.defaultFluidState(); }
@Override public boolean setBlock(BlockPos pos, BlockState state, int flags, int limit) {
blocks.put(pos.immutable(), state);
return true;
}
@Override public void levelEvent(Entity source, int type, BlockPos pos, int data) { }
@Override public void gameEvent(Holder<GameEvent> event, Vec3 pos, GameEvent.Context context) { }
@Override public void destroyBlockProgress(int id, BlockPos pos, int progress) {
events.add("stage " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " " + progress);
}
@Override public void addBreakingBlockEffects(BlockPos pos, Direction direction, boolean sound) { }
@Override public FeatureFlagSet enabledFeatures() { return FeatureFlags.REGISTRY.allFlags(); }
}
static final class Connection0 extends ClientPacketListener {
Connection0() {
super(null, null, null);
}
@Override public void send(Packet<?> packet) { }
}
static final class Resolver extends ItemModelResolver {
Resolver() {
super(null);
}
// No vanilla item model turns hand_animation_on_swap off, and only
// spears (not scripted here) scale the swap animation.
@Override public boolean shouldPlaySwapAnimation(ItemStack stack) { return true; }
@Override public float swapAnimationScale(ItemStack stack) { return 1.0F; }
}
static final class Player0 extends LocalPlayer {
AttributeMap attributes;
boolean inRange;
Player0() {
super(null, null, null, null, null, Input.EMPTY, false, null, null);
}
@Override public AttributeMap getAttributes() { return attributes; }
@Override public boolean isEyeInFluid(TagKey<Fluid> type) { return false; }
@Override public boolean onGround() { return true; }
@Override public boolean isWithinEntityInteractionRange(Entity entity, double buffer) { return inRange; }
@Override public void attack(Entity entity) { events.add("attack"); }
@Override public boolean swing(InteractionHand hand, SwingAnimation animation, boolean send) {
boolean started = super.swing(hand, animation, send);
events.add("swing " + hand(hand) + " " + animation.duration() + " " + started);
return started;
}
@Override public void itemUsed(InteractionHand hand) {
events.add("used " + hand(hand));
super.itemUsed(hand);
}
}
static final class GameMode0 extends MultiPlayerGameMode {
GameMode0(Minecraft minecraft, ClientPacketListener connection) {
super(minecraft, connection);
}
@Override public boolean destroyBlock(BlockPos pos) {
boolean changed = super.destroyBlock(pos);
events.add("destroy " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " " + changed);
return changed;
}
@Override public void releaseUsingItem(Player player) { events.add("release"); }
InteractionResult scripted(String kind, Player player, InteractionHand hand) {
String name = step.getAsJsonObject("uses").get(kind).getAsString();
events.add("use " + kind + " " + hand(hand) + " " + name);
InteractionResult r = result(name);
if (r instanceof InteractionResult.Success && step.get("shrink").getAsBoolean()) {
player.getItemInHand(hand).shrink(1);
}
return r;
}
@Override public InteractionResult useItemOn(LocalPlayer player, InteractionHand hand, BlockHitResult hit) { return scripted("block", player, hand); }
@Override public InteractionResult useItem(Player player, InteractionHand hand) { return scripted("item", player, hand); }
@Override public InteractionResult interact(Player player, Entity entity, EntityHitResult hit, InteractionHand hand) {
return scripted("entity", player, hand);
}
}
static ItemStack stack(JsonElement e) {
if (e.isJsonNull()) {
return ItemStack.EMPTY;
}
var a = e.getAsJsonArray();
return new ItemStack(BuiltInRegistries.ITEM.getValue(Identifier.parse(a.get(0).getAsString())), a.get(1).getAsInt());
}
/** LivingEntity.collectEquipmentChanges for the main hand: the item's attribute modifiers. */
static AttributeMap attributes(ItemStack main) {
var map = new AttributeMap(Player.createAttributes().build());
main.forEachModifier(EquipmentSlot.MAINHAND, (attribute, modifier) -> {
var instance = map.getInstance(attribute);
if (instance != null) {
instance.addTransientModifier(modifier);
}
});
return map;
}
public static void main(String[] args) throws Exception {
// The vanilla data pack: item components (tools, attribute modifiers) and block tags.
ParityData.init();
var u = Class.forName("sun.misc.Unsafe");
unsafe = field(u, "theUnsafe").get(null);
allocate = u.getMethod("allocateInstance", Class.class);
Method startAttack = method(Minecraft.class, "startAttack");
Method continueAttack = method(Minecraft.class, "continueAttack", boolean.class);
Method startUseItem = method(Minecraft.class, "startUseItem");
Field missTime = field(Minecraft.class, "missTime");
Field rightClickDelay = field(Minecraft.class, "rightClickDelay");
Field swingState = field(LivingEntity.class, "swingState");
Field attackTicker = field(LivingEntity.class, "attackStrengthTicker");
Field swapTicker = field(LivingEntity.class, "itemSwapTicker");
Field lastItem = field(Player.class, "lastItemInMainHand");
Field hands = field(LocalPlayer.class, "firstPersonHandsAndItems");
Field handsBusy = field(LocalPlayer.class, "handsBusy");
Field using = field(LocalPlayer.class, "startedUsingItem");
Field destroyDelay = field(MultiPlayerGameMode.class, "destroyDelay");
Field destroying = field(MultiPlayerGameMode.class, "isDestroying");
Field progress = field(MultiPlayerGameMode.class, "destroyProgress");
Field mainHeight = field(FirstPersonHandsAndItems.class, "mainHandHeight");
Field oMainHeight = field(FirstPersonHandsAndItems.class, "oMainHandHeight");
Field offHeight = field(FirstPersonHandsAndItems.class, "offHandHeight");
Field oOffHeight = field(FirstPersonHandsAndItems.class, "oOffHandHeight");
Field ticks = field(LivingEntity.SwingState.class, "ticks");
Field animation = field(LivingEntity.SwingState.class, "animation");
Field oldAnimation = field(LivingEntity.SwingState.class, "oldAnimation");
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
var lines = Files.lines(Path.of(args[0]))) {
int n = 0;
for (var line : (Iterable<String>) lines::iterator) {
var c = JsonParser.parseString(line).getAsJsonObject();
out.println("== " + n++);
blocks.clear();
for (var e : c.getAsJsonArray("blocks")) {
var b = e.getAsJsonArray();
blocks.put(new BlockPos(b.get(0).getAsInt(), b.get(1).getAsInt(), b.get(2).getAsInt()),
BuiltInRegistries.BLOCK.getValue(Identifier.parse(b.get(3).getAsString())).defaultBlockState());
}
Minecraft mc = make(Minecraft.class);
Level0 level = make(Level0.class);
field(ClientLevel.class, "worldBorder").set(level, new WorldBorder());
field(ClientLevel.class, "blockStatePredictionHandler").set(level, new BlockStatePredictionHandler());
Connection0 connection = make(Connection0.class);
Player0 player = make(Player0.class);
field(LocalPlayer.class, "minecraft").set(player, mc);
field(LocalPlayer.class, "connection").set(player, connection);
field(Entity.class, "level").set(player, level);
field(Entity.class, "id").setInt(player, 1);
var equipment = new PlayerEquipment(player);
field(LivingEntity.class, "equipment").set(player, equipment);
field(Player.class, "inventory").set(player, new Inventory(player, equipment));
var abilities = new Abilities();
boolean creative = c.get("creative").getAsBoolean();
abilities.instabuild = creative;
field(Player.class, "abilities").set(player, abilities);
swingState.set(player, new LivingEntity.SwingState());
lastItem.set(player, ItemStack.EMPTY);
hands.set(player, new FirstPersonHandsAndItems());
var effects = new HashMap<Holder<MobEffect>, MobEffectInstance>();
if (!c.get("haste").isJsonNull()) {
effects.put(MobEffects.HASTE, new MobEffectInstance(MobEffects.HASTE, 100000, c.get("haste").getAsInt()));
}
if (!c.get("fatigue").isJsonNull()) {
effects.put(MobEffects.MINING_FATIGUE, new MobEffectInstance(MobEffects.MINING_FATIGUE, 100000, c.get("fatigue").getAsInt()));
}
field(LivingEntity.class, "activeEffects").set(player, effects);
player.getInventory().setItem(0, stack(c.get("main")));
equipment.set(EquipmentSlot.OFFHAND, stack(c.get("off")));
player.attributes = attributes(player.getMainHandItem());
field(Minecraft.class, "player").set(mc, player);
field(Minecraft.class, "level").set(mc, level);
field(Minecraft.class, "tutorial").set(mc, make(Tutorial.class));
field(Minecraft.class, "itemModelResolver").set(mc, make(Resolver.class));
var gameMode = new GameMode0(mc, connection);
field(MultiPlayerGameMode.class, "localPlayerMode").set(gameMode, creative ? GameType.CREATIVE : GameType.SURVIVAL);
field(Minecraft.class, "gameMode").set(mc, gameMode);
out.println("attack_speed " + Long.toHexString(Double.doubleToRawLongBits(player.getAttributeValue(Attributes.ATTACK_SPEED))));
var entity = make(net.minecraft.world.entity.item.ItemEntity.class);
field(Entity.class, "blockPosition").set(entity, BlockPos.ZERO);
field(Entity.class, "id").setInt(entity, 2);
int s = 0;
for (var e : c.getAsJsonArray("steps")) {
step = e.getAsJsonObject();
events.clear();
if (!step.get("set").isJsonNull()) {
var set = step.getAsJsonObject("set");
if (set.get("hand").getAsString().equals("main")) {
player.getInventory().setItem(0, stack(set.get("stack")));
} else {
equipment.set(EquipmentSlot.OFFHAND, stack(set.get("stack")));
}
}
var hit = step.getAsJsonArray("hit");
HitResult hitResult = switch (hit.get(0).getAsString()) {
case "block" -> new BlockHitResult(Vec3.ZERO, Direction.from3DDataValue(hit.get(4).getAsInt()),
new BlockPos(hit.get(1).getAsInt(), hit.get(2).getAsInt(), hit.get(3).getAsInt()), false);
case "entity" -> {
player.inRange = hit.get(1).getAsBoolean();
yield new EntityHitResult(entity, Vec3.ZERO);
}
default -> BlockHitResult.miss(Vec3.ZERO, Direction.UP, BlockPos.ZERO);
};
field(Minecraft.class, "hitResult").set(mc, hitResult);
handsBusy.setBoolean(player, step.get("busy").getAsBoolean());
using.setBoolean(player, step.get("using").getAsBoolean());
boolean attackDown = step.get("ad").getAsBoolean();
boolean useDown = step.get("ud").getAsBoolean();
// Minecraft.tick: rightClickDelay, then handleKeybinds' attack and use part.
if (rightClickDelay.getInt(mc) > 0) {
rightClickDelay.setInt(mc, rightClickDelay.getInt(mc) - 1);
}
boolean instant = false;
if (player.isUsingItem()) {
if (!useDown) {
gameMode.releaseUsingItem(player);
}
} else {
for (int i = 0; i < step.get("ac").getAsInt(); i++) {
instant |= (boolean) startAttack.invoke(mc);
}
for (int i = 0; i < step.get("uc").getAsInt(); i++) {
startUseItem.invoke(mc);
}
}
if (useDown && rightClickDelay.getInt(mc) == 0 && !player.isUsingItem()) {
startUseItem.invoke(mc);
}
continueAttack.invoke(mc, !instant && attackDown);
if (missTime.getInt(mc) > 0) {
missTime.setInt(mc, missTime.getInt(mc) - 1);
}
// LocalPlayer.tick: LivingEntity.baseTick's swing, the equipment's
// attribute modifiers, Player.tick's tickers, then
// FirstPersonHandsAndItems.tick.
var swing = (LivingEntity.SwingState) swingState.get(player);
swing.tick();
// LivingEntity.tick's detectEquipmentUpdates: the main hand's modifiers.
player.attributes = attributes(player.getMainHandItem());
attackTicker.setInt(player, attackTicker.getInt(player) + 1);
swapTicker.setInt(player, swapTicker.getInt(player) + 1);
ItemStack main = player.getMainHandItem();
if (!ItemStack.matches((ItemStack) lastItem.get(player), main)) {
if (!ItemStack.isSameItem((ItemStack) lastItem.get(player), main)) {
player.resetAttackStrengthTicker();
}
lastItem.set(player, main.copy());
}
var fp = (FirstPersonHandsAndItems) hands.get(player);
fp.tick(player);
for (var ev : events) {
out.println(ev);
}
var current = player.getCurrentSwing();
out.println("tick " + s++ + " " + (current == null ? "- 0" : hand(current.hand()) + " " + current.durationTicks())
+ " " + ticks.getInt(swing) + " " + fb(animation.getFloat(swing)) + " " + fb(oldAnimation.getFloat(swing))
+ " " + fb(player.getSwingAnimation(0.5F))
+ " " + attackTicker.getInt(player) + " " + swapTicker.getInt(player) + " " + fb(player.getItemSwapScale(1.0F))
+ " " + fb(mainHeight.getFloat(fp)) + " " + fb(oMainHeight.getFloat(fp))
+ " " + fb(offHeight.getFloat(fp)) + " " + fb(oOffHeight.getFloat(fp))
+ " " + missTime.getInt(mc) + " " + rightClickDelay.getInt(mc) + " " + destroyDelay.getInt(gameMode)
+ " " + destroying.getBoolean(gameMode) + " " + fb(progress.getFloat(gameMode)));
}
}
}
System.exit(0);
}
}
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more