Compare commits

...
Author SHA1 Message Date
Ludovic Marie 5115bc276b Merge pull request #1650 from socksprox/fix/proxy-transfers
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 17:26:57 +01:00
Ludovic Marie 7bac73094f Merge pull request #1860 from ludovic111/fix/launcher-pack-test-windows
🤖 Agent: merged after verify and a launcher dry run on all three platforms (Windows tests pass); the launcher release follows.
2026-10-25 17:25:11 +01:00
Ludovic Marie ef82e97d67 Merge pull request #1652 from socksprox/fix/remote-player-skins
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 17:24:13 +01:00
Ludovic Marie 1920106445 Merge pull request #1793 from socksprox/launcher-dark-titlebar
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 17:23:29 +01:00
Ludovic Marie aca93fa902 Merge pull request #1779 from levzzz5154/fix/sprint-fov-stuck
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 17:23:02 +01:00
Ludovic Marie e99414e9d3 Merge pull request #1795 from socksprox/t3/fix-remote-close-ghost-grid
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 17:16:15 +01:00
Ludovic Marie 66a35ab2ea Merge pull request #1865 from ludovic111/fix/website-docker-data
🤖 Agent: merged after verify; image build simulated locally (hub, data, docs/api in dist).
2026-10-25 17:15:31 +01:00
Ludovic MarieandClaude Opus 5.5 d41a3075aa Launcher: a local registry's file:/// URLs open on Windows
file:///C:/x left /C:/x after the scheme, which Windows can't open; the pack tests now
write real file:/// URLs (the index only accepts those), with forward slashes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:13:26 +01:00
Ludovic MarieandClaude Opus 5.5 fd154dcbba Website image: copy data/ so the build finds the roadmap
build.mjs reads website/data/roadmap.json since the hub (#1819), but the Dockerfile never
copied data/, so every site deploy since then failed and playhorizon.dev kept the old build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:13:06 +01:00
Ludovic Marie 7ab91a4d2e Merge pull request #1859 from ludovic111/feat/mod-tool-content
🤖 Agent: merged after verify passed; site built with every docs page and no broken links (platform-2), website tests pass.
2026-10-25 17:00:44 +01:00
Ludovic Marie 9f761a3e3b Merge pull request #1810 from ludovic111/docs/api-portal-website
🤖 Agent: merged after verify passed; site built with every docs page and no broken links (platform-2), website tests pass.
2026-10-25 16:58:41 +01:00
Ludovic MarieandClaude Opus 5.5 c640561000 Launcher: the registry pack tests build file URLs with forward slashes, so they pass on Windows
A Windows temp path's backslashes went into the test index's JSON as escapes, so the
index failed to parse and the Windows launcher release stopped at its tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:56:26 +01:00
Ludovic Marie d18b7701da Merge pull request #1852 from ludovic111/feat/hub-docs
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:56:07 +01:00
Ludovic Marie 1bf957989a Merge pull request #1851 from ludovic111/feat/hub-forms
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:54:01 +01:00
Ludovic Marie a230411fa3 Merge pull request #1839 from ludovic111/feat/hub-intake
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:51:54 +01:00
Ludovic Marie 26b94efcdf Merge pull request #1838 from ludovic111/feat/hub-votes
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:49:41 +01:00
Ludovic Marie e5214f7f25 Merge pull request #1821 from ludovic111/feat/hub-board
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:48:00 +01:00
Ludovic Marie 2576e80ff2 Merge pull request #1819 from ludovic111/feat/roadmap-hub
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:45:51 +01:00
Ludovic MarieandClaude Opus 5.5 effea2cfd4 Merge feat/hub-board (main's registry pages) into feat/hub-forms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:41:10 +01:00
Ludovic MarieandClaude Opus 5.5 bcee0d02ec Merge feat/roadmap-hub (main's registry pages) into feat/hub-board
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:40:01 +01:00
Ludovic MarieandClaude Opus 5.5 78ab03e554 Merge main into feat/roadmap-hub: the hub beside the registry's pages
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:39:17 +01:00
Ludovic MarieandClaude Opus 5.5 ba4cf45aac cargo horizon-mod: content mods (new --template content, build packs assets and data, check takes no code)
- `new --template content`: a content mod with one block (Glowing Amethyst,
  copying vanilla's amethyst block) and its block state, model, item model,
  name, loot, recipe and pickaxe tag; `--template code` is the default.
- `build` packs the mod's `assets/` and `data/` (read and checked as the
  game reads them) beside the component; a folder without `Cargo.toml` is a
  content mod and needs no compiler.
- `check` takes a `.hmod` with no `mod.wasm`: "no code: content only,
  nothing to run" instead of the trial run, which refused it, so the
  registry can publish content mods.
- The guide (content mods, make a mod) and docs/mods.md, docs/api/mods.md
  say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:35:58 +01:00
Ludovic MarieandClaude Opus 5.5 a6f951e126 Website: Make a mod, the full guide from the first line to the registry
Six pages under a "Make a mod" group of the docs: set up and see a mod in
the game (the launcher's Game arguments or cargo horizon-mod run), the
manifest and every capability, the hooks and calls with a worked example
(Playtime, built and checked), content mods, testing and check, and
publishing (minisign signing, the submit form, review, updates). /mods,
the submit page, the docs index, Mods and data packs and the Mod API link
to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:22:19 +01:00
Ludovic Marie 1a91727eee Merge pull request #1815 from ludovic111/feat/roadmap
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:17:14 +01:00
Ludovic MarieandClaude Opus 5.5 818b9638ed CONTRIBUTING.md and docs/README.md point to the hub
Where to start is playhorizon.dev/hub: the roadmap, the work board, good first
tasks and the forms; roadmap PRs carry their roadmap:<id> label, ideas go to the
suggestion queue and issues are for bugs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:15:16 +01:00
Ludovic Marie c462eb0b48 Merge pull request #1846 from ludovic111/feat/registry-owner-links
🤖 Agent: merged after verify passed (website + launcher owner links, tests added).
2026-10-25 16:14:48 +01:00
Ludovic MarieandClaude Opus 5.5 c1ffa17c87 Hub: vote for roadmap items, report a bug and suggest something from the page
Each item still to come gets a "Want it" button with its count; the Contribute
tab gets a bug report form (the #horizon-bugs template, which the telemetry
server opens as a GitHub issue and links back) and a suggestion form (to the
private queue Kira relays), and a Contributors list that shows the Discord name
of those who agreed on Kira (/contributors.json). The forms and buttons appear
once the hub's server answers; until then the page points to GitHub and Discord.
roadmap/README.md documents the intake API that Kira shares.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:14:12 +01:00
Ludovic MarieandClaude Opus 5.5 4a9ca29887 Website: the API portal at /docs/api, with Markdown tables
The repository's docs/api/ (the portal, its surface pages and the generated mod and
server references) is built into the docs section: /docs/api is its README and
/docs/api/<page> each page, in two nav groups, "Build on Horizon" and "API reference".
The pages are read from the checkout or from main on GitHub, like the README, so the
site never holds a copy. Links between portal pages become the site's (and the build's
link check covers them); other relative links go to the file on GitHub.

The renderer gains tables: the "Ways in" table, the references and the other tables
on the site (apis, performance) showed as raw pipes. A link whose label is a code span
no longer throws.

Caddy and the dev server route /docs/api/<page> and redirect /docs/api/ to /docs/api;
Railway rebuilds when docs/api/ changes. /docs/mods links the Mod API, its reference
and its ways in; the summary page is now "APIs at a glance".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:14:03 +01:00
Ludovic MarieandClaude Opus 5.5 bac51e3fba Telemetry server: the hub's bug reports and suggestions
POST /hub/report opens a GitHub issue at once (labels bug and from-website, the
every template field required, people's text made inert (no mentions, issue
references, HTML or closing keywords) and the same report twice answered with the
first issue. If GitHub fails it answers 202 and the upkeep tries again for a week.
Off (503) without HORIZON_TELEMETRY_GITHUB_TOKEN.

POST /hub/suggest queues a suggestion; GET /hub/suggestions?after=<id> hands the
queue to Kira with the HORIZON_TELEMETRY_HUB_TOKEN bearer (404 when unset, 401 for
a wrong token). Nothing is published.

Per-address limits (20 an hour, bursts of 2) on keyed hashes of the address, daily
caps from everyone (30 reports, 200 suggestions), a honeypot field, entries kept
90 days.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:10:18 +01:00
Ludovic MarieandClaude Opus 5.5 91148cdf74 Telemetry server: hub votes allow 600 an hour per address (bursts of 60)
A visitor voting down the roadmap, or a school behind one address, ran out of
the 12-request burst. Votes are cheap; the item cap and the voter id still bound them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:09:48 +01:00
Ludovic MarieandClaude Opus 5.5 0eb475152e Registry: show who publishes a project, linked to their GitHub profile
The index already carries each project's owners (the GitHub accounts the
registry checked at submission), but neither the website nor the launcher
showed them. The project page now says "Published by @login" with the
avatar and a link to the profile; the launcher adds an @login link per
owner. Only strings that are valid GitHub logins become links.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:07:46 +01:00
Ludovic MarieandClaude Opus 5.5 e458e37249 Hub: journal.json in the flat format Kira reads, at /data/journal.json
One entry per merged pull request (with its roadmap item), game or launcher
release, shipped roadmap item (post and announce) and weekly summary, newest
first, ids stable forever: the feed kira's src/journal.js posts to #updates and
X. The journal's item links are hub#<id>; the board's and the journal's ids are
reserved too, and the page-wide unique-id test covers every tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:02:36 +01:00
Ludovic MarieandClaude Opus 5.5 c04b7afdb9 Hub: the Kanban and the Journal tabs, and /hub/journal.json
Kanban: every open issue and pull request and what closed in the last seven days, placed in To do,
In progress, In review and Done by the rules of Kira's work board, each card with its roadmap item
(its roadmap:<id> label or the item linking it) and a filter by item. Journal: every release with its
changes grouped by roadmap item, the merges no release has yet, and a weekly summary from
roadmap/journal/<year>-<week>.md. The same journal is /hub/journal.json, for Kira's #updates and posts
elsewhere: each entry names the roadmap items it moved and shipped, with their 240-character text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:00:03 +01:00
Ludovic MarieandClaude Opus 5.5 68234ac407 Hub: the roadmap at /data/roadmap.json and items at hub#<id>, as Kira reads them
Kira (kira #10-#14) fetches https://playhorizon.dev/data/roadmap.json and links
items as <hub>#<id>: the JSON moves there (readable from any origin) with the
announce flag, and each item's anchor is its id. Ids the page uses itself (tabs,
sections, headings) are refused by the check; a test keeps every id of the page
unique, which caught the overview's headings sharing ids with the roadmap's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:59:31 +01:00
Ludovic MarieandClaude Opus 5.5 e936e412ba Website: the hub (/hub) with the roadmap, a Simple / Technical switch and live states
playhorizon.dev/hub is the public entry point to what is happening on Horizon. Overview: where
Horizon is, the 3 to 5 things in focus, what is next, what shipped last, how to help. Roadmap: Now /
Next / Later / Help wanted / Shipped, filtered by area, every item with its issues and pull requests
in their GitHub state (baked at build, refreshed in the browser). Contribute: good first tasks,
claiming, connecting an agent to Kira, the rules. A Simple / Technical switch swaps every item's
plain and dev text and is remembered. /roadmap redirects to the Roadmap tab.

tools/roadmap.py build now also writes website/data/roadmap.json, which the site reads and serves at
/hub/roadmap.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:57:46 +01:00
Ludovic MarieandClaude Opus 5.5 a95be8a7f3 Roadmap: announce = true marks items Kira also posts on X when they ship
Kira's X poster (kira #12) posts shipped items flagged announce in journal.json.
Set on the five whose release is news: modpack world generation, the mod
registry, Discord Rich Presence, Microsoft sign-in and CurseForge. Flags must be
booleans.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:57:34 +01:00
Ludovic Marie bd591f9857 Merge pull request #1814 from ludovic111/fix/mod-registry-pr-marker
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:54:52 +01:00
Ludovic MarieandClaude Opus 5.5 5d40d6a33e Telemetry server: the hub's votes (POST /hub/vote, GET /hub/votes)
A thumbs-up on a roadmap item at playhorizon.dev/hub: one per browser (a random
id the page keeps), taken back with "up": false, counts cached a minute. Item ids
are checked for shape and capped at 1000 distinct items. Rate limits key their
buckets by a keyed hash of the address (key drawn at start, in memory); no address
is stored. CORS preflight for the website's JSON posts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:54:39 +01:00
Ludovic Marie 930a18c912 Merge pull request #1640 from ludovic111/feat/launcher-horizon-modpacks
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:53:18 +01:00
Ludovic Marie 1c67541379 Merge pull request #1638 from ludovic111/feat/launcher-horizon-tab
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:51:16 +01:00
Ludovic Marie e28b453762 Merge pull request #1637 from ludovic111/feat/launcher-horizon-mods
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:49:13 +01:00
Ludovic Marie 6a2512bfe7 Merge pull request #1636 from ludovic111/feat/launcher-horizon-source
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:47:09 +01:00
Ludovic Marie bd7bb1dfc6 Merge pull request #1822 from ludovic111/feat/native-shaders-sdk
🤖 Agent: merged after verify passed (horizon-shader-api only).
2026-10-25 15:46:54 +01:00
Ludovic Marie e785362aa7 Merge pull request #1635 from ludovic111/feat/website-mods-submit
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:45:17 +01:00
Ludovic Marie 6c4d1d6cbb Merge pull request #1596 from ludovic111/feat/website-mods
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:43:14 +01:00
Ludovic Marie 2dbdcda89c Merge pull request #1820 from ludovic111/feat/native-shaders-check
🤖 Agent: merged after verify passed (horizon-shader-api only).
2026-10-25 15:41:39 +01:00
Ludovic MarieandClaude Opus 5.5 49f62d02cb Horizon shader packs: horizon-shader new/check/pack, templates, an example pack and the guide
The SDK's tool, in horizon-shader-api: `horizon-shader new <dir> --template
post|sky|material` makes a pack that passes the check, `check <pack>...
[--json]` runs the check the game, the launcher and the registry run and
prints each stage's cost and the pack's SHA-256 or every problem at its
file:line:column, and `pack <dir>` checks then writes the reproducible
.hshader. Golden Hour, the example pack, warms the sky around a low sun and
adds a soft glow and a colour grade, with five options. docs/shaders.md is the
authors' guide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:38:23 +01:00
Ludovic Marie 220f057647 Merge pull request #1818 from ludovic111/feat/native-shaders-stages
🤖 Agent: merged after verify passed (new crate horizon-shader-api + generated reference with drift test).
2026-10-25 15:37:32 +01:00
Ludovic Marie f94b05032b Merge pull request #1817 from ludovic111/feat/native-shaders-format
🤖 Agent: merged after verify passed (new crate horizon-shader-api only).
2026-10-25 15:34:25 +01:00
Ludovic MarieandClaude Opus 5.5 923fdee42e Horizon shader packs: the check every pack passes before it runs
horizon_shader_api::check builds each stage as the renderer does and judges
it the same way wherever it runs (the game, the launcher, the registry's CI,
the coming horizon-shader tool): naga parses and validates it; the stage
function must have its signature; the pack's files declare no bindings, no
entry points and no horizon_ names; each override is an option of its type or
has a default, and each option is used; a stage stays within 20 000
expressions and 64 texture reads; and every entry point translates to SPIR-V,
Metal and HLSL with the options at their defaults, minimums and maximums.
Problems carry the pack's own file, line and column. Pack::sha256 gives the
reproducible package's hash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:34:07 +01:00
Ludovic MarieandClaude Opus 5.5 6b97107dc7 Transfers: handshake with the TRANSFER intent, answer login cookie requests
Vanilla 26.3 reconnects after ClientboundTransferPacket with a TransferState,
so Connection.initiateServerboundPlayConnection sends ClientIntent.TRANSFER (3):
a target with accepts-transfers=false refuses the player, and the target can
ask for the carried cookies during login
(ClientHandshakePacketListenerImpl.handleRequestCookie). Both now match.

A transfer from single player closes the world instead of connecting
elsewhere (handleTransfer: "Cannot transfer to server from singleplayer").

Adds a unit test for the play-state store/request round trip and the
Transfer event.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:29:59 +01:00
Ludovic MarieandClaude Opus 5.5 608d1a70a7 Horizon shader packs: the stages of API 0.1 and their generated reference
Three stages, each a WGSL function with a fixed signature that Horizon calls
inside its own pass: sky (fn sky(input: SkyInput) -> vec4<f32>), material
(fn material(input: MaterialInput) -> Surface) and post (fn post(input:
PostInput) -> vec4<f32>). A stage's WGSL is a generated prelude (the frame
uniform, the stage's structs, its resources in bind group 3, the post
helpers), the pack's shared files, its own file, then the entry points; each
part is located so errors point at the pack's file and line.

The frame is the Rust struct the renderer will upload; its WGSL, its offsets
and docs/api/shaders-reference.md are generated from it, and tests check that
the preludes compile, that naga's layout of the WGSL is the Rust layout, that
the sample pack's stages compile, and that the reference is up to date.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:27:43 +01:00
Ludovic MarieandClaude Opus 5.5 98c8db50a3 Horizon shader packs: horizon-shaders.toml (schema 1) and the .hshader package
A new crate, horizon-shader-api, reads and checks Horizon's own WGSL shader
packs (docs/api/shaders.md), so the game, the launcher and the registry judge
a pack the same way. This first part is the format: the manifest (id, name,
version, the api line, shared files, the sky, material and post stages,
options typed for WGSL override constants, a GPU time budget) and the package
(a folder or a .hshader zip, built reproducibly so its SHA-256 depends only on
its files). The stages' WGSL is checked in the next parts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:18:03 +01:00
Ludovic MarieandClaude Opus 5.5 6d418fdb3e Docs: publish a mod through the registry, and how horizon-mods runs
docs/mods.md gets "Publish your mod" (the form, the review, updates) and
its Distribution section points at the registry instead of the plan for
it. docs/api/mod-index.md says how horizon-mods runs: its checks, merging,
Pages, the weekly cleanup, and that removing an entry needs an admin merge
(takedowns go in revoked.toml). The service's README says where it runs on
Railway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:17:30 +01:00
Ludovic MarieandClaude Opus 5.5 98bebd26bd Launcher: one place reads the pinned appearance (theme::pinned_dark)
The title bar and the theme both resolved HORIZON_LAUNCHER_APPEARANCE and the
Settings choice; titlebar::apply_appearance now uses the theme's helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:14:48 +01:00
Ludovic MarieandClaude Opus 5.5 22c3f2bd0f Roadmap: roadmap/roadmap.toml, ROADMAP.md generated from it, and its checks
One source of truth for what is being done, what comes next and where to help: roadmap/roadmap.toml,
each item said twice (plain words for players, the technical picture for contributors), Now / Next /
Later / Help wanted / Shipped, no dates. tools/roadmap.py builds ROADMAP.md from it, checks its rules
offline (CI, through tests/test_roadmap.py) and against GitHub (links resolve, no stale Now item),
ships items whose pull requests all went out in a release (sync), and keeps a roadmap:<id> label per
item (labels). README links it; roadmap/README.md is the maintenance guide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:13:51 +01:00
Ludovic MarieandClaude Opus 5.5 ceff5a2110 Launcher: installing a taken-down registry mod says it was taken down, and why
It said the mod had no version for 26.3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:06:34 +01:00
Ludovic MarieandClaude Opus 5.5 93d05591c2 horizon-mod-registry: a submission's pull request says it came through the form
The form opens pull requests with the registry's token, which is Ludovic's
account, so the body now starts "🤖 Submitted through playhorizon.dev by
@login" and says neither Ludovic nor a reviewer wrote it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:04:04 +01:00
Ludovic MarieandClaude Opus 5.5 5680da9834 Merge feat/launcher-horizon-tab (main merged) into feat/launcher-horizon-modpacks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:32 +01:00
Ludovic MarieandClaude Opus 5.5 41e68aeb76 Merge feat/launcher-horizon-mods (main merged) into feat/launcher-horizon-tab
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:31 +01:00
Ludovic MarieandClaude Opus 5.5 28ad41eb22 Merge feat/launcher-horizon-source (main merged) into feat/launcher-horizon-mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:31 +01:00
Ludovic MarieandClaude Opus 5.5 de195755f7 Merge origin/main into feat/launcher-horizon-source
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:20 +01:00
Ludovic Marie 587af1e345 Merge feat/website-mods (main merged) into feat/website-mods-submit
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	website/build.mjs
2026-10-25 14:57:08 +01:00
Ludovic MarieandClaude Opus 5.5 65fa108cba Merge origin/main into feat/website-mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:56:52 +01:00
socksproxandDevin 56560b467a Multiplayer: answer cookie requests, store cookies, follow transfer packets
ClientboundCookieRequestPacket in play state decoded as Unknown and was
dropped; StoreCookie and Transfer likewise, and the configuration-phase
CookieRequest was always answered empty. Proxies (BungeeCord, Velocity)
store and read cookies around a server switch and send Transfer to move
a player to another address — both ended in a kick.

Play-state cookie_request/store_cookie/transfer now have packet types,
cookie requests answer from the stored cookies in both configuration and
play (ClientCommonPacketListenerImpl.handleRequestCookie), stored cookies
land in the session's map (handleStoreCookie), and a transfer reconnects
the game to the target address carrying them (handleTransfer →
ConnectScreen.startConnecting with TransferState).

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 14:51:32 +01:00
Ludovic MarieandClaude Opus 5.5 fb1142cd71 Skins: keep vanilla's whole-payload rejection, drop the dead name fallback
authlib 10.0.77 MinecraftServicesSessionService.unpackTextures returns
MinecraftProfileTextures.EMPTY when any texture has no URL or a URL outside
the allowed domains, so skins::unpack goes back to main's behaviour.

remote_player_names: r.players and r.tab_players are filled from the same
ADD_PLAYER entries and emptied by the same PlayerInfoRemove, so the
tab-list fallback could never apply.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:31:41 +01:00
Ludovic MarieandClaude Opus 5.5 e069750ca1 Merge origin/main into fix/sprint-fov-stuck
Keeps main's crystals check and this branch's sprint-fov check in tools/lan_e2e.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:29:28 +01:00
Ludovic Marie 6a065fa489 Merge pull request #1788 from ludovic111/feat/discord-presence
🤖 Agent: merged after verify passed; Rich Presence e2e 6/6 on macOS and Linux.
2026-10-25 14:24:58 +01:00
Ludovic Marie e6dbc3b39f Merge pull request #1787 from ludovic111/feat/discord-presence-ipc
🤖 Agent: merged after verify passed; Rich Presence e2e 6/6 on macOS and Linux (see #1788).
2026-10-25 14:22:55 +01:00
Ludovic Marie dbc66a5a41 Merge pull request #1697 from ludovic111/feat/shader-packs-terrain
Shader packs, clean room 13: loading the terrain program
2026-10-25 14:21:33 +01:00
Ludovic Marie d14b100a97 Merge pull request #1670 from ludovic111/feat/shader-packs-atlas
Shader packs, clean room 12: the block atlas packs sample
2026-10-25 14:21:21 +01:00
Ludovic Marie 9ed21bf436 Merge pull request #1708 from rdbtCVS/t3/7c6098f0
Website: unify navigation across Home, Releases, Benchmarks and Docs
2026-10-25 14:21:12 +01:00
Ludovic Marie ab2006a230 Merge pull request #1669 from ludovic111/feat/shader-packs-gbuffers-translate
Shader packs, clean room 11: translating gbuffers programs
2026-10-25 14:21:07 +01:00
Ludovic Marie b61ef8f9be Merge pull request #1668 from ludovic111/feat/shader-packs-post-perf
Shader packs, clean room: fewer passes and loads in post-processing
2026-10-25 14:20:56 +01:00
Ludovic Marie bd4236f3dd Merge pull request #1656 from ludovic111/feat/shader-packs-post
Shader packs, clean room 10: post-processing over Horizon's world
2026-10-25 14:20:44 +01:00
Ludovic Marie 8ec23a2606 Merge pull request #1655 from ludovic111/feat/shader-packs-uniforms
Shader packs, clean room 9: frame uniforms, and gl_FragCoord in buffer passes
2026-10-25 14:20:32 +01:00
Ludovic Marie 1b6a55e47d Merge pull request #1654 from ludovic111/feat/shader-packs-post-programs
Shader packs, clean room 8: a dimension's post-processing programs
2026-10-25 14:20:19 +01:00
Ludovic Marie c143be7fd0 Merge pull request #1653 from ludovic111/feat/shader-packs-post-load
Shader packs, clean room 7: buffer settings and output lists from stage files
2026-10-25 14:20:05 +01:00
Ludovic Marie da8ae88eee Merge pull request #1601 from ludovic111/feat/shader-packs-translate
Shader packs, clean room 6: composite-style programs to naga
2026-10-25 14:19:52 +01:00
Ludovic Marie 3a419877b0 Merge pull request #1752 from rdbtCVS/t3/fix-end-crystal-attack
Fix End crystal attacks from connected clients
2026-10-25 14:11:52 +01:00
Ludovic Marie 5fefa6ce42 Merge pull request #1689 from rdbtCVS/t3/d001fd2c
Slow Falling clears the fall distance so landing does not hurt
2026-10-25 14:11:43 +01:00
Ludovic Marie 6e4cb6210e Merge pull request #1687 from rdbtCVS/fix/creative-hotbar-hover
Creative inventory: highlight hovered hotbar slots
2026-10-25 14:11:33 +01:00
Ludovic Marie 985ec67b83 Merge pull request #1685 from sdraeger/fix/nbt-fireballs
Fireballs from NBT fly: /summon, chunk loads, selectors and /data get
2026-10-25 14:11:23 +01:00
Ludovic Marie ca05c89c6e Merge pull request #1676 from sdraeger/fix/ctl-screenshot-spaces
horizon-ctl screenshot: paths with spaces
2026-10-25 14:11:15 +01:00
Ludovic Marie 18c74b438c Merge pull request #1675 from sdraeger/fix/tp-mob-pitch
/tp: keep the pitch for mobs
2026-10-25 14:11:05 +01:00
Ludovic Marie ff7cfe0f10 Merge pull request #1695 from Domanffe/chore/deslop
Cleanup: one predicate property helper, a Windows-only allow, tolerant locks in the bots
2026-10-25 14:10:57 +01:00
Ludovic Marie 250c148e78 Merge pull request #1673 from Domanffe/fix/title-menu-labels
Title: the main menu labels follow the game language
2026-10-25 14:10:49 +01:00
Ludovic Marie 40fcc91eff Merge pull request #1682 from Domanffe/feat/horizon-lang-namespace
Options: Horizon's own captions read a bundled language namespace
2026-10-25 14:10:41 +01:00
Ludovic Marie 8b3d4a069c Merge pull request #1686 from Domanffe/fix/launcher-remove-retry
Launcher: a version removal retries a locked file and leaves the UI thread
2026-10-25 14:10:32 +01:00
Ludovic Marie a839062259 Merge pull request #1694 from Domanffe/fix/swallowed-save-errors
Save: the write paths report a failure instead of dropping it
2026-10-25 14:10:24 +01:00
Ludovic Marie 6194463454 Merge pull request #1693 from Domanffe/fix/pack-overlay-windows
Packs: an untouched vanilla directory is mirrored on Windows too
2026-10-25 14:10:15 +01:00
Ludovic Marie a5f574b77e Merge pull request #1707 from Domanffe/chore/clippy-green
Clippy: clear the two warnings the whole tree fails on
2026-10-25 14:10:08 +01:00
Ludovic Marie c4573fb561 Merge pull request #1674 from sdraeger/fix/client-mods-server-thread
Client mods: tick, commands and chat on the server thread
2026-10-25 14:09:59 +01:00
Ludovic Marie 57fc1570b2 Merge pull request #1681 from socksprox/fix/join-loading-status
Multiplayer: drop the player name from the join loading line
2026-10-25 14:09:51 +01:00
Ludovic Marie 628e230217 Merge main (the save_root lint fix, #1789) 2026-10-25 14:08:29 +01:00
Ludovic Marie 6926c76492 Merge pull request #1590 from socksprox/feat/boss-overlay 2026-10-25 14:02:40 +01:00
Ludovic MarieandClaude Opus 5.5 47d1b060b2 Discord presence: test where the game looks for Discord on macOS, Linux, Flatpak and Snap
sockets() reads the environment through a function, so a unit test can
check the paths a real Discord listens on for each platform ($TMPDIR on
macOS, $XDG_RUNTIME_DIR and its Flatpak and Snap subfolders on Linux,
named pipes on Windows). Offscreen e2e runs can't cover this: an automated
run only talks to a fake Discord named by HORIZON_DISCORD_IPC_DIR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:57:19 +01:00
socksproxandDevin 87b3c9aa38 Drop the stale craft grid when a remote menu closes
The server's AbstractContainerMenu.removed gives the grid and the
carried stack back to the inventory and syncs those slots, but the
client kept its copies: a crafting table closed with items inside left
ghost stacks in the inventory's 2x2 grid that could not be picked up.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 13:51:12 +01:00
socksproxandDevin 3740de5116 Launcher: pin the macOS title bar to the chosen appearance
The launcher's Appearance setting darkened the page but not the window's native chrome: NSWindow keeps the system appearance, so a launcher set to Dark still drew a light title bar on a light desktop. apply_appearance (titlebar.rs) pins the NSWindow's appearance to the choice at startup and when the setting changes; System clears the pin so the chrome follows the computer again.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 13:40:07 +01:00
Ludovic Marie d2c96af142 Merge pull request #1789 from ludovic111/fix/main-unused-save-root
Fix main's build: unused save_root in open_world
2026-10-25 13:32:01 +01:00
socksproxandDevin 0bc85843ce Client: remote players keep their skins instead of falling back
skins::unpack discarded a profile's whole textures payload when any one
entry lacked "url" or pointed outside the texture services' domains, so
a player with e.g. an unusual cape lost their skin too; it now drops only
the offending texture. The 32 skin atlas slots evicted past players back
to defaults on busy servers — now 128. A dimension reset (join game or
respawn onto another world) now also clears the entity-id→profile map so
a re-used entity id can't pin the wrong skin or name to a player, and the
nametag join falls back to the player-list entry's name.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 13:27:54 +01:00
Ludovic MarieandClaude Opus 5.5 d33f84ef73 Fix main's build: drop the unused save_root left in open_world
#1665 moved the save path into finish_open_world and #1666's merge kept the
old line, so clippy -D warnings fails on main (unused variable) since 7d0ae5658.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:24:35 +01:00
Ludovic Marie ad63f2e960 Merge pull request #1659 from ludovic111/feat/mod-registry-server 2026-10-25 12:46:00 +01:00
Ludovic Marie 7d0ae56589 Merge pull request #1666 from rdbtCVS/t3/explicit-execution-roles 2026-10-25 12:04:39 +01:00
Ludovic Marie e3de4a4604 Merge pull request #1665 from rdbtCVS/t3/separate-world-entry-loading 2026-10-25 12:02:41 +01:00
Ludovic Marie 9ae86086dc Merge pull request #1664 from Domanffe/fix/data-get-live-passengers 2026-10-25 12:00:55 +01:00
levzzzandCodex 57ee081038 Keep sprint state independent of held sprint keys
Let PlayerCommand control the server sprint flag, matching vanilla, so releasing Ctrl cannot leave the client's speed modifier and FOV stuck. Cover the packet sequence and FOV recovery in regression tests.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-25 15:56:53 +05:00
Ludovic MarieandClaude Opus 5.5 3063d87199 docs: Discord Rich Presence is live with the Horizon application
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:49:53 +01:00
Ludovic MarieandClaude Opus 5.5 15e46114a6 Discord Rich Presence: show what the player does, with a switch in Horizon Settings
Each frame the game hands the presence thread its status (menus; single
player, multiplayer or hosting a world with its party size; the
dimension; the game mode), and only a change wakes it. The switch
"Discord Rich Presence" (on by default, discordPresence in
horizon-extras.txt) sits beside Controller Settings. Dedicated servers
never start it. tools/discord_presence_e2e.py runs real games against a
fake Discord: title, world, Nether and quit on the server thread and
in-frame, a LAN host (both paths) and guest, the switch, and a Discord
started after the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:49:44 +01:00
Ludovic Marie fbfec6435f Merge pull request #1588 from socksprox/feat/server-resource-packs 2026-10-25 11:49:29 +01:00
Ludovic MarieandClaude Opus 5.5 e49de342ed Discord Rich Presence: the Horizon application's id
Discord's "Horizon" application (1563857609738690560, public) has the
horizon logo as its large image; no game-mode images yet. The feature is
live: only HORIZON_NO_DISCORD=1, the switch and automated runs keep it
quiet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:47:13 +01:00
Ludovic Marie 7a3578b7d6 Merge pull request #1587 from levzzz5154/fix/predict-block-placement
Predict block placement on the client, so fast bridging stops losing clicks
2026-10-25 11:32:09 +01:00
Ludovic MarieandClaude Opus 5.5 604ec7e2e4 Discord Rich Presence: the IPC client and its thread (inert until wired)
discord_presence.rs speaks Discord's local IPC itself (discord-ipc-N
socket or named pipe; opcode + length + JSON frames; HANDSHAKE, then
SET_ACTIVITY with the pid) from a thread of its own. The game hands it a
status; the thread connects lazily, retries every 15 s, keeps within 5
updates per 20 s, refreshes every minute and clears the activity on
quit. Inert while DISCORD_APP_ID is the placeholder, with
HORIZON_NO_DISCORD=1, and for automated runs unless
HORIZON_DISCORD_IPC_DIR points at a test's fake Discord; lab.game_env
sets HORIZON_NO_DISCORD=1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:04:38 +01:00
Ludovic Marie 21ff80eb8c Merge pull request #1775 from ludovic111/docs/benchmark-your-computer
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 11:03:40 +01:00
Ludovic MarieandClaude Opus 5.5 f3213aa158 Docs: how players benchmark their computer and share the report
The Performance page now gives the launcher command (horizon-launcher play --arg=--benchmark
--arg=standard), where the report lands on each system, what it holds, and the two ways to
share it: horizon-launcher data benchmark, or the Discord.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:02:06 +01:00
Ludovic MarieandClaude Opus 5.5 71f3c23dd9 Merge main into the launcher removal branch
Main moved the removal to a thread in #1715 (Msg::Removed, the removing
row, the selection), which is this branch's UI half: main's version is
kept. What stays is backend::remove's retry on a file Windows still
holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:38:45 +01:00
Ludovic Marie 574d54e6e0 Merge pull request #1747 from ludovic111/perf/mesh-model-lookup 2026-10-25 09:38:10 +01:00
Ludovic MarieandClaude Opus 5.5 c74490a003 Multiplayer: the join line reads through lang with en_us's own fallback
lang::get_or, as the title screen's labels do; en_us 26.3 has
multiplayer.downloadingTerrain = "Loading terrain...".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:37:56 +01:00
Ludovic MarieandClaude Opus 5.5 d6cc631735 Merge main into the End crystal attack branch
Conflict in tools/lan_e2e.py's check list: main added shulker_bullets
beside crystals; both kept, crystals' entry aligned with the others.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:37:40 +01:00
Ludovic MarieandClaude Opus 5.5 0d18ff7ca7 Fall distance: Levitation resets it too, as LivingEntity.aiStep does
aiStep calls resetFallDistance under either Slow Falling or Levitation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:36:54 +01:00
Ludovic MarieandClaude Opus 5.5 b7fcd446b6 Fireballs: LeftOwner and HasBeenShot are saved as Projectile writes them
Projectile.addAdditionalSaveData writes LeftOwner only when it is set and
HasBeenShot always (true from the first tick on); fireball_to_nbt wrote
LeftOwner:0b and no HasBeenShot. Same shape as arrow_to_nbt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:36:32 +01:00
Ludovic MarieandClaude Opus 5.5 f23db61ecf /tp: a mob's head turns and its old rotation follows, as teleportSetPosition does
Entity.teleportSetPosition calls setYRot, setYHeadRot and setXRot, then
setOldPosAndRot: the head faces the new yaw and yRotO/xRotO match, so a
teleported mob doesn't sweep from its old pitch on the next frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:35:46 +01:00
Ludovic MarieandClaude Opus 5.5 b470bec351 Cleanup: one lock helper in horizon-bots, the predicate helper beside its trait
The twenty unwrap_or_else(|e| e.into_inner()) calls become one lock()
helper, so the bots read as they did with the same poison tolerance.
bound_property moves from worldgen.rs (between DecodeError's impls) to
block_predicate.rs, next to PredicateBlockResolver, whose
predicate_property it implements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:35:25 +01:00
Ludovic MarieandClaude Opus 5.5 53e1191480 Title: the Friends icon reads vanilla's gui.friends.open
FriendsButton's message is gui.friends.open, so the title screen needs
no Horizon key for it. The fallbacks are en_us's own text for the
three icons ("Language...", "Accessibility Settings..."), as the
other labels' are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:34:33 +01:00
Ludovic MarieandClaude Opus 5.5 f28722fb15 Lang: drop horizon.menu.friends, vanilla has gui.friends.open
TitleScreen's Friends button is vanilla's FriendsButton, whose message
is gui.friends.open ("Friends" in en_us, translated in every language
the prepared resources carry), so Horizon needs no key of its own for
it. Bundled now sits after the trait it implements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:34:24 +01:00
Ludovic MarieandClaude Opus 5.5 a61c7faa23 Launcher: a version being removed shows Removing… instead of its buttons
With the removal off the UI thread, the row kept its Use and Remove
buttons, so a second Remove or Use could race the deletion. The retry
loop also reads as one match now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:34:02 +01:00
Ludovic MarieandClaude Opus 5.5 c0bf5cf954 Dedicated: the list saves log vanilla's warnings
DedicatedPlayerList logs "Failed to save user banlist: " (and ip
banlist, operators list, white-list) when a startup save fails; the
shared closure printed "Failed to load user banlist write". The closure
now takes the verb, so loads and saves read as vanilla's lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:33:13 +01:00
Ludovic MarieandClaude Opus 5.5 fb1f421117 Clippy: take the decorated region by value, and gate the unix-only test helper
receive_decorated moved the nine protos out of the box anyway (the
array iterator holds them inline), so taking [Proto; 9] and unboxing at
the three call sites costs the same and leaves boxed_local nothing to
flag on any target. The #[expect] failed on Linux, where the lint does
not fire, which is why verify was red.

scratch is only called from the #[cfg(unix)] tests, so it carries the
same cfg as installed() next to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:32:55 +01:00
Ludovic Marie 0c5d229ad0 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	Cargo.toml
2026-10-25 09:13:06 +01:00
Ludovic Marie 327a6cafc7 Merge pull request #1736 from ludovic111/feat/yung-jigsaw-draws 2026-10-25 09:08:04 +01:00
Ludovic Marie 6a32ee68d4 Merge pull request #1725 from RVZO6/feature/text-display-rendering
Client: render server text displays and lobby labels
2026-10-25 09:07:59 +01:00
Ludovic Marie 0e73edceff Merge pull request #1718 from RVZO6/feature/text-display-state
Client: model text-display metadata against the Java wire codec
2026-10-25 09:07:02 +01:00
Ludovic Marie f7bd3e381b Merge pull request #1729 from RVZO6/fix/stop-sound-packet
Client: honor server stop-sound event and source filters
2026-10-25 09:06:02 +01:00
Ludovic MarieandClaude Sonnet 5.5 ea0905e8a2 lan_e2e: no blank lines inside the docstring after the merge
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 08:58:25 +01:00
Ludovic Marie b149a0ec3f Merge main into fix/stop-sound-packet: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:51:09 +01:00
Ludovic Marie 81b1be055c Merge pull request #1739 from rdbtCVS/t3/fix-hero-trading-discount
Trading: keep Hero of the Village discounts for server players
2026-10-25 08:50:37 +01:00
Ludovic Marie ebf76f4c03 Merge main into t3/fix-hero-trading-discount: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:41:23 +01:00
Ludovic Marie fa093994ae Merge pull request #1733 from rdbtCVS/t3/d8cfe14b
/tp: dismount players before teleporting
2026-10-25 08:40:30 +01:00
Ludovic Marie 273c456adc Merge pull request #1717 from ludovic111/fix/jigsaw-oracle-layouts 2026-10-25 08:34:01 +01:00
Ludovic Marie d30be3d994 Merge main into t3/d8cfe14b: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:32:49 +01:00
Ludovic Marie c1e90297e2 Merge pull request #1728 from rdbtCVS/t3/7a98935a
Server thread: remove forgotten recipes from the client's book
2026-10-25 08:32:26 +01:00
Ludovic Marie 9dfdff9d62 Merge main into t3/7a98935a: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:25:09 +01:00
Ludovic Marie e56acaa7a9 Merge pull request #1727 from rdbtCVS/t3/9148b77e
Fix invisible shulker bullets on server clients
2026-10-25 08:24:27 +01:00
Ludovic Marie 65425da01d Merge pull request #1633 from some-du6e/fix/remote-trims 2026-10-25 08:23:48 +01:00
Ludovic Marie 6b88291009 Merge pull request #1631 from some-du6e/fix/item-components 2026-10-25 08:21:58 +01:00
Ludovic Marie e29a71e5e0 Merge pull request #1632 from some-du6e/fix/advancement-text 2026-10-25 08:20:14 +01:00
Ludovic Marie 80eafe84e9 Merge main into t3/9148b77e: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:16:44 +01:00
Ludovic Marie 6017830623 Merge pull request #1719 from rdbtCVS/t3/13ed5f77
Fix crops remaining after server-side farmland trampling
2026-10-25 08:16:06 +01:00
Ludovic MarieandClaude Sonnet 5.5 fb34815ce1 Merge main into server-farmland-trampling: keep both appended lan_e2e checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 08:07:28 +01:00
Ludovic Marie f49e3a2d24 Merge pull request #1726 from rdbtCVS/t3/9a51354a
Fix offhand eating and drinking cancellation
2026-10-25 08:07:06 +01:00
Ludovic Marie cb0865e242 Merge pull request #1724 from rdbtCVS/t3/19c12e83
Fix spectator collision after dimension travel
2026-10-25 08:07:03 +01:00
Ludovic Marie a27322f250 Merge pull request #1716 from rdbtCVS/t3/395312eb
Fix double-chest inventory and hotbar clicks
2026-10-25 08:05:50 +01:00
Ludovic Marie ed3243ca62 Merge pull request #1730 from rdbtCVS/fix/launcher-java-pairing
Launcher: use one compatible JDK for resource preparation
2026-10-25 08:05:45 +01:00
rdbtCVSandOpenCode GPT-6.1 9409d2c19b Multiplayer: allow punching End crystals
Include End crystals in attack-only object hit-testing using their network entity IDs. Add a real-mouse LAN regression for survival and creative, reach, block occlusion, passing item use, and explosion damage.

Co-Authored-By: OpenCode GPT-6.1 <[email protected]>
2026-10-25 05:38:00 +00:00
Ludovic MarieandClaude Opus 5.5 b15e46c3af lan_e2e stopsound: each target's client gets the stop (status counts stops)
Sounds can't be observed playing without an audio device, so the control
status counts each stopped selection, as it counts the sounds heard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:26:45 +01:00
Ludovic MarieandClaude Opus 5.5 504cb4f74c Text displays: lines split once per render state, as vanilla caches them
TextDisplayRenderer.splitLines uses Font.split (no indent on wrapped
lines, unlike chat's wrapComponents), and TextDisplay keeps the result
until its render state changes; the frame no longer wraps and measures
every display again. A render state is rebuilt only for the ids vanilla
lists (RENDER_STATE_IDS, TEXT_RENDER_STATE_IDS) and starts from the
progress the last frame drew (Display.lastProgress).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:13:11 +01:00
Ludovic MarieandClaude Opus 5.5 659bfad86c Merge the text-display model fixups (#1718) into the renderer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:13:00 +01:00
Ludovic MarieandClaude Opus 5.5 76408b734e Text displays: an Align enum, and the text shared rather than copied
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:12:31 +01:00
Ludovic MarieandClaude Opus 5.5 4e3e038c80 Meshing: look up a block's baked models by index, not by model name
Every block the mesher draws asked the model catalog for its models: a
BTreeMap lookup of the state, a Vec of the chosen variants, then for each
variant a clone of its model name and a string-keyed BTreeMap lookup of the
baked model, collected into a second Vec. On generated terrain that was a
fifth of the mesher's time.

The catalog now resolves each variant to its baked model's index once, at
load (CompiledChoices maps its variants), keeps states in a Vec by id, and
the mesher reuses one list of models for every block of a section. The
random draws are the same calls in the same order (CompiledChoices::for_each
is the old collect), so the same models are chosen.

examples/chunk_load_bench.rs generates and lights real terrain with the
vanilla generator and meshes it on one thread, with a checksum of the meshes:
identical meshes, 45.8 -> 35.4 us per section on the Mac mini.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:11:52 +01:00
Ludovic MarieandClaude Opus 5.5 b0918f3be1 /stopsound: send each targeted server player the stop-sound packet
StopSoundCommand sends ClientboundStopSoundPacket to every target. The
command stopped the server's own sounds instead, so on the server thread
(single player's default) and for LAN guests nothing stopped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:07:43 +01:00
Ludovic MarieandClaude Opus 5.5 08491bcda3 Java selection: keep Windows JDK homes free of the \\?\ prefix
fs::canonicalize returns verbatim paths on Windows, and the chosen home
goes into JAVA_HOME and the PATH of external preparers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:59:21 +01:00
Ludovic MarieandClaude Opus 5.5 b165506d6e Recipe book: removeRecipes returns the display ids it removed, as vanilla
The /recipe take path no longer computes them a second time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:49:46 +01:00
Ludovic MarieandClaude Opus 5.5 09e95ae5a1 Merge main: keep both appended lan_e2e checks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:48:59 +01:00
Ludovic MarieandClaude Opus 5.5 5a0e650e63 Offhand use: one in-hand check for held and consumed items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:48:40 +01:00
Ludovic MarieandClaude Opus 5.5 3b586ff903 Server players: one helper finishes a landing's block updates, turtle eggs too
The server's player tick dropped FallEvent::Destroy, so an egg a guest
broke by landing left its neighbours unupdated. Both paths now share the
same helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:48:29 +01:00
Ludovic MarieandClaude Opus 5.5 41f8cfa3bc YUNG's API jigsaw draws: yung_jigsaw structures and yung_single_element read as written
Clean-room, from spec-yungsapi.md §2.1 and §3.1 (no mod source read).
A `yungsapi:yung_jigsaw` structure lays out with the jigsaw assembler and
YUNG's draws: the start pool's file entries shuffled, then the rotation,
then a weighted pick in the shuffled order; each jigsaw's target pool is
drawn by weight from its shuffled entries without replacement, an element
whose `name` already has `max_count` pieces skipped at no other draw.
`yungsapi:yung_single_element` places what a single element places; its
pool entry carries the name and limit.

tools/test_structure_layout_parity.py --profile betterdungeons reads the
mod's own data from its jar into an overlay of the 26.3 resources and
compares the mod world's pieces: 82 of 84 Better Dungeons starts match
piece for piece (60 small, 11 skeleton, 11 zombie dungeons, up to 790
pieces); the other 2 sit in 26.3's sulfur_caves, which 1.21.1 lacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:08:43 +01:00
rdbtCVSandOpenCode gpt-6.1-sol 5d19fcaa06 Dismount players before command teleports
Reuse stop_riding before applying a player teleport destination so the riding tick cannot return the player to the vehicle. Add vanilla command parity and both-mode LAN regressions for mounted teleports.

Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 03:38:17 +00:00
rdbtCVSandOpenCode 489b5a4ffa Trading: keep Hero discounts for server players
Preserve the Hero effect captured by the guest's merchant session and cover discounted prices and payments for both single-player modes.

Fixes #1365

Co-Authored-By: OpenCode (gpt-6.1-sol) <[email protected]>
2026-10-25 03:36:35 +00:00
rdbtCVSandOpenCode gpt-6.1-sol 342cb6c500 Fix Java compiler/runtime selection during preparation
Pair PATH tools from a compatible JDK and always forward its home to external preparers so Java 25 exporters run with a compatible runtime.

Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 03:32:23 +00:00
rdbtCVSandOpenCode eed98be9cf Merge main and retain both LAN regression checks
Combine the shulker bullet check with main's new falling-entity coverage.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-25 03:31:45 +00:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol 859236bd9d Fix recipe removal notifications for server players
Notify connected players of the display IDs forgotten by /recipe take.
Discard queued additions for recipes the player no longer knows.
Add LAN regression coverage for both single-player paths.

Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-25 03:25:56 +00:00
RVZO6andCodex 07519df062 Handle server stop-sound packets through the existing sound engine
Decode source/event selection flags, including 26.3 UI source 10, and route them to Audio.stop_sounds. Compare all 36 native Java codec selections and round-trip their bytes; reject invalid sources and truncated packets.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-24 23:23:59 -04:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol b1eae8a5b8 Tests: keep spectator regression separate from new LAN checks
Move the spectator-travel check before the existing villagers check so it merges cleanly with the newly added falling check on main.

Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-25 03:13:12 +00:00
rdbtCVSandOpenCode cc65eeec82 Make shulker bullets visible on server clients
Assign mob-fired projectiles IDs when they are added and recognize shulker bullet spawns on remote clients. Extend the live LAN checks for both hosting modes.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-25 03:11:09 +00:00
RVZO6andCodex 792ac58f46 Render server text displays with billboard transforms and styled glyphs
Apply metadata, lifetime and position/transform interpolation; render wrapped/aligned text, background, opacity, shadows, brightness and see-through flags. Upload dynamic font glyphs and expose actual emitted vertices for end-to-end checks. Verify 108 matrices with the native client renderer and exercise a real Java-server/Rust-client fixture.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-24 23:10:47 -04:00
rdbtCVSandOpenCode GPT-6.1-sol 578f077347 Merge main and retain both LAN regression checks
Resolve the concurrent farmland and falling check additions in tools/lan_e2e.py.

Co-Authored-By: OpenCode GPT-6.1-sol <[email protected]>
2026-10-25 03:06:23 +00:00
rdbtCVSandOpenCode b6ec56ac9b Merge origin/main into double-chest click fix
Co-Authored-By: OpenCode (GPT-6.1-Sol) <[email protected]>
2026-10-25 03:00:14 +00:00
rdbtCVSandOpenCode gpt-6.1-sol e334dd3d55 Fix offhand eating and drinking cancellation
Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 02:59:33 +00:00
Ludovic MarieandClaude Opus 5.5 8948bf90c4 Structure layout harness: Horizon's starts piece by piece against the structure oracle
The clean-room structure oracle saved, for 31 starts of vanilla structures
(villages, outposts, trial chambers, shipwrecks, ruined portals, desert
pyramids over seeds 1234, 2 and 42), the no-mod world's pieces. A new
structure_pieces probe lists the starts a chunk owns, piece by piece, and
tools/test_structure_layout_parity.py compares them with those saves.

22 of the 31 match piece for piece (every village and outpost that 26.3
still places there, and the horizontal boxes of the code-built pieces).
The other 9 differ between 1.21.1 and 26.3 themselves: 26.3 vanilla's own
pieces there (VanillaChunkGenerationOracle scan) equal Horizon's for all 18
starts that differ from 1.21.1. No vanilla jigsaw fix is needed.

Templates now keep the id they were loaded under, so a piece names its
element.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:57:01 +01:00
rdbtCVSandOpenCode GPT-6.1-sol 753a99b933 Fix crop updates after server-side farmland trampling
Forward landing block-write events to notify_written so unsupported crops break, drop their items, and synchronize to clients. Add LAN regression coverage for the host and guest in both hosting paths.

Co-Authored-By: OpenCode GPT-6.1-sol <[email protected]>
2026-10-25 02:55:30 +00:00
RVZO6andCodex dc1e16f472 Model every text-display metadata field against the Java wire codec
Add a native Java oracle for the actual Display/TextDisplay accessors and encoded metadata, with 513 combinations of flags, opacity, billboard mode and transforms. Preserve styled text and partial updates.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-24 22:52:51 -04:00
rdbtCVSandOpenCode c36e81916b Fix double-chest inventory and hotbar clicks
Co-Authored-By: OpenCode (GPT-6.1-Sol) <[email protected]>
2026-10-25 02:24:16 +00:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol 6e86e07781 Multiplayer: keep spectator collision state after dimension travel
Restore the current game mode's spectator flag when remote_teleport creates a replacement player body. Add an end-to-end wall traversal regression for Nether travel and return, covering the integrated-server host, LAN guest and in-frame host.

Fixes #1299

Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-25 02:22:41 +00:00
rdbtCVSandOpenCode gpt-6.1-sol e820e9e1af Website: use one navigation bar across pages
Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 01:40:39 +00:00
Alexander Krylov 961af30997 Clippy: clear the two warnings that the whole tree fails on
The update tests' scratch helper only feeds unix-gated tests, and
receive_decorated takes the box the queue already put the protos in.
2026-10-25 04:34:05 +03:00
Alexander Krylov c10261f008 Cleanup: one predicate property helper, a Windows-only allow, tolerant locks in the bots
predicate_property was the same fifteen lines in block_feature and
state_provider; it is worldgen::bound_property now. The mojang link helpers are
unix-only in use but worth testing everywhere, so they are allowed to look dead
elsewhere, which clears the launcher's -D warnings red on Windows. The bots
harness recovers from a poisoned lock instead of panicking.
2026-10-25 03:58:02 +03:00
Alexander Krylov a96dccf691 Save: the write paths report a failure instead of dropping it
The moderation lists on a dedicated server, command storage, boss bars, the
world clocks, the difficulty and the emergency save before a crash all used
let _ = and lost the error. write_dat also swallowed the rename that moves the
current file aside, which made the following rename fail on the existing path
with nothing reporting it.
2026-10-25 03:57:31 +03:00
Alexander Krylov 6d1f6965d2 Packs: an untouched vanilla directory is mirrored on Windows too (#1688)
link cannot symlink a directory on Windows, so mirror has to recurse into one
itself, but it only did so when a pack had filters. An untouched directory fell
through to the link branch and vanished from the overlay, which took
textures/font and the panorama with it and made a pack that overrides a font
crash the client before its window.
2026-10-25 03:57:00 +03:00
some-du6eandClaude Opus 5.5 f66c257ae5 Advancement chat hover: normalise the title with canonical_component
scoreboard::canonical_component already re-encodes a component as vanilla
does, list form included, so decorate_name uses it instead of its own copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:46:24 -06:00
some-du6eandClaude Opus 5.5 9f1a880951 Advancement chat hover: a title in list form keeps its first element's style
A component list is its first element with the rest as siblings, so
decorateName merges the frame colour under that element's style. The
parsed list moved a styled first element down a level, which gave the
description the frame colour instead of the title's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:43:58 -06:00
some-du6eandClaude Opus 5.5 7a7bfdde79 Advancement chat hover: the description takes the title's own style
Advancement.decorateName merges the frame colour under the title's own
style and appends the description after its siblings. Built from the
parsed Text, a styled root with siblings had its style moved down a level,
so the description showed in the frame colour instead of the title's
(a gold, bold title gave a green description). The hover is now built on
the title's JSON, and the local spans are decoded from that JSON, so an
in-frame player sees the description coloured as a client does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:39:57 -06:00
rdbtCVSandopencode 6240124557 Slow Falling clears the fall distance so landing does not hurt
The effect capped downward gravity but left the fall distance to accumulate, so the landing tick still dealt fall damage. Clear it at the start of check_fall_damage, where both local travel and the server thread's replay of a guest's move pass.

Co-Authored-By: opencode (deepseek-v4.1-flash-free) <[email protected]>
2026-10-25 01:21:59 +01:00
Ludovic MarieandClaude Opus 5.5 e55b5b7c00 Boss events: custom bars first, as a joining player gets them
CustomBossEvents.onPlayerConnect adds a joining player to its bars before any
entity, dragon fight or raid tells it about theirs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:21:03 +02:00
rdbtCVSandopencode b0a5334cb5 Creative inventory: highlight hovered hotbar slots
The item-tab hotbar loop only set the tooltip, so the nine slots never
showed the white hover fill the 5x9 grid uses. Apply the same fill,
including over empty slots, before picking the tooltip item.

Fixes #1341

Co-Authored-By: opencode (deepseek-v4.1-flash-free) <[email protected]>
2026-10-25 01:17:28 +01:00
Alexander Krylov a93c1507f9 Launcher: a version removal retries a locked file and leaves the UI thread
remove_dir_all stops at the first file another process still holds and leaves
the rest deleted, so a removal that worked reported os error 5. Retry five times
250ms apart and treat a missing folder as done. The window froze because the
deletion ran from the update loop; it now runs on a thread and reports back
with Msg::Removed, as install already did.
2026-10-25 03:16:56 +03:00
Ludovic MarieandClaude Opus 5.5 a26297058a Trims: a Horizon host sends them too
The host wrote no trim component, so single player on its server thread,
LAN guests and dedicated-server players saw armour without its trim.
network_stack now takes the connection's synced-registry lookup for any
registry and writes Component::Trim when both names are in it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:15:25 +02:00
Ludovic MarieandClaude Opus 5.5 42afd721e7 Server packs: the download toast shows over the connection screen
A server sends its packs in the configuration phase, while the joining
screen covers the frame; vanilla's ToastManager draws over every screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:04:39 +02:00
Simon Dräger 4d3f3b6391 Load fireballs from NBT as live projectiles 2026-10-24 17:02:57 -07:00
Ludovic MarieandClaude Opus 5.5 75c009e3fc Boss events: one path from the host's diff to every overlay
The host diffed each viewer's boss events into a Change enum, then turned the
changes into packets for guests and, through a second match, into setter calls
on the window's own overlay; the client turned packets into the same setters a
third way. The diff now yields ClientboundBossEventPacket values, guests get
them as packets and the local overlay applies them with BossOverlay::apply,
the same function a server's packets reach. Gone: Change, Snap, Sent,
apply_local, boss_packet and the overlay's nine-argument add and setters.
UUIDUtil.uuidFromIntArray joins uuid_ints in lan::playerdata.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:53:03 +02:00
Alexander Krylov e30166f23b Options: Horizon's own captions read a bundled language namespace (#1672)
The settings screen's own options (controller, vibration, dead zones and the
rest) are not vanilla options and have no vanilla keys, so their captions
were English literals. They now read horizon.options.* from assets/horizon/lang,
which the client bundles as one more language source placed first, so a pack
or the prepared root can still override it. English and Russian ship here.
2026-10-25 02:51:11 +03:00
socksproxandDevin ba09b086a6 Multiplayer: drop the player name from the join loading line
The pre-world overlay said "Joined as {name}; loading terrain..." after
Event::Joined. Vanilla's LevelLoadingScreen only ever draws the
multiplayer.downloadingTerrain text there — use that translation, no
name.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 01:49:31 +02:00
Ludovic MarieandClaude Opus 5.5 f07d66c7cf Trims: their own arm in Component::read
The trim decode sat inside the read-past fallback and matched the kind
there; it now has a TRIM arm, and the fallback only reads past. Long
comment lines wrapped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:44:21 +02:00
Ludovic MarieandClaude Opus 5.5 6d735c26d3 Server packs: the download toast follows DownloadedPackSource's notifier
What the toast showed differed from vanilla 26.3 in several ways:
- the title and the failure line had no arguments: download.pack.title takes
  the request number and the batch size, download.pack.failed the failures
  and the batch size;
- sizes read "1.3 KiB" style; com.mojang.realmsclient.Unit.humanReadable gives
  "1.3 KB" ("512 B" below 1 KiB, half-up rounding), checked against the jar;
- each update didn't restart SystemToast's 5 s display time, so a download
  longer than 5 s lost its toast, and the Notification Time option was ignored;
- a finished batch removed the toast at once instead of forceHide's slide out;
- the failure title sat at y 7 in white; SystemToast draws titles yellow, and a
  title without a message at y 12;
- a second batch replaced the first one's channel and lost its events.

The download thread now reports HttpUtil's listener calls (requestStart,
downloadStart with the Content-Length, downloadedBytes, requestFinished), and
each batch gets a PackNotifier (createDownloadNotifier) with its own toast. The
toast is a shared toasts::SystemToast; the system background moved to
toasts.rs with a y, so the pack toasts stack under an advancement toast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:40:53 +02:00
Ludovic MarieandClaude Opus 5.5 3dc14b6eda Advancement chat names: the description goes after the title's siblings
Advancement.decorateName appends a new line and the description to the title's copy;
adv_name replaced the title's extra list, so a title built from siblings (a
plugin's {"text":"","extra":[...]}) reached clients as just the description.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:29:12 +02:00
Alexander Krylov f500be1b5e Title: the main menu labels follow the game language (#1672)
The screen built them from literals, so the buttons stayed English in every
language while the world select screen next to them was translated. Each has
a vanilla key the prepared resources already carry; Friends is Horizon's own
and reads horizon.menu.friends with the English text as fallback.
2026-10-25 02:28:27 +03:00
Ludovic MarieandClaude Opus 5.5 8698b4ad0c Shader packs, clean room 13: loading the terrain program
load_post takes the game's terrain vertex inputs and, given them, loads
the first program of gbuffers_terrain's fallback chain that translates
(gbuffers_terrain, textured_lit, textured, basic). Program loading is
shared by the post-processing and gbuffers programs.

The translator no longer rewrites a variable, parameter or member that
has the name of a sampler (`float specular` beside `uniform sampler2D
specular`), which kept Complementary's gbuffers programs from compiling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:20:16 +02:00
Ludovic MarieandClaude Opus 5.5 f31c18aa6c Shader packs, clean room 12: the block atlas packs sample
Packs sample blocks from one 2D atlas; Horizon keeps them in sprite
arrays. While a pack is loaded, every layer and its mip levels are
copied into a grid, and the animator reports the layers it rewrote so
only those are copied again. The sprite arrays gain COPY_SRC.

The gbuffers pass binds it in the next slice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:14:25 +02:00
Ludovic MarieandClaude Opus 5.5 a2a91f98f9 Shader packs, clean room 11: translating gbuffers programs
translate_gbuffers places a vertex-input glue the game writes (it knows
its own vertex formats) in front of a pack's gbuffers program, sets the
compatibility matrices from gbufferModelView/gbufferProjection and the
lightmap texture matrix, and gives declared attributes the game's values
(zero when it has none).

Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:14:15 +02:00
rdbtCVSandOpenAI Codex GPT-6.1 c41a053c15 refactor: separate client world entry from authoritative loading
Give integrated and dedicated startup a synchronous loading entry point without client deferral or integrated-server selection. Keep client orchestration and background loading unchanged.

Co-Authored-By: OpenAI Codex GPT-6.1 <[email protected]>
2026-10-24 23:53:29 +01:00
rdbtCVSandOpenAI Codex 0d1736da8f Make startup and world-loading execution roles explicit
Separate client, integrated-server and dedicated-server roles from connection state. Use roles for startup, presentation work and server dimension loading while preserving in-frame client simulation.

Co-Authored-By: OpenAI Codex (GPT-6.1) <[email protected]>
2026-10-24 23:53:00 +01:00
Alexander KrylovandHermes Agent 977ef7181f Data: /data get entity writes a mob's live riders
A mounted mob answered "Found no elements matching Passengers": the command
built the entity with the flat serializer, so `Passengers` was never in the
tag, even though the game had the relationship and saving a world kept it.

Persistence already writes the tree (`Game::mob_tree_nbt`, which also carries
the leash), and vanilla's command path uses the same one, so the command reads
it now.

Fixes #1519.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-25 01:51:47 +03:00
Ludovic MarieandClaude Opus 5.5 466b1de4c4 Shader packs: fewer passes and no needless loads in post-processing
- A buffer is cleared only when a program reads it before one writes all
  of it; the clears left ride in the blit pass as attachments the blit
  shader leaves alone, instead of passes of their own.
- The blit writes depthtex0 only when some program reads it.
- Attachments a pass overwrites whole (the blit's colortex0 and depth,
  every composite output, final's window) start cleared rather than
  loaded, which spares a tiled GPU reading them back first.
- The blit's bindings are kept until the world target changes size.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 00:46:07 +02:00
socksproxandDevin 282ecf16a9 Tests: fix boss-event verify failures
boss_event_round_trips missed its #[test] attribute, failing clippy's
dead-code lint; unknown_packets_keep_their_payload decoded boss_event,
which this PR gives a real decoder, so the garbage payload no longer
comes back Unknown — it uses player_look_at, which still has none.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:45:16 +02:00
Ludovic MarieandClaude Opus 5.5 1776d7ca5c Shader packs, clean room 10: post-processing over Horizon's world
The first slice that draws: with HORIZON_SHADER_PACK=<name>, the pack in
shaderpacks/<name> (folder or zip) runs its prepare, deferred, composite
and final programs over the world, under the hand and the HUD. Gbuffers
programs come later, so the composites read Horizon's own picture.

- The world renders into its own target (as with a resolution scale); its
  colour becomes colortex0 and its reverse-Z depth becomes depthtex0 in
  GL's window depth (1 - d for the same planes), rows flipped to GL's
  order. depthtex1 and depthtex2 read the same depth for now.
- colortex buffers in the formats the pack asked for (the nearest one
  wgpu renders to), created only when a program uses them, each with two
  textures: programs read main and write alt, the buffers a program wrote
  flip after it, flip.* keys and deferred_pre/composite_pre apply, and
  the mapping carries over to the next frame. Clears at the start of the
  frame with the pack's colours or the defaults.
- noisetex (256x256 white noise, repeat), a lit stand-in behind shadow
  samplers until shadow maps exist, a 1x1 stand-in for other names; the
  frame uniforms of the previous commit and the translator's own block.
- final draws onto the window; without it colortex0 is shown.
- Pipelines are built through validation scopes: a program wgpu refuses
  is reported with the full error and skipped. The dimension's folder is
  reloaded when the player changes dimension.

Nothing changes without the environment variable: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:46:24 +02:00
Ludovic MarieandClaude Opus 5.5 7b56b59e7c Mod registry: the submission form's service (crates/horizon-mod-registry)
Its own workspace, as the telemetry server's, for its own Railway service.
GitHub sign-in with no scope (the user's token reads the login once and is
dropped), signed-cookie sessions, an origin check and a CSRF token, six
submissions per account and sixty in all per hour. A submission runs the
file's own checks (horizon_mods::index::inspect, the signature, the entry's
fields through registry::project), uploads it to the registry's `staging`
release as <sha256>.<extension>, writes mods/<id>.toml on a new branch with
the submitter as co-author, and opens the pull request. Only an entry's
owners publish updates; a key change is a pull request by hand. Shader packs
are refused until their format exists.

Tested end to end against a mock GitHub: sign-in, the signed sample mod,
what lands on GitHub, and what is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:43:20 +02:00
Ludovic MarieandClaude Opus 5.5 303d497a67 Shader packs, clean room 9: frame uniforms, and gl_FragCoord in buffer passes
The values a pack's post-processing programs read once per frame, in
GL's conventions: camera position and the previous one, eye altitude,
up/sun/moon/shadow-light positions in view space (the sky's own frame for
the sun and moon), sun and shadow angles, world time, day and moon phase,
frame counter, frame time and run time, view size and aspect, near and
far, rain, smoothed wetness, thunder, fog colour, start and end, sky
colour, eye in fluid, blindness, night vision, darkness, brightness, GUI
hidden, eye brightness and its smoothed value, and the model-view and
projection matrices with their inverses and last frame's. The projection
is GL's perspective with the world's near and far planes times vanilla's
view bobbing. Each value is written as the type the pack declared.

The game passes what only it knows (day time, weather levels, the fluid
at the camera, light at the camera, blindness, F1, brightness) in the
renderer's Frame when HORIZON_SHADER_PACK is set.

Fix (slice 4): programs that draw to buffers read gl_FragCoord through a
variable only final set; every target sets it now, with a test. Found by
the first frame drawn with a pack.

Nothing reads the uniforms yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:26:42 +02:00
Ludovic MarieandClaude Opus 5.5 dbdc0389fe Launcher: Horizon modpacks from the registry, each its own profile
A registry modpack (.hpack) now shows under Modpacks with the Horizon
source, with its mods and what each may do, and installs into
horizon-packs/<id>/: the checked pack, pack.json and home/, where each of
its mods is downloaded and checked like any registry mod (pinned to the
pack's version and SHA-256) and turned on or off as the pack says. Its
overrides are copied on the first install only. A pack whose mod was taken
down installs nothing and leaves nothing behind. The Modpacks tab lists
installed registry packs with Play and Remove (confirmed); they play through
modpacks::apply_profile with their own HORIZON_HOME, taken-down mods turned
off first. CLI: `mods install --source horizon <pack>`, and `modpack
list|play|remove` see registry packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 f602ad6fe3 Launcher Mods page: Horizon's registry beside Modrinth
The source switch now picks Modrinth or Horizon (CurseForge stays coming
soon). On Horizon each result says whether its files are signed and what
the mod may do, in the game's words, before Install; no Java check, no
download count, no Modrinth filters. Installed Horizon mods get an
on/off switch (horizon-mods.txt); one the registry took down is turned
off when the page reads the index and can't be turned back on.
Registry icons load from its GitHub Pages. HORIZON_LAUNCHER_SOURCE=horizon
opens the page on it, for screenshots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 b49fd602df Launcher: install from Horizon's registry, enable and disable, takedowns
Mods install into horizon-mods/<id>.hmod (turned on) and resource packs into
resourcepacks/, each downloaded to mod-browser/downloads/ and checked by
horizon_mods::index::check_file (size, SHA-256, signature, manifest) before it
is moved into place. Files the index takes down are turned off when the index
is read or the game starts, and can't be installed or turned back on. CLI (and
so MCP): --source horizon, mods enable|disable, and search shows whether a
project is signed and what a mod may do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 7e513d2d5c Launcher: Horizon's registry as a mod browser source
A browser source for Horizon's own registry (docs/api/mod-index.md): it reads index.json
(HORIZON_MOD_INDEX for a test registry, cached in the game folder for when the registry can't
be reached), searches it by kind and words, lists versions with the SHA-256 the index gives,
and blocks the versions it says were taken down. Downloads accept the registry's release host
and check SHA-256; file:// files only from a local index.

Installing, the takedown check before play and the CLI come next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
some-du6eandClaude Opus 5.5 4ad05aa94a Show armour trims on items from a server
Decode the trim component as its material and pattern registry ids when both
are references (a direct holder stays read past) and name them through the
server's synced trim_material and trim_pattern registries, as enchantments
are, so worn and held trimmed armour draws its trim in multiplayer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 14:44:29 -06:00
some-du6eandClaude Opus 5.5 8c611e677b Advancement titles and descriptions show a whole text component
A server's custom advancements often name themselves with an empty text and
the words in its siblings ({"text":"","extra":[...]}); only the top-level
text or translate key was read, so their tooltips were blank. Read them as
the component they are (ComponentSerialization), with siblings, styles,
translation arguments and fallbacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 14:44:28 -06:00
Ludovic MarieandClaude Opus 5.5 3f1a7b28af Placement prediction: one helper for predicted blocks, the sound where the block went
- predict_use and predict_place set their blocks through one
  predict_blocks (retainKnownServerState), instead of two copies of the
  loop.
- remote_can_place returns where BlockItem.place put the block, and the
  placer hears the place sound there: before, set_block only played it at
  the clicked face's neighbour, so a block that replaced grass or snow
  where the player clicked made no sound for its placer now that the
  server leaves the placer out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:39:13 +02:00
Ludovic MarieandClaude Opus 5.5 6b2ec5f830 Shader packs, clean room 8: a dimension's post-processing programs
What the renderer needs to run a pack's post-processing, still without
wgpu: the programs of one dimension (prepare, deferred, composite and
final with their numbered variants, in the order they run), each
preprocessed and translated, with the buffers it writes and the flips
that follow it, and the buffer settings its stage files carry.

- A dimension's folder (world0, world-1, world1) replaces the base folder
  when it exists. program.<name>.enabled expressions over switch options,
  flip.<program>.<buffer> keys and the virtual deferred_pre and
  composite_pre. Programs with a geometry stage, or that fail to
  preprocess or translate, are reported and skipped.
- tests/packs/post-test: Horizon's own small test pack (tint, depth in
  colortex2, depth bands on half the screen, vignette).

Black box over the public test packs: every dimension of every pack
loads its post-processing programs (2 to 9) with no note.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:23:43 +02:00
Ludovic MarieandClaude Opus 5.5 d064dba945 Shader packs, clean room 7: buffer settings and output lists from stage files
The configuration packs write into their stage files, read from live code
only (the preprocessor has dropped false branches, so an option's choice
is the one in effect):

- the buffers a fragment shader writes: the last live DRAWBUFFERS or
  RENDERTARGETS comment, at most 8, buffers 0-7 without one;
- per colortex buffer (names and aliases): <buffer>Format in block
  comments, with every format name of the spec in any case, as channels,
  bits and kind; <buffer>Clear, ClearColor and MipmapEnabled.
  A later file changing a setting, an unknown format and a value of the
  wrong type are reported with their file and line. Buffer 1 becomes
  RGBA32F when a program declares gdepth and the pack set no format.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:23:43 +02:00
Simon Dräger 72f88befb8 Allow spaces in horizon-ctl screenshot paths 2026-10-24 13:21:23 -07:00
Simon Dräger 891e1c1283 Run client mod hooks on the server thread 2026-10-24 13:21:23 -07:00
Simon Dräger d83b3e18bc Keep mob pitch on /tp 2026-10-24 13:21:23 -07:00
Ludovic MarieandClaude Opus 5.5 fb4897ec09 Website: /mods/submit, the registry's submission form
Sign in with GitHub through the registry's service (no scope), then send
the file and its page; the service checks the file, stages it and opens the
pull request, and the page links to it or says what to fix. A file named
<id>-<version>.<ext> fills in its kind, id and version. Shader packs are
listed and disabled until Horizon's own format exists. Caddy sends
/api/mods/* to REGISTRY_UPSTREAM (unset: the form says it isn't open), and
"Submit yours" now leads here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:10:05 +02:00
socksproxandDevin a5f5d8df34 Server packs: the download progress toast
#1570 landed the client pack manager without vanilla's
PackDownloadNotificationComponent: the SystemToast that follows a
download with download.pack.progress.percent or .bytes and stays as
download.pack.failed when a batch fails. The download queue reports
requestStart/downloadedBytes/downloadFinished on a channel (vanilla's
DownloadProgressListener), the game ticks them into a toast it slides
in for the vanilla 5 seconds with the toast in/out sounds.

Co-Authored-By: Devin <[email protected]>
2026-10-24 21:49:01 +02:00
levzzzandClaude Opus 5.5 90b5e162ae Play a placed block's sound once for its placer
BlockItem.place plays it with playSound(player, ...): the server sends it to
the others, and the placer's client plays it as its block appears. The
server sent it to the placer too, so each block sounded twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 00:38:39 +05:00
Ludovic MarieandClaude Opus 5.5 b61b25f80f Shader packs, clean room 6: composite-style programs to naga
A program's preprocessed GLSL, any version from 110 to 460, compatibility
or core, rewritten into the Vulkan-flavoured GLSL 450 that naga's frontend
reads, then parsed and validated into naga modules with the program's
interface: its uniform block (offsets from naga's layout), its samplers
and their bindings, its output locations. The rewrite works on
preprocessing tokens and keeps the pack's lines, so every error points at
the pack's own file and line through the preprocessor's line map.

- Loose uniforms go into one block shared by both stages; bool is stored
  as int. Samplers are split into texture and sampler objects, combined
  again where used, and passed as two arguments to the pack's functions.
- The compatibility built-ins become declarations of ours (prefix hz_):
  gl_Vertex, gl_MultiTexCoordN, the matrices and ftransform(), gl_FragData,
  gl_FragColor, gl_TexCoord, gl_FrontColor, gl_Fog (from vanilla's fog),
  texture2D and the other old texture functions, shadow2D; varyings get
  locations by name across the stages; words that became keywords after
  GLSL 1.x are renamed in old code.
- naga gaps covered: modf, conditional initializers of globals (run by our
  main), and unsized arrays sized from their constructor.
- The pack's main is called by one of ours that draws the quad from (0,0)
  to (1,1) and adapts GL's conventions: clip y negated for every target but
  the window, so memory rows are GL's rows and gl_FragCoord needs no change;
  clip z from GL's [-w, w] to [0, w], so stored depth is GL's window depth.
  In final, gl_FragCoord.y is counted from the bottom of the window.

Black box over the public test packs: all 105 composite-style programs
(composite, deferred, final, prepare, begin, shadowcomp of the base and
dimension folders) translate and validate.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:35:37 +02:00
socksproxandDevin d742d725f4 Client: the boss-overlay — BossEvent bars, wither darkening and world fog
The clientbound boss_event packet (add/update/remove/style/progress), a
BossOverlay that renders each bar, darkens the world under a wither, and
suppresses the create-the-world fog — vanilla's BossHealthOverlay and
GameRenderer behaviour on a real server.

Co-Authored-By: Devin <[email protected]>
2026-10-24 21:29:08 +02:00
Ludovic MarieandClaude Opus 5.5 787c3a2f19 Website: /mods, Horizon's registry of mods, modpacks and resource packs
The list (search, a filter per kind) and a page per project: what a mod
may do in the game's own words, Signed or Unsigned, how to install it
from the launcher, the CLI or by hand, its file, SHA-256 and versions,
and links to its source, its registry entry and the report form. Baked
from the published index at build time (HORIZON_MOD_INDEX for a test
index) and refreshed by the page; Caddy serves /mods/<id> from its baked
page, else the list page, which shows a project published since.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:20:34 +02:00
Ludovic MarieandClaude Opus 5.5 f239955997 Shader packs, clean room 5: the GLSL preprocessor and #include
A stage file goes through comment removal, #include, conditionals and
macro expansion, and every output line remembers the pack file and line it
came from, so later errors point at the pack's own code.

- #include "x" from the including file's folder, "/x" from the shaders
  folder, expanded in live code only (an include inside a false #if is not
  read), ten levels at most, paths that leave the pack refused; a file is
  read once per run however often it is included.
- #define, #undef, line continuations, __LINE__ and __VERSION__; #version
  and #extension kept apart for the translation's own header; the block
  comments of live code kept for the directives packs write in them
  (DRAWBUFFERS, buffer formats); #error and unknown directives diagnosed.

Black box over the public test packs: every stage file of the base and
dimension folders (126 to 219 per pack) preprocesses with no diagnostic.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:09:29 +02:00
Ludovic MarieandClaude Opus 5.5 4c65ed2f5c Shader packs, clean room 4: function-like macros and the standard macros
The macro engine the GLSL preprocessor needs, shared with the .properties
reader: function-like macros take their arguments (expanded first, raw
beside ##, which pastes) when a ( follows their name; a macro is still not
expanded inside its own expansion. Text is cut into preprocessing tokens,
so numbers such as 1e5 or 0x1F are never mistaken for names.

The #if/#elif/#else/#endif state moves out of the .properties reader so
the preprocessor uses the same code.

Standard macros: MC_GL_VERSION and MC_GLSL_VERSION 460, the 24
MC_RENDER_STAGE_* as ascending integers in draw order, render and shadow
quality 1.0, MC_HAND_DEPTH 0.125, MAX_COLOR_BUFFERS 16.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:09:07 +02:00
levzzzandClaude Opus 5.5 352645b417 Predict block placement on the client, so fast bridging stops losing clicks
MultiPlayerGameMode.useItemOn places the block on the client's level at
once, kept under the use's sequence until BlockChangedAck. Without it the
next fast click aimed past the block still on its way from the server, the
server refused it, and the arm swung for nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 00:07:31 +05:00
Ludovic MarieandClaude Opus 5.5 397d529db7 Shader packs, clean room 3: .properties files with conditional directives
shaders.properties and the id files (block, item, entity, dimension):
key=value lines in file order with their line numbers, # comments, a value
continued on the next line by a trailing backslash, and the conditional
directives evaluated with the standard and option macros. A # line is a
directive only when its first word is one, so a commented-out key such as
#ifdefault=1 stays a comment. #define and #undef apply to the lines after
them in every one of these files: a public pack defines a macro inside
#ifdef MC_OS_MAC in its shaders.properties and tests it further down. A
later duplicate key replaces the earlier one; broken conditionals, lines
that are not key=value and duplicates are kept as notes for the pack's log.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:42:33 +02:00
Ludovic MarieandClaude Opus 5.5 eb0a6f74e6 Shader packs, clean room 2: macros and #if conditions
The integer arithmetic of #if/#elif that a pack's .properties files and,
later, its GLSL preprocessor share: defined X and defined(X), decimal,
hexadecimal and octal literals, the C operators with C precedence, macros
expanded to their values (a macro is not expanded inside its own
expansion), and an identifier that is not a macro read as 0. A bad
condition says why. The standard macros that need no renderer:
MC_VERSION, from the pinned version by the documented rule (26.3 is
260300), and the system's MC_OS_*.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:42:05 +02:00
some-du6eandClaude Opus 5.5 9223b401e7 Decode every item data component a server sends
Horizon decoded 25 of 26.3's 122 data component types. Item data has no
length prefix, so a stack with any other component (an armour trim,
banner patterns, pot sherds, a stew's effects, a plugin's item) made the
whole packet undecodable and it was dropped: on a Paper server the
inventory arrived empty until each slot was clicked, the advancements
screen said "There doesn't seem to be anything here..." and the recipe
book was empty.

Every other component is now read to its end with its vanilla
StreamCodec (item/skip.rs) and kept as its bytes, written back as they
came; components without a network codec go as their NBT. The client
leaves them out of the item's components, so the item keeps its
prototype's values for them.

tools/test_item_wire_parity.py checks it against vanilla: the oracle
encodes 374 stacks with ItemStack.OPTIONAL_STREAM_CODEC (every default
component value of every item, SNBT fixtures for the rest, direct
holders and removals built in Java), covering all 122 component types,
and the Rust probe must consume every byte, find the same component ids
and encode the read-past ones back to the same bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 12:20:45 -06:00
283 changed files with 27373 additions and 984 deletions

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
name: mod-registry
# The submission service is its own workspace (crates/horizon-mod-registry), so the checks job doesn't build
# it: this one does, when it or horizon-mods (the checks it shares with the launcher and the game) changes.
on:
push:
branches: [main]
paths:
- 'crates/horizon-mods/**'
- 'crates/horizon-mod-registry/**'
- '.github/workflows/mod-registry.yml'
pull_request:
paths:
- 'crates/horizon-mods/**'
- 'crates/horizon-mod-registry/**'
- '.github/workflows/mod-registry.yml'
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: crates/horizon-mod-registry
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: crates/horizon-mod-registry
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo +stable fmt --check
- run: cargo +stable clippy --all-targets --locked -- -D warnings
- run: cargo +stable test --locked
+1
View File
@@ -45,6 +45,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
+5
View File
@@ -7,6 +7,11 @@ welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
## Build it
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
Generated
+29
View File
@@ -428,6 +428,8 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width",
]
@@ -1266,6 +1268,7 @@ dependencies = [
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1434,10 +1437,25 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-shader-api"
version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
"serde",
"serde_json",
"sha2",
"toml",
"zip",
]
[[package]]
name = "horizon-shaders"
version = "0.1.0"
dependencies = [
"horizon-core",
"naga",
"zip",
]
@@ -1973,6 +1991,7 @@ dependencies = [
"naga-types",
"num-traits",
"once_cell",
"pp-rs",
"rustc-hash 1.1.0",
"spirv",
"thiserror 2.0.21",
@@ -2649,6 +2668,15 @@ dependencies = [
"zerovec",
]
[[package]]
name = "pp-rs"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee"
dependencies = [
"unicode-xid",
]
[[package]]
name = "presser"
version = "0.3.1"
@@ -4197,6 +4225,7 @@ dependencies = [
"hashbrown 0.17.1",
"js-sys",
"log",
"naga",
"parking_lot",
"portable-atomic",
"profiling",
+2 -2
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
resolver = "2"
[workspace.package]
+5
View File
@@ -16,6 +16,7 @@
<a href="#download"><b>Download</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
<a href="#project-status"><b>Project status</b></a> ·
<a href="#contributing"><b>Contributing</b></a>
</p>
@@ -109,6 +110,10 @@ Everything prepared lands in the gitignored `.local/`. It is derived from the ga
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
## Roadmap
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
## Contributing
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
+341
View File
@@ -0,0 +1,341 @@
# Horizon roadmap
<!-- Generated by tools/roadmap.py from roadmap/roadmap.toml: edit that file, then run `python3 tools/roadmap.py build`. -->
What is being done on Horizon, what comes next and where you can help. There are no dates: things are Now, Next or Later. Each item is said in plain words, with the technical picture folded under it. The same roadmap, with a Simple / Technical switch, the board, the journal and how to help, is on [playhorizon.dev/hub](https://playhorizon.dev/hub). Pull requests carry the `roadmap:<id>` label of their item.
## At a glance
Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it runs faster on the same computer. Single player, LAN, servers, resource packs and Horizon's own mods work today, on your own copy of the game.
**Being done now**
- **Steady frames at long render distances**: Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
- **Modpacks' world generation, natively**: Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
- **Playing on servers: everything shows as it should**: When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs.
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
**Developers**: Read CONTRIBUTING.md, pick a Help wanted item or a To do card, check vanilla's code for the behaviour, and paste the block `python3 tools/pr_ready.py` prints in your pull request.
## Now
### Performance
#### Steady frames at long render distances
Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
<details><summary>Technical</summary>
Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
</details>
#### Faster block lookups
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
<details><summary>Technical</summary>
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
</details>
### Modpacks
#### Modpacks' world generation, natively
Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
<details><summary>Technical</summary>
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
</details>
### Servers and multiplayer
#### Playing on servers: everything shows as it should
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
<details><summary>Technical</summary>
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
</details>
#### Server dialogs and login screens
Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon.
<details><summary>Technical</summary>
26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
</details>
### Mods and API
#### Horizon's mod registry
One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
<details><summary>Technical</summary>
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
More: [docs/mods.md](docs/mods.md)
</details>
### Gameplay parity
#### Single player as a server: the last differences
Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
<details><summary>Technical</summary>
Since [#1514](https://github.com/ludovic111/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
</details>
#### Play in your language
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
</details>
### Rendering
#### Shader packs, written again from scratch
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
<details><summary>Technical</summary>
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
</details>
### Horizon extras
#### Discord Rich Presence
Your Discord profile can show that you are playing Horizon, with a switch to turn it off.
<details><summary>Technical</summary>
A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
</details>
### Code and tools
#### A leaner codebase that builds faster
Cleaning up Horizon's code: removing what nothing uses and making it quicker to build, so fixes and new features land faster.
<details><summary>Technical</summary>
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
</details>
## Next
### Performance
#### Fresh benchmarks against vanilla
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, on the website's Benchmarks page.
<details><summary>Technical</summary>
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
</details>
### Modpacks
#### CurseForge in the mod browser (coming soon)
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
<details><summary>Technical</summary>
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
</details>
### Gameplay parity
#### Pistons push players and mobs
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
<details><summary>Technical</summary>
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
</details>
### Launcher
#### Microsoft sign-in (coming soon)
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
<details><summary>Technical</summary>
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
</details>
#### Horizon on Intel Macs
Builds of the game and the launcher for Macs with an Intel processor.
<details><summary>Technical</summary>
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/ludovic111/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
</details>
## Later
### Gameplay parity
#### Following new Minecraft versions
When Mojang releases a new version of the game, Horizon follows it, and every system is checked against the new version again.
<details><summary>Technical</summary>
The port pipeline detects a release, diffs its classes against 26.3 and writes a port report ([docs/version-ports.md](docs/version-ports.md)); the harnesses then run against the new version.
</details>
### Horizon extras
#### An optional AI teammate
An AI teammate that plays beside you and that you can talk to. It is off unless you turn it on, and the No AI switch hides it completely.
<details><summary>Technical</summary>
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)), and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). Local models first; it ships only once the safety evaluation passes.
</details>
## Help wanted
### Performance
#### Benchmark your computer
Run Horizon's built-in benchmark and share the report: every new computer makes the performance work more accurate. No coding needed.
<details><summary>Technical</summary>
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
</details>
### Gameplay parity
#### End city banners keep their pattern
Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
</details>
#### Enchanted loot in End cities
Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
</details>
#### Cooldowns drawn on items
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
</details>
#### Chat settings and team prefixes
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
</details>
#### A pointing hand over buttons
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
</details>
#### Ctrl+Backspace and scroll bars in menus
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
</details>
#### Chickens flap while they fall
Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
</details>
## Recently shipped
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
+13 -3
View File
@@ -21,6 +21,8 @@ pub struct Report {
pub sha256: String,
pub size: u64,
pub signed: bool,
/// A mod has code to run (a content mod may have none).
pub code: bool,
/// A mod's component imports.
pub imports: Vec<String>,
/// A mod's commands, each run once.
@@ -49,6 +51,7 @@ impl Report {
"sha256": self.sha256,
"size": self.size,
"signed": self.signed,
"code": self.code,
"imports": self.imports,
"commands": self.commands,
"output": self.output,
@@ -87,7 +90,9 @@ impl Report {
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
if self.kind == Kind::Mod {
if self.kind == Kind::Mod && !self.code {
out += " no code: content only, nothing to run\n";
} else if self.kind == Kind::Mod {
let commands = if self.commands.is_empty() {
String::new()
} else {
@@ -142,6 +147,7 @@ pub fn check(
sha256: index::sha256_file(path)?,
size,
signed,
code: false,
imports: Vec::new(),
commands: Vec::new(),
output: Vec::new(),
@@ -153,10 +159,14 @@ pub fn check(
}
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
/// storage lands in a folder removed afterwards.
/// storage lands in a folder removed afterwards. A content mod has no code to
/// run: `inspect` already read its content as the game does.
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
let package = Package::read(path)?;
let wasm = package.wasm.as_deref().ok_or("the .hmod has no mod.wasm")?;
let Some(wasm) = package.wasm.as_deref() else {
return Ok(());
};
report.code = true;
report.imports = component_imports(wasm)?;
package
.manifest
+147 -21
View File
@@ -1,14 +1,16 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
//! the mod; run `build` again and the game reloads it.
//! - `check <file> [--json]`: what the registry checks of a submission
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
//! - `registry build|review|check|list`: the registry repository's CI.
use horizon_mods::{
Manifest, Package,
Capability, Content, Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
};
mod check;
@@ -36,7 +38,7 @@ fn main() -> ExitCode {
Some("run") => run(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -90,19 +92,68 @@ fn new(args: &[String]) -> Result<(), String> {
.replace("{name}", &name)
.replace("{sdk}", &sdk)
};
let files = [
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
(MANIFEST, include_str!("../template/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/src/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
];
for (file, text) in files {
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
let content = [
(
MANIFEST,
include_str!("../template/content/horizon-mod.toml"),
),
(
"data/{id}/horizon/block/glowing_amethyst.json",
include_str!("../template/content/block.json"),
),
(
"assets/{id}/blockstates/glowing_amethyst.json",
include_str!("../template/content/blockstate.json"),
),
(
"assets/{id}/models/block/glowing_amethyst.json",
include_str!("../template/content/model.json"),
),
(
"assets/{id}/items/glowing_amethyst.json",
include_str!("../template/content/item.json"),
),
(
"assets/{id}/lang/en_us.json",
include_str!("../template/content/lang.json"),
),
(
"data/{id}/loot_table/blocks/glowing_amethyst.json",
include_str!("../template/content/loot.json"),
),
(
"data/{id}/recipe/glowing_amethyst.json",
include_str!("../template/content/recipe.json"),
),
(
"data/minecraft/tags/block/mineable/pickaxe.json",
include_str!("../template/content/pickaxe.json"),
),
(".gitignore", include_str!("../template/gitignore")),
];
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
other => return Err(format!("no template {other}: code or content")),
};
for &(file, text) in files {
let file = fill(file);
let file = file.as_str();
let to = dir.join(file);
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
.map_err(|e| e.to_string())?;
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
}
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
let manifest =
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
Content::read(dir, &manifest)?;
println!(
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
dir.display()
@@ -110,14 +161,53 @@ fn new(args: &[String]) -> Result<(), String> {
Ok(())
}
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
/// the manifest, the crate's component (none without a `Cargo.toml`: a
/// content mod) and the files in `assets/` and `data/`.
fn build(release: bool) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(MANIFEST)
build_in(Path::new("."), release)
}
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
let manifest = Manifest::parse(&manifest_text)?;
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
let (wasm, target) = compile(root, release)?;
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
(Some(wasm), target)
} else if manifest.capabilities.contains(&Capability::Registries) {
(None, root.join("target"))
} else {
return Err(format!(
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
));
};
// The same checks as the game's, and the files in the order it lists them.
let content = Content::read(root, &manifest)?;
let mut reader = content.reader()?;
let mut files = Vec::with_capacity(content.files.len());
for name in &content.files {
files.push((name.as_str(), reader.read(name)?));
}
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
let dir = target_dir.join("horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
}
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.args([
cmd.current_dir(root).args([
"build",
"--lib",
"--target",
@@ -145,17 +235,8 @@ fn build(release: bool) -> Result<PathBuf, String> {
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
let dir = target_dir.join("horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
Ok((wasm, target.to_path_buf()))
}
/// `check <file>`: the registry's checks of a submission, printed; fails
@@ -237,4 +318,49 @@ mod tests {
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
let path = dir.join("gems");
let new_args = [
path.display().to_string(),
"--template".into(),
"content".into(),
];
new(&new_args).unwrap();
assert!(!path.join("Cargo.toml").exists());
let hmod = build_in(&path, true).unwrap();
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
let package = Package::read(&hmod).unwrap();
assert!(package.wasm.is_none());
let content = Content::read(&hmod, &package.manifest).unwrap();
assert!(
content
.files
.contains(&"data/gems/horizon/block/glowing_amethyst.json".into())
);
assert_eq!(content.files.len(), 8, "{:?}", content.files);
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
assert!(!report.code);
assert!(
report.text().contains("no code: content only"),
"{}",
report.text()
);
// Without registries, a mod needs code.
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
let manifest = manifest.replace("[\"registries\"]", "[]");
std::fs::write(path.join(MANIFEST), manifest).unwrap();
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
assert!(
new(&[
dir.join("x").display().to_string(),
"--template".into(),
"lua".into()
])
.is_err()
);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -0,0 +1,4 @@
{
"template": "minecraft:amethyst_block",
"properties": { "hardness": 2.0, "light": 7 }
}
@@ -0,0 +1,5 @@
{
"variants": {
"": { "model": "{id}:block/glowing_amethyst" }
}
}
@@ -0,0 +1,10 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks and items to your worlds".
capabilities = ["registries"]
@@ -0,0 +1,3 @@
{
"model": { "type": "minecraft:model", "model": "{id}:block/glowing_amethyst" }
}
@@ -0,0 +1,3 @@
{
"block.{id}.glowing_amethyst": "Glowing Amethyst"
}
@@ -0,0 +1,11 @@
{
"type": "minecraft:block",
"pools": [
{
"condition": { "type": "minecraft:survives_explosion" },
"entries": [{ "type": "minecraft:item", "name": "{id}:glowing_amethyst" }],
"rolls": 1
}
],
"random_sequence": "{id}:blocks/glowing_amethyst"
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:block/cube_all",
"textures": { "all": "minecraft:block/amethyst_block" }
}
@@ -0,0 +1,3 @@
{
"values": ["{id}:glowing_amethyst"]
}
@@ -0,0 +1,6 @@
{
"type": "minecraft:crafting_shapeless",
"category": "building",
"ingredients": ["minecraft:amethyst_block", "minecraft:glowstone_dust"],
"result": { "id": "{id}:glowing_amethyst" }
}
+50 -15
View File
@@ -4,15 +4,15 @@ use crate::json::{Result, error};
use horizon_core::random::RandomSource;
#[derive(Clone, Debug)]
enum Choice {
Single(Variant),
enum Choice<T> {
Single(T),
Weighted {
variants: Vec<Variant>,
variants: Vec<T>,
ends: Vec<i32>,
total: i32,
},
}
impl Choice {
impl Choice<Variant> {
fn new(value: &VariantSet) -> Result<Self> {
match value {
VariantSet::Single(value) => Ok(Self::Single(value.clone())),
@@ -41,7 +41,23 @@ impl Choice {
}
}
}
fn select(&self, random: &mut impl RandomSource) -> &Variant {
}
impl<T> Choice<T> {
fn map<U>(&self, f: &mut impl FnMut(&T) -> U) -> Choice<U> {
match self {
Self::Single(value) => Choice::Single(f(value)),
Self::Weighted {
variants,
ends,
total,
} => Choice::Weighted {
variants: variants.iter().map(&mut *f).collect(),
ends: ends.clone(),
total: *total,
},
}
}
fn select(&self, random: &mut impl RandomSource) -> &T {
match self {
Self::Single(value) => value,
Self::Weighted {
@@ -60,10 +76,11 @@ impl Choice {
}
/// Compile once per definition and share across its bound block states. Prefix
/// sums avoid rebuilding weight totals or scanning all entries during selection.
/// `map` swaps each variant for what it stands for (its baked model).
#[derive(Clone, Debug)]
pub struct CompiledChoices {
variants: Vec<Choice>,
multipart: Vec<Choice>,
pub struct CompiledChoices<T = Variant> {
variants: Vec<Choice<T>>,
multipart: Vec<Choice<T>>,
}
impl CompiledChoices {
pub fn new(definition: &BlockStateDefinition) -> Result<Self> {
@@ -82,10 +99,19 @@ impl CompiledChoices {
.collect::<Result<_>>()?,
})
}
}
impl<T> CompiledChoices<T> {
/// The same choices, with `f` of each variant in its place.
pub fn map<U>(&self, mut f: impl FnMut(&T) -> U) -> CompiledChoices<U> {
CompiledChoices {
variants: self.variants.iter().map(|c| c.map(&mut f)).collect(),
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
}
}
/// The variant `particleMaterial` comes from: the first entry of a weighted
/// list (`WeightedVariants`) and, for a multipart root, of its first
/// selector whether or not that selector applies (`MultiPartModel`).
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&Variant> {
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&T> {
let choice = match selected {
SelectedModels::Variant(index) => self.variants.get(*index)?,
SelectedModels::Multipart(_) => self.multipart.first()?,
@@ -95,14 +121,23 @@ impl CompiledChoices {
Choice::Weighted { variants, .. } => variants.first()?,
})
}
/// Append selected variants without allocating intermediate part lists. A
/// multipart root consumes nextLong even for an empty selected subset, then
/// resets the same random source to that seed before each selected part.
/// Append selected variants without allocating intermediate part lists.
pub fn collect<'a>(
&'a self,
selected: &SelectedModels,
random: &mut impl RandomSource,
output: &mut Vec<&'a Variant>,
output: &mut Vec<&'a T>,
) -> Result<()> {
self.for_each(selected, random, |v| output.push(v))
}
/// Each selected variant, in order. A multipart root consumes nextLong
/// even for an empty selected subset, then resets the same random source
/// to that seed before each selected part.
pub fn for_each<'a>(
&'a self,
selected: &SelectedModels,
random: &mut impl RandomSource,
mut f: impl FnMut(&'a T),
) -> Result<()> {
match selected {
SelectedModels::Variant(index) => {
@@ -110,7 +145,7 @@ impl CompiledChoices {
.variants
.get(*index)
.ok_or_else(|| error("invalid variant selection"))?;
output.push(choice.select(random));
f(choice.select(random));
}
SelectedModels::Multipart(indices) => {
if indices.iter().any(|index| *index >= self.multipart.len()) {
@@ -119,7 +154,7 @@ impl CompiledChoices {
let seed = random.next_long();
for index in indices {
random.set_seed(seed);
output.push(self.multipart[*index].select(random));
f(self.multipart[*index].select(random));
}
}
}
+25 -25
View File
@@ -173,6 +173,11 @@ impl Random {
}
}
/// One bot thread that panics poisons the locks it held; the others and the report go on.
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
let mut w = Writer::new();
w.varint(id);
@@ -329,7 +334,7 @@ fn read(
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
let send = |body: Vec<u8>| lock(&writer).send(&body);
let mut seen = HashSet::new();
let mut batch = BatchSize::new();
let mut teleports = 0u64;
@@ -338,7 +343,7 @@ fn read(
Ok(packet) => packet,
Err(e) => {
if bot.online.swap(false, Ordering::SeqCst) {
*bot.error.lock().unwrap() = Some(e.to_string());
*lock(&bot.error) = Some(e.to_string());
}
return;
}
@@ -356,7 +361,7 @@ fn read(
cb::LEVEL_CHUNK_WITH_LIGHT => {
bot.chunks.fetch_add(1, Ordering::Relaxed);
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
if !bot.held.lock().unwrap().insert((x, z)) {
if !lock(&bot.held).insert((x, z)) {
bot.resent.fetch_add(1, Ordering::Relaxed);
}
if seen.insert((x, z)) {
@@ -368,7 +373,7 @@ fn read(
cb::FORGET_LEVEL_CHUNK => {
if let Ok(play::Clientbound::ForgetLevelChunk(p)) =
play::Clientbound::decode(id, &body)
&& bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1]))
&& lock(&bot.held).remove(&(p.pos[0], p.pos[1]))
{
bot.forgotten.fetch_add(1, Ordering::Relaxed);
}
@@ -389,7 +394,7 @@ fn read(
}
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
Ok(play::Clientbound::PlayerPosition(p)) => {
let mut at = bot.teleport.lock().unwrap();
let mut at = lock(&bot.teleport);
let previous = at.unwrap_or([0.0; 3]);
// Relative flags: X 1, Y 2, Z 4.
let pos: [f64; 3] = std::array::from_fn(|i| {
@@ -420,7 +425,7 @@ fn read(
},
cb::DISCONNECT | cb::START_CONFIGURATION => {
bot.online.store(false, Ordering::SeqCst);
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
format!(
"disconnected: {}",
String::from_utf8_lossy(&body)
@@ -455,7 +460,7 @@ fn run_bot(
let connection = match join(&o, &name) {
Ok(c) => c,
Err(e) => {
*bot.error.lock().unwrap() = Some(e);
*lock(&bot.error) = Some(e);
return;
}
};
@@ -469,12 +474,12 @@ fn run_bot(
use ids::play::serverbound as sb;
// Wait for the spawn position.
let spawn = loop {
if let Some(at) = *bot.teleport.lock().unwrap() {
if let Some(at) = *lock(&bot.teleport) {
break at;
}
if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) {
if bot.error.lock().unwrap().is_none() {
*bot.error.lock().unwrap() = Some("no spawn position within 60 s".into());
if lock(&bot.error).is_none() {
*lock(&bot.error) = Some("no spawn position within 60 s".into());
}
return;
}
@@ -482,7 +487,7 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body).is_ok();
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
send(raw(sb::PLAYER_LOADED, |_| {}));
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
@@ -491,17 +496,15 @@ fn run_bot(
let mut target = pos;
let mut next = Instant::now();
loop {
if until.lock().unwrap().is_some_and(|u| Instant::now() >= u)
|| !bot.online.load(Ordering::SeqCst)
{
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
break;
}
// A teleport from the server wins over where the bot thought it was.
if let Some(at) = bot.teleport.lock().unwrap().take() {
if let Some(at) = lock(&bot.teleport).take() {
pos = at;
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
if !bot.held.lock().unwrap().contains(&here) {
if !lock(&bot.held).contains(&here) {
bot.waited.fetch_add(1, Ordering::Relaxed);
} else if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
@@ -558,7 +561,7 @@ fn run_bot(
}
// Offline first, so the reader doesn't count the closed connection as a failure.
bot.online.store(false, Ordering::SeqCst);
writer.lock().unwrap().shutdown();
lock(&writer).shutdown();
}
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
@@ -612,13 +615,10 @@ fn main() {
let total = |f: fn(&Bot) -> &AtomicU64| -> u64 {
bots.iter().map(|b| f(b).load(Ordering::Relaxed)).sum()
};
let failed = bots
.iter()
.filter(|b| b.error.lock().unwrap().is_some())
.count();
let failed = bots.iter().filter(|b| lock(&b.error).is_some()).count();
if first.is_none() && online > 0 {
first = Some(Instant::now());
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
*lock(&until) = Some(Instant::now() + Duration::from_secs(o.seconds));
}
println!(
"{}",
@@ -636,10 +636,10 @@ fn main() {
})
);
previous_chunks = chunks;
let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u);
let over = lock(&until).is_some_and(|u| Instant::now() >= u);
let all_failed = failed == o.bots;
if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) {
*until.lock().unwrap() = Some(Instant::now());
*lock(&until) = Some(Instant::now());
break;
}
}
@@ -669,7 +669,7 @@ fn main() {
"forgotten": b.forgotten.load(Ordering::Relaxed),
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
"waited_ticks": b.waited.load(Ordering::Relaxed),
"error": *b.error.lock().unwrap(),
"error": *lock(&b.error),
})).collect::<Vec<_>>(),
});
if o.report.as_deref() == Some("-") {
+3 -1
View File
@@ -16,11 +16,13 @@ horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
horizon-friends = { path = "../horizon-friends" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
@@ -0,0 +1,15 @@
{
"horizon.options.controller": "Controller",
"horizon.options.vibration": "Vibration",
"horizon.options.adaptive_triggers": "Adaptive Triggers",
"horizon.options.lightbar": "Lightbar",
"horizon.options.look_sensitivity": "Look Sensitivity",
"horizon.options.invert_x": "Invert Look X",
"horizon.options.invert_y": "Invert Look Y",
"horizon.options.sneak_button": "Sneak Button",
"horizon.options.move_deadzone": "Move Deadzone",
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
}
@@ -0,0 +1,15 @@
{
"horizon.options.controller": "Геймпад",
"horizon.options.vibration": "Вибрация",
"horizon.options.adaptive_triggers": "Адаптивные триггеры",
"horizon.options.lightbar": "Подсветка",
"horizon.options.look_sensitivity": "Чувствительность обзора",
"horizon.options.invert_x": "Инверсия обзора по X",
"horizon.options.invert_y": "Инверсия обзора по Y",
"horizon.options.sneak_button": "Кнопка приседания",
"horizon.options.move_deadzone": "Мёртвая зона движения",
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
}
@@ -0,0 +1,168 @@
//! What a column costs to load, on real terrain: generates and lights the
//! columns around a point with the vanilla generator, as a world being explored
//! does, then meshes every section of the inner columns on this one thread, so a
//! change to generation or meshing shows as time per column and per section.
//!
//! cargo run --release -p horizon-client --example chunk_load_bench -- \
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 8]
//! [--threads N] [--rounds 3]
//!
//! The default centre is the village of `tools/perf_bench.py`. The generation
//! figure is wall time with N workers (the game's own pool); the mesh figure is
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
//! work on the machine changes it less. The checksum of the meshes shows that an
//! A/B pair of builds made the same ones.
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::HEIGHT,
light::LightTables,
mesher::{MeshTables, Mesher, Region, borrow},
models,
spans::thread_cpu,
streaming::{Streaming, WorkerContext},
vanillagen::VanillaTerrain,
world::World,
};
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
use horizon_world::registry::BlockStateId;
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::PathBuf,
sync::Arc,
thread,
time::{Duration, Instant},
};
type Error = Box<dyn std::error::Error>;
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1)?.parse().ok())
.unwrap_or(default)
}
fn main() -> Result<(), Error> {
let args: Vec<String> = std::env::args().collect();
let root = PathBuf::from(args.get(1).ok_or("usage: chunk_load_bench <resources>")?);
let seed: i64 = flag(&args, "--seed", 1234);
let radius: i32 = flag(&args, "--radius", 8);
let rounds: usize = flag(&args, "--rounds", 3);
let threads: usize = flag(
&args,
"--threads",
thread::available_parallelism().map_or(2, |n| n.get()),
);
let center = args
.iter()
.position(|a| a == "--center")
.map(|i| -> Result<(i32, i32), Error> {
Ok((
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
))
})
.transpose()?
.unwrap_or((-60, 10));
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let visibility = models::load_visibility(&root, assets.registry)?;
let biomes = Arc::new(Biomes::load(&root)?);
let context = Arc::new(WorkerContext {
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
biomes: biomes.clone(),
assets: assets.clone(),
render,
});
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
// Generation and light, on the game's worker pool. Meshing is left out
// here and measured on its own below.
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
streaming.mesh = false;
streaming.center = center;
streaming.radius = radius;
let mut world = World::new(BlockStateId(0), 0);
let started = Instant::now();
let mut quiet = 0;
while quiet < 100 {
let before = world.chunks.len();
let _ = streaming.update(&mut world);
thread::sleep(Duration::from_millis(2));
quiet = if streaming.pending() == 0 && before == world.chunks.len() {
quiet + 1
} else {
0
};
}
let generated = started.elapsed() - Duration::from_millis(2 * 100);
let columns = world.chunks.len();
println!(
"generated and lit {columns} columns in {:.0} ms with {threads} workers ({:.2} ms per column)",
generated.as_secs_f64() * 1e3,
generated.as_secs_f64() * 1e3 / columns.max(1) as f64
);
// Meshing every section of the columns whose neighbours are all loaded.
let inner = radius - 1;
let regions: Vec<(Region, i32)> = (center.0 - inner..=center.0 + inner)
.flat_map(|x| (center.1 - inner..=center.1 + inner).map(move |z| (x, z)))
.filter_map(|(x, z)| {
let region: Region = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
world
.chunks
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
.cloned()
})
});
region
.iter()
.flatten()
.all(Option::is_some)
.then_some(region)
})
.flat_map(|region| (0..HEIGHT / 16).map(move |sy| (region.clone(), sy)))
.collect();
let mut best = Duration::MAX;
let (mut vertices, mut checksum) = (0, 0);
for _ in 0..rounds.max(1) {
let mut mesher = Mesher {
nether_shading: false,
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility: context.tables.visibility.clone(),
};
vertices = 0;
let mut hash = DefaultHasher::new();
let mut spent = Duration::ZERO;
for (region, sy) in &regions {
let started = thread_cpu().ok_or("no thread CPU clock here")?;
let mesh = mesher.mesh(&borrow(region), *sy);
spent += thread_cpu().ok_or("no thread CPU clock here")? - started;
vertices += mesh.solid.len() + mesh.translucent.len();
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
}
checksum = hash.finish();
best = best.min(spent);
}
println!(
"meshed {} sections ({vertices} vertices, checksum {checksum:016x}) in {:.1} ms of CPU ({:.1} us per section)",
regions.len(),
best.as_secs_f64() * 1e3,
best.as_secs_f64() * 1e6 / regions.len().max(1) as f64
);
Ok(())
}
@@ -85,6 +85,7 @@ mod sprint_probe;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
mod text_display_fixture;
mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trading_fixture;
@@ -170,6 +171,7 @@ probes! {
team_rules_fixture,
toggle_keys_probe,
tooltip_display_fixture,
text_display_fixture,
trading_fixture,
translucency_fixture,
weather_fixture,
@@ -83,7 +83,7 @@ pub fn main() -> horizon_client::Result<()> {
let mut extra = String::new();
match kind {
"give" => count = book.add_recipes(&index, &list()).0,
"take" => count = book.remove_recipes(&index, &list()),
"take" => count = book.remove_recipes(&index, &list()).len(),
"seen" => book.remove_highlight(op["id"].as_str().unwrap_or_default()),
"set" => {
let i = match op["type"].as_str().unwrap_or_default() {
@@ -0,0 +1,37 @@
use horizon_client::text_display::TextDisplayData;
use horizon_protocol::{ids, packets::play::Clientbound};
use std::{error::Error, io::Write};
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let mut out = std::fs::File::create(&args[2])?;
for line in std::fs::read_to_string(&args[1])?.lines() {
let input: serde_json::Value = serde_json::from_str(line)?;
let hex = input["wire"].as_str().unwrap();
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
.collect::<Result<_, _>>()?;
let Clientbound::SetEntityData(packet) =
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
else {
unreachable!()
};
let mut display = TextDisplayData::default();
display.apply(&packet.values);
if let Some(angles) = input["angles"].as_array() {
let a: Vec<_> = angles.iter().map(|v| v.as_f64().unwrap() as f32).collect();
writeln!(
out,
"{}",
serde_json::to_string(&display.matrix([a[0], a[1]], [a[2], a[3]]).to_cols_array())?
)?;
continue;
}
let mut state = serde_json::to_value(&display)?;
state["text"] = display.text.plain().into();
state["alignment"] = format!("{:?}", display.align()).to_uppercase().into();
writeln!(out, "{}", state)?;
}
Ok(())
}
+12
View File
@@ -161,6 +161,9 @@ pub struct Audio {
/// How many times each positional sound played (the control socket's `status`),
/// with or without an audio device.
pub heard: std::collections::BTreeMap<String, u32>,
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
/// any), with or without an audio device, as `heard`.
pub stopped: std::collections::BTreeMap<String, u32>,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -595,6 +598,7 @@ impl Audio {
source_override: None,
capture: None,
heard: Default::default(),
stopped: Default::default(),
}
}
#[cfg(not(feature = "audio"))]
@@ -825,6 +829,14 @@ impl Audio {
/// and/or a source.
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
let event = event.map(crate::sound_events::normalize);
let selection = format!(
"{} {}",
source
.and_then(|s| crate::settings::SOUND_SOURCES.get(s))
.map_or("*", |s| s),
event.as_deref().unwrap_or("*")
);
*self.stopped.entry(selection).or_default() += 1;
self.engine
.stop_matching(event.as_deref(), source, &mut self.backend);
}
+243
View File
@@ -0,0 +1,243 @@
//! `BossHealthOverlay` and `LerpingBossEvent`: the boss bars a client is
//! showing, in the order their add packets arrived.
use crate::{
hud_overlays::BossBar,
scoreboard::{comp_text, tag_to_json},
text::Text,
};
use horizon_protocol::packets::play::{BossAction, BossEvent};
/// `LerpingBossEvent.LERP_MILLISECONDS`.
const LERP_MS: u128 = 100;
/// `Util.getMillis`.
pub fn now_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
/// One `LerpingBossEvent`.
#[derive(Clone, Debug)]
struct Event {
id: u128,
name: Text,
/// The progress the lerp started from (`progress`).
progress: f32,
/// `targetPercent`.
target: f32,
/// `setTime`.
set_at: u128,
color: u8,
overlay: u8,
darken: bool,
music: bool,
fog: bool,
}
impl Event {
/// `LerpingBossEvent.getProgress`.
fn displayed(&self, now: u128) -> f32 {
let t = ((now.saturating_sub(self.set_at)) as f32 / LERP_MS as f32).clamp(0.0, 1.0);
self.progress + (self.target - self.progress) * t
}
/// `LerpingBossEvent.setProgress`: the bar starts the next lerp from
/// whatever it is showing now.
fn set_progress(&mut self, progress: f32, now: u128) {
self.progress = self.displayed(now);
self.target = progress;
self.set_at = now;
}
}
/// `BossHealthOverlay`: the events, plus `GameRenderer`'s world darkening.
#[derive(Clone, Debug, Default)]
pub struct BossOverlay {
events: Vec<Event>,
/// `bossOverlayWorldDarkening` and the previous tick's value.
darken: f32,
darken_o: f32,
}
impl BossOverlay {
pub fn reset(&mut self) {
*self = Self::default();
}
/// `ClientboundBossEventPacket.dispatch` into `BossHealthOverlay`'s
/// handler. An id already shown keeps its place when added again.
pub fn apply(&mut self, packet: &BossEvent, now: u128) {
// An ordinal past the enum shows as its last value.
let color = |c: i32| c.clamp(0, 6) as u8;
let overlay = |o: i32| o.clamp(0, 4) as u8;
let id = packet.id;
if let BossAction::Add {
name,
progress,
color: c,
overlay: o,
darken,
music,
fog,
} = &packet.action
{
let event = Event {
id,
name: comp_text(&tag_to_json(name)),
progress: *progress,
target: *progress,
set_at: now,
color: color(*c),
overlay: overlay(*o),
darken: *darken,
music: *music,
fog: *fog,
};
match self.events.iter_mut().find(|e| e.id == id) {
Some(slot) => *slot = event,
None => self.events.push(event),
}
return;
}
if let BossAction::Remove = packet.action {
self.events.retain(|e| e.id != id);
return;
}
let Some(e) = self.events.iter_mut().find(|e| e.id == id) else {
return;
};
match &packet.action {
BossAction::UpdateProgress { progress } => e.set_progress(*progress, now),
BossAction::UpdateName { name } => e.name = comp_text(&tag_to_json(name)),
BossAction::UpdateStyle {
color: c,
overlay: o,
} => {
e.color = color(*c);
e.overlay = overlay(*o);
}
BossAction::UpdateProperties { darken, music, fog } => {
(e.darken, e.music, e.fog) = (*darken, *music, *fog);
}
BossAction::Add { .. } | BossAction::Remove => {}
}
}
/// `GameRenderer.tick` while the level runs.
pub fn tick_darken(&mut self, running: bool) {
if !running {
return;
}
self.darken_o = self.darken;
if self.should_darken() {
self.darken = (self.darken + 0.05).min(1.0);
} else if self.darken > 0.0 {
self.darken = (self.darken - 0.0125).max(0.0);
}
}
/// `GameRenderer.bossOverlayWorldDarkening`.
pub fn darken_amount(&self, partial: f32) -> f32 {
let t = partial.clamp(0.0, 1.0);
self.darken_o + (self.darken - self.darken_o) * t
}
pub fn should_darken(&self) -> bool {
self.events.iter().any(|e| e.darken)
}
pub fn should_play_music(&self) -> bool {
self.events.iter().any(|e| e.music)
}
pub fn should_create_world_fog(&self) -> bool {
self.events.iter().any(|e| e.fog)
}
/// The bars `extractRenderState` walks, with the lerped progress.
pub fn bars(&self, now: u128) -> Vec<BossBar> {
self.events
.iter()
.map(|e| BossBar {
name: e.name.clone(),
progress: e.displayed(now),
color: e.color as usize,
overlay: e.overlay as usize,
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::Tag;
fn add(id: u128, name: &str, progress: f32, darken: bool, fog: bool) -> BossEvent {
BossEvent {
id,
action: BossAction::Add {
name: Tag::String(name.into()),
progress,
color: 5,
overlay: 0,
darken,
music: false,
fog,
},
}
}
fn progress(id: u128, progress: f32) -> BossEvent {
BossEvent {
id,
action: BossAction::UpdateProgress { progress },
}
}
#[test]
fn progress_lerps_over_a_tenth_of_a_second() {
let mut overlay = BossOverlay::default();
overlay.apply(&add(1, "W", 0.0, true, false), 0);
overlay.apply(&progress(1, 1.0), 0);
assert!((overlay.bars(50)[0].progress - 0.5).abs() < 1.0e-5);
assert!((overlay.bars(100)[0].progress - 1.0).abs() < 1.0e-5);
// A new target starts from the value on screen.
overlay.apply(&progress(1, 0.0), 50);
assert!((overlay.bars(150)[0].progress - 0.0).abs() < 1.0e-5);
}
#[test]
fn darken_ramps_and_fades() {
let mut overlay = BossOverlay::default();
overlay.apply(&add(1, "W", 1.0, true, false), 0);
overlay.tick_darken(true);
assert!((overlay.darken_amount(1.0) - 0.05).abs() < 1.0e-5);
overlay.apply(
&BossEvent {
id: 1,
action: BossAction::Remove,
},
0,
);
overlay.tick_darken(true);
assert!((overlay.darken - 0.0375).abs() < 1.0e-5);
assert!(!overlay.should_play_music());
}
#[test]
fn bars_keep_arrival_order() {
let mut overlay = BossOverlay::default();
overlay.apply(&add(2, "b", 1.0, false, true), 0);
overlay.apply(&add(1, "a", 1.0, false, false), 0);
overlay.apply(&add(2, "c", 0.5, false, true), 0);
// An update for a bar this client never got is ignored.
overlay.apply(&progress(9, 0.5), 0);
let names: Vec<_> = overlay
.bars(0)
.into_iter()
.map(|b| b.name.plain())
.collect();
assert_eq!(names, ["c", "a"]);
assert!(overlay.should_create_world_fog());
overlay.apply(
&BossEvent {
id: 2,
action: BossAction::Remove,
},
0,
);
assert!(!overlay.should_create_world_fog());
}
}
+71 -37
View File
@@ -14,40 +14,64 @@ pub const STYLES: [&str; 5] = [
"notched_12",
"notched_20",
];
/// `BossBarColor.getFormatting`.
/// `BossBarColor.getFormatting`. Purple is dark blue in 26.3.
pub fn color_rgb(color: &str) -> u32 {
let name = match color {
"pink" => "red",
"red" => "dark_red",
"purple" => "dark_purple",
"purple" => "dark_blue",
other => other,
};
text::named_color(name).unwrap_or(text::WHITE)
}
/// A version-4 shaped id from a resource id. Saved boss events do not keep
/// their UUID (`CustomBossEvents` mints a new one on load).
pub fn stable_uuid(id: &str) -> u128 {
let mut h = 0xcbf29ce484222325u64;
for b in id.as_bytes() {
h ^= u64::from(*b);
h = h.wrapping_mul(0x100000001b3);
}
let most = (h as i64) & -61441 | 16384;
let least = (h.rotate_left(17) as i64) & 4_611_686_018_427_387_903 | i64::MIN;
((most as u64 as u128) << 64) | (least as u64 as u128)
}
#[derive(Clone, Debug, PartialEq)]
pub struct BossBar {
pub id: String,
/// The `ServerBossEvent` UUID. Not saved.
pub uuid: u128,
pub name: Comp,
pub color: String,
pub style: String,
pub value: i32,
pub max: i32,
/// `ServerBossEvent.visible`. A new bar is visible; nobody sees it until
/// a player is added.
pub visible: bool,
/// Whether the (single) local player is in the bar's player set.
pub player: bool,
pub darken: bool,
pub music: bool,
pub fog: bool,
/// `CustomBossEvent.players`, the UUIDs told about the bar.
pub players: Vec<[i32; 4]>,
}
impl BossBar {
pub fn new(id: &str, name: Comp) -> Self {
Self {
id: id.to_owned(),
uuid: stable_uuid(id),
name,
color: "white".into(),
style: "progress".into(),
value: 0,
max: 100,
visible: true,
player: false,
darken: false,
music: false,
fog: false,
players: Vec::new(),
}
}
/// `CustomBossEvent.getDisplayName`.
@@ -91,31 +115,34 @@ impl BossBars {
pub fn ids(&self) -> Vec<String> {
self.bars.iter().map(|b| b.id.clone()).collect()
}
/// The saved compound; the player's UUID stands for "the local player".
pub fn pack(&self, player: Option<[i32; 4]>) -> Tag {
/// The saved compound (`CustomBossEvent.Packed`).
pub fn pack(&self) -> Tag {
let mut c = Compound::new();
for b in &self.bars {
let mut e = vec![
("Name", json_to_tag(&b.name)),
("Visible", Tag::Byte(i8::from(b.visible))),
("Value", Tag::Int(b.value)),
("Max", Tag::Int(b.max)),
("Color", Tag::String(b.color.as_str().into())),
("Overlay", Tag::String(b.style.as_str().into())),
("DarkenScreen", Tag::Byte(0)),
("PlayBossMusic", Tag::Byte(0)),
("CreateWorldFog", Tag::Byte(0)),
];
let players = match (b.player, player) {
(true, Some(u)) => vec![Tag::IntArray(u.to_vec())],
_ => Vec::new(),
};
e.push(("Players", Tag::List(players)));
c.insert(b.id.as_str().into(), Tag::compound(e));
let players = b
.players
.iter()
.map(|u| Tag::IntArray(u.to_vec()))
.collect();
c.insert(
b.id.as_str().into(),
Tag::compound([
("Name", json_to_tag(&b.name)),
("Visible", Tag::Byte(i8::from(b.visible))),
("Value", Tag::Int(b.value)),
("Max", Tag::Int(b.max)),
("Color", Tag::String(b.color.as_str().into())),
("Overlay", Tag::String(b.style.as_str().into())),
("DarkenScreen", Tag::Byte(i8::from(b.darken))),
("PlayBossMusic", Tag::Byte(i8::from(b.music))),
("CreateWorldFog", Tag::Byte(i8::from(b.fog))),
("Players", Tag::List(players)),
]),
);
}
Tag::Compound(c)
}
pub fn unpack(data: &Tag, player: Option<[i32; 4]>) -> Self {
pub fn unpack(data: &Tag) -> Self {
let mut bars = Vec::new();
if let Tag::Compound(c) = data {
for (id, e) in c {
@@ -130,21 +157,30 @@ impl BossBars {
Some(Tag::Byte(v)) => i32::from(*v),
_ => d,
};
let in_players = match (get("Players"), player) {
(Some(Tag::List(l)), Some(u)) => l
let players = match get("Players") {
Some(Tag::List(l)) => l
.iter()
.any(|t| matches!(t, Tag::IntArray(a) if a.as_slice() == u)),
_ => false,
.filter_map(|t| match t {
Tag::IntArray(a) if a.len() == 4 => Some([a[0], a[1], a[2], a[3]]),
_ => None,
})
.collect(),
_ => Vec::new(),
};
let id = id.to_string_lossy();
bars.push(BossBar {
id: id.to_string_lossy(),
uuid: stable_uuid(&id),
id,
name: get("Name").map_or_else(|| literal(""), tag_to_json),
color: text("Color", "white"),
style: text("Overlay", "progress"),
value: num("Value", 0),
max: num("Max", 100),
visible: num("Visible", 0) != 0,
player: in_players,
darken: num("DarkenScreen", 0) != 0,
music: num("PlayBossMusic", 0) != 0,
fog: num("CreateWorldFog", 0) != 0,
players,
});
}
}
@@ -167,17 +203,15 @@ mod tests {
bar.value = 25;
bar.max = 50;
bar.visible = true;
bar.player = true;
let uuid = Some([1, 2, 3, 4]);
let back = BossBars::unpack(&b.pack(uuid), uuid);
bar.darken = true;
bar.players = vec![[1, 2, 3, 4]];
let back = BossBars::unpack(&b.pack());
assert_eq!(back, b);
// Another player's UUID does not include the local player.
assert!(!BossBars::unpack(&b.pack(uuid), Some([9, 9, 9, 9])).bars[0].player);
let bar = b.get("test:x").unwrap();
assert_eq!(bar.progress(), 0.5);
assert_eq!(bar.display_name().plain(), "chat.square_brackets");
assert_eq!(color_rgb("pink"), text::RED);
assert_eq!(color_rgb("purple"), 0xAA00AA);
assert_eq!(color_rgb("purple"), text::named_color("dark_blue").unwrap());
b.remove("test:x");
assert!(b.bars.is_empty());
}
+4 -2
View File
@@ -55,7 +55,7 @@ impl CommandStorage {
.iter()
.map(|(k, v)| (k.as_str().into(), v.clone()))
.collect();
let _ = save::write_dat(
if let Err(e) = save::write_dat(
&file(root, namespace),
Tag::compound([
(
@@ -64,7 +64,9 @@ impl CommandStorage {
),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
]),
);
) {
log::warn!("command storage {namespace} was not written: {e}");
}
}
}
/// `get`: an empty compound when unset.
@@ -0,0 +1,623 @@
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
//! or with `HORIZON_NO_DISCORD=1`.
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::sync::{Condvar, Mutex, OnceLock};
use std::thread::Thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use crate::command::args::GameMode;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
pub const DISCORD_APP_ID: &str = "1563857609738690560";
/// Whether the application has the game-mode images (`survival`, `creative`, `adventure`,
/// `spectator`) beside the logo; it doesn't yet.
const MODE_IMAGES: bool = false;
/// Points the game at a test's fake Discord (a folder holding `discord-ipc-0`).
const IPC_DIR_ENV: &str = "HORIZON_DISCORD_IPC_DIR";
/// Discord drops activity updates beyond 5 in 20 seconds.
const BURST: usize = 5;
const WINDOW: Duration = Duration::from_secs(20);
/// Between two tries to reach Discord while it isn't running.
const RETRY: Duration = Duration::from_secs(15);
/// The activity is sent again this often, so a Discord restarted meanwhile gets it back.
const REFRESH: Duration = Duration::from_secs(60);
const HANDSHAKE: u32 = 0;
const FRAME: u32 = 1;
const CLOSE: u32 = 2;
const PING: u32 = 3;
const PONG: u32 = 4;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dimension {
Overworld,
Nether,
End,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Play {
Singleplayer,
/// A server or someone else's LAN world. Its address and name are never shown.
Multiplayer,
/// This world is open to LAN or friends; `players` counts the host.
Hosting {
players: usize,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
/// The player turned Rich Presence off: the activity is cleared.
Off,
Menus,
World {
play: Play,
dimension: Dimension,
mode: GameMode,
},
}
/// What the game wants shown, and since when it has been in a world.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Wanted {
status: Status,
/// Unix seconds: when the player entered the world (dimension changes keep it).
since: Option<u64>,
}
impl Wanted {
/// A new status at `now`; false if it is the one already wanted.
fn update(&mut self, status: Status, now: u64) -> bool {
if self.status == status {
return false;
}
let in_world = |s: Status| matches!(s, Status::World { .. });
if !in_world(status) {
self.since = None;
} else if !in_world(self.status) {
self.since = Some(now);
}
self.status = status;
true
}
}
struct Shared {
wanted: Wanted,
stop: bool,
stopped: bool,
}
static SHARED: Mutex<Shared> = Mutex::new(Shared {
wanted: Wanted {
status: Status::Menus,
since: None,
},
stop: false,
stopped: false,
});
static STOPPED: Condvar = Condvar::new();
static THREAD: OnceLock<Thread> = OnceLock::new();
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
pub fn start(enabled: bool, automated: bool) {
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
if THREAD.get().is_some()
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
|| (automated && !fake_discord)
{
return;
}
if !enabled {
lock().wanted.status = Status::Off;
}
match std::thread::Builder::new()
.name("Discord presence".into())
.spawn(run)
{
Ok(handle) => {
let _ = THREAD.set(handle.thread().clone());
}
Err(e) => log::warn!("Horizon: no Discord presence: {e}"),
}
}
/// What the player does now; only a change wakes the thread. Free without [`start`].
pub fn set(status: Status) {
let Some(thread) = THREAD.get() else {
return;
};
if lock().wanted.update(status, unix_now()) {
thread.unpark();
}
}
/// On quit: clears the activity, waiting at most a moment for Discord.
pub fn stop() {
let Some(thread) = THREAD.get() else {
return;
};
let mut shared = lock();
shared.stop = true;
thread.unpark();
let _ = STOPPED.wait_timeout_while(shared, Duration::from_millis(300), |s| !s.stopped);
}
fn lock() -> std::sync::MutexGuard<'static, Shared> {
SHARED.lock().unwrap_or_else(|e| e.into_inner())
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn run() {
let mut client: Option<Client> = None;
let mut sent: Option<(Wanted, Instant)> = None;
let mut last_try: Option<Instant> = None;
let mut limit = RateLimit::default();
loop {
let (wanted, stop) = {
let s = lock();
(s.wanted, s.stop)
};
if stop || wanted.status == Status::Off {
if let Some(mut c) = client.take() {
let _ = c.set_activity(None);
c.close();
}
sent = None;
if stop {
lock().stopped = true;
STOPPED.notify_all();
return;
}
std::thread::park();
continue;
}
let now = Instant::now();
if client.is_none() {
if let Some(wait) = last_try.and_then(|t| RETRY.checked_sub(now - t)) {
std::thread::park_timeout(wait);
continue;
}
last_try = Some(now);
client = Client::connect();
if client.is_none() {
continue;
}
}
let due = sent.is_none_or(|(w, at)| w != wanted || now - at >= REFRESH);
if !due {
let (_, at) = sent.expect("sent");
std::thread::park_timeout(REFRESH - (now - at));
continue;
}
let wait = limit.wait(now);
if !wait.is_zero() {
std::thread::park_timeout(wait);
continue;
}
limit.record(now);
let c = client.as_mut().expect("connected");
match c.set_activity(activity(wanted.status, wanted.since)) {
Ok(()) => sent = Some((wanted, now)),
Err(e) => {
log::info!("Horizon: Discord presence disconnected ({e})");
client = None;
sent = None;
}
}
}
}
/// The activity Discord shows for `status`; `None` clears it.
fn activity(status: Status, since: Option<u64>) -> Option<Value> {
let mut assets = json!({"large_image": "horizon", "large_text": "Horizon"});
let (details, state, party) = match status {
Status::Off => return None,
Status::Menus => ("In the menus", None, None),
Status::World {
play,
dimension,
mode,
} => {
if MODE_IMAGES {
let (key, text) = match mode {
GameMode::Survival => ("survival", "Survival"),
GameMode::Creative => ("creative", "Creative"),
GameMode::Adventure => ("adventure", "Adventure"),
GameMode::Spectator => ("spectator", "Spectator"),
};
assets["small_image"] = key.into();
assets["small_text"] = text.into();
}
let place = match dimension {
Dimension::Overworld => "In the Overworld",
Dimension::Nether => "In the Nether",
Dimension::End => "In the End",
};
let (details, party) = match play {
Play::Singleplayer => ("Singleplayer", None),
Play::Multiplayer => ("Multiplayer", None),
Play::Hosting { players } => ("Hosting a world", Some(players)),
};
(details, Some(place), party)
}
};
let mut a = json!({"details": details, "assets": assets});
if let Some(state) = state {
a["state"] = state.into();
}
if let Some(start) = since {
a["timestamps"] = json!({"start": start});
}
if let Some(players) = party {
let max = crate::lan::MAX_PLAYERS;
a["party"] = json!({
"id": format!("horizon-{}", std::process::id()),
"size": [players.clamp(1, max), max],
});
}
Some(a)
}
/// At most [`BURST`] updates in any [`WINDOW`].
#[derive(Default)]
struct RateLimit {
sent: VecDeque<Instant>,
}
impl RateLimit {
/// How long until the next update may go.
fn wait(&self, now: Instant) -> Duration {
if self.sent.len() < BURST {
return Duration::ZERO;
}
(self.sent[0] + WINDOW).saturating_duration_since(now)
}
fn record(&mut self, now: Instant) {
if self.sent.len() == BURST {
self.sent.pop_front();
}
self.sent.push_back(now);
}
}
fn encode(op: u32, payload: &Value) -> Vec<u8> {
let body = payload.to_string();
let mut frame = Vec::with_capacity(8 + body.len());
frame.extend_from_slice(&op.to_le_bytes());
frame.extend_from_slice(&(body.len() as u32).to_le_bytes());
frame.extend_from_slice(body.as_bytes());
frame
}
fn read_frame(r: &mut impl Read) -> io::Result<(u32, Value)> {
let mut header = [0; 8];
r.read_exact(&mut header)?;
let op = u32::from_le_bytes(header[..4].try_into().expect("4 bytes"));
let len = u32::from_le_bytes(header[4..].try_into().expect("4 bytes")) as usize;
if len > 1 << 20 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"frame too large",
));
}
let mut body = vec![0; len];
r.read_exact(&mut body)?;
Ok((op, serde_json::from_slice(&body)?))
}
trait Pipe: Read + Write + Send {}
impl<T: Read + Write + Send> Pipe for T {}
struct Client {
pipe: Box<dyn Pipe>,
nonce: u64,
}
impl Client {
/// The first Discord socket that answers the handshake with READY.
fn connect() -> Option<Self> {
sockets().into_iter().find_map(|path| {
let mut client = Client {
pipe: open(&path).ok()?,
nonce: 0,
};
match client.handshake() {
Ok(()) => {
log::info!("Horizon: connected to Discord for Rich Presence");
Some(client)
}
Err(e) => {
log::debug!("Horizon: Discord at {} refused: {e}", path.display());
None
}
}
})
}
fn handshake(&mut self) -> io::Result<()> {
let hello = json!({"v": 1, "client_id": DISCORD_APP_ID});
self.pipe.write_all(&encode(HANDSHAKE, &hello))?;
self.reply(|frame| frame["evt"] == "READY")
}
fn set_activity(&mut self, activity: Option<Value>) -> io::Result<()> {
self.nonce += 1;
let nonce = self.nonce.to_string();
let command = json!({
"cmd": "SET_ACTIVITY",
"args": {"pid": std::process::id(), "activity": activity},
"nonce": nonce,
});
self.pipe.write_all(&encode(FRAME, &command))?;
self.reply(|frame| frame["nonce"] == nonce.as_str())
}
/// Reads until the frame `ours` picks, answering pings; an ERROR is logged, not fatal.
fn reply(&mut self, ours: impl Fn(&Value) -> bool) -> io::Result<()> {
loop {
match read_frame(&mut self.pipe)? {
(FRAME, frame) if ours(&frame) => {
if frame["evt"] == "ERROR" {
log::info!("Horizon: Discord presence: {}", frame["data"]["message"]);
}
return Ok(());
}
(PING, frame) => self.pipe.write_all(&encode(PONG, &frame))?,
(CLOSE, frame) => {
return Err(io::Error::new(
io::ErrorKind::ConnectionAborted,
frame["message"].as_str().unwrap_or("closed").to_owned(),
));
}
_ => {}
}
}
}
fn close(mut self) {
let _ = self.pipe.write_all(&encode(CLOSE, &json!({})));
}
}
/// Where Discord listens: `discord-ipc-0..9` in the temporary folders (and Flatpak's and
/// Snap's subfolders) on Linux and macOS, named pipes on Windows.
fn sockets() -> Vec<PathBuf> {
sockets_with(|key| std::env::var_os(key), cfg!(windows))
}
fn sockets_with(var: impl Fn(&str) -> Option<std::ffi::OsString>, windows: bool) -> Vec<PathBuf> {
let names = (0..10).map(|n| format!("discord-ipc-{n}"));
if let Some(dir) = var(IPC_DIR_ENV) {
return names.map(|n| PathBuf::from(&dir).join(n)).collect();
}
if windows {
return names.map(|n| PathBuf::from(r"\\?\pipe\").join(n)).collect();
}
let mut dirs: Vec<PathBuf> = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"]
.into_iter()
.filter_map(&var)
.map(PathBuf::from)
.collect();
dirs.push("/tmp".into());
dirs.dedup();
let mut paths = Vec::new();
for dir in dirs {
for sub in ["", "app/com.discordapp.Discord", "snap.discord"] {
paths.extend(names.clone().map(|n| dir.join(sub).join(n)));
}
}
paths
}
#[cfg(unix)]
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
let stream = std::os::unix::net::UnixStream::connect(path)?;
// A Discord that stops answering costs this thread a few seconds, never the game.
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
Ok(Box::new(stream))
}
#[cfg(windows)]
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
let pipe = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)?;
Ok(Box::new(pipe))
}
#[cfg(test)]
mod tests {
use super::*;
fn world(play: Play, dimension: Dimension) -> Status {
Status::World {
play,
dimension,
mode: GameMode::Survival,
}
}
/// The folders a real Discord listens in: `$TMPDIR` on macOS, `$XDG_RUNTIME_DIR` on Linux (and its
/// Flatpak and Snap subfolders), named pipes on Windows.
#[test]
fn sockets_cover_where_discord_listens() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |key: &str| {
pairs
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| std::ffi::OsString::from(v))
}
};
let macos = sockets_with(env(&[("TMPDIR", "/var/folders/xy/abc/T/")]), false);
assert_eq!(
macos[0],
PathBuf::from("/var/folders/xy/abc/T/discord-ipc-0")
);
assert!(macos.contains(&PathBuf::from("/var/folders/xy/abc/T/discord-ipc-9")));
assert!(macos.contains(&PathBuf::from("/tmp/discord-ipc-0")));
let linux = sockets_with(
env(&[("XDG_RUNTIME_DIR", "/run/user/1000"), ("TMPDIR", "/tmp")]),
false,
);
assert_eq!(linux[0], PathBuf::from("/run/user/1000/discord-ipc-0"));
for path in [
"/run/user/1000/app/com.discordapp.Discord/discord-ipc-0",
"/run/user/1000/snap.discord/discord-ipc-0",
"/tmp/discord-ipc-0",
] {
assert!(linux.contains(&PathBuf::from(path)), "{path}");
}
assert_eq!(linux.len(), 2 * 3 * 10, "/tmp is listed once");
let bare = sockets_with(env(&[]), false);
assert_eq!(bare[0], PathBuf::from("/tmp/discord-ipc-0"));
let windows = sockets_with(env(&[("TEMP", r"C:\Temp")]), true);
assert_eq!(windows.len(), 10);
// `Path::join` only uses `\` as the separator on Windows itself.
#[cfg(windows)]
assert_eq!(windows[0], PathBuf::from(r"\\?\pipe\discord-ipc-0"));
let fake = sockets_with(
env(&[("HORIZON_DISCORD_IPC_DIR", "/tmp/hz"), ("TMPDIR", "/x")]),
false,
);
assert_eq!(fake.len(), 10);
assert_eq!(fake[0], PathBuf::from("/tmp/hz/discord-ipc-0"));
}
#[test]
fn frames_round_trip() {
let frame = encode(FRAME, &json!({"cmd": "SET_ACTIVITY"}));
assert_eq!(&frame[..4], &[1, 0, 0, 0]);
assert_eq!(frame[4..8], (frame.len() as u32 - 8).to_le_bytes());
let (op, value) = read_frame(&mut frame.as_slice()).unwrap();
assert_eq!((op, value), (FRAME, json!({"cmd": "SET_ACTIVITY"})));
assert!(read_frame(&mut &frame[..6]).is_err());
let huge = [1, 0, 0, 0, 0, 0, 0, 2];
assert!(read_frame(&mut huge.as_slice()).is_err());
}
#[test]
fn activities_say_what_the_player_does_and_nothing_private() {
assert_eq!(activity(Status::Off, None), None);
let menus = activity(Status::Menus, None).unwrap();
assert_eq!(menus["details"], "In the menus");
assert!(menus.get("state").is_none() && menus.get("timestamps").is_none());
assert_eq!(menus["assets"]["large_image"], "horizon");
let single = activity(world(Play::Singleplayer, Dimension::Nether), Some(1000)).unwrap();
assert_eq!(single["details"], "Singleplayer");
assert_eq!(single["state"], "In the Nether");
assert_eq!(single["timestamps"]["start"], 1000);
assert!(single.get("party").is_none());
assert!(single["assets"].get("small_image").is_none());
let multi = activity(world(Play::Multiplayer, Dimension::End), Some(5)).unwrap();
assert_eq!(multi["details"], "Multiplayer");
assert_eq!(multi["state"], "In the End");
let host = world(Play::Hosting { players: 3 }, Dimension::Overworld);
let host = activity(host, Some(5)).unwrap();
assert_eq!(host["details"], "Hosting a world");
assert_eq!(host["state"], "In the Overworld");
assert_eq!(host["party"]["size"], json!([3, 8]));
}
#[test]
fn time_counts_from_entering_the_world() {
let mut w = Wanted {
status: Status::Menus,
since: None,
};
assert!(!w.update(Status::Menus, 10));
assert!(w.update(world(Play::Singleplayer, Dimension::Overworld), 20));
assert_eq!(w.since, Some(20));
// Another dimension, opening to LAN: the same visit.
assert!(w.update(world(Play::Singleplayer, Dimension::Nether), 30));
assert!(w.update(world(Play::Hosting { players: 1 }, Dimension::Nether), 40));
assert_eq!(w.since, Some(20));
assert!(w.update(Status::Menus, 50));
assert_eq!(w.since, None);
assert!(w.update(world(Play::Multiplayer, Dimension::Overworld), 60));
assert_eq!(w.since, Some(60));
assert!(w.update(Status::Off, 70));
assert_eq!(w.since, None);
}
#[test]
fn at_most_five_updates_in_twenty_seconds() {
let t = Instant::now();
let mut limit = RateLimit::default();
for i in 0..5 {
let now = t + Duration::from_secs(i);
assert_eq!(limit.wait(now), Duration::ZERO);
limit.record(now);
}
assert_eq!(
limit.wait(t + Duration::from_secs(5)),
Duration::from_secs(15)
);
assert_eq!(limit.wait(t + WINDOW), Duration::ZERO);
limit.record(t + WINDOW);
assert_eq!(limit.wait(t + WINDOW), Duration::from_secs(1));
}
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
#[cfg(unix)]
#[test]
fn talks_to_discord_over_its_socket() {
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("discord-ipc-0");
let _ = std::fs::remove_file(&path);
let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
let server = std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let (op, hello) = read_frame(&mut s).unwrap();
assert_eq!((op, &hello["v"]), (HANDSHAKE, &json!(1)));
let ready = json!({"cmd": "DISPATCH", "evt": "READY", "data": {"v": 1}});
s.write_all(&encode(FRAME, &ready)).unwrap();
let mut got = Vec::new();
for _ in 0..2 {
let (op, cmd) = read_frame(&mut s).unwrap();
assert_eq!(op, FRAME);
s.write_all(&encode(PING, &json!({"n": 1}))).unwrap();
assert_eq!(read_frame(&mut s).unwrap(), (PONG, json!({"n": 1})));
let reply = json!({"cmd": "SET_ACTIVITY", "nonce": cmd["nonce"], "data": null});
s.write_all(&encode(FRAME, &reply)).unwrap();
got.push(cmd);
}
got
});
let mut client = Client {
pipe: open(&path).unwrap(),
nonce: 0,
};
client.handshake().unwrap();
let shown = activity(world(Play::Singleplayer, Dimension::End), Some(7));
client.set_activity(shown.clone()).unwrap();
client.set_activity(None).unwrap();
let got = server.join().unwrap();
assert_eq!(got[0]["cmd"], "SET_ACTIVITY");
assert_eq!(got[0]["args"]["pid"], std::process::id());
assert_eq!(got[0]["args"]["activity"], shown.unwrap());
assert_eq!(got[1]["args"]["activity"], Value::Null);
assert_ne!(got[0]["nonce"], got[1]["nonce"]);
let _ = std::fs::remove_dir_all(dir);
}
}
+104
View File
@@ -1155,6 +1155,72 @@ pub fn arrow_from_nbt(
Some((p, tag.get("inBlockState").cloned()))
}
/// `LargeFireball` and `SmallFireball`: `AbstractHurtingProjectile`'s
/// `acceleration_power`, `Projectile`'s owner and flags, `ExplosionPower`.
pub fn fireball_to_nbt(
p: &Projectile,
uuid: [i32; 4],
owner: Option<[i32; 4]>,
raw: Option<&Tag>,
) -> Option<Tag> {
let power = match p.kind {
ProjectileKind::LargeFireball { power } => Some(power),
ProjectileKind::SmallFireball => None,
_ => return None,
};
let base = Base {
id: p.kind.id(),
uuid,
position: p.position,
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put(
&mut tag,
"acceleration_power",
Tag::Double(p.acceleration.to_bits()),
);
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
// (a fireball saved before its first tick keeps what it was loaded with).
if let Tag::Compound(map) = &mut tag {
map.remove(&"LeftOwner".into());
}
if p.left_owner {
put(&mut tag, "LeftOwner", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
if let Some(power) = power {
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
}
if let Some(owner) = owner {
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
}
Some(tag)
}
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = match kind(tag)?.as_str() {
"minecraft:fireball" => ProjectileKind::LargeFireball {
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
},
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
_ => return None,
};
let owner = uuid_from_tag(tag.get("Owner"));
let from_player = player.is_some() && owner == player;
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
Some(p)
}
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
#[allow(clippy::too_many_arguments)]
pub fn falling_block_to_nbt(
@@ -2139,3 +2205,41 @@ mod sheep_nbt_tests {
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
}
#[cfg(test)]
mod fireball_nbt_tests {
use super::*;
#[test]
fn a_fireball_round_trips_its_flight() {
let raw = Tag::compound([
("id", Tag::String("minecraft:fireball".into())),
("Pos", doubles(&[1.0, 70.0, -2.5])),
("Motion", doubles(&[0.0, -0.5, 1.0])),
("ExplosionPower", Tag::Byte(4)),
("acceleration_power", Tag::Double(0.0f64.to_bits())),
]);
let p = fireball_from_nbt(&raw, None).unwrap();
assert_eq!(p.kind, ProjectileKind::LargeFireball { power: 4 });
assert_eq!((p.velocity.z, p.acceleration), (1.0, 0.0));
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, None).unwrap();
assert_eq!(number(back.get("ExplosionPower")), Some(4));
assert_eq!(float(back.get("acceleration_power")), Some(0.0));
// Projectile.addAdditionalSaveData: no LeftOwner unless set, HasBeenShot always.
assert!(back.get("LeftOwner").is_none());
assert_eq!(number(back.get("HasBeenShot")), Some(0));
assert_eq!(
read_doubles::<3>(back.get("Motion")),
Some([0.0, -0.5, 1.0])
);
// A default fireball keeps vanilla's power and acceleration.
let plain = Tag::compound([
("id", Tag::String("minecraft:fireball".into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
let p = fireball_from_nbt(&plain, None).unwrap();
assert_eq!(
(p.kind, p.acceleration),
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
);
}
}
+19 -3
View File
@@ -1,7 +1,8 @@
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`.
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
@@ -112,6 +113,8 @@ pub struct Extras {
/// Agents may control the game the player starts: it opens its control socket for
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
pub agent_control: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -142,6 +145,7 @@ impl Default for Extras {
sort_key: "key.keyboard.unknown".into(),
no_ai: false,
agent_control: false,
discord_presence: true,
}
}
}
@@ -207,6 +211,7 @@ impl Extras {
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
_ => {}
}
}
@@ -214,7 +219,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -246,6 +251,7 @@ impl Extras {
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.discord_presence),
)
}
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
@@ -466,6 +472,8 @@ pub enum Item {
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -546,6 +554,7 @@ impl Item {
Item::KeystrokesCps => "Clicks per second",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -559,7 +568,7 @@ impl Item {
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl => {
| AutoReconnect | NoAi | AgentControl | DiscordPresence => {
return None;
}
})
@@ -606,6 +615,9 @@ impl Item {
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
@@ -641,6 +653,7 @@ impl Item {
Item::Sort => e.sort,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
_ => return None,
})
}
@@ -698,6 +711,7 @@ impl Item {
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::ZoomKey | Item::SortKey => {}
}
}
@@ -721,6 +735,7 @@ mod tests {
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -746,6 +761,7 @@ mod tests {
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.discord_presence = false;
assert_eq!(Extras::parse(&e.serialize()), e);
}
+29
View File
@@ -198,6 +198,8 @@ pub struct FogInputs<'a> {
pub blindness: Option<(i32, bool)>,
/// The darkness effect's blend factor, while the effect is on.
pub darkness: Option<f32>,
/// `GameRenderer.bossOverlayWorldDarkening`: a wither's bar dims the fog.
pub darken_world: f32,
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
/// `createWorldFog` is shown (the Ender Dragon's).
pub boss_world_fog: bool,
@@ -263,6 +265,13 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
let brightness = (1.0 - darkness) * (1.0 - darkness);
color = color.map(|c| c * brightness);
}
// FogRenderer.computeFogColor: a boss bar's world darkening.
if input.darken_world > 0.0 {
let t = input.darken_world;
color[0] += (color[0] * 0.7 - color[0]) * t;
color[1] += (color[1] * 0.6 - color[1]) * t;
color[2] += (color[2] * 0.6 - color[2]) * t;
}
let brighten = if eye_in_water { water_vision } else { 0.0 };
if color.iter().all(|&c| c != 0.0) {
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
@@ -394,6 +403,7 @@ mod tests {
skybox: true,
blindness: None,
darkness: None,
darken_world: 0.0,
boss_world_fog: false,
}
}
@@ -422,6 +432,25 @@ mod tests {
assert_eq!(water.environmental_start, -8.0);
}
#[test]
fn boss_darkening_dims_fog_color() {
// FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel.
let sky = Sky {
fog_color: [1.0, 1.0, 1.0],
sky_color: [1.0, 1.0, 1.0],
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&sky);
input.skybox = false;
input.darken_world = 1.0;
let fog = setup_fog(&tables, &FogState::default(), &input);
assert!((fog.color[0] - 0.7).abs() < 1.0e-5);
assert!((fog.color[1] - 0.6).abs() < 1.0e-5);
assert!((fog.color[2] - 0.6).abs() < 1.0e-5);
}
#[test]
fn void_darkness_starts_32_above_the_floor() {
let sky = Sky {
fog_end: 1024.0,
+102 -46
View File
@@ -36,6 +36,7 @@ mod debug_keys;
mod decorated_pot;
mod dedicated;
mod deflect;
mod discord;
mod dispense_fixture;
mod dispenser;
mod dragon;
@@ -44,6 +45,7 @@ mod enchant_effects;
mod end;
mod entity_culling;
mod events;
mod execution_role;
mod explosion;
mod extras;
mod falling_damage;
@@ -140,6 +142,7 @@ mod sculk;
mod sculk_fixture;
mod server_packs;
mod server_travel;
mod shader_packs;
mod shield;
mod sign_edit;
mod sniffers;
@@ -154,6 +157,7 @@ mod storage;
mod structures;
mod subtitle_hud;
mod sulfur_cubes;
mod text_displays;
mod titles;
mod tnt;
mod totem;
@@ -290,8 +294,8 @@ struct Options {
/// as vanilla's does: the default. `--in-frame` (or `--no-server-thread`,
/// or `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead.
server_thread: bool,
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
integrated: bool,
/// What this Game executes; independent of its current server connection.
execution_role: execution_role::ExecutionRole,
}
impl Options {
/// The name to join servers with: the one given, else the offline name kept
@@ -348,7 +352,7 @@ impl Options {
// itself, without the event loop: it stays in the frame.
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
integrated: false,
execution_role: execution_role::ExecutionRole::Client,
};
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
let mut args = std::env::args().skip(1);
@@ -362,6 +366,7 @@ impl Options {
return Ok(None);
}
"--dedicated" => {
o.execution_role = execution_role::ExecutionRole::DedicatedServer;
o.dedicated = Some(args.by_ref().collect());
break;
}
@@ -1422,6 +1427,8 @@ struct Game<'a> {
dragon_model: Option<horizon_client::mobs::Part>,
/// Map textures waiting for the next HUD batch to upload them.
map_patches: Vec<horizon_client::font::Patch>,
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
text_display_vertices: usize,
/// Player skin downloads, and which skin each atlas skin slot holds
/// (least recently shown first).
skin_downloads: RefCell<horizon_client::skins::Downloader>,
@@ -1566,6 +1573,8 @@ struct Game<'a> {
score: scoreboard::ScoreState,
fns: functions::FnState,
bosses: bossbars::BossState,
/// `Hud.bossOverlay`: the bars this window is showing.
boss_overlay: horizon_client::boss_overlay::BossOverlay,
exec: commands::execute::ExecState,
rand_sequences: horizon_client::random_sequences::RandomSequences,
border: horizon_client::worldborder::WorldBorder,
@@ -4046,6 +4055,20 @@ impl Game<'_> {
self.capture_loaded_world();
Ok(())
}
/// Landing rules (farmland trampling, turtle eggs) wrote or removed the block
/// raw: finish `Level.setBlock`'s neighbour updates, so a crop on trampled
/// farmland breaks. The local player's and every server player's landings.
fn landing_block_updates(&mut self, event: &horizon_client::landing::FallEvent) {
use horizon_client::landing::FallEvent;
let (position, flags) = match *event {
FallEvent::Write {
position, flags, ..
} => (position, flags as i32),
FallEvent::Destroy { position, .. } => (position, level_flags::UPDATE_ALL),
_ => return,
};
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
}
fn tick(&mut self) -> Result<()> {
self.tick_debug_keys()?;
self.tick_debug_charts();
@@ -4064,6 +4087,8 @@ impl Game<'_> {
self.track_experience_display();
// `/tick freeze` stops the world but not the player.
let running = self.clock.world_runs();
// GameRenderer.tick: a wither's bar darkens the world, then fades.
self.boss_overlay.tick_darken(running);
if running {
self.world.game_time += 1;
if self.ui.rules.bool("advance_weather") {
@@ -4260,23 +4285,7 @@ impl Game<'_> {
};
self.audio.play(name, Some(feet), volume, pitch);
}
MotionEvent::Fall(horizon_client::landing::FallEvent::Write {
position,
flags,
..
}) => {
// Landing rules (farmland trampling, turtle eggs) wrote the block raw.
self.with_level(|b, level, ticks| {
b.notify_written(level, ticks, position, flags as i32)
});
}
MotionEvent::Fall(horizon_client::landing::FallEvent::Destroy {
position, ..
}) => {
self.with_level(|b, level, ticks| {
b.notify_written(level, ticks, position, level_flags::UPDATE_ALL)
});
}
MotionEvent::Fall(event) => self.landing_block_updates(&event),
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
@@ -4439,6 +4448,8 @@ impl Game<'_> {
});
horizon_client::spans::time("tick_particles", || self.tick_particles());
}
// ServerBossEvent broadcasts, after this level's entities have ticked.
self.publish_boss_bars();
// Minecraft.handleKeybinds: held use repeats only while no item is in
// use (a raised shield or a drawn bow places and uses nothing).
if self.right
@@ -4819,11 +4830,15 @@ impl Game<'_> {
}
let atlas_ptr: *const HudAtlas = &self.gui.atlas;
let _ = atlas_ptr;
// The ToastManager draws over every screen: a server's packs
// download while the connection screen shows.
if self.remote_overlay(&mut batch) {
self.draw_pack_toasts(&mut batch);
return batch;
}
if let Some(reason) = self.loading_reason() {
self.draw_level_loading(&mut batch, reason);
self.draw_pack_toasts(&mut batch);
return batch;
}
// Hud.extractRenderState: F1 (keyToggleGui) hides camera overlays, the
@@ -5035,6 +5050,7 @@ impl Game<'_> {
Screen::Playing | Screen::Options | Screen::Win => {}
}
self.draw_toasts(&mut batch);
self.draw_pack_toasts(&mut batch);
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
@@ -6772,6 +6788,7 @@ impl Game<'_> {
let mut see_through = std::mem::take(&mut self.see_through_vertices);
see_through.clear();
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
self.see_through_vertices = see_through;
// Entity shadows (EntityRenderer.extractShadow).
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
@@ -7502,7 +7519,7 @@ impl Game<'_> {
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
(out, stars)
}
/// Load (or create) a world folder under saves/ and start streaming it.
/// Enter a local world from the client, selecting its simulation mode.
fn open_world(
&mut self,
folder: &str,
@@ -7521,20 +7538,16 @@ impl Game<'_> {
return Ok(());
}
self.join_world_rest()?;
let save_root = std::env::current_dir()?.join("saves").join(folder);
if integrated::wanted(&self.options, &save_root) {
if self
.options
.execution_role
.starts_integrated_server(self.options.server_thread)
{
return self.open_integrated(folder, name, seed, creative, create);
}
self.perf.world_opening();
let request = world_loading::WorldRequest {
save_root,
folder: folder.to_owned(),
name: name.map(str::to_owned),
seed,
creative,
create: create.cloned(),
};
let request = world_loading::WorldRequest::new(folder, name, seed, creative, create)?;
if self.window.is_some() {
self.perf.world_opening();
if self.title.is_none() {
self.title = Some(Title::new(
std::env::current_dir()?.join("saves"),
@@ -7550,6 +7563,14 @@ impl Game<'_> {
)?);
return Ok(());
}
self.load_world(request)
}
/// Load authoritative world state synchronously, without client entry or
/// integrated-server selection. Server startup must use this entry point.
fn load_world(&mut self, request: world_loading::WorldRequest) -> Result<()> {
self.join_world_rest()?;
self.perf.world_opening();
let prepared = request.prepare(&self.res, &self.pack, &self.options, &self.settings)?;
self.finish_open_world(prepared)
}
@@ -7963,6 +7984,8 @@ impl Game<'_> {
self.session_lock = None;
horizon_client::crash::clear_detail("World");
self.debug_charts.reset();
self.boss_overlay.reset();
self.bosses = Default::default();
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
self.player = None;
@@ -9541,13 +9564,13 @@ impl Game<'_> {
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
let stack = self.inventory[c].clone();
self.draw_slot_item(batch, drag.as_ref(), c, stack.clone(), x, y);
if let Some(stack) = stack
&& self.cursor.0 >= x
if self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0
{
hovered = Some(stack);
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
hovered = stack;
}
}
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
@@ -10064,6 +10087,11 @@ impl Game<'_> {
} else {
self.gather_dynamic_lights(camera, alpha);
}
let pack_inputs = if title {
Default::default()
} else {
self.shader_pack_inputs(camera, alpha as f32)
};
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
@@ -10096,7 +10124,7 @@ impl Game<'_> {
if !self.settings.horizon.fog
&& !underwater
&& self.darkness_fog().is_none()
&& !self.dragon_bar_shown()
&& !self.boss_overlay.should_create_world_fog()
&& self.potions.effects.get("blindness").is_none()
&& fog.environmental_end >= (self.streaming.radius * 16) as f32
{
@@ -10147,6 +10175,7 @@ impl Game<'_> {
hand: &hand,
hand_fov_degrees: self.hud_fov_degrees(camera_alpha as f32, camera.to_array()),
screen: &screen,
pack: pack_inputs,
border,
game_time: self.world.game_time as f32,
shader_game_time: ((self.world.game_time % 24000) as f32 + alpha as f32) / 24000.0,
@@ -10167,6 +10196,9 @@ impl Game<'_> {
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(),
block_flicker: self.fog.block_light_flicker,
boss_darken: self
.boss_overlay
.darken_amount(self.clock.fraction() as f32),
menu_blur: self.menu_blur(),
};
let result = match self.renderer.as_mut() {
@@ -10557,7 +10589,7 @@ impl Game<'_> {
}
// The loading screen's chunk map fills ring by ring, as vanilla's.
self.streaming.loading =
self.options.dedicated.is_none() && self.loading_reason().is_some();
self.options.execution_role.is_client() && self.loading_reason().is_some();
let (uploads, unloaded) =
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
if let Some(lod) = &mut self.lod {
@@ -11394,6 +11426,7 @@ impl ApplicationHandler for Game<'_> {
self.track_toggle_screens();
// For the "I'm playing" ping: an atomic, sent on when it changes.
horizon_client::heartbeat::set_lan(self.is_published());
self.update_discord_presence();
let steps = self.clock.advance(elapsed);
// The world ticking in the frame: once its ticks took a
// tick's length, the rest are dropped (`FixedStep::tick_fits`).
@@ -11884,6 +11917,8 @@ impl<'a> Game<'a> {
breath_clouds: Vec::new(),
dragon_model: prepared.dragon_model,
map_patches: Vec::new(),
text_displays: Default::default(),
text_display_vertices: 0,
skin_downloads: RefCell::new(horizon_client::skins::Downloader::new(
std::env::current_dir()
.unwrap_or_default()
@@ -11971,6 +12006,7 @@ impl<'a> Game<'a> {
score: Default::default(),
fns: Default::default(),
bosses: Default::default(),
boss_overlay: Default::default(),
exec: Default::default(),
rand_sequences: Default::default(),
border: Default::default(),
@@ -12062,6 +12098,8 @@ enum PendingWorldEntry {
Server {
address: String,
name: String,
/// A transfer's carried-over server cookies (`TransferState`).
cookies: Option<horizon_client::net::Cookies>,
},
}
@@ -12136,8 +12174,12 @@ impl Game<'_> {
}) => {
self.open_world(&folder, name.as_deref(), seed, creative, create.as_deref())?;
}
Some(PendingWorldEntry::Server { address, name }) => {
self.connect_server(&address, &name)?;
Some(PendingWorldEntry::Server {
address,
name,
cookies,
}) => {
self.connect_server_with_cookies(&address, &name, cookies)?;
}
None => {}
}
@@ -12244,7 +12286,7 @@ fn synchronous_startup(options: &Options) -> bool {
|| options.world_frames.is_some()
|| options.benchmark.is_some()
|| options.control.is_some()
|| options.dedicated.is_some()
|| !options.execution_role.is_client()
|| std::env::var_os("HORIZON_DISPENSE_FIXTURE").is_some()
|| std::env::var_os("HORIZON_SCULK_FIXTURE").is_some()
|| std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
@@ -12689,6 +12731,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
@@ -12698,6 +12741,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
}
}
@@ -12785,7 +12829,7 @@ fn start_game<'a>(
game.sculk_fixture(&spec)?;
return Ok(None);
}
if game.options.dedicated.is_some() {
if game.options.execution_role == execution_role::ExecutionRole::DedicatedServer {
game.run_dedicated()?;
return Ok(None);
}
@@ -12860,6 +12904,7 @@ fn write_report(game: &Game<'_>) -> Result<()> {
fn run_event_loop(mut game: Game<'_>) -> Result<()> {
EventLoop::new()?.run_app(&mut game)?;
horizon_client::discord_presence::stop();
write_report(&game)?;
if let Some(e) = game.error.take() {
return Err(e);
@@ -13062,7 +13107,7 @@ impl Loading {
);
let renderer_ms = renderer_at.elapsed().as_millis();
// Audio is created on the main thread, after the core is ready.
let audio = if options.dedicated.is_some() {
let audio = if !options.execution_role.is_client() {
Audio::silent(&options.resources)
} else {
Audio::new(&options.resources)
@@ -13217,6 +13262,7 @@ fn run_loading(
};
let mut app = App::Loading(Box::new(loading));
EventLoop::new()?.run_app(&mut app)?;
horizon_client::discord_presence::stop();
match app {
App::Ready(mut game) => {
write_report(&game)?;
@@ -13237,13 +13283,15 @@ pub fn run() -> Result<()> {
let Some(mut options) = Options::read()? else {
return Ok(());
};
if options.dedicated.is_some() && !dedicated::preflight(&mut options)? {
if options.execution_role == execution_role::ExecutionRole::DedicatedServer
&& !dedicated::preflight(&mut options)?
{
return Ok(());
}
// Before any worker starts: on Linux they go behind the server thread
// and the render thread.
horizon_threads::put_helpers_behind();
if options.dedicated.is_none() {
if options.execution_role.is_client() {
// The render thread, like the server thread, ahead of the workers on
// macOS and Windows. While terrain loads, the worker pools (two with
// single player's server thread) and other programs outnumber the
@@ -13253,7 +13301,7 @@ pub fn run() -> Result<()> {
}
// Someone is playing: the anonymous ping behind the launcher's "N playing now". Not for a
// server, a benchmark or a run that quits by itself.
if options.dedicated.is_none()
if options.execution_role.is_client()
&& options.benchmark.is_none()
&& !options.offscreen
&& options.frames.is_none()
@@ -13265,6 +13313,14 @@ pub fn run() -> Result<()> {
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"));
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
if options.dedicated.is_none() {
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
}
// Snapshots of derived tables live in the game folder, never beside the
// shared resources.
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
@@ -13294,7 +13350,7 @@ pub fn run() -> Result<()> {
)?;
let gui = loaded.gui.ok_or("missing menu bundle")?;
options.resources = loaded.root.clone();
let audio = if options.dedicated.is_some() {
let audio = if !options.execution_role.is_client() {
Audio::silent(&options.resources)
} else {
Audio::new(&options.resources)
+131 -29
View File
@@ -881,30 +881,40 @@ pub(super) fn adv_name(a: &adv::Advancement) -> Text {
let Some(d) = &a.display else {
return Text::literal(a.id.clone());
};
let color = match d.frame {
Frame::Challenge => text::DARK_PURPLE,
_ => text::GREEN,
};
// Advancement.decorateName: the title in the frame's colour (styles
// merged into it), a new line and the description.
let coloured = text_of(&d.title).with_color(color);
let mut component = coloured.component();
if let Some(o) = component.as_object_mut() {
o.insert(
"extra".into(),
serde_json::json!(["\n", text_of(&d.description).component()]),
);
}
let tooltip = coloured
.append("\n")
.append(text_of(&d.description))
.with_component(component);
let color = frame_color(d.frame);
let title = text_of(&d.title).with_style(&Style {
hover: Some(Box::new(tooltip)),
hover: Some(Box::new(decorate_name(d))),
..Style::default()
});
Text::translate("chat.square_brackets", &[title]).with_color(color)
}
fn frame_color(frame: Frame) -> u32 {
match frame {
Frame::Challenge => text::DARK_PURPLE,
_ => text::GREEN,
}
}
/// `Advancement.decorateName`: a copy of the title with the frame's colour
/// under its own style (mergeStyles), then a new line and the description
/// after the title's own siblings, so both inherit the title's root style.
/// Built on the title's JSON (a list as its first element with the rest as
/// siblings): a parsed Text moves a styled root's style down a level once it
/// has siblings.
fn decorate_name(d: &adv::Display) -> Text {
let mut component = match horizon_client::scoreboard::canonical_component(&d.title) {
Value::String(s) => serde_json::json!({ "text": s }),
c => c,
};
if let Some(o) = component.as_object_mut() {
let frame = Text::empty().with_color(frame_color(d.frame)).component();
o.entry("color").or_insert_with(|| frame["color"].clone());
let extra = o.entry("extra").or_insert_with(|| serde_json::json!([]));
if let Some(extra) = extra.as_array_mut() {
extra.extend(["\n".into(), d.description.clone()]);
}
}
text_of(&component).with_component(component)
}
impl Game<'_> {
/// `AdvancementCommands.Action.GRANT` on one advancement (or one of
/// its criteria): false when nothing changed.
@@ -1051,17 +1061,10 @@ impl Game<'_> {
}
}
}
/// A text component from advancement JSON (`translate` or `text`).
/// An advancement's title or description: a whole component (ComponentSerialization.CODEC),
/// with its siblings and styles, as a plugin's `{"text":"","extra":[...]}` needs.
pub(super) fn text_of(v: &Value) -> Text {
if let Some(key) = v["translate"].as_str() {
Text::translate(key, &[])
} else if let Some(s) = v["text"].as_str() {
Text::literal(s)
} else if let Some(s) = v.as_str() {
Text::literal(s)
} else {
Text::empty()
}
horizon_client::scoreboard::comp_text(v)
}
#[cfg(test)]
@@ -1095,6 +1098,105 @@ mod tests {
(seen, None)
}
#[test]
fn titles_keep_their_siblings_and_styles() {
let title = text_of(&serde_json::json!({
"text": "",
"extra": [{"text": "Quest ", "color": "gold"}, "Board"],
}));
assert_eq!(title.plain(), "Quest Board");
assert_eq!(title.spans[0].style.color, Some(text::GOLD));
assert_eq!(title.spans[1].style.color, None);
let vanilla = text_of(&serde_json::json!({"translate": "advancements.story.root.title"}));
assert_eq!(
vanilla,
Text::translate("advancements.story.root.title", &[])
);
assert_eq!(
vanilla.translation_key(),
Some("advancements.story.root.title")
);
}
fn announced(title: Value, description: Value) -> adv::Advancement {
adv::Advancement {
id: "quests:board".into(),
parent: None,
display: Some(adv::Display {
icon: "minecraft:book".into(),
title,
description,
frame: Frame::Task,
background: None,
show_toast: true,
announce: true,
hidden: false,
}),
criteria: Vec::new(),
requirements: Vec::new(),
rewards: adv::Rewards::default(),
}
}
/// The chat hover: the JSON a client receives, and the spans drawn here.
fn hover(a: &adv::Advancement) -> (Value, Text) {
let local = decorate_name(a.display.as_ref().unwrap());
let sent = adv_name(a).component()["with"][0]["hover_event"]["value"].clone();
(sent, local)
}
#[test]
fn chat_hover_keeps_the_titles_siblings_before_the_description() {
let a = announced(
serde_json::json!({"text": "", "extra": [{"text": "Quest ", "color": "gold"}, "Board"]}),
serde_json::json!("Open the board"),
);
let (sent, local) = hover(&a);
let sent = horizon_client::scoreboard::comp_text(&sent);
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
let colors = |t: &Text| t.spans.iter().map(|s| s.style.color).collect::<Vec<_>>();
let (gold, green) = (Some(text::GOLD), Some(text::GREEN));
assert_eq!(colors(&sent), [gold, green, green, green]);
// The spans drawn here come from the same JSON a client receives.
assert_eq!(colors(&local), colors(&sent));
}
#[test]
fn chat_hover_description_takes_a_styled_titles_own_style() {
// mergeStyles keeps the title's gold; the description inherits it.
let object = announced(
serde_json::json!({"text": "Quest", "color": "gold", "bold": true, "extra": [" Board"]}),
serde_json::json!("Open the board"),
);
let list = announced(
serde_json::json!([{"text": "Quest", "color": "gold", "bold": true}, " Board"]),
serde_json::json!("Open the board"),
);
for a in [object, list] {
let (sent, local) = hover(&a);
let sent = horizon_client::scoreboard::comp_text(&sent);
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
let last = &sent.spans.last().unwrap().style;
assert_eq!((last.color, last.bold), (Some(text::GOLD), Some(true)));
assert_eq!(local.spans.last().unwrap().style, *last);
}
}
#[test]
fn chat_hover_of_a_vanilla_advancement() {
let a = announced(
serde_json::json!({"translate": "advancements.story.root.title"}),
serde_json::json!({"translate": "advancements.story.root.description"}),
);
assert_eq!(
hover(&a).0,
serde_json::json!({
"translate": "advancements.story.root.title",
"color": "green",
"extra": ["\n", {"translate": "advancements.story.root.description"}],
})
);
}
#[test]
fn toasts_slide_in_squared_and_out_after_five_seconds() {
let mut toast = recipes(1);
+2 -1
View File
@@ -469,7 +469,8 @@ impl Game<'_> {
"flying": p.flying,
"fall_flying": p.fall_flying,
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
"sprinting": p.sprinting,
"fov_modifier": self.view.fov_modifier,
"health": p.health.health(),
"absorption": p.health.absorption(),
// The hurt animation's ticks left (the camera's hurt tilt).
+370 -52
View File
@@ -1,22 +1,29 @@
//! `/bossbar` (BossBarCommands) and drawing the custom boss bars.
use super::{
Game,
commands::{R, err},
Dim, Game,
commands::{R, Target, err},
};
use horizon_client::{
boss_overlay,
bossbar::{self, BossBars},
command::{Arg, Context},
hud_overlays, save,
scoreboard::{self as sb, Comp, comp_text},
mobs::Species,
save,
scoreboard::{self as sb, Comp, json_to_tag},
scoreboard_cmd::format_list,
text::Text,
};
use horizon_core::random::RandomSource;
use horizon_protocol::packets::play::{self, BossAction, Clientbound as C};
use horizon_world::nbt::Tag;
use std::collections::{HashMap, HashSet};
#[derive(Default)]
pub(super) struct BossState {
pub bars: BossBars,
loaded: bool,
/// What each viewer was last sent, so a change becomes one packet.
sent: HashMap<u128, HashMap<u128, Offer>>,
}
fn num(n: impl std::fmt::Display) -> Text {
@@ -36,7 +43,7 @@ impl Game<'_> {
if let Some(root) = save::read_dat(&self.bossbar_path())
&& let Some(data) = root.get("data")
{
self.bosses.bars = BossBars::unpack(data, self.level.player_uuid);
self.bosses.bars = BossBars::unpack(data);
}
self.ui.registries.bossbars = self.bosses.bars.ids();
}
@@ -47,13 +54,15 @@ impl Game<'_> {
if self.bosses.bars.bars.is_empty() && !self.bossbar_path().exists() {
return;
}
let _ = save::write_dat(
if let Err(e) = save::write_dat(
&self.bossbar_path(),
Tag::compound([
("data", self.bosses.bars.pack(self.level.player_uuid)),
("data", self.bosses.bars.pack()),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
]),
);
) {
log::warn!("boss bars were not written: {e}");
}
}
pub(in crate::game) fn bossbar_exists(&self, id: &str) -> bool {
self.bosses.bars.get(id).is_some()
@@ -68,6 +77,7 @@ impl Game<'_> {
b.value = b.value.min(b.max);
}
}
self.publish_boss_bars();
}
fn bar_id(ctx: &Context) -> String {
match ctx.get("id") {
@@ -86,6 +96,7 @@ impl Game<'_> {
self.load_bossbars();
let result = self.bossbar_command(id, ctx);
self.ui.registries.bossbars = self.bosses.bars.ids();
self.publish_boss_bars();
result
}
fn bossbar_command(&mut self, id: &str, ctx: &Context) -> R<i32> {
@@ -97,6 +108,10 @@ impl Game<'_> {
}
let name = self.component_json(ctx, "name")?;
self.bosses.bars.add(&bar, name);
if let Some(b) = self.bosses.bars.get_mut(&bar) {
// CustomBossEvents.create: Mth.createInsecureUUID(level.random).
b.uuid = insecure_uuid(&mut self.world.random);
}
let shown = self
.bosses
.bars
@@ -173,19 +188,20 @@ impl Game<'_> {
i32::from(b.visible)
}
_ => {
if b.player {
let name = self.target_name(super::commands::Target::Player);
self.feedback(Text::translate(
"commands.bossbar.get.players.some",
&[s, num(1), format_list(vec![name])],
));
} else {
let names = self.bossbar_player_names(&b.players);
let n = names.len();
if n == 0 {
self.feedback(Text::translate(
"commands.bossbar.get.players.none",
&[s],
));
} else {
self.feedback(Text::translate(
"commands.bossbar.get.players.some",
&[s, num(n), format_list(names)],
));
}
i32::from(b.player)
n as i32
}
})
}
@@ -295,31 +311,46 @@ impl Game<'_> {
Ok(0)
}
_ => {
// players [<targets>]
let include = if ctx.get("targets").is_some() {
!self.entities(ctx, "targets", true)?.is_empty()
// players [<targets>]: replace the set. No argument clears it.
let targets = if ctx.get("targets").is_some() {
self.entities(ctx, "targets", true)?
} else {
false
Vec::new()
};
let mut uuids = Vec::new();
let mut names = Vec::new();
for t in &targets {
let Some(uuid) = self.player_target_uuid(*t) else {
continue;
};
if !uuids.contains(&uuid) {
uuids.push(uuid);
names.push(self.target_name(*t));
}
}
let b = self.bosses.bars.get_mut(bar).expect("checked");
if b.player == include {
let mut had = b.players.clone();
had.sort();
let mut next = uuids.clone();
next.sort();
if had == next {
return Err(err("commands.bossbar.set.players.unchanged", &[]));
}
b.player = include;
b.players = uuids;
let n = b.players.len();
let s = shown(self);
if include {
let name = self.target_name(super::commands::Target::Player);
self.feedback(Text::translate(
"commands.bossbar.set.players.success.some",
&[s, num(1), format_list(vec![name])],
));
} else {
if n == 0 {
self.feedback(Text::translate(
"commands.bossbar.set.players.success.none",
&[s],
));
} else {
self.feedback(Text::translate(
"commands.bossbar.set.players.success.some",
&[s, num(targets.len()), format_list(names)],
));
}
Ok(i32::from(include))
Ok(n as i32)
}
}
}
@@ -333,29 +364,316 @@ impl Game<'_> {
Ok(self.resolve_component(value))
}
// ---- drawing ------------------------------------------------------------------
/// The visible custom bars (`/bossbar`), after the built-in ones.
pub(super) fn custom_bossbars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
for b in self
.bosses
.bars
.bars
.iter()
.filter(|b| b.visible && b.player)
{
bars.push(hud_overlays::BossBar {
name: comp_text(&b.name),
progress: b.progress(),
color: bossbar::COLORS
/// Names of the online players in a boss bar's set, in join order.
fn bossbar_player_names(&self, players: &[[i32; 4]]) -> Vec<Text> {
self.known_boss_players()
.into_iter()
.filter(|(uuid, _)| players.contains(uuid))
.map(|(_, name)| name)
.collect()
}
/// The players who can be told about a bar: the one in place, then guests.
fn known_boss_players(&self) -> Vec<([i32; 4], Text)> {
let mut out = Vec::new();
if self.player.is_some() {
out.push((self.acting_uuid(), self.target_name(Target::Player)));
}
if let Some(host) = &self.lan {
for g in host.hub.guests.iter().filter(|g| g.placed) {
let ints = horizon_client::lan::playerdata::uuid_ints(g.uuid);
if out.iter().any(|(uuid, _)| *uuid == ints) {
continue;
}
out.push((ints, Text::literal(g.name.clone())));
}
}
out
}
fn player_target_uuid(&self, target: Target) -> Option<[i32; 4]> {
match target {
Target::Player => Some(self.acting_uuid()),
Target::Guest(conn) => self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.position(|c| *c == b.color)
.unwrap_or(0),
overlay: bossbar::STYLES
.iter()
.position(|s| *s == b.style)
.unwrap_or(0),
});
.find(|g| g.conn == conn)
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid))
}),
_ => None,
}
}
// ---- ServerBossEvent delivery ------------------------------------------------
/// Tell every viewer the boss events they should be seeing. The window
/// that simulates the world applies them to its own overlay; everyone
/// else gets `ClientboundBossEventPacket`s.
pub(super) fn publish_boss_bars(&mut self) {
if self.remote.is_some() {
return;
}
self.load_bossbars();
let now = boss_overlay::now_millis();
let viewers = self.boss_viewers();
let live: HashSet<u128> = viewers.iter().map(|v| v.uuid).collect();
self.bosses.sent.retain(|id, _| live.contains(id));
let mut packets = Vec::new();
for viewer in &viewers {
let desired = self.boss_events_for(viewer);
for event in self.diff_boss_events(viewer, desired) {
if viewer.local {
self.boss_overlay.apply(&event, now);
} else if let Some(conn) = viewer.conn {
packets.push((conn, C::BossEvent(event)));
}
}
}
if packets.is_empty() {
return;
}
let Some(host) = self.lan.as_ref() else {
return;
};
for (conn, packet) in &packets {
host.hub.send_to(*conn, packet);
}
}
fn boss_viewers(&self) -> Vec<Viewer> {
let mut out = Vec::new();
// While `as_guest` has swapped a guest in, the host's own position
// is not in place. Their overlay updates once the tick puts it back.
let local =
self.serving.is_none() && self.options.dedicated.is_none() && self.lan_acting.is_none();
if local && let Some(p) = &self.player {
let ints = self.level.player_uuid.unwrap_or_else(|| self.acting_uuid());
out.push(Viewer {
uuid: horizon_client::lan::playerdata::ints_uuid(ints),
ints,
at: [p.position.x, p.position.y, p.position.z],
alive: !p.health.dead_or_dying(),
here: true,
local: true,
conn: None,
view_chunks: self.settings.render_distance,
});
}
if let Some(host) = &self.lan {
for g in host.hub.guests.iter().filter(|g| g.placed) {
if out.iter().any(|v| v.uuid == g.uuid) {
continue;
}
out.push(Viewer {
uuid: g.uuid,
ints: horizon_client::lan::playerdata::uuid_ints(g.uuid),
at: g.position,
alive: !g.dead,
here: g.dimension == self.dim.id(),
local: false,
conn: Some(g.conn),
view_chunks: g.view_distance,
});
}
}
out
}
/// Custom bars (`CustomBossEvents.onPlayerConnect` sends them as a player
/// joins), then the dragon, withers and the raid.
fn boss_events_for(&self, viewer: &Viewer) -> Vec<Offer> {
let mut out = Vec::new();
for b in &self.bosses.bars.bars {
if !b.visible || !b.players.contains(&viewer.ints) {
continue;
}
out.push(Offer {
id: b.uuid,
name: json_to_tag(&b.name),
progress: b.progress(),
color: color_index(&b.color),
overlay: style_index(&b.style),
darken: b.darken,
music: b.music,
fog: b.fog,
scope: Scope::Global,
});
}
if viewer.here
&& let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive)
{
out.push(Offer {
id: bossbar::stable_uuid("minecraft:ender_dragon"),
name: translate_tag("entity.minecraft.ender_dragon"),
progress,
color: 0,
overlay: 0,
darken: false,
music: true,
fog: true,
scope: Scope::Level(self.dim),
});
}
if viewer.here {
let range = f64::from(10.min(viewer.view_chunks.max(0)) * 16);
for m in self
.mobs
.iter()
.filter(|m| m.species() == Species::Wither && m.alive() && !m.removed)
{
let (dx, dz) = (m.position.x - viewer.at[0], m.position.z - viewer.at[2]);
if dx * dx + dz * dz > range * range {
continue;
}
let progress = if m.extra.counter > 0 {
1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32
} else {
(m.health / m.attrs.max_health).clamp(0.0, 1.0)
};
let name = if let Some(text) = &m.custom_name_text {
json_to_tag(&text.component())
} else if let Some(name) = &m.custom_name {
Tag::String(name.as_str().into())
} else {
translate_tag("entity.minecraft.wither")
};
out.push(Offer {
id: super::lan::entity_uuid(m.id),
name,
progress,
color: 5,
overlay: 0,
darken: true,
music: false,
fog: false,
scope: Scope::Level(self.dim),
});
}
}
if viewer.here
&& let Some((id, name, progress)) = self.raid_boss_at(viewer.at, viewer.alive)
{
out.push(Offer {
id: bossbar::stable_uuid(&format!("raid:{id}")),
name,
progress,
color: 2,
overlay: 2,
darken: false,
music: false,
fog: false,
scope: Scope::Level(self.dim),
});
}
out
}
/// The packets that take `viewer` from what it was last sent to `desired`
/// (`ServerBossEvent`'s broadcasts).
fn diff_boss_events(&mut self, viewer: &Viewer, desired: Vec<Offer>) -> Vec<play::BossEvent> {
let full = viewer.local;
let dim = self.dim;
let sent = self.bosses.sent.entry(viewer.uuid).or_default();
let mut out = Vec::new();
let event = |id, action| play::BossEvent { id, action };
sent.retain(|id, prev| {
let owned = full || prev.scope == Scope::Global || prev.scope == Scope::Level(dim);
let keep = !owned || desired.iter().any(|e| e.id == *id);
if !keep {
out.push(event(*id, BossAction::Remove));
}
keep
});
for offer in desired {
let Some(prev) = sent.get(&offer.id) else {
out.push(event(
offer.id,
BossAction::Add {
name: offer.name.clone(),
progress: offer.progress,
color: offer.color,
overlay: offer.overlay,
darken: offer.darken,
music: offer.music,
fog: offer.fog,
},
));
sent.insert(offer.id, offer);
continue;
};
let id = offer.id;
if prev.progress != offer.progress {
let progress = offer.progress;
out.push(event(id, BossAction::UpdateProgress { progress }));
}
if prev.name != offer.name {
let name = offer.name.clone();
out.push(event(id, BossAction::UpdateName { name }));
}
if (prev.color, prev.overlay) != (offer.color, offer.overlay) {
let (color, overlay) = (offer.color, offer.overlay);
out.push(event(id, BossAction::UpdateStyle { color, overlay }));
}
if (prev.darken, prev.music, prev.fog) != (offer.darken, offer.music, offer.fog) {
let (darken, music, fog) = (offer.darken, offer.music, offer.fog);
out.push(event(
id,
BossAction::UpdateProperties { darken, music, fog },
));
}
sent.insert(id, offer);
}
out
}
/// `ClientPacketListener.handleBossUpdate`.
pub(super) fn apply_boss_event(&mut self, packet: &play::BossEvent) {
self.boss_overlay.apply(packet, boss_overlay::now_millis());
}
}
/// `Mth.createInsecureUUID`.
fn insecure_uuid(random: &mut impl RandomSource) -> u128 {
let most = random.next_long() & -61441 | 16384;
let least = random.next_long() & 4_611_686_018_427_387_903 | i64::MIN;
((most as u64 as u128) << 64) | (least as u64 as u128)
}
fn translate_tag(key: &str) -> Tag {
Tag::compound([("translate", Tag::String(key.into()))])
}
fn color_index(color: &str) -> i32 {
bossbar::COLORS
.iter()
.position(|c| *c == color)
.unwrap_or(6) as i32
}
fn style_index(style: &str) -> i32 {
bossbar::STYLES
.iter()
.position(|s| *s == style)
.unwrap_or(0) as i32
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Global,
Level(Dim),
}
struct Viewer {
uuid: u128,
ints: [i32; 4],
at: [f64; 3],
alive: bool,
here: bool,
local: bool,
conn: Option<u32>,
view_chunks: i32,
}
/// A boss event as one viewer should see it, and as it was last sent.
struct Offer {
id: u128,
name: Tag,
progress: f32,
/// `BossBarColor` and `BossBarOverlay` ordinals.
color: i32,
overlay: i32,
darken: bool,
music: bool,
fog: bool,
scope: Scope,
}
+13 -3
View File
@@ -570,8 +570,14 @@ impl Game<'_> {
/// ChatScreen.handleChatInput: history, then a command or a chat line.
pub(super) fn handle_chat_input(&mut self, message: &str) {
self.ui.chat.add_recent(message);
// On a server (single player's own included) the server runs it.
// On a server (single player's own included) the server runs it,
// unless a client mod owns the command.
if self.remote.is_some() {
if let Some(command) = message.strip_prefix('/')
&& self.mod_command(command)
{
return;
}
self.send_chat(message);
return;
}
@@ -816,8 +822,12 @@ impl Game<'_> {
("data", data),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
]);
let _ = horizon_client::save::write_dat(&path, root);
let _ = write_difficulty(&self.world_root, self.ui.difficulty);
if let Err(e) = horizon_client::save::write_dat(&path, root) {
log::warn!("the world clocks were not written: {e}");
}
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
log::warn!("the difficulty was not written: {e}");
}
}
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
/// cause is known to the client).
+30 -19
View File
@@ -42,7 +42,8 @@ pub(super) enum Target {
Mob(u32),
Item(u32),
Orb(usize),
Arrow(usize),
/// By index in `creatures.projectiles`.
Projectile(usize),
Stand(u32),
Frame(u32),
Painting(u32),
@@ -327,7 +328,7 @@ impl Game<'_> {
.orbs
.get(i)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Arrow(i) => self
Target::Projectile(i) => self
.creatures
.projectiles
.get(i)
@@ -382,7 +383,11 @@ impl Game<'_> {
}),
Target::Item(_) => "minecraft:item".into(),
Target::Orb(_) => "minecraft:experience_orb".into(),
Target::Arrow(_) => "minecraft:arrow".into(),
Target::Projectile(i) => self
.creatures
.projectiles
.get(i)
.map_or_else(String::new, |p| p.kind.id().into()),
Target::Stand(_) => "minecraft:armor_stand".into(),
Target::Frame(id) => {
if self.item_frames.iter().any(|f| f.id == id && f.glow) {
@@ -503,6 +508,7 @@ impl Game<'_> {
_ => {}
}
if s.kind != 's' {
use horizon_client::projectiles::ProjectileKind as K;
candidates.extend(self.player_candidates(s));
candidates.extend(
self.mobs
@@ -517,8 +523,12 @@ impl Game<'_> {
.projectiles
.iter()
.enumerate()
.filter(|(_, p)| p.arrow.is_some())
.map(|(i, _)| Target::Arrow(i)),
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.arrow.is_some()
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
@@ -1082,6 +1092,8 @@ impl Game<'_> {
self.for_player(t, |g| g.teleport_to(Target::Player, pos, rotation));
}
Target::Player => {
// ServerPlayer.teleport: removeVehicle before placing the player.
self.stop_riding();
if let Some(p) = self.player.as_mut() {
p.position = v;
p.velocity = Vec3::new(0.0, 0.0, 0.0);
@@ -1098,9 +1110,15 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.position = v;
m.previous = v;
if let Some([yaw, _]) = rotation {
// Entity.teleportSetPosition: setYRot, setYHeadRot, setXRot
// (clamped to -90..90), then setOldRot.
if let Some([yaw, pitch]) = rotation {
m.yaw = yaw;
m.body_yaw = yaw;
m.head_yaw = yaw;
m.head_pitch = pitch.clamp(-90.0, 90.0);
m.previous_yaw = m.yaw;
m.previous_head_pitch = m.head_pitch;
}
}
}
@@ -1116,7 +1134,7 @@ impl Game<'_> {
o.previous = v;
}
}
Target::Arrow(i) => {
Target::Projectile(i) => {
if let Some(a) = self.creatures.projectiles.get_mut(i) {
a.position = v;
a.previous = v;
@@ -1540,14 +1558,10 @@ impl Game<'_> {
self.orbs[i].age = u32::MAX / 2;
}
}
Target::Arrow(i) => {
if let Some(a) = self
.creatures
.projectiles
.get_mut(i)
.and_then(|p| p.arrow.as_mut())
{
a.removed = true;
// cmd_kill goes from the highest index down.
Target::Projectile(i) => {
if i < self.creatures.projectiles.len() {
self.creatures.projectiles.remove(i);
}
}
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
@@ -1569,7 +1583,7 @@ impl Game<'_> {
let mut ordered = targets.clone();
ordered.sort_by_key(|t| {
std::cmp::Reverse(match t {
Target::Orb(i) | Target::Arrow(i) => *i,
Target::Orb(i) | Target::Projectile(i) => *i,
_ => 0,
})
});
@@ -1577,9 +1591,6 @@ impl Game<'_> {
self.kill_target(t);
}
self.orbs.retain(|o| o.age < u32::MAX / 2);
self.creatures
.projectiles
.retain(|p| !p.arrow.as_ref().is_some_and(|a| a.removed));
let message = if targets.len() == 1 {
Text::translate("commands.kill.success.single", &[names[0].clone()])
} else {
@@ -65,9 +65,10 @@ impl Game<'_> {
tag
}
Target::Mob(id) => {
let uuid = self.uuid_for(id);
let m = self.mobs.iter().find(|m| m.id == id)?;
entity_nbt::mob_to_nbt(m, uuid, self.persist.raw.get(&id))
// `saveWithoutId` writes the entity as saved, riders under
// `Passengers`: `/data get` reads the same tree persistence does (#1519).
let i = self.mobs.iter().position(|m| m.id == id)?;
self.mob_tree_nbt(i)
}
Target::Item(id) => {
let uuid = self.uuid_for(id);
@@ -113,7 +114,7 @@ impl Game<'_> {
self.other_to_nbt(&o, uuid)
}
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
Target::Arrow(i) => {
Target::Projectile(i) => {
let p = self.creatures.projectiles.get(i)?;
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
player_uuid
@@ -125,7 +126,8 @@ impl Game<'_> {
.as_ref()
.and_then(|a| a.last_state)
.map(|s| self.state_to_nbt(s));
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)?
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
}
})
}
@@ -59,7 +59,15 @@ impl Game<'_> {
self.recipe_unlocked(&unlocked);
count as i32
} else {
self.level.recipe_book.remove_recipes(index, &recipes) as i32
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
let count = removed.len() as i32;
// A network player's client drops them from its book.
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
count
};
tracker.track(&h, n);
}
@@ -345,7 +353,26 @@ impl Game<'_> {
Some(Arg::Resource(s)) => Some(s.clone()),
_ => None,
};
self.audio.stop_sounds(sound.as_deref(), source);
// StopSoundCommand: each target's connection gets the packet; the
// window's own player stops its sounds.
let packet = C::StopSound(play::StopSound {
source: source.map(|s| s as i32),
sound: sound.clone(),
});
for &t in &targets {
match t {
Target::Player if self.lan_acting.is_some() => {
self.guest_packets.push(packet.clone());
}
Target::Player => self.audio.stop_sounds(sound.as_deref(), source),
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
}
}
_ => {}
}
}
let key = match (source, &sound) {
(Some(_), Some(_)) => "commands.stopsound.success.source.sound",
(Some(_), None) => "commands.stopsound.success.source.any",
+5 -1
View File
@@ -513,7 +513,10 @@ impl Game<'_> {
if self.pending_screenshot.is_some() {
return Err("a screenshot is already pending".into());
}
renderer.screenshot = Some(arg(1)?.into());
arg(1)?;
// The rest of the line: a path may hold spaces.
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
renderer.screenshot = Some(path.trim().into());
self.pending_screenshot = Some(reply.clone());
Ok(None)
}
@@ -991,6 +994,7 @@ impl Game<'_> {
status["destroying"] = json!(destroying);
status["physical_keys"] = json!(physical_keys);
status["heard"] = json!(self.audio.heard);
status["stopped_sounds"] = json!(self.audio.stopped);
// Boats, minecarts, armor stands, item frames and paintings.
status["objects"] = json!(self.objects_status());
status["lightning"] = json!({"bolts": self.bolts.len(), "struck": self.lightning_seen[0],
+3 -1
View File
@@ -978,7 +978,9 @@ impl Game<'_> {
m.ai.target = Some(target);
}
}
MobEvent::Projectile(p) => {
MobEvent::Projectile(mut p) => {
self.next_entity += 1;
p.id = self.next_entity;
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
self.creatures.projectiles.push(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
+3 -1
View File
@@ -185,7 +185,9 @@ impl Game<'_> {
// Minecraft.emergencySaveAndCrash: the world is saved, then the
// game stops; main writes the crash report.
let crash = horizon_client::crash::DebugCrash::new();
let _ = self.save_game();
if let Err(e) = self.save_game() {
log::error!("the emergency save before the crash failed: {e}");
}
return Err(crash.into());
}
}
+14 -13
View File
@@ -6,7 +6,7 @@
//! its simulation and the play-state hub that LAN hosting uses, configured
//! from server.properties, with the operator, whitelist and ban lists and a
//! console on standard input.
use super::{Game, Options, lan::LanSettings};
use super::{Game, Options, lan::LanSettings, world_loading::WorldRequest};
use horizon_client::{
Result,
lan::{LoginGate, NetConfig, NetLog, OnlineMode, hub::HubConfig},
@@ -218,21 +218,22 @@ impl Lists {
use_whitelist: false,
enforce_whitelist: false,
};
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
let warn = |what: &str, r: std::result::Result<(), String>| {
if let Err(e) = r {
log("WARN", &format!("Failed to load {what}: {e}"));
log("WARN", &format!("Failed to {what}: {e}"));
}
};
// DedicatedPlayerList's constructor order.
warn("user banlist", l.bans.load());
let _ = l.bans.save();
warn("ip banlist", l.ip_bans.load());
let _ = l.ip_bans.save();
warn("operators list", l.ops.load());
warn("white-list", l.white.load());
let _ = l.ops.save();
let saved = |r: std::io::Result<()>| r.map_err(|e| e.to_string());
warn("load user banlist", l.bans.load());
warn("save user banlist", saved(l.bans.save()));
warn("load ip banlist", l.ip_bans.load());
warn("save ip banlist", saved(l.ip_bans.save()));
warn("load operators list", l.ops.load());
warn("load white-list", l.white.load());
warn("save operators list", saved(l.ops.save()));
if !l.white.file.exists() {
let _ = l.white.save();
warn("save white-list", saved(l.white.save()));
}
l
}
@@ -546,13 +547,13 @@ impl Game<'_> {
let lists = Arc::new(Mutex::new(lists));
log("INFO", &format!("Preparing level \"{level_name}\""));
let folder = world_dir.to_string_lossy().into_owned();
self.open_world(
self.load_world(WorldRequest::new(
&folder,
Some(&level_name),
Some(props.seed),
game_mode == 1,
None,
)?;
)?)?;
if new_world {
self.level.hardcore = props.hardcore;
}
+43
View File
@@ -0,0 +1,43 @@
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
use super::*;
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
impl Game<'_> {
/// Each frame: only a change reaches the presence thread.
pub(super) fn update_discord_presence(&self) {
presence::set(self.discord_status());
}
fn discord_status(&self) -> Status {
if !self.settings.extras.discord_presence {
return Status::Off;
}
if self.title.is_some() {
return Status::Menus;
}
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
},
_ => Play::Singleplayer,
}
} else if self.remote.is_some() {
Play::Multiplayer
} else {
Play::Singleplayer
};
Status::World {
play,
dimension: match self.dim {
Dim::Overworld => Dimension::Overworld,
Dim::Nether => Dimension::Nether,
Dim::End => Dimension::End,
},
mode: self.mode,
}
}
}
+23 -23
View File
@@ -12,7 +12,6 @@ use horizon_client::{
hud_layout, hud_overlays,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -2157,23 +2156,12 @@ impl Game<'_> {
});
}
}
/// `BossHealthOverlay`: the dragon's pink bar, then the withers', the
/// raids' and the custom bars, in that order.
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
/// (or a server's packets) put on this client's overlay.
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
let mut bars = Vec::new();
if let Some(d) = self.dragon.as_ref().filter(|_| self.dragon_bar_shown()) {
bars.push(hud_overlays::BossBar {
name: Text::literal(
self.translate("entity.minecraft.ender_dragon", "Ender Dragon"),
),
progress: (d.health / MAX_HEALTH).clamp(0.0, 1.0),
color: 0,
overlay: 0,
});
}
self.wither_bars(&mut bars);
self.raid_bars(&mut bars);
self.custom_bossbars(&mut bars);
let bars = self
.boss_overlay
.bars(horizon_client::boss_overlay::now_millis());
let draws = hud_overlays::boss_overlay(
batch.width as i32,
batch.height as i32,
@@ -2182,14 +2170,26 @@ impl Game<'_> {
);
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
/// The dragon fight's boss bar shows (`EnderDragonFight.dragonEvent`,
/// with `setCreateWorldFog`).
pub(super) fn dragon_bar_shown(&self) -> bool {
self.dim == Dim::End && self.dragon.is_some()
/// The dragon fight's bar for a player at `at`
/// (`EnderDragonFight.dragonEvent`, visible while the fight is not won
/// and the player is alive within 192 blocks of (0, 128, 0)).
pub(super) fn dragon_boss_progress(&self, at: [f64; 3], alive: bool) -> Option<f32> {
if self.dim != Dim::End || !alive || !self.fight.loaded || self.fight.killed {
return None;
}
let near = at[0].powi(2) + (at[1] - 128.0).powi(2) + at[2].powi(2) <= 192.0 * 192.0;
if !near {
return None;
}
Some(
self.dragon
.as_ref()
.map_or(1.0, |d| (d.health / MAX_HEALTH).clamp(0.0, 1.0)),
)
}
/// `music.dragon` while the boss bar plays music.
/// `BossHealthOverlay.shouldPlayMusic`.
pub(super) fn boss_music(&self) -> bool {
self.dim == Dim::End && self.dragon.is_some() || self.wither_near()
self.boss_overlay.should_play_music()
}
}
pub(super) struct DragonFireball {
@@ -0,0 +1,39 @@
//! A Game's execution role is fixed at construction. Connecting or disconnecting
//! changes `remote`, not this role. A client may simulate a world in-frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ExecutionRole {
Client,
IntegratedServer,
DedicatedServer,
}
impl ExecutionRole {
/// Only a client owns presentation work (audio, loading UI, chunk meshes).
pub fn is_client(self) -> bool {
self == Self::Client
}
/// The optional in-frame simulation remains a client execution mode.
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
self.is_client() && server_thread
}
}
#[cfg(test)]
mod tests {
use super::ExecutionRole::*;
#[test]
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
for (role, client, threaded) in [
(Client, true, true),
(IntegratedServer, false, false),
(DedicatedServer, false, false),
] {
assert_eq!(role.is_client(), client);
assert_eq!(role.starts_integrated_server(true), threaded);
assert!(!role.starts_integrated_server(false));
}
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ impl Game<'_> {
})
}
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
match fog_type(&self.world, self.res.facts(), camera, self.forward()) {
FogType::Water => CameraFluid::Water,
FogType::Lava => CameraFluid::Lava,
+6 -9
View File
@@ -12,7 +12,9 @@
//! way.
use super::{
Game, Options, PackResources, Resources, UiResources,
execution_role::ExecutionRole,
lan::{LanSettings, Owner},
world_loading::WorldRequest,
};
use horizon_client::{
Result,
@@ -26,7 +28,6 @@ use horizon_protocol::connection::Pipe;
use horizon_protocol::{crypto::offline_uuid, text::translatable};
use std::{
net::TcpListener,
path::Path,
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
@@ -170,11 +171,6 @@ struct Launch {
player: String,
}
/// Whether this world opens on a server thread: asked for.
pub(super) fn wanted(options: &Options, _world: &Path) -> bool {
options.server_thread && options.dedicated.is_none()
}
impl Game<'_> {
/// `Minecraft.hasSingleplayerServer`: a world of this window's own, run
/// in the frame or on its server thread.
@@ -215,9 +211,10 @@ impl Game<'_> {
};
let mut options = self.options.clone();
// A server without a console: no local player, no window, no control socket.
// Compatibility for gameplay checks outside startup/world loading.
options.dedicated = Some(Vec::new());
options.server_thread = false;
options.integrated = true;
options.execution_role = ExecutionRole::IntegratedServer;
options.control = None;
options.benchmark = None;
options.frames = None;
@@ -287,13 +284,13 @@ impl Game<'_> {
tasks: Receiver<Task>,
ready: &Sender<Ready>,
) -> Result<()> {
self.open_world(
self.load_world(WorldRequest::new(
&launch.folder,
launch.name.as_deref(),
launch.seed,
launch.creative,
launch.create.as_ref(),
)?;
)?)?;
let saved = Box::new(self.level.clone());
// Math.max(2, options.renderDistance): past vanilla's 32 when the
// window's render distance is (Horizon's options go to 64).
+35 -12
View File
@@ -1939,7 +1939,7 @@ impl Game<'_> {
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sneaking = g.sneaking;
let sounds = self.as_guest(host, conn, |game| {
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
let Some(player) = game.player.as_mut() else {
@@ -2036,20 +2036,17 @@ impl Game<'_> {
// A hurt since the last tick marked the velocity (Entity.markHurt):
// ServerEntity.sendChanges sends it as it is after this travel.
let marked = std::mem::take(&mut player.health.sync_velocity);
let sounds = events
.into_iter()
.filter_map(|e| match e {
MotionEvent::HurtSound(sound) => Some(sound),
_ => None,
})
.collect::<Vec<_>>();
(sounds, marked.then_some(player.velocity))
(events, marked.then_some(player.velocity))
});
let Some((sounds, marked)) = sounds else {
let Some((events, marked)) = events else {
return;
};
for sound in sounds {
self.player_hurt_sound(sound, at);
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
_ => {}
}
}
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
g.send(&C::SetEntityMotion(play::SetEntityMotion {
@@ -2452,6 +2449,32 @@ impl Game<'_> {
.map_or(&[][..], |log| log.get(logged..).unwrap_or(&[]))
.to_vec();
let facts = self.res.facts();
// BlockItem.place: `playSound(player, ...)`, for the others only. The
// placer's client plays it as its block appears.
let placed: Vec<([f64; 3], String)> = changed
.iter()
.map(|&(x, y, z)| {
let at = [f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5];
let name = &facts.get(self.world.block(x, y, z)).sounds.place;
(at, name.trim_start_matches("minecraft:").to_owned())
})
.collect();
if let Some(capture) = self.audio.capture.as_mut() {
let mut i = heard.min(capture.len());
while i < capture.len() {
let (name, at, ..) = &capture[i];
let place = placed
.iter()
.any(|(p, n)| p == at && name.trim_start_matches("minecraft:") == n.as_str());
if place {
let (name, at, volume, pitch) = capture.remove(i);
let source = horizon_client::settings::sound_source(&name) as i32;
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
} else {
i += 1;
}
}
}
if changed.is_empty()
|| !changed.iter().all(|&(x, y, z)| {
super::remote::hand_operated(&facts.get(self.world.block(x, y, z)).name)
+1 -14
View File
@@ -321,20 +321,7 @@ impl Game<'_> {
}
for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) {
use horizon_client::projectiles::ProjectileKind as K;
let kind = match &p.kind {
K::Arrow { .. } => "minecraft:arrow",
K::Snowball => "minecraft:snowball",
K::Egg => "minecraft:egg",
K::EnderPearl => "minecraft:ender_pearl",
K::SmallFireball => "minecraft:small_fireball",
K::LargeFireball { .. } => "minecraft:fireball",
K::LlamaSpit => "minecraft:llama_spit",
K::WindCharge => "minecraft:wind_charge",
K::BreezeWindCharge => "minecraft:breeze_wind_charge",
K::ShulkerBullet { .. } => "minecraft:shulker_bullet",
K::WitherSkull { .. } => "minecraft:wither_skull",
K::Potion { .. } => "minecraft:splash_potion",
};
let kind = p.kind.id();
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
e.pitch = p.pitch;
e.velocity = at(p.velocity);
@@ -48,10 +48,9 @@ impl Game<'_> {
if let Some(id) = state.mob
&& state.session.villager
{
// startTrading with the guest's reputation (their effects are
// not modelled on the host: no Hero of the Village).
// startTrading with the guest's reputation. The session already
// captured their Hero effect while they acted as the player.
state.session.reputation = self.villager_reputation_of(id, uuid);
state.session.hero = None;
state.session.update_special_prices();
}
let title = self.merchant_display_name(&state);
+2 -2
View File
@@ -221,7 +221,7 @@ impl Game<'_> {
/// holds it (`self.lan` is taken during the network tick).
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
// The in-frame game keeps its one level (`enter_dimension`).
if self.options.dedicated.is_none() {
if self.options.execution_role.is_client() {
return Ok(());
}
if target != self.dim {
@@ -261,7 +261,7 @@ impl Game<'_> {
/// nobody is in it; returns the level to put back afterwards. Only a
/// server holds other levels.
pub(super) fn enter_command_level(&mut self, dim: Dim) -> Option<Dim> {
if self.options.dedicated.is_none() || dim == self.dim {
if self.options.execution_role.is_client() || dim == self.dim {
return None;
}
let back = self.dim;
+11 -2
View File
@@ -156,6 +156,8 @@ impl Game<'_> {
kind.as_str(),
"minecraft:experience_orb"
| "minecraft:arrow"
| "minecraft:fireball"
| "minecraft:small_fireball"
| "minecraft:falling_block"
| "minecraft:end_crystal"
| "minecraft:ender_dragon"
@@ -195,6 +197,11 @@ impl Game<'_> {
self.creatures.projectiles.push(p);
}
}
"minecraft:fireball" | "minecraft:small_fireball" => {
if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
self.creatures.projectiles.push(p);
}
}
"minecraft:falling_block" => {
if let Some((state, position, velocity, age, fall_distance)) =
entity_nbt::falling_block_from_nbt(&tag)
@@ -642,13 +649,15 @@ impl Game<'_> {
} else {
None
};
let (uuid, raw) = anon(&self.persist, "minecraft:arrow", p.position);
let (uuid, raw) = anon(&self.persist, p.kind.id(), p.position);
let in_block = p
.arrow
.as_ref()
.and_then(|a| a.last_state)
.map(|s| self.state_to_nbt(s));
if let Some(tag) = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref()) {
let tag = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref())
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref()));
if let Some(tag) = tag {
out.entry(chunk_of(p.position)).or_default().push(tag);
}
}
+42 -32
View File
@@ -6,7 +6,6 @@ use super::{Dim, Game};
use horizon_client::mobs::villager as mobs_villager;
use horizon_client::{
effects::{EffectInstance, OmenTick},
hud_overlays,
mobs::{
Mob, Species, raider,
spawn_rules::{self, SpawnLevel},
@@ -15,9 +14,9 @@ use horizon_client::{
},
patrol::{GameplayAttributes, Member, PatrolLevel, PatrolSpawner},
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, RaiderType, Raids, Spawn},
text::Text,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_world::nbt::Tag;
use std::collections::{HashMap, HashSet};
/// Patrol and raid state owned by the game.
@@ -1063,37 +1062,18 @@ impl Game<'_> {
}
}
/// The raid boss bars: red and notched in tens, with the raid's title.
pub(super) fn raid_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
for raid in self.raiding.raids.map.values() {
if !raid.bar.visible || !raid.bar.player || raid.is_stopped() {
continue;
}
let title = self.translate("event.minecraft.raid", "Raid");
let name = match raid.bar.name {
BarName::Raid => title,
BarName::Remaining(n) => format!(
"{title} - {}",
self.translate(
"event.minecraft.raid.raiders_remaining",
"Raiders Remaining: %s"
)
.replace("%s", &n.to_string())
),
BarName::Victory => {
self.translate("event.minecraft.raid.victory.full", "Raid - Victory")
}
BarName::Defeat => {
self.translate("event.minecraft.raid.defeat.full", "Raid - Defeat")
}
};
bars.push(hud_overlays::BossBar {
name: Text::literal(name),
progress: raid.bar.progress,
color: 2,
overlay: 2,
});
/// The raid bar a living player at `at` is in (`Raid.getPlayers`): red,
/// notched in tens. The name is the translate component the client draws.
pub(super) fn raid_boss_at(&self, at: [f64; 3], alive: bool) -> Option<(i32, Tag, f32)> {
if !alive || self.dim != Dim::Overworld {
return None;
}
let block = block_of(Vec3::new(at[0], at[1], at[2]));
let (id, raid) = self.raiding.raids.nearby(block)?;
if !raid.bar.visible || raid.is_stopped() {
return None;
}
Some((id, raid_name_tag(raid.bar.name), raid.bar.progress))
}
/// A firework a villager launches to celebrate (`CelebrateVillagersSurvivedRaid`).
pub(super) fn villager_firework(&mut self, at: [f64; 3], color: usize, flight: u8) {
@@ -1239,6 +1219,36 @@ impl Game<'_> {
}
}
/// `Raid.raidEvent`'s name: the raid title, the remaining-raiders line, or
/// the victory and defeat lines.
fn raid_name_tag(name: BarName) -> Tag {
let key = |k: &str| Tag::compound([("translate", Tag::String(k.into()))]);
match name {
BarName::Raid => key("event.minecraft.raid"),
BarName::Victory => key("event.minecraft.raid.victory.full"),
BarName::Defeat => key("event.minecraft.raid.defeat.full"),
BarName::Remaining(n) => Tag::compound([
("translate", Tag::String("event.minecraft.raid".into())),
(
"extra",
Tag::List(vec![
Tag::String(" - ".into()),
Tag::compound([
(
"translate",
Tag::String("event.minecraft.raid.raiders_remaining".into()),
),
(
"with",
Tag::List(vec![Tag::String(n.to_string().as_str().into())]),
),
]),
]),
),
]),
}
}
#[cfg(test)]
mod tests {
use super::*;
+214 -52
View File
@@ -193,6 +193,9 @@ pub(super) struct Remote {
/// the oldest of them first waited (`poll_remote`).
backlog: VecDeque<Event>,
backlog_since: Option<Instant>,
/// `ClientboundTransferPacket`'s target: when the connection ends the
/// game connects here, the stored cookies carried over (`TransferState`).
transfer: Option<String>,
}
/// The time a frame spends on chunk packets before it leaves the rest of
@@ -257,14 +260,25 @@ fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFo
impl Game<'_> {
/// Leave the title for a server (ConnectScreen): an empty world fed by the connection.
pub(super) fn connect_server(&mut self, address: &str, name: &str) -> Result<()> {
self.connect_server_with_cookies(address, name, None)
}
/// `connect_server` carrying a transfer's stored server cookies
/// (`TransferState`); None on an ordinary join.
pub(super) fn connect_server_with_cookies(
&mut self,
address: &str,
name: &str,
cookies: Option<horizon_client::net::Cookies>,
) -> Result<()> {
if self.defer_world_entry(super::PendingWorldEntry::Server {
address: address.to_owned(),
name: name.to_owned(),
cookies: cookies.clone(),
})? {
return Ok(());
}
self.join_world_rest()?;
if let Err(error) = self.join_server(address, name, None) {
if let Err(error) = self.join_server(address, name, None, cookies) {
log::warn!("Horizon: could not join server: {error}");
if self.title.is_none() {
self.title = Some(horizon_client::title::Title::new(
@@ -285,13 +299,14 @@ impl Game<'_> {
pipe: horizon_protocol::connection::Pipe,
name: &str,
) -> Result<()> {
self.join_server("local", name, Some(pipe))
self.join_server("local", name, Some(pipe), None)
}
fn join_server(
&mut self,
address: &str,
name: &str,
local: Option<horizon_protocol::connection::Pipe>,
cookies: Option<horizon_client::net::Cookies>,
) -> Result<()> {
self.perf.world_opening();
let root = self.options.resources.clone();
@@ -323,7 +338,17 @@ impl Game<'_> {
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
None => {
self.extras_connecting(address, name);
Session::connect(address, name, auth.clone(), view, lang.clone())
match cookies {
Some(cookies) => Session::connect_transferred(
address,
name,
auth.clone(),
view,
lang.clone(),
cookies,
),
None => Session::connect(address, name, auth.clone(), view, lang.clone()),
}
}
};
let player_type = super::perspective::player_type(&root);
@@ -383,6 +408,7 @@ impl Game<'_> {
}
self.apply_settings();
self.clear_waypoints();
self.boss_overlay.reset();
self.remote = Some(Remote {
session,
brand: "unknown".into(),
@@ -445,6 +471,7 @@ impl Game<'_> {
chunk_center: (0, 0),
backlog: VecDeque::new(),
backlog_since: None,
transfer: None,
});
// The level's TickRateManager follows the server's ticking packets.
self.clock = horizon_client::clock::FixedStep::client();
@@ -465,6 +492,7 @@ impl Game<'_> {
self.extras_left();
}
self.clear_waypoints();
self.boss_overlay.reset();
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
@@ -612,6 +640,21 @@ impl Game<'_> {
continue;
};
let value = match c {
// Read past, not decoded: the item keeps its prototype's value.
Component::Raw(_) => continue,
// Trim materials and patterns are synced registries, as enchantments are.
Component::Trim { material, pattern } => {
let (Some(material), Some(pattern)) = (
r.registries.name("minecraft:trim_material", *material),
r.registries.name("minecraft:trim_pattern", *pattern),
) else {
continue;
};
Tag::compound([
("material", Tag::String(material.into())),
("pattern", Tag::String(pattern.into())),
])
}
Component::Int(v) if *kind == component::RARITY => {
let rarity = ["common", "uncommon", "rare", "epic"];
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
@@ -738,6 +781,9 @@ impl Game<'_> {
self.ticks,
self.screen == Screen::Chat,
);
if self.mods.running() {
self.mods_chat(&text);
}
if let Some(r) = self.remote.as_mut() {
r.chat.push((text, Instant::now()));
r.chat_received += 1;
@@ -834,7 +880,11 @@ impl Game<'_> {
r.joined = true;
r.profile = uuid;
r.profile_name = name.clone();
r.status = format!("Joined as {name}; loading terrain...");
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Brand(brand) => {
@@ -843,6 +893,15 @@ impl Game<'_> {
}
}
Event::Reconfigured(registries) => self.set_registries(registries),
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
if let Some(r) = self.remote.as_mut()
&& r.session.address != "local"
{
r.transfer = Some(format!("{host}:{port}"));
}
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::DecodeError(e) => {
@@ -858,6 +917,20 @@ impl Game<'_> {
from_server,
} => {
log::info!("Horizon: disconnected: {reason}");
// ClientboundTransferPacket left a target: connect there
// instead of showing the disconnect, the stored cookies
// carried over (ClientCommonPacketListenerImpl.
// handleTransfer → ConnectScreen.startConnecting).
let transfer = self.remote.as_mut().and_then(|r| {
r.transfer.take().map(|address| {
(address, r.session.name.clone(), r.session.cookies.clone())
})
});
if let Some((address, name, cookies)) = transfer {
self.leave_server(None)?;
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let retry = self.reconnect_after(&reason, from_server);
self.leave_server(Some(reason))?;
self.offer_reconnect(retry);
@@ -991,6 +1064,9 @@ impl Game<'_> {
r.stepped.clear();
r.head_lerps.clear();
r.pending_items.clear();
// The entity ids are the old world's; the new dimension's
// player entities re-map to their profiles on AddEntity.
r.level_players.clear();
// A new ClientLevel: a new chunk storage, centred on 0, 0.
r.chunk_center = (0, 0);
}
@@ -1496,6 +1572,7 @@ impl Game<'_> {
);
}
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
self.move_remote_text_display(id, position, rotation);
self.turn_remote_projectile(id, rotation);
}
fn remove_entity(&mut self, id: i32) {
@@ -1601,6 +1678,12 @@ impl Game<'_> {
}
}
}
/// Find a game slot in the menu's complete, contiguous slot mapping.
fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
(0..)
.map_while(|slot| Self::menu_slot(menu, slot))
.position(|slot| slot == internal)
}
/// The workstation screen of a menu type a server opens.
pub(super) fn remote_station_kind(menu: &str) -> Option<super::stations::Kind> {
use super::stations::Kind;
@@ -1805,6 +1888,9 @@ impl Game<'_> {
self.audio.play(&name, Some(at), p.volume, p.pitch);
}
}
C::StopSound(p) => self
.audio
.stop_sounds(p.sound.as_deref(), p.source.map(|s| s as usize)),
C::AddEntity(p) => {
let kind = self
.remote
@@ -1838,6 +1924,7 @@ impl Game<'_> {
p.data,
);
}
self.move_remote_text_display(p.id, p.position, Some((rotation[0], rotation[1])));
}
C::RemoveEntities(p) => {
for id in p.ids {
@@ -2691,6 +2778,7 @@ impl Game<'_> {
}
// ClientPacketListener.setActionBarText, setTitleText,
// setSubtitleText, setTitlesAnimation and handleTitlesClear.
C::BossEvent(p) => self.apply_boss_event(&p),
C::SetActionBarText(p) => self.titles.set_overlay(remote_text(&p.text), false),
C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)),
C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)),
@@ -3064,6 +3152,8 @@ impl Game<'_> {
}
None => {
let mut player = PlayerMotion::new(self.motion, position)?;
// Player.tick derives noPhysics from the current game mode.
player.spectator = self.spectator();
player.velocity = delta;
self.player = Some(player);
self.pad.player_replaced();
@@ -3098,6 +3188,8 @@ impl Game<'_> {
/// One client tick while connected (Minecraft.tick with a ClientLevel).
pub(super) fn tick_remote(&mut self) -> Result<()> {
self.tick_remote_level()?;
// Client mods tick on any server, single player's own included.
self.tick_mods();
// Minecraft.tick sends this even when the player cannot tick yet,
// including while waiting for chunks. Keep it outside early returns.
if self.remote.as_ref().is_some_and(|r| r.joined) {
@@ -3123,6 +3215,7 @@ impl Game<'_> {
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
// the time only while the level runs normally.
let running = self.clock.world_runs();
self.boss_overlay.tick_darken(running);
if running {
self.world.game_time += 1;
self.remote_clock_tick();
@@ -3407,6 +3500,9 @@ impl Game<'_> {
/// ClientLevel.tickEntities; a frozen level only ticks the players
/// (`TickRateManager.isEntityFrozen`).
fn tick_remote_entities(&mut self, running: bool) {
if running {
self.tick_text_displays();
}
// EntityFluidInteraction.update for the entities this window only displays: the
// server never sends the fluid contact, and the renderers read it (cod, salmon and
// tropical fish lie on their side without `in_water`, #1591).
@@ -3883,7 +3979,7 @@ impl Game<'_> {
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
// A boat, minecart, item frame, armor stand or painting nearer than
// any mob: the server uses it (AbstractBoat.interact boards it).
if let Some(hit) = self.remote_object_hit()
if let Some(hit) = self.remote_object_hit(false)
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.before_block(hit.distance)
{
@@ -3969,11 +4065,17 @@ impl Game<'_> {
self.predict_item_on_block(stack, &hit);
}
if held_block {
if reacts
|| held
.as_ref()
.is_some_and(|stack| self.remote_can_place(&hit, stack))
{
let placed = if reacts {
None
} else {
held.as_ref()
.and_then(|stack| self.remote_can_place(&hit, stack))
};
if let Some(placed) = &placed {
self.predict_place(placed, seq);
self.with_hand(hand, Game::consume_held);
}
if reacts || placed.is_some() {
self.with_hand(hand, Game::swing_arm);
}
// BlockItem.place returns FAIL when placement is rejected:
@@ -4033,18 +4135,7 @@ impl Game<'_> {
{
return;
}
for &(p, state) in changes {
let pos = [p.x, p.y, p.z];
let before = self.world.block(p.x, p.y, p.z);
// BlockStatePredictionHandler.retainKnownServerState.
if let Some(r) = self.remote.as_mut() {
r.predictions
.entry(pos)
.and_modify(|e| e.0 = seq)
.or_insert((seq, before.0 as i32));
}
self.set_block(pos, state.0 as i32);
}
self.predict_blocks(changes, seq);
// `level.playSound(player, ...)`: the clicker hears it from its own use; the
// server sends it to the others only.
for (pos, sound, volume, pitch) in sounds {
@@ -4104,12 +4195,45 @@ impl Game<'_> {
})
}
/// Run BlockItem.place on a disposable level before predicting its swing.
/// The real level supplies collision shapes and building-blocking entities;
/// block writes, random draws, scheduled ticks and events stay in the preview.
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
/// MultiPlayerGameMode.useItemOn inside startPrediction, for a block item:
/// the blocks `BlockItem.place` set at `pos` are placed on this client's
/// level at once, so the next click of a fast bridge aims at them rather
/// than past them (the server would refuse it). Its place sound is the
/// placer's own (`playSound(player, ...)`: the server sends it to the
/// others only).
fn predict_place(&mut self, (pos, changes): &DryPlace, seq: i32) {
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
// set_block plays the place sound of the block at `placing`.
r.placing = Some(([pos.x, pos.y, pos.z], ticks));
}
self.predict_blocks(changes, seq);
}
/// The blocks a use changed, set on this client's level and kept as
/// predicted under the use's sequence until the server acknowledges it
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
/// server refused goes back.
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
for &(p, state) in changes {
let pos = [p.x, p.y, p.z];
let before = self.world.block(p.x, p.y, p.z);
if let Some(r) = self.remote.as_mut() {
r.predictions
.entry(pos)
.and_modify(|e| e.0 = seq)
.or_insert((seq, before.0 as i32));
}
self.set_block(pos, state.0 as i32);
}
}
/// Run BlockItem.place on a disposable level: where it placed and the
/// blocks it set. The real level supplies collision shapes and
/// building-blocking entities; random draws, scheduled ticks and events
/// stay in the preview.
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> Option<DryPlace> {
if !self.may_build() {
return false;
return None;
}
let ctx = horizon_world::level::PlaceContext {
clicked_pos: horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
@@ -4124,14 +4248,16 @@ impl Game<'_> {
};
self.with_level(|b, level, _| {
let mut level = DryLevel::new(level);
matches!(
b.place_block(
&mut level,
&mut horizon_world::level::Ticks::default(),
&ctx
),
horizon_world::level::PlaceOutcome::Placed { .. }
)
match b.place_block(
&mut level,
&mut horizon_world::level::Ticks::default(),
&ctx,
) {
horizon_world::level::PlaceOutcome::Placed { pos, .. } => {
Some((pos, level.changed.into_iter().collect()))
}
horizon_world::level::PlaceOutcome::Failed => None,
}
})
}
@@ -4233,11 +4359,13 @@ impl Game<'_> {
return;
};
let stack = self.stack_in(slot).cloned();
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
// A drawn bow, a charging crossbow, a raised shield...: held until
// the button lets go (or another slot is picked).
// the button lets go.
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
self.using = (slot == self.selected || slot == super::shield::OFFHAND_SLOT)
.then_some((slot, ticks + 1));
self.using = in_hand.then_some((slot, ticks + 1));
return;
}
let consumable = stack
@@ -4251,7 +4379,7 @@ impl Game<'_> {
// Button release belongs to the next handleKeybinds phase, which
// sends RELEASE_USE_ITEM. Do not silently clear a use begun by a
// quick press/release between ticks.
if slot != self.selected {
if !in_hand {
self.using = None;
return;
}
@@ -4360,12 +4488,12 @@ impl Game<'_> {
id: self.creatures.projectiles[i].id as i32,
}));
self.reset_attack_strength();
} else if let Some(hit) = self.remote_object_hit()
} else if let Some(hit) = self.remote_object_hit(true)
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.before_block(hit.distance)
{
// VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer,
// a painting broken: the server decides.
// a painting broken or an EndCrystal hurtServer: the server decides.
self.send(S::Attack(play::Attack { id: hit.id }));
self.reset_attack_strength();
} else if let Some((i, distance)) = self.mob_hit()
@@ -4572,12 +4700,10 @@ impl Game<'_> {
for &(slot, button, input) in ops {
let slot = match slot {
None => -999,
Some(internal) => {
match (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal)) {
Some(s) => s as i16,
None => return false,
}
}
Some(internal) => match Self::menu_slot_index(&menu, internal) {
Some(s) => s as i16,
None => return false,
},
};
clicks.push((slot, button, input));
}
@@ -4646,7 +4772,7 @@ impl Game<'_> {
/// such slot.
pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool {
let (container, menu) = self.remote_menu();
let slot = (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal));
let slot = Self::menu_slot_index(&menu, internal);
let Some(slot) = slot else { return false };
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
@@ -4722,6 +4848,11 @@ impl Game<'_> {
self.mount_menu_reset();
// The client's menu has no level access: what it holds is the server's.
self.station = None;
// AbstractContainerMenu.removed: the server gives the grid and the
// carried stack back to the inventory and syncs it; the copies here
// are stale the moment the window closes.
self.craft_grid.fill(None);
self.carried = None;
self.craft_size = 2;
if let Some(key) = self.container.take() {
// Server containers stay renderable but lose their copied slots.
@@ -5081,6 +5212,8 @@ impl Game<'_> {
.collect::<Vec<_>>(),
// The player's own model exists (third person and the inventory draw it).
"player_model": self.player_model.is_some(),
"text_display_vertices": self.text_display_vertices,
"text_displays": self.text_displays.len(),
"health": r.health.0,
"food": r.health.1,
"decode_errors": r.decode_errors,
@@ -5180,11 +5313,12 @@ type DrySound = (
f32,
f32,
);
/// The blocks a use on the dry level set.
type DryChanges = Vec<(horizon_core::position::BlockPos, BlockStateId)>;
/// What a use on the dry level changed, and the sounds it played.
type DryUse = (
Vec<(horizon_core::position::BlockPos, BlockStateId)>,
Vec<DrySound>,
);
type DryUse = (DryChanges, Vec<DrySound>);
/// Where `BlockItem.place` on the dry level placed, and the blocks it set.
type DryPlace = (horizon_core::position::BlockPos, DryChanges);
/// The blocks a guest's hand flips that its use predicts (and hears at once): a wooden or
/// copper door, trapdoor or fence gate, a lever or a button. An iron door or trapdoor has
@@ -5373,6 +5507,10 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
}
impl Remote {
/// Players in the server's player list, this one included.
pub(super) fn player_count(&self) -> usize {
self.players.len()
}
/// The local player's profile UUID.
pub(super) fn profile(&self) -> u128 {
self.profile
@@ -5505,4 +5643,28 @@ mod tests {
}
assert_eq!(Game::menu_slot("inventory", 46), None);
}
#[test]
fn remote_clicks_find_slots_past_the_old_large_chest_cutoff() {
for (menu, internal, index) in [
("minecraft:generic_9x6", 18, 63),
("minecraft:generic_9x6", 19, 64),
("minecraft:generic_9x6", 0, 81),
("minecraft:generic_9x6", 8, 89),
("minecraft:generic_9x6", 353, 53),
("minecraft:generic_9x5", 8, 80),
("minecraft:generic_9x4", 8, 71),
("minecraft:generic_9x3", 8, 62),
("inventory", 504, 45),
] {
assert_eq!(
Game::menu_slot_index(menu, internal),
Some(index),
"{menu}, game slot {internal}"
);
}
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 354), None);
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 504), None);
assert_eq!(Game::menu_slot_index("unknown", 0), None);
}
}
@@ -24,7 +24,12 @@ impl Game<'_> {
let at = Vec3::new(position[0], position[1], position[2]);
let block = position.map(|v| v.floor() as i32);
self.remove_remote_object(id);
if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
if kind == "minecraft:text_display" {
self.text_displays.insert(
id as i32,
super::text_displays::RemoteTextDisplay::new(position, yaw),
);
} else if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
let mut c = super::minecarts::Minecart::new(id, cart, at);
c.yaw = yaw;
c.previous_yaw = yaw;
@@ -93,6 +98,10 @@ impl Game<'_> {
/// `handleSetEntityData` for an object: what changes how it looks.
pub(super) fn remote_object_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
if let Some(display) = self.text_displays.get_mut(&id) {
display.metadata(values);
return;
}
let id = id as u32;
for (index, _, value) in values {
if self.item_frames.iter().any(|f| f.id == id) {
@@ -273,6 +282,7 @@ impl Game<'_> {
/// Every object a server sent (a new connection, level or dimension).
pub(super) fn clear_remote_objects(&mut self) {
self.text_displays.clear();
self.carts.clear();
self.boats.clear();
self.armor_stands.clear();
@@ -281,6 +291,7 @@ impl Game<'_> {
}
pub(super) fn remove_remote_object(&mut self, id: u32) {
self.text_displays.remove(&(id as i32));
self.carts.retain(|c| c.id != id);
self.boats.retain(|b| b.id != id);
self.armor_stands.retain(|a| a.id != id);
@@ -24,6 +24,14 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
"minecraft:llama_spit" => ProjectileKind::LlamaSpit,
"minecraft:wind_charge" => ProjectileKind::WindCharge,
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
// Homing state stays on the server; the client interpolates its positions.
"minecraft:shulker_bullet" => ProjectileKind::ShulkerBullet {
target: horizon_client::mobs::Who::Player,
dir: None,
steps: 0,
delta: [0.0; 3],
seed: 0,
},
"minecraft:wither_skull" => ProjectileKind::WitherSkull { dangerous: false },
_ => return None,
})
@@ -9,8 +9,8 @@
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on
//! the server with the rider's input, and the rider sits in it.
use super::{Game, Keys, view_vector};
use horizon_core::geometry::Vec3;
use super::{Game, Keys, ray_box, view_vector};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::packets::play::{self, Serverbound as S};
/// An object under the crosshair: its entity id, how far along the view it
@@ -24,8 +24,8 @@ pub(super) struct ObjectHit {
impl Game<'_> {
/// The nearest boat, minecart, item frame, armor stand or painting the
/// view hits within reach.
pub(super) fn remote_object_hit(&self) -> Option<ObjectHit> {
/// view hits within reach; also End crystals when attacking.
pub(super) fn remote_object_hit(&self, attacking: bool) -> Option<ObjectHit> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let at = |d: f64| {
@@ -64,6 +64,22 @@ impl Game<'_> {
let p = self.carts[i].position;
hits.push((self.carts[i].id as i32, d, [p.x, p.y, p.z]));
}
if attacking {
// EndCrystal.isPickable: its 2 x 2 box, with the server's entity
// id, not special_targets' local projectile target id.
// Entity.interact passes on a crystal, so right clicks use the item.
let reach = if self.creative { 5.0 } else { 3.0 };
for c in self.crystals.iter().filter(|c| c.id != 0) {
let p = c.position;
let bounds = Aabb::new(
Vec3::new(p.x - 1.0, p.y, p.z - 1.0),
Vec3::new(p.x + 1.0, p.y + 2.0, p.z + 1.0),
);
if let Some(d) = ray_box(eye, dir, reach, bounds) {
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
}
}
}
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
let point = at(distance);
Some(ObjectHit {
@@ -100,7 +100,10 @@ impl Game<'_> {
skybox: self.dim == Dim::Overworld,
blindness: None,
darkness: self.darkness_fog(),
boss_world_fog: self.dragon_bar_shown(),
darken_world: self
.boss_overlay
.darken_amount(self.clock.fraction() as f32),
boss_world_fog: self.boss_overlay.should_create_world_fog(),
},
)
}
+1 -1
View File
@@ -180,7 +180,7 @@ impl Game<'_> {
| Target::Cart(id)
| Target::Other(id) => self.persist.raw.get(&id),
Target::Player => self.level.player_extra.as_ref(),
Target::Guest(_) | Target::Orb(_) | Target::Arrow(_) => None,
Target::Guest(_) | Target::Orb(_) | Target::Projectile(_) => None,
};
match raw.and_then(|r| r.get("Tags")) {
Some(Tag::List(l)) => l
+246 -3
View File
@@ -8,14 +8,19 @@ use horizon_client::{
Result,
confirm_screen::Confirm,
hud::{HudAtlas, HudBatch},
multiplayer, packs,
java_text, multiplayer, packs,
scoreboard::{comp_text, tag_to_json},
server_packs::{Prompt, ServerPacks, action, download, parse_sha1, valid_url},
text::{Text, named_color},
toasts::SystemToast,
ui,
};
use horizon_core::jmath;
use horizon_protocol::{packets::configuration::ResourcePackPush, servers};
use std::path::PathBuf;
use std::{
path::PathBuf,
sync::mpsc::{Receiver, TryRecvError},
};
/// `ClientCommonPacketListenerImpl.PackConfirmScreen`: the packs it asks
/// about, whether the last one is required, and the server's message.
@@ -75,6 +80,111 @@ pub(super) struct ServerPackState {
user: (String, u128),
/// The packs loaded on top of the player's own, bottom first.
pub(super) active: Vec<packs::Pack>,
/// The download batches' notifiers, oldest first.
notifiers: Vec<PackNotifier>,
}
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
/// own `SystemToast`.
struct PackNotifier {
progress: Receiver<download::Progress>,
total: usize,
/// `count`: the requests started.
count: usize,
/// `failCount`.
failed: usize,
/// `totalBytes`: the Content-Length of the request in flight.
size: Option<u64>,
/// The bytes it has written so far: none before the first response.
bytes: Option<u64>,
/// The last request has finished.
done: bool,
toast: Option<SystemToast>,
}
impl PackNotifier {
fn new(progress: Receiver<download::Progress>, total: usize) -> Self {
Self {
progress,
total,
count: 0,
failed: 0,
size: None,
bytes: None,
done: false,
toast: None,
}
}
/// The batch's events since the last call, with the toast updated once
/// for all of them. False once the batch has ended and its toast is gone.
fn poll(&mut self) -> bool {
let (mut changed, mut hide) = (false, false);
let ended = loop {
let event = match self.progress.try_recv() {
Ok(event) => event,
Err(e) => break e == TryRecvError::Disconnected,
};
changed |= match event {
download::Progress::RequestStart => {
self.count += 1;
true
}
download::Progress::DownloadStart(size) => {
self.size = size;
self.bytes = Some(0);
true
}
download::Progress::Bytes(bytes) => {
self.bytes = Some(bytes);
true
}
download::Progress::RequestFinished(ok) => {
self.failed += usize::from(!ok);
self.done = self.count == self.total;
// Every pack came: forceHide; else the failure title.
hide |= self.done && self.failed == 0;
self.done && self.failed > 0
}
};
};
if changed {
let (title, message) = self.text();
let (title, message) = (title.plain(), message.map(|m| m.plain()));
// SystemToast.addOrUpdate.
match &mut self.toast {
Some(toast) => toast.reset(title, message),
None => self.toast = Some(SystemToast::new(title, message)),
}
}
if hide && let Some(toast) = &mut self.toast {
toast.force_hide();
}
!ended || self.toast.is_some()
}
/// The toast's title and message.
fn text(&self) -> (Text, Option<Text>) {
if self.done && self.failed > 0 {
let failed = Text::tr("download.pack.failed", &[&self.failed, &self.total]);
return (failed, None);
}
let title = Text::tr("download.pack.title", &[&self.count, &self.total]);
let message = self.bytes.map(|bytes| match self.size {
Some(size) if size > 0 => {
Text::tr("download.pack.progress.percent", &[&(bytes * 100 / size)])
}
_ => Text::tr("download.pack.progress.bytes", &[&human_readable(bytes)]),
});
(title, message)
}
}
/// `com.mojang.realmsclient.Unit.humanReadable`, for
/// `download.pack.progress.bytes`.
fn human_readable(bytes: u64) -> String {
if bytes < 1024 {
return format!("{bytes} B");
}
let exp = (jmath::log(bytes as f64) / jmath::log(1024.0)) as i32;
let prefix = &"KMGTPE"[exp as usize - 1..exp as usize];
let value = bytes as f64 / 1024f64.powi(exp);
format!("{} {prefix}B", java_text::format_fixed(value, 1))
}
fn saves() -> PathBuf {
@@ -89,6 +199,7 @@ impl Game<'_> {
let state = &mut self.server_packs;
state.user = user;
state.prompt = None;
state.notifiers.clear();
let Some(address) = address else {
state.address = None;
state.packs.local_world();
@@ -103,6 +214,7 @@ impl Game<'_> {
/// `Minecraft.clearDownloadedResourcePacks`: every server pack goes at once.
pub(super) fn server_packs_disconnect(&mut self) {
self.server_packs.prompt = None;
self.server_packs.notifiers.clear();
self.server_packs.packs.disconnect();
self.tick_server_packs();
}
@@ -197,6 +309,7 @@ impl Game<'_> {
/// that finished, the ones to start, a resource reload when the active
/// packs change, and the statuses for the server.
pub(super) fn tick_server_packs(&mut self) {
self.server_packs.notifiers.retain_mut(PackNotifier::poll);
// A reload waits for the world's resources (`reload_packs`).
if self.rest_loader.is_some() || self.world_loader.is_some() {
return;
@@ -238,6 +351,15 @@ impl Game<'_> {
Ok(queue) => state.queue = Some(queue),
Err(e) => {
log::error!("Horizon: failed to open the download queue: {e}");
// Every request failed, as the notifier would hear it.
let (progress, events) = std::sync::mpsc::channel();
for _ in &requests {
let _ = progress.send(download::Progress::RequestStart);
let _ = progress.send(download::Progress::RequestFinished(false));
}
state
.notifiers
.push(PackNotifier::new(events, requests.len()));
let failed = requests.iter().map(|r| r.id).collect();
state.packs.downloaded(horizon_client::server_packs::Batch {
downloaded: Vec::new(),
@@ -248,8 +370,12 @@ impl Game<'_> {
}
}
let headers = download::headers(&state.user.0, state.user.1);
let (progress, events) = std::sync::mpsc::channel();
state
.notifiers
.push(PackNotifier::new(events, requests.len()));
if let Some(queue) = &state.queue {
queue.download(requests, headers);
queue.download(requests, headers, progress);
}
}
/// `DownloadedPackSource.startReload`: the packs on top of the player's
@@ -297,6 +423,29 @@ impl Game<'_> {
.layout(atlas, (w, h))
.draw(batch, atlas, self.cursor, [&yes, &no]);
}
/// The download notifiers' toasts, each in the next 32-pixel slot
/// below an advancement or recipe toast (`ToastManager`).
pub(super) fn draw_pack_toasts(&mut self, batch: &mut HudBatch) {
let multiplier = f64::from(self.settings.notification_display_time);
let mut slot = usize::from(!self.adv.toasts.is_empty());
for notifier in &mut self.server_packs.notifiers {
let Some(toast) = &mut notifier.toast else {
continue;
};
let (sound, finished) = toast.update(multiplier);
if let Some(show) = sound {
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
self.audio
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
}
if finished {
notifier.toast = None;
continue;
}
toast.draw(&self.gui.atlas, batch, 32.0 * slot as f32);
slot += 1;
}
}
/// A left click on the prompt: its answer when it hit a button.
pub(super) fn pack_prompt_click(&mut self) -> Result<()> {
let Some(prompt) = &self.server_packs.prompt else {
@@ -314,3 +463,97 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use download::Progress::*;
fn notifier(total: usize, events: &[download::Progress]) -> PackNotifier {
let (send, receive) = std::sync::mpsc::channel();
for event in events {
send.send(*event).unwrap();
}
let mut notifier = PackNotifier::new(receive, total);
assert!(notifier.poll());
notifier
}
fn texts(n: &PackNotifier) -> (serde_json::Value, Option<serde_json::Value>) {
let (title, message) = n.text();
(title.component(), message.map(|m| m.component()))
}
#[test]
fn the_toast_counts_requests_and_shows_progress() {
let n = notifier(2, &[RequestStart]);
let (title, message) = texts(&n);
assert_eq!(title["translate"], "download.pack.title");
assert_eq!(title["with"], serde_json::json!([1, 2]));
assert_eq!(message, None);
let n = notifier(2, &[RequestStart, DownloadStart(Some(400)), Bytes(100)]);
assert_eq!(
texts(&n).1.unwrap(),
serde_json::json!({"translate": "download.pack.progress.percent", "with": [25]})
);
let n = notifier(1, &[RequestStart, DownloadStart(None), Bytes(1280)]);
assert_eq!(
texts(&n).1.unwrap()["with"],
serde_json::json!([{"text": "1.3 KB"}])
);
}
#[test]
fn a_failed_pack_turns_the_toast_into_the_failure_count() {
let events = [
RequestStart,
RequestFinished(false),
RequestStart,
DownloadStart(Some(3)),
Bytes(3),
];
let (send, receive) = std::sync::mpsc::channel();
for event in events {
send.send(event).unwrap();
}
let mut n = PackNotifier::new(receive, 2);
assert!(n.poll());
// Not the last request yet: still downloading.
assert_eq!(texts(&n).0["translate"], "download.pack.title");
send.send(RequestFinished(true)).unwrap();
drop(send);
assert!(n.poll(), "the failure toast stays until it slides out");
let (title, message) = texts(&n);
assert_eq!(
title,
serde_json::json!({"translate": "download.pack.failed", "with": [1, 2]})
);
assert_eq!(message, None);
}
#[test]
fn a_finished_batch_without_a_toast_is_dropped() {
let (send, receive) = std::sync::mpsc::channel::<download::Progress>();
drop(send);
assert!(!PackNotifier::new(receive, 1).poll());
}
#[test]
fn sizes_read_as_realms_unit_human_readable() {
// com.mojang.realmsclient.Unit.humanReadable, run on the 26.3 jar.
for (bytes, text) in [
(0, "0 B"),
(1023, "1023 B"),
(1024, "1.0 KB"),
(1280, "1.3 KB"),
(1535, "1.5 KB"),
(10_000, "9.8 KB"),
(1_048_575, "1024.0 KB"),
(1_048_576, "1.0 MB"),
(52_428_800, "50.0 MB"),
(262_144_000, "250.0 MB"),
(1_073_741_824, "1.0 GB"),
] {
assert_eq!(human_readable(bytes), text, "{bytes}");
}
}
}
@@ -0,0 +1,84 @@
//! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=<name>`
//! runs `shaderpacks/<name>` (a folder or a zip beside `options.txt`) over the world. The
//! programs of the current dimension's folder are loaded when the dimension changes (spec 1.2).
use horizon_client::camera::CameraFluid;
use horizon_client::gfx::pack_uniforms::PackInputs;
use horizon_shaders::{Macros, Pack, Properties, dimension_folder, load_post};
use super::{Dim, Game};
impl Game<'_> {
/// Loads the pack for the current dimension when it changed, and returns this frame's
/// inputs to the pack's uniforms.
pub(super) fn shader_pack_inputs(&mut self, camera: glam::DVec3, partial: f32) -> PackInputs {
let Some(name) = std::env::var_os("HORIZON_SHADER_PACK") else {
return PackInputs::default();
};
let dimension = match self.dim {
Dim::Overworld => 0,
Dim::Nether => -1,
Dim::End => 1,
};
if let Some(renderer) = &mut self.renderer
&& renderer.shader_pack_dimension() != Some(dimension)
{
let name = name.to_string_lossy();
let started = std::time::Instant::now();
let path = std::env::current_dir()
.unwrap_or_default()
.join("shaderpacks")
.join(&*name);
match Pack::open(&path) {
Ok(pack) => {
let macros = Macros::standard();
let text = pack.read_text("shaders.properties").unwrap_or_default();
let properties = Properties::parse(&text, &macros);
for note in &properties.notes {
log::warn!(
"shader pack {name}: shaders.properties:{}: {}",
note.line,
note.message
);
}
let folder = dimension_folder(&pack, dimension);
let post = load_post(&pack, &folder, &properties, &macros, None);
if let Some((programs, notes)) = renderer.set_shader_pack(dimension, Some(post))
{
for note in notes {
log::warn!("shader pack {name}: {note}");
}
log::info!(
"shader pack {name}: folder `{folder}`, {} programs ({}) in {} ms",
programs.len(),
programs.join(" "),
started.elapsed().as_millis()
);
}
}
Err(e) => {
log::warn!("shader pack {name}: {e}");
renderer.set_shader_pack(dimension, None);
}
}
}
let lerp = |(old, new): (f32, f32)| old + (new - old) * partial;
let at = camera.floor().as_ivec3();
let light = self.world.light(at.x, at.y, at.z);
PackInputs {
day_time: self.world.day_time,
rain: lerp(self.world.weather.rain_level),
thunder: lerp(self.world.weather.thunder_level),
eye_in: match self.camera_fluid(camera.to_array()) {
CameraFluid::None => 0,
CameraFluid::Water => 1,
CameraFluid::Lava => 2,
CameraFluid::PowderSnow => 3,
},
blindness: f32::from(u8::from(self.potions.effects.get("blindness").is_some())),
eye_light: [i32::from(light & 15), i32::from(light >> 4)],
hide_gui: self.ui.hide_gui,
brightness: self.settings.gamma,
}
}
}
@@ -33,6 +33,7 @@ impl StartupMenu {
Some(PendingWorldEntry::Server {
address: address.clone(),
name: options.player_name(),
cookies: None,
})
} else {
options
@@ -125,6 +126,7 @@ impl StartupMenu {
Action::Connect { address } => PendingWorldEntry::Server {
address,
name: player_name,
cookies: None,
},
_ => return,
};
@@ -762,7 +764,7 @@ mod tests {
"Guest".into(),
);
assert!(
matches!(menu.pending.take(), Some(PendingWorldEntry::Server { address, name })
matches!(menu.pending.take(), Some(PendingWorldEntry::Server { address, name, .. })
if address == "localhost:25565" && name == "Guest")
);
assert!(menu.pending.take().is_none(), "an entry is consumed once");
@@ -0,0 +1,302 @@
//! Server text displays: metadata, interpolation, billboard transforms and world text.
use super::Game;
use horizon_client::{
hud::{HudBatch, rgba},
mesher::Vertex,
text::Text,
text_display::{Align, TextDisplayData},
};
use horizon_protocol::entity_data::Value;
pub(super) struct RemoteTextDisplay {
pub data: TextDisplayData,
from: TextDisplayData,
shown: TextDisplayData,
position: glam::DVec3,
previous: glam::DVec3,
target: glam::DVec3,
rotation: [f32; 2],
old_rotation: [f32; 2],
target_rotation: [f32; 2],
steps: i32,
age: i64,
start: i64,
duration: i32,
dirty: bool,
start_dirty: bool,
duration_dirty: bool,
initialized: bool,
/// `Display.lastProgress`: the progress the last frame drew, where a new
/// interpolation starts from.
last_progress: f32,
/// `TextDisplay.clientDisplayCache`: the split lines, until the render
/// state changes.
lines: Option<Lines>,
}
/// `TextDisplay.CachedInfo`: each line and its width, and the widest.
struct Lines {
lines: Vec<(Text, f32)>,
width: f32,
}
/// `Display.RENDER_STATE_IDS` and `TextDisplay.TEXT_RENDER_STATE_IDS`: the
/// data a render state is built from (not the view range, size or glow).
fn renders(id: u8) -> bool {
matches!(id, 11..=16 | 18 | 19 | 23..=27)
}
impl RemoteTextDisplay {
pub fn new(position: [f64; 3], yaw: f32) -> Self {
Self {
data: Default::default(),
from: Default::default(),
shown: Default::default(),
position: position.into(),
previous: position.into(),
target: position.into(),
rotation: [yaw, 0.0],
old_rotation: [yaw, 0.0],
target_rotation: [yaw, 0.0],
steps: 0,
age: 0,
start: 0,
duration: 0,
dirty: true,
start_dirty: false,
duration_dirty: false,
initialized: false,
last_progress: 0.0,
lines: None,
}
}
fn progress(&self, partial: f32) -> f32 {
if self.duration <= 0 {
1.0
} else {
(((self.age - self.start) as f32 + partial) / self.duration as f32).clamp(0.0, 1.0)
}
}
pub fn metadata(&mut self, values: &[(u8, i32, Value)]) {
self.start_dirty |= values.iter().any(|v| v.0 == 8);
self.duration_dirty |= values.iter().any(|v| v.0 == 9);
self.dirty |= values.iter().any(|v| renders(v.0));
self.data.apply(values);
}
fn tick(&mut self) {
self.age += 1;
self.previous = self.position;
self.old_rotation = self.rotation;
if self.steps > 0 {
self.position += (self.target - self.position) / f64::from(self.steps);
for i in 0..2 {
self.rotation[i] +=
angle(self.target_rotation[i] - self.rotation[i]) / self.steps as f32;
}
self.steps -= 1;
}
if self.start_dirty {
self.start = self.age + i64::from(self.data.interpolation_delay);
}
if self.duration_dirty {
self.duration = self.data.interpolation_duration;
}
if self.dirty {
self.from = if self.initialized && self.duration > 0 {
self.shown.blend(&self.from, self.last_progress)
} else {
self.data.clone()
};
self.shown = self.data.clone();
self.initialized = true;
self.lines = None;
}
self.dirty = false;
self.start_dirty = false;
self.duration_dirty = false;
}
fn move_to(&mut self, position: [f64; 3], rotation: Option<(f32, f32)>) {
self.target = position.into();
if let Some((yaw, pitch)) = rotation {
self.target_rotation = [yaw, pitch];
}
self.steps = self.data.teleport_duration.clamp(0, 59);
if self.steps == 0 {
self.position = self.target;
self.previous = self.target;
self.rotation = self.target_rotation;
self.old_rotation = self.rotation;
}
}
}
fn angle(d: f32) -> f32 {
(d + 180.0).rem_euclid(360.0) - 180.0
}
impl Game<'_> {
pub(super) fn move_remote_text_display(
&mut self,
id: i32,
position: [f64; 3],
rotation: Option<(f32, f32)>,
) {
if let Some(d) = self.text_displays.get_mut(&id) {
d.move_to(position, rotation);
}
}
pub(super) fn tick_text_displays(&mut self) {
for display in self.text_displays.values_mut() {
display.tick();
}
}
pub(super) fn text_display_geometry(
&mut self,
camera: glam::DVec3,
partial: f64,
normal: &mut Vec<Vertex>,
see_through: &mut Vec<Vertex>,
) {
self.text_display_vertices = 0;
let atlas = &self.gui.atlas;
let front = self.camera_type == super::perspective::CameraType::ThirdPersonFront;
let angles = if front {
[self.yaw + 180.0, -self.pitch]
} else {
[self.yaw, self.pitch]
};
for display in self.text_displays.values_mut() {
display.last_progress = display.progress(partial as f32);
let d = display.shown.blend(&display.from, display.last_progress);
let position = display.previous.lerp(display.position, partial);
let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance);
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
continue;
}
let rotation = std::array::from_fn(|i| {
display.old_rotation[i]
+ angle(display.rotation[i] - display.old_rotation[i]) * partial as f32
});
let mut matrix = glam::Mat4::from_translation((position - camera).as_vec3())
* d.matrix(rotation, angles)
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
* glam::Mat4::from_scale(glam::Vec3::splat(-0.025));
if !matrix.is_finite() {
continue;
}
// TextDisplayRenderer.splitLines: Font.split, no indent on wrapped lines.
let Lines { lines, width } = display.lines.get_or_insert_with(|| {
let lines: Vec<_> = atlas
.font
.split(&d.text, d.line_width as f32)
.into_iter()
.map(|line| {
let width = atlas.font.width_text(&line).ceil();
(line, width)
})
.collect();
let width = lines.iter().map(|l| l.1).fold(0.0, f32::max);
Lines { lines, width }
});
let width = *width;
let height = lines.len() as f32 * 10.0 - 1.0;
matrix *=
glam::Mat4::from_translation(glam::Vec3::new(1.0 - width / 2.0, -height, 0.0));
let mut batch = HudBatch::new([1, 1]);
let bg = if d.flags & 4 != 0 {
self.settings.text_background(0.25)
} else {
d.background as u32
};
if bg != 0 {
batch.fill(
atlas,
-1.0,
-1.0,
width + 1.0,
height + 1.0,
rgba(
(bg >> 16) as u8,
(bg >> 8) as u8,
bg as u8,
(bg >> 24) as u8,
),
);
}
let align = d.align();
for (i, (line, line_width)) in lines.iter().enumerate() {
let x = match align {
Align::Left => 0.0,
Align::Right => width - line_width,
Align::Center => (width - line_width) / 2.0,
};
batch.text_styled(
atlas,
line,
x,
i as f32 * 10.0,
rgba(255, 255, 255, d.opacity as u8),
d.flags & 1 != 0,
);
}
let world_light = self.world.light(
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
);
let light = if d.brightness == -1 {
horizon_client::block_entity_render::vertex_light(world_light)
} else {
((d.brightness as u32 >> 20 & 15) * 16)
| (((d.brightness as u32 >> 4 & 15) * 16) << 8)
};
let through = d.flags & 2 != 0;
let out = if through {
&mut *see_through
} else {
&mut *normal
};
for quad in batch.vertices.as_chunks::<6>().0 {
for i in [0, 1, 2, 5] {
let v = quad[i];
let point =
matrix.transform_point3(glam::Vec3::new(v.pos[0], v.pos[1], v.depth));
out.push(Vertex {
pos: point.to_array(),
uv: v.uv,
color: v.color,
light,
layer: if through { 2 } else { 0 },
normal: [0; 4],
});
}
}
self.text_display_vertices += batch.vertices.len() / 6 * 4;
self.map_patches.extend(batch.patches);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_and_position_interpolation_keep_their_separate_clocks() {
let mut d = RemoteTextDisplay::new([0.0; 3], 0.0);
d.tick();
d.metadata(&[
(8, 1, Value::Int(0)),
(9, 1, Value::Int(4)),
(10, 1, Value::Int(2)),
(11, 0, Value::Floats(vec![4.0, 0.0, 0.0])),
]);
d.tick();
assert_eq!(d.shown.blend(&d.from, d.progress(0.5)).translation[0], 0.5);
d.move_to([2.0, 0.0, 0.0], Some((90.0, 0.0)));
d.tick();
assert_eq!(d.position.x, 1.0);
assert_eq!(d.rotation[0], 45.0);
d.tick();
assert_eq!(d.position.x, 2.0);
assert_eq!(d.shown.blend(&d.from, d.progress(0.0)).translation[0], 2.0);
}
}
-41
View File
@@ -2,10 +2,8 @@
//! its boss bar, and its skulls hitting and exploding.
use super::Game;
use horizon_client::{
hud_overlays,
mobs::{Damage, DamageKind, Species, Who},
projectiles::{Projectile, ProjectileKind},
text::Text,
};
impl Game<'_> {
@@ -78,45 +76,6 @@ impl Game<'_> {
}
Ok(())
}
/// The wither boss bars (purple), within range.
pub(super) fn wither_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
let Some(p) = self.player.as_ref() else {
return;
};
for m in self
.mobs
.iter()
.filter(|m| m.species() == Species::Wither && m.alive())
{
if m.position.distance_squared(p.position) > 64.0 * 64.0 {
continue;
}
let progress = if m.extra.counter > 0 {
1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32
} else {
(m.health / m.attrs.max_health).clamp(0.0, 1.0)
};
let name = m
.custom_name
.clone()
.unwrap_or_else(|| self.translate("entity.minecraft.wither", "Wither"));
bars.push(hud_overlays::BossBar {
name: Text::literal(name),
progress,
color: 5,
overlay: 0,
});
}
}
pub(super) fn wither_near(&self) -> bool {
self.player.as_ref().is_some_and(|p| {
self.mobs.iter().any(|m| {
m.species() == Species::Wither
&& m.alive()
&& m.position.distance_squared(p.position) <= 64.0 * 64.0
})
})
}
/// `WitherSkull.onHitEntity` / `onHit`: 8 damage (healing the wither 5
/// when it kills), withering for 10 seconds, then a power 1 blast.
pub(super) fn wither_skull_hit(
@@ -22,6 +22,23 @@ pub(super) struct PreparedWorld {
}
impl WorldRequest {
pub fn new(
folder: &str,
name: Option<&str>,
seed: Option<i64>,
creative: bool,
create: Option<&horizon_client::title::WorldOptions>,
) -> Result<Self> {
Ok(Self {
save_root: std::env::current_dir()?.join("saves").join(folder),
folder: folder.to_owned(),
name: name.map(str::to_owned),
seed,
creative,
create: create.cloned(),
})
}
pub fn prepare(
self,
res: &Resources,
@@ -250,7 +267,7 @@ pub(super) fn make_streaming(
streaming.nether_shading = dim == Dim::Nether;
// A server draws nothing: neither the integrated server nor the
// dedicated one meshes sections.
streaming.mesh = !options.integrated && options.dedicated.is_none();
streaming.mesh = options.execution_role.is_client();
Ok((streaming, save, locator))
}
+116 -9
View File
@@ -68,6 +68,9 @@ mod flat;
pub mod lightning;
mod lod;
mod occlusion;
mod pack_atlas;
mod pack_post;
pub mod pack_uniforms;
mod page_runs;
pub mod panorama;
mod resident;
@@ -87,6 +90,7 @@ fn world_pass_after_culling<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
depth: &wgpu::TextureView,
keep_depth: bool,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world, after culling"),
@@ -103,7 +107,12 @@ fn world_pass_after_culling<'e>(
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Discard,
// A shader pack reads it as depthtex0.
store: if keep_depth {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
}),
stencil_ops: None,
}),
@@ -249,6 +258,8 @@ pub struct Frame<'a> {
pub hand_fov_degrees: f32,
/// Full-screen camera effects drawn over the world and hand, under the HUD.
pub screen: &'a [crate::screen_effects::ScreenQuad],
/// What a shader pack's uniforms need from the game.
pub pack: pack_uniforms::PackInputs,
/// The world border wall, when near enough to be drawn.
pub border: Option<BorderDraw>,
pub game_time: f32,
@@ -268,6 +279,8 @@ pub struct Frame<'a> {
pub night_vision: f32,
/// Torch flicker added to the block light factor of 1.4.
pub block_flicker: f32,
/// `lightmap.fsh` BossOverlayWorldDarkeningFactor (a wither's bar).
pub boss_darken: f32,
/// `Screen.extractBlurredBackground`: blur the frame by this radius
/// (`menuBackgroundBlurriness`) before the screen is drawn over it.
pub menu_blur: Option<f32>,
@@ -458,6 +471,10 @@ pub struct Renderer {
occlusion: occlusion::Occlusion,
/// Resolution scale and FXAA (Horizon settings).
world_post: world_post::Post,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The block sprites as one atlas, while a shader pack is loaded.
pack_atlas: Option<pack_atlas::PackAtlas>,
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
menu_blur: Option<crate::menu_blur::MenuBlur>,
@@ -1306,6 +1323,8 @@ impl Renderer {
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
// Shader packs' atlas copies them (pack_atlas.rs).
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::STORAGE_BINDING,
view_formats: &[],
},
@@ -2303,6 +2322,8 @@ impl Renderer {
block_entity_revision: next_block_entity_revision(),
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
world_post,
pack_post: None,
pack_atlas: None,
hud,
menu_blur: None,
info,
@@ -2733,6 +2754,51 @@ impl Renderer {
self.world_post.scale = scale.clamp(0.25, 2.0);
self.world_post.antialias = antialias;
}
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`.
/// Returns the programs that run and the notes of loading them.
pub fn set_shader_pack(
&mut self,
dimension: i32,
pack: Option<horizon_shaders::Post>,
) -> Option<(Vec<String>, Vec<String>)> {
let post = pack.map(|post| {
pack_post::PackPost::new(
&self.device,
&self.queue,
post,
self.config.format.remove_srgb_suffix(),
)
});
if post.is_none() {
self.pack_atlas = None;
} else if self.pack_atlas.is_none() {
let mut encoder = self.device.create_command_encoder(&Default::default());
let layers = self
.sprite_textures
.iter()
.map(|t| t.depth_or_array_layers())
.sum();
self.pack_atlas = pack_atlas::PackAtlas::new(
&self.device,
&mut encoder,
&self.sprite_textures,
sprite_split(),
layers,
self.sprite_textures[0].width(),
);
self.queue.submit([encoder.finish()]);
}
let report = post.as_ref().map(|p| {
let programs = p.programs().into_iter().map(str::to_owned).collect();
(programs, p.notes.clone())
});
self.pack_post = Some((dimension, post));
report
}
/// The dimension the shader pack was last set for.
pub fn shader_pack_dimension(&self) -> Option<i32> {
self.pack_post.as_ref().map(|p| p.0)
}
/// The Mipmap Levels option: block textures sample at most `levels`
/// reduced copies (0: none).
pub fn set_mipmap_levels(&mut self, levels: u32) {
@@ -2793,6 +2859,7 @@ impl Renderer {
gamma: f32,
night_vision: f32,
flicker: f32,
boss_darken: f32,
) {
// lightmap.fsh: per (block, sky) level.
let brightness = |level: f32| level / (4.0 - 3.0 * level);
@@ -2810,6 +2877,8 @@ impl Renderer {
let parabolic = (2.0 * block_level - 1.0).powi(2);
let block_color = Vec3::from(sky.block_light_tint).lerp(Vec3::ONE, 0.9 * parabolic);
color += block_color * block_brightness;
// lightmap.fsh: a boss bar mixes the light toward 70/60/60%.
color = color.lerp(color * Vec3::new(0.7, 0.6, 0.6), boss_darken);
color -= Vec3::splat(darkness);
color = color.clamp(Vec3::ZERO, Vec3::ONE);
let max = color.max_element().max(1e-6);
@@ -2849,6 +2918,13 @@ impl Renderer {
}
fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64) {
self.animations.animate(encoder, &mut self.uploads, tick);
if let Some(atlas) = &self.pack_atlas {
atlas.copy(
encoder,
&self.sprite_textures,
self.animations.changed().iter().copied(),
);
}
}
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
let rendered = self.draw_frame(frame);
@@ -2924,6 +3000,7 @@ impl Renderer {
(self.gamma - frame.darkness.0).max(0.0),
frame.night_vision,
frame.block_flicker,
frame.boss_darken,
);
let view_proj = self.view_projection(frame);
if frame.title {
@@ -3594,9 +3671,12 @@ impl Renderer {
timer.begin(&mut encoder);
}
// The world's targets: the screen, or the scaled target of `post`.
let scaled = self
.world_post
.prepare(&self.device, [self.config.width, self.config.height]);
let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some());
let scaled = self.world_post.prepare(
&self.device,
[self.config.width, self.config.height],
pack_post,
);
let (world_view, world_depth) = match &scaled {
Some((color, depth)) => (color, depth),
None => (&view_target, &self.depth),
@@ -3633,8 +3713,8 @@ impl Renderer {
view: world_depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
// Phase 2 of occlusion culling draws over it.
store: if cull.is_some() {
// Phase 2 of occlusion culling draws over it; a shader pack reads it.
store: if cull.is_some() || pack_post {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
@@ -3693,7 +3773,12 @@ impl Renderer {
drop(pass);
cull.test_pass(&mut encoder);
flat.flatten_phase2(&mut encoder);
pass = world_pass_after_culling(&mut encoder, world_view, world_depth);
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
pack_post,
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_pipeline(&flat.solid);
flat.draw_phase2(&mut pass, vertices);
@@ -3717,7 +3802,12 @@ impl Renderer {
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder);
pass = world_pass_after_culling(&mut encoder, world_view, world_depth);
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
pack_post,
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_pipeline(&self.solid);
pass.set_index_buffer(
@@ -3876,7 +3966,24 @@ impl Renderer {
);
}
}
if scaled.is_some() {
if let Some((color, depth)) = &scaled
&& pack_post
&& let Some((_, Some(post))) = &mut self.pack_post
{
let size = self.world_post.size().unwrap_or([1, 1]);
post.run(
&self.device,
&self.queue,
&mut encoder,
color,
depth,
&view_target,
frame,
&frame.pack,
size,
[self.config.width, self.config.height],
);
} else if scaled.is_some() {
self.world_post.finish(&mut encoder, &view_target);
}
if hand_quads > 0 {
+10
View File
@@ -51,6 +51,8 @@ pub(super) struct SpriteAnimator {
pipeline: wgpu::ComputePipeline,
/// Edge of the arrays' layers.
size: u32,
/// The layers the last `animate` rewrote, counted over all arrays.
changed: Vec<u32>,
}
impl SpriteAnimator {
@@ -205,9 +207,15 @@ impl SpriteAnimator {
jobs,
pipeline,
size,
changed: Vec::new(),
}
}
/// The layers the last `animate` rewrote (for the shader pack atlas).
pub fn changed(&self) -> &[u32] {
&self.changed
}
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
pub fn animate(
@@ -245,12 +253,14 @@ impl SpriteAnimator {
/// its layer shows; the number of jobs.
fn collect(&mut self, tick: u64) -> usize {
let mut count = 0;
self.changed.clear();
for sprite in &mut self.sprites {
let blend = sprite.animation.blend(tick);
if sprite.shown == Some(blend) {
continue;
}
sprite.shown = Some(blend);
self.changed.push(sprite.animation.layer);
let job = [
sprite.layer,
sprite.frames[blend.frame],
+197
View File
@@ -0,0 +1,197 @@
//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas
//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every
//! layer is copied into a grid, row by row from the top left, and an animated layer again
//! whenever its frame changes.
#[expect(dead_code, reason = "the gbuffers pass binds it (next slice)")]
pub(super) struct PackAtlas {
texture: wgpu::Texture,
pub view: wgpu::TextureView,
/// Sprites per row.
pub columns: u32,
/// A sprite's edge in pixels, at the first level.
pub sprite: u32,
/// Layers per sprite array.
split: u32,
}
impl PackAtlas {
/// Copies `layers` layers of `arrays` (`split` per array, `sprite` pixels wide, `levels`
/// mip levels); `None` when the grid is larger than the device allows.
pub fn new(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
layers: u32,
sprite: u32,
) -> Option<Self> {
let layers = layers.max(1);
let columns = (f64::from(layers).sqrt().ceil() as u32).max(1);
let rows = layers.div_ceil(columns);
let max = device.limits().max_texture_dimension_2d;
if columns * sprite > max || rows * sprite > max {
return None;
}
// Levels past a sprite's single pixel would mix neighbours: stop there.
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack block atlas"),
size: wgpu::Extent3d {
width: columns * sprite,
height: rows * sprite,
depth_or_array_layers: 1,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let atlas = Self {
texture,
view,
columns,
sprite,
split,
};
atlas.copy(encoder, arrays, 0..layers);
Some(atlas)
}
/// The atlas's size in pixels (`atlasSize`).
#[cfg_attr(
not(test),
expect(dead_code, reason = "the gbuffers pass reads it (next slice)")
)]
pub fn size(&self) -> [u32; 2] {
[self.texture.width(), self.texture.height()]
}
/// Copies `layers` from the sprite arrays again, every level.
pub fn copy(
&self,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
layers: impl IntoIterator<Item = u32>,
) {
for layer in layers {
let Some(array) = arrays.get((layer / self.split) as usize) else {
continue;
};
let (column, row) = (layer % self.columns, layer / self.columns);
for level in 0..self.texture.mip_level_count() {
let edge = self.sprite >> level;
encoder.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: array,
mip_level: level,
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: layer % self.split,
},
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: &self.texture,
mip_level: level,
origin: wgpu::Origin3d {
x: column * edge,
y: row * edge,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d {
width: edge,
height: edge,
depth_or_array_layers: 1,
},
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Two arrays of 2x2 sprites, one layer split; each layer a colour of its own.
#[test]
fn layers_land_in_their_cells() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let (split, layers, sprite) = (3u32, 5u32, 2u32);
let arrays: Vec<wgpu::Texture> = (0..2)
.map(|a| {
let count = (layers - a * split).min(split);
let pixels: Vec<u8> = (0..count)
.flat_map(|l| [(a * split + l) as u8 * 40, 0, 0, 255].repeat(4))
.collect();
wgpu::util::DeviceExt::create_texture_with_data(
&device,
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: sprite,
height: sprite,
depth_or_array_layers: count,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&pixels,
)
})
.collect();
let mut encoder = device.create_command_encoder(&Default::default());
let atlas = PackAtlas::new(&device, &mut encoder, &arrays, split, layers, sprite).unwrap();
assert_eq!((atlas.columns, atlas.size()), (3, [6, 4]));
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256 * 4,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
atlas.texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 6,
height: 4,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
let red = |x: usize, y: usize| data[y * 256 + x * 4];
// Layer 4 (second array, its second layer) is in row 1, column 1.
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
}
}
+929
View File
@@ -0,0 +1,929 @@
//! A shader pack's post-processing over Horizon's own world (spec 2.1 rows 4, 6, 8, 9; 6.1; 6.2;
//! 6.4): the world renders into its own target as with a resolution scale, its colour becomes
//! colortex0 and its depth `depthtex0`, then the pack's `prepare`, `deferred`, `composite` and
//! `final` programs run in order, and `final` draws onto the screen under the hand and HUD.
//! Gbuffers programs come later; until then the pack's composites read Horizon's picture.
//!
//! Every colortex buffer a program reads or writes has two textures, A and B. Programs read
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). The
//! mapping carries over to the next frame, so a buffer that is not cleared reads back what was
//! written last (OQ-8). Buffers are cleared at the start of the frame, "main" only.
//! Pack buffers store rows as GL does (row 0 at the bottom: the translator negates clip y), so
//! the world is copied in upside down, and its reverse-Z depth becomes GL window depth.
use std::collections::HashMap;
use horizon_shaders::{Format, Group, Kind, Post};
use super::pack_uniforms::{self, FrameUniforms, PackInputs};
const BUFFERS: usize = horizon_shaders::BUFFERS;
/// The texture behind a pack sampler name.
#[derive(Clone, Copy, PartialEq)]
enum Source {
Color(usize),
Depth,
Noise,
/// A 1×1 stand-in for names this slice does not provide yet (custom textures...).
Dummy,
/// A 1×1 depth of 1.0 behind a comparison sampler, until shadow maps exist: no shadow.
Shadow,
}
struct Binding {
source: Source,
binding: u32,
/// A filtering sampler (the texture is filterable).
filtering: bool,
}
struct Pass {
name: String,
group: Group,
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec<u8>,
/// (index into `pack_uniforms::NAMES`, declared type, offset).
uniforms: Vec<(Option<usize>, String, u32)>,
samplers: Vec<Binding>,
/// (location, buffer) of each output that reaches a buffer.
writes: Vec<(u32, usize)>,
/// Buffers flipped after the pass.
flip: u16,
/// Buffers sampled, for the bind group cache key.
reads: u16,
/// Buffers written.
written: u16,
groups: HashMap<u16, wgpu::BindGroup>,
}
struct Targets {
size: [u32; 2],
/// A and B of each buffer used.
color: [Option<[wgpu::TextureView; 2]>; BUFFERS],
depth: wgpu::TextureView,
}
pub struct PackPost {
pub notes: Vec<String>,
passes: Vec<Pass>,
deferred_pre: u16,
composite_pre: u16,
formats: [wgpu::TextureFormat; BUFFERS],
clears: [Option<[f32; 4]>; BUFFERS],
used: u16,
/// Bit n set: B is buffer n's "main".
main: u16,
targets: Option<Targets>,
noise: wgpu::TextureView,
dummy: wgpu::TextureView,
/// The stand-in shadow map and its comparison sampler; cleared on the first frame.
shadow: (wgpu::TextureView, wgpu::Sampler),
shadow_cleared: bool,
linear: wgpu::Sampler,
nearest: wgpu::Sampler,
repeat: wgpu::Sampler,
blit: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
/// The blit also writes depthtex0 (some program reads it) and clears these buffers.
blit_depth: bool,
blit_clears: Vec<usize>,
/// Buffers cleared in passes of their own (more than the blit's eight attachments).
other_clears: Vec<usize>,
/// The blit's bindings, for the world target of this size.
blit_group: Option<([u32; 2], wgpu::BindGroup)>,
/// Shows colortex0 when the pack has no `final` (OQ-5).
copy: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
uniforms: FrameUniforms,
}
/// The wgpu format of a pack format (6.7), the nearest one wgpu can render to.
fn texture_format(f: Format, features: wgpu::Features) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let pick = |r, rg, rgba| match f.channels {
1 => r,
2 => rg,
_ => rgba,
};
let half = pick(T::R16Float, T::Rg16Float, T::Rgba16Float);
match (f.kind, f.bits) {
(Kind::Unorm, 10) => T::Rgb10a2Unorm,
(Kind::Unorm, 16) if features.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM) => {
pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm)
}
(Kind::Unorm, b) if b <= 8 => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
(Kind::Float, 11) if features.contains(wgpu::Features::RG11B10UFLOAT_RENDERABLE) => {
T::Rg11b10Ufloat
}
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
(Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint),
(Kind::Uint, 10) => T::Rgb10a2Uint,
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
// Snorm, 16-bit norm without the feature, RGB9_E5: half floats hold them.
_ => half,
}
}
fn source(name: &str) -> Source {
match name {
"depthtex0" | "gdepthtex" | "depthtex1" | "depthtex2" => Source::Depth,
"noisetex" => Source::Noise,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
/// 256×256 RGB white noise (6.4), from a fixed seed so every run looks the same.
fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
let mut state = 0x2545_f491_4f6c_dd1du64;
let texels: Vec<u8> = (0..256 * 256 * 4)
.map(|i| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
if i % 4 == 3 { 255 } else { (state >> 56) as u8 }
})
.collect();
texture(device, queue, "Shader pack noisetex", 256, &texels)
}
fn texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
side: u32,
texels: &[u8],
) -> wgpu::TextureView {
let size = wgpu::Extent3d {
width: side,
height: side,
depth_or_array_layers: 1,
};
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
tex.as_image_copy(),
texels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(side * 4),
rows_per_image: None,
},
size,
);
tex.create_view(&Default::default())
}
fn sampler(
device: &wgpu::Device,
filter: wgpu::FilterMode,
address: wgpu::AddressMode,
) -> wgpu::Sampler {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack"),
address_mode_u: address,
address_mode_v: address,
address_mode_w: address,
mag_filter: filter,
min_filter: filter,
..Default::default()
})
}
/// Runs `create` and returns its validation error, if any.
fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, String> {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let value = create();
match pollster::block_on(scope.pop()) {
Some(e) => {
// The details are in the error's sources.
let mut message = e.to_string();
let mut source = std::error::Error::source(&e);
while let Some(s) = source {
message = format!("{message}: {s}");
source = s.source();
}
Err(message)
}
None => Ok(value),
}
}
/// A full-screen helper pipeline of ours (`pack_post.wgsl`).
fn helper(
device: &wgpu::Device,
vertex: &str,
entry: &str,
entries: &[wgpu::BindGroupLayoutEntry],
targets: &[Option<wgpu::ColorTargetState>],
) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout) {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader pack helpers"),
source: wgpu::ShaderSource::Wgsl(include_str!("pack_post.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(entry),
entries,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(entry),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(entry),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vertex),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
targets,
}),
multiview_mask: None,
cache: None,
});
(pipeline, layout)
}
fn entry(binding: u32, ty: wgpu::BindingType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty,
count: None,
}
}
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
entry(
binding,
wgpu::BindingType::Texture {
sample_type,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
)
}
fn target(format: wgpu::TextureFormat) -> Option<wgpu::ColorTargetState> {
Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})
}
fn attach(
view: &wgpu::TextureView,
ops: wgpu::Operations<wgpu::Color>,
) -> Option<wgpu::RenderPassColorAttachment<'_>> {
Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops,
})
}
impl PackPost {
/// Builds the pipelines of `post` for a screen of `screen` format.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
post: Post,
screen: wgpu::TextureFormat,
) -> Self {
let features = device.features();
let formats: [wgpu::TextureFormat; BUFFERS] =
std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features));
let clears = std::array::from_fn(|i| {
let b = &post.config.buffers[i];
b.clear.then(|| {
b.clear_color
.unwrap_or(if i == 1 { [1.0; 4] } else { [0.0; 4] })
})
});
let mut notes = post.notes;
notes.extend(post.config.notes);
let mask =
|flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0);
let mut used = 1u16;
let mut passes = Vec::new();
for p in post.programs {
let interface = &p.program.interface;
let mut entries = vec![entry(
0,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
)];
let mut samplers = Vec::new();
let mut reads = 0u16;
for s in &interface.samplers {
let mut src = source(&s.name);
let float = wgpu::TextureSampleType::Float { filterable: true };
let mut sample_type = match src {
Source::Color(n) => formats[n]
.sample_type(None, Some(features))
.unwrap_or(float),
Source::Depth => wgpu::TextureSampleType::Float { filterable: false },
_ => float,
};
let int = !matches!(sample_type, wgpu::TextureSampleType::Float { .. });
if s.ty == "sampler2DShadow" {
(src, sample_type) = (Source::Shadow, wgpu::TextureSampleType::Depth);
}
let wanted = match s.ty.as_str() {
"sampler2DShadow" => true,
"sampler2D" => !int,
"isampler2D" => sample_type == wgpu::TextureSampleType::Sint,
"usampler2D" => sample_type == wgpu::TextureSampleType::Uint,
_ => false,
};
if !wanted {
notes.push(format!(
"{}: `{} {}` is not provided yet",
p.name, s.ty, s.name
));
(src, sample_type) = (Source::Dummy, float);
}
let filtering = sample_type == float;
if let Source::Color(n) = src {
reads |= 1 << n;
}
entries.push(texture_entry(s.binding, sample_type));
entries.push(entry(
s.binding + 1,
wgpu::BindingType::Sampler(if src == Source::Shadow {
wgpu::SamplerBindingType::Comparison
} else if filtering {
wgpu::SamplerBindingType::Filtering
} else {
wgpu::SamplerBindingType::NonFiltering
}),
));
samplers.push(Binding {
source: src,
binding: s.binding,
filtering,
});
}
let final_pass = p.group == Group::Final;
let mut targets = Vec::new();
let mut writes = Vec::new();
for &location in &interface.outputs {
let l = location as usize;
if targets.len() <= l {
targets.resize(l + 1, None);
}
if final_pass {
if location == 0 {
targets[0] = target(screen);
}
} else if let Some(&buffer) = p.buffers.get(l) {
targets[l] = target(formats[buffer as usize]);
writes.push((location, buffer as usize));
}
}
if !final_pass && writes.is_empty() {
notes.push(format!("{}: writes no buffer; skipped", p.name));
continue;
}
let written = writes.iter().fold(0u16, |m, w| m | 1 << w.1);
let kept = p
.flips
.iter()
.filter(|f| !f.1)
.fold(0u16, |m, f| m | 1 << f.0);
let flip = if final_pass {
0
} else {
(written & !kept) | mask(&p.flips)
};
used |= reads | written | flip;
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(&p.name),
entries: &entries,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(&p.name),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let created = checked(device, || {
let module = |m: naga::Module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&p.name),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
})
};
let (vs, fs) = (module(p.program.vertex), module(p.program.fragment));
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&p.name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &targets,
}),
multiview_mask: None,
cache: None,
})
});
let pipeline = match created {
Ok(p) => p,
Err(e) => {
notes.push(format!("{}: skipped: {e}", p.name));
continue;
}
};
let interface = &p.program.interface;
passes.push(Pass {
ubo: device.create_buffer(&wgpu::BufferDescriptor {
label: Some(&p.name),
size: u64::from(interface.uniform_size.max(16)),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
block: vec![0; interface.uniform_size as usize],
uniforms: interface
.uniforms
.iter()
.map(|u| (pack_uniforms::index(&u.name), u.ty.clone(), u.offset))
.collect(),
name: p.name,
group: p.group,
pipeline,
layout,
samplers,
writes,
flip,
reads,
written,
groups: HashMap::new(),
});
}
// A clear only matters when a program reads the buffer before one writes all of it
// (composite passes cover their whole target); the others are skipped.
let mut cleared = Vec::new();
for b in 1..BUFFERS {
let bit = 1u16 << b;
let first = passes
.iter()
.find(|p| (p.reads | p.flip | p.written) & bit != 0);
if clears[b].is_some()
&& first.is_some_and(|p| p.reads & bit != 0 || p.written & bit == 0)
{
cleared.push(b);
}
}
let blit_depth = passes
.iter()
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth));
let room = 8 - 1 - usize::from(blit_depth);
let other_clears = cleared.split_off(room.min(cleared.len()));
let float = wgpu::TextureSampleType::Float { filterable: true };
let mut blit_targets = vec![target(formats[0])];
if blit_depth {
blit_targets.push(target(wgpu::TextureFormat::R32Float));
}
// Cleared by the pass's load operation; the shader writes nothing there.
blit_targets.extend(cleared.iter().map(|&b| {
Some(wgpu::ColorTargetState {
format: formats[b],
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})
}));
let blit = helper(
device,
"vs_full",
if blit_depth {
"fs_blit"
} else {
"fs_blit_color"
},
&[
texture_entry(0, float),
texture_entry(1, wgpu::TextureSampleType::Depth),
],
&blit_targets,
);
let copy = helper(
device,
"vs_uv",
"fs_copy",
&[
texture_entry(0, float),
entry(
2,
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
),
],
&[target(screen)],
);
let white = [255u8; 4];
Self {
notes,
passes,
deferred_pre: mask(&post.deferred_pre),
composite_pre: mask(&post.composite_pre),
formats,
clears,
used,
blit_depth,
blit_clears: cleared,
other_clears,
blit_group: None,
main: 0,
targets: None,
noise: noise(device, queue),
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
shadow: (
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack shadow stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default()),
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack shadow"),
compare: Some(wgpu::CompareFunction::LessEqual),
..Default::default()
}),
),
shadow_cleared: false,
linear: sampler(
device,
wgpu::FilterMode::Linear,
wgpu::AddressMode::ClampToEdge,
),
nearest: sampler(
device,
wgpu::FilterMode::Nearest,
wgpu::AddressMode::ClampToEdge,
),
repeat: sampler(device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat),
blit,
copy,
uniforms: FrameUniforms::default(),
}
}
/// The names of the programs that run, in order.
pub fn programs(&self) -> Vec<&str> {
self.passes.iter().map(|p| p.name.as_str()).collect()
}
fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if self.targets.as_ref().is_some_and(|t| t.size == size) {
return;
}
let make = |label: &str, format| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
let color = std::array::from_fn(|i| {
(self.used & 1 << i != 0).then(|| {
let label = format!("colortex{i}");
[make(&label, self.formats[i]), make(&label, self.formats[i])]
})
});
self.targets = Some(Targets {
size,
color,
depth: make("depthtex0", wgpu::TextureFormat::R32Float),
});
for pass in &mut self.passes {
pass.groups.clear();
}
}
/// Runs the pack over the world drawn into `world` (colour) and `world_depth`, then draws
/// the result onto `screen`.
#[allow(clippy::too_many_arguments)]
pub fn run(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
world: &wgpu::TextureView,
world_depth: &wgpu::TextureView,
screen: &wgpu::TextureView,
frame: &super::Frame,
inputs: &PackInputs,
size: [u32; 2],
window: [u32; 2],
) {
self.resize(device, size);
self.uniforms.update(frame, inputs, size);
let Some(targets) = &self.targets else { return };
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
let view = |m: u16, i: usize, alt: bool| {
targets.color[i]
.as_ref()
.map(|t| &t[main(m, i) ^ usize::from(alt)])
};
if !self.shadow_cleared {
self.shadow_cleared = true;
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack shadow stand-in"),
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.shadow.0,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
let clear_op = |b: usize| {
let c = self.clears[b].unwrap_or_default();
wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: c[0].into(),
g: c[1].into(),
b: c[2].into(),
a: c[3].into(),
}),
store: wgpu::StoreOp::Store,
}
};
// Overwritten whole: clearing lets a tiled GPU skip loading the old content.
let overwrite = wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
};
for chunk in self.other_clears.chunks(8) {
let attachments: Vec<_> = chunk
.iter()
.map(|&b| attach(view(self.main, b, false).expect("used buffer"), clear_op(b)))
.collect();
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack clear"),
color_attachments: &attachments,
..Default::default()
});
}
// The world into colortex0 and depthtex0, flipped to GL's rows, and the clears.
if self.blit_group.as_ref().is_none_or(|g| g.0 != size) {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack world"),
layout: &self.blit.1,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(world),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world_depth),
},
],
});
self.blit_group = Some((size, group));
}
{
let mut attachments = vec![attach(
view(self.main, 0, false).expect("colortex0"),
overwrite,
)];
if self.blit_depth {
attachments.push(attach(&targets.depth, overwrite));
}
for &b in &self.blit_clears {
attachments.push(attach(
view(self.main, b, false).expect("used buffer"),
clear_op(b),
));
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack world"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.blit.0);
pass.set_bind_group(0, self.blit_group.as_ref().map(|g| &g.1), &[]);
pass.draw(0..3, 0..1);
}
let fog = self.uniforms.fog();
let mut seen = (false, false);
let mut shown = false;
for pass in &mut self.passes {
if pass.group == Group::Deferred && !seen.0 {
seen.0 = true;
self.main ^= self.deferred_pre;
}
if pass.group == Group::Composite && !seen.1 {
seen.1 = true;
self.main ^= self.composite_pre;
}
let final_pass = pass.group == Group::Final;
let out = if final_pass { window } else { size };
// Our block members first (`hz_target`, `hz_fog_color`, `hz_fog`), then the pack's.
let ours = [
[
out[0] as f32,
out[1] as f32,
1.0 / out[0] as f32,
1.0 / out[1] as f32,
],
fog[0].to_array(),
fog[1].to_array(),
];
let head: &[u8] = bytemuck::cast_slice(&ours);
let n = head.len().min(pass.block.len());
pass.block[..n].copy_from_slice(&head[..n]);
for (index, ty, offset) in &pass.uniforms {
if let Some(v) = index.and_then(|i| self.uniforms.values.get(i)) {
v.write(ty, &mut pass.block, *offset as usize);
}
}
if !pass.block.is_empty() {
queue.write_buffer(&pass.ubo, 0, &pass.block);
}
let key = self.main & pass.reads;
let group = pass.groups.entry(key).or_insert_with(|| {
let mut entries = vec![wgpu::BindGroupEntry {
binding: 0,
resource: pass.ubo.as_entire_binding(),
}];
for s in &pass.samplers {
let filter = if s.filtering {
&self.linear
} else {
&self.nearest
};
let (texture, sampler) = match s.source {
Source::Color(i) => (view(key, i, false).unwrap_or(&self.dummy), filter),
Source::Depth => (&targets.depth, &self.nearest),
Source::Noise => (&self.noise, &self.repeat),
Source::Dummy => (&self.dummy, &self.linear),
Source::Shadow => (&self.shadow.0, &self.shadow.1),
};
entries.push(wgpu::BindGroupEntry {
binding: s.binding,
resource: wgpu::BindingResource::TextureView(texture),
});
entries.push(wgpu::BindGroupEntry {
binding: s.binding + 1,
resource: wgpu::BindingResource::Sampler(sampler),
});
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(&pass.name),
layout: &pass.layout,
entries: &entries,
})
});
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
if final_pass {
attachments.push(attach(screen, overwrite));
shown = true;
} else {
for &(location, buffer) in &pass.writes {
let l = location as usize;
if attachments.len() <= l {
attachments.resize(l + 1, None);
}
attachments[l] =
view(self.main, buffer, true).and_then(|v| attach(v, overwrite));
}
}
{
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(&pass.name),
color_attachments: &attachments,
..Default::default()
});
rp.set_pipeline(&pass.pipeline);
rp.set_bind_group(0, &*group, &[]);
rp.draw(0..6, 0..1);
}
self.main ^= pass.flip;
}
if !shown {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack colortex0"),
layout: &self.copy.1,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(
view(self.main, 0, false).expect("colortex0"),
),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
],
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack colortex0"),
color_attachments: &[attach(screen, overwrite)],
..Default::default()
});
rp.set_pipeline(&self.copy.0);
rp.set_bind_group(0, &group, &[]);
rp.draw(0..3, 0..1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use wgpu::TextureFormat as T;
#[test]
fn pack_formats_map_to_renderable_ones() {
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
assert_eq!(f("RGBA"), T::Rgba8Unorm);
assert_eq!(f("RGB8"), T::Rgba8Unorm, "no three-channel formats in wgpu");
assert_eq!(f("RG16F"), T::Rg16Float);
assert_eq!(f("R32F"), T::R32Float);
assert_eq!(f("RGB10_A2"), T::Rgb10a2Unorm);
assert_eq!(f("R32UI"), T::R32Uint);
assert_eq!(f("RGBA16"), T::Rgba16Float, "16-bit norm needs a feature");
assert_eq!(f("RGBA8_SNORM"), T::Rgba16Float, "snorm is not renderable");
assert_eq!(f("R11F_G11F_B10F"), T::Rgba16Float);
let all =
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM | wgpu::Features::RG11B10UFLOAT_RENDERABLE;
let g = |name| texture_format(Format::parse(name).unwrap(), all);
assert_eq!(g("RGBA16"), T::Rgba16Unorm);
assert_eq!(g("R11F_G11F_B10F"), T::Rg11b10Ufloat);
}
#[test]
fn sampler_names() {
assert!(source("gaux1") == Source::Color(4));
assert!(source("gdepthtex") == Source::Depth);
assert!(source("noisetex") == Source::Noise);
assert!(source("shadowcolor0") == Source::Dummy);
}
}
@@ -0,0 +1,54 @@
// Helpers of a shader pack's post-processing (pack_post.rs): the world into colortex0 and
// depthtex0, and colortex0 onto the screen for packs without `final`.
@vertex
fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
}
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var world_depth: texture_depth_2d;
struct World {
@location(0) color: vec4<f32>,
@location(1) depth: vec4<f32>,
}
// Pack buffers keep GL's rows (row 0 at the bottom), and GL's window depth: Horizon's
// reverse-Z depth is 1 - d for the same near and far planes.
@fragment
fn fs_blit(@builtin(position) p: vec4<f32>) -> World {
let size = vec2<i32>(textureDimensions(source));
let at = vec2<i32>(i32(p.x), size.y - 1 - i32(p.y));
var out: World;
out.color = textureLoad(source, at, 0);
out.depth = vec4<f32>(1.0 - textureLoad(world_depth, at, 0), 0.0, 0.0, 1.0);
return out;
}
// The same without depthtex0, for packs that never read it.
@fragment
fn fs_blit_color(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
let size = vec2<i32>(textureDimensions(source));
return textureLoad(source, vec2<i32>(i32(p.x), size.y - 1 - i32(p.y)), 0);
}
@group(0) @binding(2) var linear: sampler;
struct Full {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vs_uv(@builtin(vertex_index) i: u32) -> Full {
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
// Screen top (uv.y = 0 in wgpu) shows the buffer's top row, GL's last.
return Full(vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), vec2<f32>(uv.x, 1.0 - uv.y));
}
@fragment
fn fs_copy(v: Full) -> @location(0) vec4<f32> {
return textureSample(source, linear, v.uv);
}
@@ -0,0 +1,329 @@
//! The uniforms a shader pack's post-processing programs read once per frame (spec 5.2, class
//! F), from the renderer's `Frame` and the few values only the game knows (`PackInputs`).
//! Matrices follow GL's conventions (5.5): `gbufferProjection` maps view depth to clip z in
//! [-w, w], and the depth buffers packs read hold GL window depth.
use glam::{Mat4, Vec3, Vec4};
use super::Frame;
/// What the game passes for the pack's uniforms, beyond what `Frame` carries.
#[derive(Clone, Copy, Debug, Default)]
pub struct PackInputs {
/// `Level.getDayTime`.
pub day_time: i64,
/// The interpolated rain and thunder levels, 0..1.
pub rain: f32,
pub thunder: f32,
/// The fluid at the camera: 0 none, 1 water, 2 lava, 3 powder snow.
pub eye_in: i32,
pub blindness: f32,
/// Block and sky light at the camera, 0..15.
pub eye_light: [i32; 2],
pub hide_gui: bool,
/// The brightness option, 0..1.
pub brightness: f32,
}
/// One uniform's value: up to 16 floats or 3 integers.
#[derive(Clone, Copy, Debug)]
pub enum Value {
F([f32; 16]),
I([i32; 3]),
}
impl Value {
fn f(v: &[f32]) -> Self {
let mut a = [0.0; 16];
a[..v.len()].copy_from_slice(v);
Value::F(a)
}
fn i(v: &[i32]) -> Self {
let mut a = [0; 3];
a[..v.len()].copy_from_slice(v);
Value::I(a)
}
/// Writes the value as the type the pack declared, at `offset` of a std140 block.
pub fn write(&self, ty: &str, out: &mut [u8], offset: usize) {
let ints = matches!(ty, "int" | "bool" | "uint") || ty.starts_with(['i', 'u']);
let (floats, integers): ([f32; 16], [i32; 16]) = match *self {
Value::F(f) => (f, f.map(|v| v as i32)),
Value::I(i) => {
let mut f = [0.0; 16];
let mut v = [0; 16];
for k in 0..3 {
f[k] = i[k] as f32;
v[k] = i[k];
}
(f, v)
}
};
let count = match ty {
"mat4" => 16,
"mat3" => {
// std140: three columns, each padded to a vec4, from the mat4's upper left.
for c in 0..3 {
let column: [f32; 3] = std::array::from_fn(|r| floats[c * 4 + r]);
put(out, offset + c * 16, bytemuck::cast_slice(&column));
}
return;
}
t => match t.as_bytes().last() {
Some(n @ b'2'..=b'4') if t.contains("vec") => (n - b'0') as usize,
_ => 1,
},
};
if ints {
put(out, offset, bytemuck::cast_slice(&integers[..count]));
} else {
put(out, offset, bytemuck::cast_slice(&floats[..count]));
}
}
}
fn put(out: &mut [u8], offset: usize, bytes: &[u8]) {
if let Some(dst) = out.get_mut(offset..offset + bytes.len()) {
dst.copy_from_slice(bytes);
}
}
/// The uniforms provided, in the order of [`FrameUniforms::values`].
pub const NAMES: &[&str] = &[
"cameraPosition",
"previousCameraPosition",
"eyeAltitude",
"upPosition",
"sunPosition",
"moonPosition",
"shadowLightPosition",
"sunAngle",
"shadowAngle",
"worldTime",
"worldDay",
"moonPhase",
"frameCounter",
"frameTime",
"frameTimeCounter",
"viewWidth",
"viewHeight",
"aspectRatio",
"near",
"far",
"rainStrength",
"wetness",
"thunderStrength",
"fogColor",
"skyColor",
"fogMode",
"fogStart",
"fogEnd",
"fogDensity",
"fogShape",
"isEyeInWater",
"blindness",
"nightVision",
"darknessFactor",
"darknessLightFactor",
"screenBrightness",
"hideGUI",
"eyeBrightness",
"eyeBrightnessSmooth",
"heldItemId",
"heldItemId2",
"heldBlockLightValue",
"heldBlockLightValue2",
"renderStage",
"gbufferModelView",
"gbufferModelViewInverse",
"gbufferProjection",
"gbufferProjectionInverse",
"gbufferPreviousModelView",
"gbufferPreviousProjection",
];
/// Values carried from frame to frame.
pub struct FrameUniforms {
pub values: Vec<Value>,
start: std::time::Instant,
last: Option<std::time::Instant>,
frame: u32,
previous: Option<(Vec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
}
impl Default for FrameUniforms {
fn default() -> Self {
Self {
values: Vec::new(),
start: std::time::Instant::now(),
last: None,
frame: 0,
previous: None,
wetness: 0.0,
eye_smooth: [0.0; 2],
}
}
}
/// `(0, 100, 0)` on the frame the sky draws a body on: Y -90°, then X by its angle in degrees.
fn body(angle: f32) -> Vec3 {
(Mat4::from_rotation_y((-90.0f32).to_radians()) * Mat4::from_rotation_x(angle.to_radians()))
.transform_vector3(Vec3::new(0.0, 100.0, 0.0))
}
/// `value` moved toward `target` with a half-life of `half_life` seconds over `dt` (5.6).
fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
target + (value - target) * 0.5f32.powf(dt / half_life)
}
impl FrameUniforms {
/// This frame's values for a render size of `size` pixels.
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
let now = std::time::Instant::now();
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
self.last = Some(now);
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = Mat4::perspective_rh_gl(
frame.fov_degrees.to_radians(),
aspect,
0.05,
frame.depth_far,
) * frame.bob;
let camera = frame.camera.as_vec3();
let (previous_camera, previous_view, previous_projection) =
self.previous
.unwrap_or((Vec3::ZERO, Mat4::ZERO, Mat4::ZERO));
self.previous = Some((camera, view, projection));
let to_view = |v: Vec3| view.transform_vector3(v);
let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0);
let day = sun_angle < 0.5;
let sun = to_view(body(frame.sky.sun_angle));
let moon = to_view(body(frame.sky.moon_angle));
// Half-lives are given in ticks (7.2); smoothing runs per frame in seconds (OQ-26).
let half_life = if inputs.rain > self.wetness {
600.0
} else {
200.0
};
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
}
let (fog_color, fog_start, fog_end) = match &frame.fog {
Some(f) => (
[f.color[0], f.color[1], f.color[2]],
f.environmental_start.min(f.render_distance_start),
f.environmental_end.min(f.render_distance_end),
),
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
};
let day_time = inputs.day_time.rem_euclid(24000) as i32;
let world_day = inputs.day_time.div_euclid(24000);
let seconds = (now - self.start).as_secs_f32() % 3600.0;
let value = |name: &str| -> Value {
let v3 = |v: Vec3| Value::f(&v.to_array());
let m4 = |m: Mat4| Value::f(&m.to_cols_array());
match name {
"cameraPosition" => v3(camera),
"previousCameraPosition" => v3(previous_camera),
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
"moonPosition" => v3(moon),
"shadowLightPosition" => v3(if day { sun } else { moon }),
"sunAngle" => Value::f(&[sun_angle]),
"shadowAngle" => Value::f(&[if day { sun_angle } else { sun_angle - 0.5 }]),
"worldTime" => Value::i(&[day_time]),
"worldDay" => Value::i(&[world_day as i32]),
"moonPhase" => Value::i(&[world_day.rem_euclid(8) as i32]),
"frameCounter" => Value::i(&[self.frame as i32]),
"frameTime" => Value::f(&[dt]),
"frameTimeCounter" => Value::f(&[seconds]),
"viewWidth" => Value::f(&[size[0] as f32]),
"viewHeight" => Value::f(&[size[1] as f32]),
"aspectRatio" => Value::f(&[aspect]),
"near" => Value::f(&[0.05]),
"far" => Value::f(&[frame.distance]),
"rainStrength" => Value::f(&[inputs.rain]),
"wetness" => Value::f(&[self.wetness]),
"thunderStrength" => Value::f(&[inputs.thunder]),
"fogColor" => Value::f(&fog_color),
"skyColor" => Value::f(&frame.sky.sky_color),
// GL_LINEAR: vanilla's fog is linear.
"fogMode" => Value::i(&[9729]),
"fogStart" => Value::f(&[fog_start]),
"fogEnd" => Value::f(&[fog_end]),
"fogDensity" => Value::f(&[1.0]),
"fogShape" => Value::i(&[0]),
"isEyeInWater" => Value::i(&[inputs.eye_in]),
"blindness" => Value::f(&[inputs.blindness]),
"nightVision" => Value::f(&[frame.night_vision]),
"darknessFactor" => Value::f(&[frame.darkness.0]),
"darknessLightFactor" => Value::f(&[frame.darkness.1]),
"screenBrightness" => Value::f(&[inputs.brightness]),
"hideGUI" => Value::i(&[inputs.hide_gui as i32]),
"eyeBrightness" => Value::i(&eye.map(|v| v as i32)),
"eyeBrightnessSmooth" => Value::i(&self.eye_smooth.map(|v| v.round() as i32)),
"gbufferModelView" => m4(view),
"gbufferModelViewInverse" => m4(view.inverse()),
"gbufferProjection" => m4(projection),
"gbufferProjectionInverse" => m4(projection.inverse()),
"gbufferPreviousModelView" => m4(previous_view),
"gbufferPreviousProjection" => m4(previous_projection),
// Held items wait for `item.properties` (5.3: 0 without the file); renderStage
// is NONE outside gbuffers draws.
_ => Value::i(&[0]),
}
};
self.values = NAMES.iter().map(|n| value(n)).collect();
self.frame = (self.frame + 1) % 720_720;
}
/// `hz_fog_color` and `hz_fog` of the translator's block (the compatibility `gl_Fog`).
pub fn fog(&self) -> [Vec4; 2] {
let get = |name| match index(name).and_then(|i| self.values.get(i)) {
Some(Value::F(f)) => *f,
_ => [0.0; 16],
};
let (color, start, end) = (get("fogColor"), get("fogStart")[0], get("fogEnd")[0]);
[
Vec4::new(color[0], color[1], color[2], 1.0),
Vec4::new(1.0, start, end, 1.0 / (end - start).max(1e-3)),
]
}
}
/// The position of `name` in [`NAMES`].
pub fn index(name: &str) -> Option<usize> {
NAMES.iter().position(|n| *n == name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_follow_the_declared_type() {
let mut out = vec![0u8; 64];
Value::i(&[7]).write("float", &mut out, 0);
assert_eq!(f32::from_ne_bytes(out[0..4].try_into().unwrap()), 7.0);
Value::f(&[1.5, 2.5]).write("ivec2", &mut out, 4);
assert_eq!(i32::from_ne_bytes(out[8..12].try_into().unwrap()), 2);
Value::i(&[1]).write("bool", &mut out, 16);
assert_eq!(i32::from_ne_bytes(out[16..20].try_into().unwrap()), 1);
Value::f(&[1.0; 16]).write("mat4", &mut out, 60);
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
#[test]
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
assert!(body(0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
assert!(body(90.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
}
}
+8 -1
View File
@@ -101,12 +101,14 @@ impl Post {
/// The world's colour and depth targets this frame, or `None` to draw
/// it straight to the screen.
/// `always`: a target even at scale 1 without FXAA (a shader pack reads it).
pub fn prepare(
&mut self,
device: &wgpu::Device,
window: [u32; 2],
always: bool,
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
if self.scale == 1.0 && !self.antialias {
if self.scale == 1.0 && !self.antialias && !always {
self.target = None;
return None;
}
@@ -155,6 +157,11 @@ impl Post {
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The size of the world's own target, when it has one.
pub fn size(&self) -> Option<[u32; 2]> {
self.target.as_ref().map(|t| t.size)
}
/// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`.
pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
+4 -1
View File
@@ -78,7 +78,10 @@ pub const MAP_SLOTS: usize = 256;
/// The atlas slot the minimap extra draws into.
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
pub const SKIN_SLOTS: usize = 32;
/// Vanilla registers a texture per skin. Here a skin evicted from its slot
/// is not stored again, and its player falls back to the default skin, so
/// the slots must cover a busy server's player list.
pub const SKIN_SLOTS: usize = 128;
/// Reused by the visible rows of the classic world selector.
pub const WORLD_ICON_SLOTS: usize = 32;
/// Atlas slots for the pack icons on the pack screen, one per icon drawn
+55 -4
View File
@@ -432,18 +432,21 @@ impl Guest {
}
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
/// `synced_id` names an entry of a synced registry (enchantments, trim
/// materials and patterns) by its id on this connection.
pub fn stack_to_network(
item_id: impl Fn(&str) -> i32,
enchantment_id: impl Fn(&str) -> Option<i32>,
synced_id: impl Fn(&str, &str) -> Option<i32>,
s: &Option<ItemStack>,
) -> Option<Stack> {
network_stack(&item_id, &enchantment_id, s)
network_stack(&item_id, &synced_id, s)
}
fn network_stack(
item_id: &dyn Fn(&str) -> i32,
enchantment_id: &dyn Fn(&str) -> Option<i32>,
synced_id: &dyn Fn(&str, &str) -> Option<i32>,
s: &Option<ItemStack>,
) -> Option<Stack> {
let enchantment_id = |name: &str| synced_id("minecraft:enchantment", name);
let s = s.as_ref()?;
let mut stack = Stack::new(item_id(&s.item), s.count as i32);
if s.damage > 0 {
@@ -481,6 +484,25 @@ fn network_stack(
horizon_protocol::item::Component::Int(rgb),
));
}
// ArmorTrim: both holders as references into the synced registries.
if let Some((material, pattern)) = &d.trim {
let id = |registry: &str, name: &str| {
if name.contains(':') {
synced_id(registry, name)
} else {
synced_id(registry, &format!("minecraft:{name}"))
}
};
if let (Some(material), Some(pattern)) = (
id("minecraft:trim_material", material),
id("minecraft:trim_pattern", pattern),
) {
stack.added.push((
horizon_protocol::item::component::TRIM,
horizon_protocol::item::Component::Trim { material, pattern },
));
}
}
if let Some(id) = d.map_id {
stack.added.push((
horizon_protocol::item::component::MAP_ID,
@@ -504,7 +526,7 @@ fn network_stack(
let charged = d
.charged
.iter()
.filter_map(|c| network_stack(item_id, enchantment_id, &Some(c.clone())))
.filter_map(|c| network_stack(item_id, synced_id, &Some(c.clone())))
.collect();
stack.added.push((
horizon_protocol::item::component::CHARGED_PROJECTILES,
@@ -545,3 +567,32 @@ fn network_stack(
}
Some(stack)
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_protocol::item::{Component, component};
#[test]
fn trims_go_as_synced_registry_references() {
let mut chestplate = ItemStack::new("minecraft:diamond_chestplate", 1);
chestplate.data_mut().trim = Some(("minecraft:gold".into(), "coast".into()));
let synced_id = |registry: &str, name: &str| match (registry, name) {
("minecraft:trim_material", "minecraft:gold") => Some(4),
("minecraft:trim_pattern", "minecraft:coast") => Some(1),
_ => None,
};
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate.clone())).unwrap();
assert_eq!(
stack.component(component::TRIM),
Some(&Component::Trim {
material: 4,
pattern: 1
})
);
// A name the connection's registry lacks sends no trim.
chestplate.data_mut().trim = Some(("minecraft:x".into(), "coast".into()));
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate)).unwrap();
assert_eq!(stack.component(component::TRIM), None);
}
}
+7 -3
View File
@@ -351,7 +351,7 @@ impl Hub {
pub fn stack(&mut self, s: &Option<ItemStack>) -> Option<horizon_protocol::item::Stack> {
let id = s.as_ref().map(|s| self.item_id(&s.item))?;
let sync = self.sync.clone();
stack_to_network(|_| id, |name| sync.id("minecraft:enchantment", name), s)
stack_to_network(|_| id, |registry, name| sync.id(registry, name), s)
}
pub fn broadcast(&self, packet: &C) {
@@ -1827,7 +1827,9 @@ impl Hub {
fn flush_advancements(&mut self, i: usize, show: bool) {
let sync = self.sync.clone();
let g = &mut self.guests[i];
let unlocked = std::mem::take(&mut g.recipes_unlocked);
let mut unlocked = std::mem::take(&mut g.recipes_unlocked);
// A recipe given and taken before this flush must not reappear.
unlocked.retain(|id| g.recipe_book.knows(id));
if let Some(add) = sync.advancements.recipes_added(&unlocked, &sync) {
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
}
@@ -1982,7 +1984,9 @@ impl Hub {
S::PlayerInput(p) => {
self.guests[i].input = p.flags;
self.guests[i].sneaking = p.flags & 32 != 0;
self.guests[i].sprinting = p.flags & 64 != 0 && p.flags & 1 != 0;
// handlePlayerInput only records keys and updates crouching.
// Sprinting persists after the sprint key is released (or
// starts with a double tap); PlayerCommand changes it.
}
// OPEN_INVENTORY (the ridden mount's inventory) and
// START_FALL_FLYING (checked against the guest's chest slot
@@ -27,6 +27,11 @@ pub fn uuid_ints(uuid: u128) -> [i32; 4] {
uuid as i32,
]
}
/// `UUIDUtil.uuidFromIntArray`.
pub fn ints_uuid(ints: [i32; 4]) -> u128 {
ints.iter()
.fold(0, |acc, &i| acc << 32 | u128::from(i as u32))
}
/// Whether a saved file exists for this player.
pub fn exists(world: &Path, uuid: u128) -> bool {
+31
View File
@@ -37,6 +37,21 @@ pub trait ResourceSource {
vec!["minecraft".into()]
}
}
/// Horizon's own strings, compiled in so they need no prepared resources.
struct Bundled;
impl ResourceSource for Bundled {
fn read(&self, path: &str) -> Option<Vec<u8>> {
let body = match path {
"assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"),
"assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"),
_ => return None,
};
Some(body.as_bytes().to_vec())
}
fn namespaces(&self) -> Vec<String> {
vec!["horizon".into()]
}
}
impl ResourceSource for std::path::PathBuf {
fn read(&self, path: &str) -> Option<Vec<u8>> {
std::fs::read(self.join(path)).ok()
@@ -86,6 +101,12 @@ impl Language {
if code != DEFAULT {
codes.push(code);
}
// Horizon's own strings first, so a pack or the prepared root can still
// override them. Vanilla has no key for "Controller vibration".
let bundled = Bundled;
let mut all: Vec<&dyn ResourceSource> = vec![&bundled];
all.extend_from_slice(sources);
let sources = all.as_slice();
let namespaces: std::collections::BTreeSet<String> =
sources.iter().flat_map(|s| s.namespaces()).collect();
for c in codes {
@@ -204,6 +225,16 @@ mod tests {
self.0.get(path).cloned()
}
}
#[test]
fn bundled_horizon_strings_follow_the_language() {
// No prepared resources at all: the namespace is compiled in.
let empty = Mem(HashMap::new());
let en = Language::load(&[&empty], "en_us");
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
let ru = Language::load(&[&empty], "ru_ru");
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
}
#[test]
fn layers_selected_language_over_english() {
let base = Mem(HashMap::from([
+3
View File
@@ -24,6 +24,7 @@ pub mod block_entity_render;
pub mod block_transform;
pub mod blocks;
pub mod border_render;
pub mod boss_overlay;
pub mod bossbar;
pub mod breaking;
pub mod brewing;
@@ -53,6 +54,7 @@ pub mod datapack;
pub mod debug_chart;
pub mod debug_keys;
pub mod debug_overlay;
pub mod discord_presence;
pub mod dynamic_light;
pub mod effects;
pub mod enchant;
@@ -177,6 +179,7 @@ pub mod tab_list;
pub mod telemetry;
pub mod testgen;
pub mod text;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
pub mod title;
+9 -5
View File
@@ -718,6 +718,8 @@ impl Mesher<'_> {
[center.x, center.z, base_y],
);
let info = &self.tables.info;
let catalog = &self.render.model_catalog;
let mut models = Vec::new();
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
@@ -747,13 +749,15 @@ impl Mesher<'_> {
continue;
}
let world = BlockPos::new(center.x * 16 + x, base_y + y, center.z * 16 + z);
let Ok(models) = self.render.model_catalog.collect(BlockStateId(id), world)
else {
if catalog
.models_at(BlockStateId(id), world, &mut models)
.is_err()
{
continue;
};
}
// ModelBlockRenderer: the first part decides smooth lighting
// for all; light-emitting blocks are always lit flat.
let Some((_, first)) = models.first() else {
let Some(first) = models.first() else {
continue;
};
let ao = self.smooth_lighting && state.emission == 0 && first.ambient_occlusion;
@@ -783,7 +787,7 @@ impl Mesher<'_> {
)
.unwrap_or(true);
}
for (_, model) in &models {
for model in &models {
for face in &model.faces {
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
continue;
+59 -28
View File
@@ -41,9 +41,12 @@ pub struct BakedModel {
}
type VariantKey = (String, [u8; 3], bool);
pub struct StateModels {
choices: Vec<CompiledChoices>,
states: BTreeMap<BlockStateId, (usize, SelectedModels)>,
baked: BTreeMap<VariantKey, BakedModel>,
/// Each definition's choices, a variant standing as its index in `baked`.
choices: Vec<CompiledChoices<u32>>,
/// By state id: its definition in `choices` and the models it selects.
states: Vec<Option<(usize, SelectedModels)>>,
/// Every transformed model once, with the variant it was baked from.
baked: Vec<(Variant, BakedModel)>,
}
fn key(variant: &Variant) -> VariantKey {
(variant.model.clone(), variant.rotations, variant.uv_lock)
@@ -85,7 +88,7 @@ impl StateModels {
models: &crate::model_library::ParsedModels,
) -> Result<Self> {
let mut choices = Vec::new();
let mut states = BTreeMap::new();
let mut states = vec![None; registry.len()];
let mut variants = BTreeSet::new();
let mut by_name: std::collections::HashMap<&str, Vec<BlockStateId>> =
std::collections::HashMap::new();
@@ -178,7 +181,7 @@ impl StateModels {
}
for ((id, _), selected) in native_order.into_iter().zip(bound) {
if let Some(selected) = selected {
states.insert(id, (choices.len(), selected));
states[id.0 as usize] = Some((choices.len(), selected));
}
}
for choice in definition
@@ -211,7 +214,8 @@ impl StateModels {
models.block_library_or_missing(root, names)?
}
};
let mut baked = BTreeMap::new();
let mut baked = Vec::with_capacity(variants.len());
let mut index = BTreeMap::new();
for (name, turns, lock) in variants {
let model = library.resolve(&name)?;
let particle = model
@@ -233,16 +237,22 @@ impl StateModels {
})
.collect::<Result<_>>()?;
let cube = super::cube::cube_styles(&faces);
baked.insert(
(name, turns, lock),
BakedModel {
ambient_occlusion: model.ambient_occlusion,
particle,
faces,
cube,
},
);
index.insert((name.clone(), turns, lock), baked.len() as u32);
let variant = Variant {
model: name,
rotations: turns,
uv_lock: lock,
};
let model = BakedModel {
ambient_occlusion: model.ambient_occlusion,
particle,
faces,
cube,
};
baked.push((variant, model));
}
// Every variant of every definition was baked above.
let choices = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
Ok(Self {
choices,
states,
@@ -260,26 +270,47 @@ impl StateModels {
/// `BlockStateModel.particleMaterial`: the particle sprite layer of the
/// state's first variant (see `CompiledChoices::particle_variant`).
pub fn particle(&self, state: BlockStateId) -> Option<u32> {
let (choice, selected) = self.states.get(&state)?;
let variant = self.choices[*choice].particle_variant(selected)?;
self.baked.get(&key(variant))?.particle
let (choice, selected) = self.state(state)?;
let model = self.choices[*choice].particle_variant(selected)?;
self.baked[*model as usize].1.particle
}
pub fn collect_seeded(
&self,
state: BlockStateId,
seed: i64,
) -> Result<Vec<(&Variant, &BakedModel)>> {
let (choice, selected) = self
.states
.get(&state)
.ok_or("unbound gallery block state")?;
let mut models = Vec::new();
self.each_seeded(state, seed, |(variant, model)| {
models.push((variant, model))
})?;
Ok(models)
}
/// The models a block draws at `position`, into `out` (cleared first):
/// `collect` for the mesher, which reuses one list for every block.
pub fn models_at<'a>(
&'a self,
state: BlockStateId,
position: BlockPos,
out: &mut Vec<&'a BakedModel>,
) -> Result<()> {
out.clear();
self.each_seeded(state, position_seed(position), |(_, model)| out.push(model))
}
fn each_seeded<'a>(
&'a self,
state: BlockStateId,
seed: i64,
mut f: impl FnMut(&'a (Variant, BakedModel)),
) -> Result<()> {
let (choice, selected) = self.state(state).ok_or("unbound gallery block state")?;
let mut random = LegacyRandom::new(seed);
let mut variants = Vec::new();
self.choices[*choice].collect(selected, &mut random, &mut variants)?;
variants
.into_iter()
.map(|v| Ok((v, self.baked.get(&key(v)).ok_or("unbaked block model")?)))
.collect()
self.choices[*choice].for_each(selected, &mut random, |&model| {
f(&self.baked[model as usize]);
})?;
Ok(())
}
fn state(&self, state: BlockStateId) -> Option<&(usize, SelectedModels)> {
self.states.get(state.0 as usize)?.as_ref()
}
}
+45
View File
@@ -1758,6 +1758,11 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> {
state: BlockStateId,
position: BlockPos,
) -> Result<()> {
// LivingEntity.aiStep: Slow Falling and Levitation reset the fall
// distance every tick, so landing under either does not hurt.
if self.player.status.slow_falling || self.player.status.levitation.is_some() {
self.player.fall_distance = 0.0;
}
if self.player.spawn_extra_particles_on_fall && grounded && self.player.fall_distance > 0.0
{
self.player.events.push(MotionEvent::SmashLanding {
@@ -2918,6 +2923,46 @@ mod tests {
}
}
#[test]
fn slow_falling_clears_the_fall_distance_so_landing_does_not_hurt() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
for slow_falling in [false, true] {
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 9.0, 20.5)).unwrap();
player.fall_distance = 30.0;
player.status.slow_falling = slow_falling;
let mut w = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
for _ in 0..200 {
if w.player.ground.on_ground {
break;
}
w.player_movement_tick([0.0, 0.0], false).unwrap();
}
assert!(w.player.ground.on_ground);
if slow_falling {
assert_eq!(w.player.health.health(), 20.0);
assert_eq!(w.player.fall_distance, 0.0);
} else {
assert!(w.player.health.health() < 20.0);
}
}
}
#[test]
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
+163 -9
View File
@@ -18,6 +18,7 @@ use horizon_protocol::{
text::{Language, plain, translatable},
};
use std::{
collections::HashMap,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
@@ -27,6 +28,12 @@ use std::{
time::Duration,
};
/// `CommonListenerCookie.serverCookies`: cookies the server asked the client
/// to store (`ClientboundStoreCookiePacket`), answered to
/// `ClientboundCookieRequestPacket`. A `transfer` carries them to the next
/// connection (`TransferState`), so a proxy's backend switch keeps them.
pub type Cookies = Arc<Mutex<HashMap<String, Vec<u8>>>>;
/// What the network thread reports to the game.
pub enum Event {
/// Progress text for the connecting screen.
@@ -48,6 +55,13 @@ pub enum Event {
PackPush(cfg::ResourcePackPush),
/// ClientboundResourcePackPopPacket: one pack, or every pack.
PackPop(Option<u128>),
/// `ClientboundTransferPacket`: the server sent us to another address.
/// The connection ends right after; the game reconnects there with the
/// stored cookies.
Transfer {
host: String,
port: i32,
},
/// The connection ended. `from_server`: the server gave the reason (or
/// the connection was refused for good), rather than the connection
/// failing (auto-reconnect retries only failures and shutdowns).
@@ -67,6 +81,8 @@ pub struct Session {
pub address: String,
pub name: String,
pub counters: Arc<Mutex<Option<Arc<Counters>>>>,
/// The cookies this server stored on us (`ClientboundStoreCookiePacket`).
pub cookies: Cookies,
/// HORIZON_NET_DEBUG=1 logs every packet sent from the game thread.
debug: bool,
}
@@ -79,7 +95,27 @@ impl Session {
view_distance: i8,
lang: Arc<Language>,
) -> Self {
Self::start(address, name, auth, view_distance, lang, None)
Self::start(address, name, auth, view_distance, lang, None, None)
}
/// `ConnectScreen.startConnecting` for a `transfer`: the cookies the
/// server stored carry to the new connection (`TransferState`).
pub fn connect_transferred(
address: &str,
name: &str,
auth: Option<Arc<AuthSession>>,
view_distance: i8,
lang: Arc<Language>,
cookies: Cookies,
) -> Self {
Self::start(
address,
name,
auth,
view_distance,
lang,
None,
Some(cookies),
)
}
/// Join an integrated server through memory (`Connection.connectToLocalServer`).
pub fn connect_local(
@@ -88,7 +124,7 @@ impl Session {
view_distance: i8,
lang: Arc<Language>,
) -> Self {
Self::start("local", name, None, view_distance, lang, Some(pipe))
Self::start("local", name, None, view_distance, lang, Some(pipe), None)
}
fn start(
address: &str,
@@ -97,12 +133,18 @@ impl Session {
view_distance: i8,
lang: Arc<Language>,
local: Option<horizon_protocol::connection::Pipe>,
cookies: Option<Cookies>,
) -> Self {
let (send, events) = channel();
let writer = Arc::new(Mutex::new(None));
let configuring = Arc::new(AtomicBool::new(true));
let counters = Arc::new(Mutex::new(None));
let (thread_writer, thread_counters) = (writer.clone(), counters.clone());
// Connection.initiateServerboundPlayConnection: a transfer's
// handshake says so (ClientIntent.TRANSFER).
let transferred = cookies.is_some();
let cookies = cookies.unwrap_or_default();
let (thread_writer, thread_counters, thread_cookies) =
(writer.clone(), counters.clone(), cookies.clone());
let thread_configuring = configuring.clone();
let (address_owned, name_owned) = (address.to_owned(), name.to_owned());
let thread = std::thread::Builder::new()
@@ -119,6 +161,8 @@ impl Session {
&thread_writer,
&thread_configuring,
&thread_counters,
&thread_cookies,
transferred,
);
let from_server = result.is_ok();
let reason = result.unwrap_or_else(|e| e);
@@ -140,6 +184,7 @@ impl Session {
address: address.to_owned(),
name: name.to_owned(),
counters,
cookies,
debug,
}
}
@@ -291,6 +336,8 @@ fn run(
shared_writer: &Mutex<Option<ConnWriter>>,
configuring: &AtomicBool,
shared_counters: &Mutex<Option<Arc<Counters>>>,
cookies: &Cookies,
transferred: bool,
) -> Result<String, String> {
let (host, port, mut c) = if let Some(pipe) = local {
(address.to_owned(), 0, Connection::local(pipe))
@@ -321,7 +368,11 @@ fn run(
protocol: PROTOCOL_VERSION,
host: host.clone(),
port,
intent: handshake::LOGIN,
intent: if transferred {
handshake::TRANSFER
} else {
handshake::LOGIN
},
})
.encode(),
)
@@ -391,11 +442,14 @@ fn run(
)
.map_err(e)?;
}
// ClientHandshakePacketListenerImpl.handleRequestCookie: the
// cookies a transfer carried over (none on a plain join).
login::Clientbound::CookieRequest(q) => {
let payload = cookies.lock().unwrap().get(&q.key).cloned();
c.send(
&login::Serverbound::CookieResponse(login::CookieResponse {
key: q.key,
payload: None,
payload,
})
.encode(),
)
@@ -419,13 +473,13 @@ fn run(
.map_err(e)?;
let (mut reader, writer) = c.split();
*shared_writer.lock().unwrap() = Some(writer);
let registries = configure(&mut reader, shared_writer, configuring, lang, send)?;
let registries = configure(&mut reader, shared_writer, configuring, lang, send, cookies)?;
let _ = send.send(Event::Joined {
registries: Arc::new(registries),
uuid: profile.uuid,
name: profile.name,
});
receive_play(&mut reader, shared_writer, configuring, lang, send)
receive_play(&mut reader, shared_writer, configuring, lang, send, cookies)
}
fn receive_play(
@@ -434,6 +488,7 @@ fn receive_play(
configuring: &AtomicBool,
lang: &Language,
send: &Sender<Event>,
cookies: &Cookies,
) -> Result<String, String> {
loop {
let (id, payload) = reader.recv().map_err(|e| e.to_string())?;
@@ -469,9 +524,31 @@ fn receive_play(
let _ = w.send(&play::Serverbound::ConfigurationAcknowledged(Empty).encode());
configuring.store(true, Ordering::Relaxed);
}
let registries = configure(reader, shared_writer, configuring, lang, send)?;
let registries =
configure(reader, shared_writer, configuring, lang, send, cookies)?;
let _ = send.send(Event::Reconfigured(Arc::new(registries)));
}
// ClientCommonPacketListenerImpl.handleRequestCookie: the stored
// cookie (or none) goes straight back.
play::Clientbound::CookieRequest(q) => {
let payload = cookies.lock().unwrap().get(&q.key).cloned();
reply(play::Serverbound::CookieResponse(cfg::CookieResponse {
key: q.key,
payload,
}));
}
play::Clientbound::StoreCookie(p) => {
cookies.lock().unwrap().insert(p.key, p.payload);
}
// ClientCommonPacketListenerImpl.handleTransfer: the connection
// ends and the game connects to the other address.
play::Clientbound::Transfer(p) => {
let _ = send.send(Event::Transfer {
host: p.host,
port: p.port,
});
return Ok(tr(lang, "disconnect.transfer"));
}
play::Clientbound::Disconnect(d) => return Ok(plain(&d.reason, lang)),
play::Clientbound::CustomPayload(p) if p.channel == "minecraft:brand" => {
receive_brand(&p, send);
@@ -493,6 +570,7 @@ fn configure(
configuring: &AtomicBool,
lang: &Language,
send: &Sender<Event>,
cookies: &Cookies,
) -> Result<Dynamic, String> {
let mut registries = Dynamic::default();
let reply = |p: cfg::Serverbound| -> Result<(), String> {
@@ -525,11 +603,22 @@ fn configure(
let _ = send.send(Event::PackPop(p.id));
}
cfg::Clientbound::CookieRequest(q) => {
let payload = cookies.lock().unwrap().get(&q.key).cloned();
reply(cfg::Serverbound::CookieResponse(cfg::CookieResponse {
key: q.key,
payload: None,
payload,
}))?
}
cfg::Clientbound::StoreCookie(p) => {
cookies.lock().unwrap().insert(p.key, p.payload);
}
cfg::Clientbound::Transfer(p) => {
let _ = send.send(Event::Transfer {
host: p.host,
port: p.port,
});
return Err(tr(lang, "disconnect.transfer"));
}
cfg::Clientbound::Disconnect(d) => return Err(plain(&d.reason, lang)),
cfg::Clientbound::FinishConfiguration(_) => {
let mut w = writer.lock().unwrap();
@@ -659,6 +748,7 @@ mod tests {
&AtomicBool::new(false),
&Language::new(),
&send,
&Cookies::default(),
)
});
let mut server = Connection::local(server);
@@ -708,6 +798,7 @@ mod tests {
address: "local".into(),
name: "Player".into(),
counters: Arc::new(Mutex::new(None)),
cookies: Cookies::default(),
debug: false,
};
let (thread_writer, thread_configuring) =
@@ -719,6 +810,7 @@ mod tests {
&thread_configuring,
&Language::new(),
&send,
&Cookies::default(),
)
});
let mut server = Connection::local(server);
@@ -749,6 +841,68 @@ mod tests {
assert!(thread.join().unwrap().is_err());
}
#[test]
fn play_answers_cookie_requests_from_stored_cookies_and_follows_transfers() {
let (client, server) = horizon_protocol::connection::Pipe::pair();
let (mut reader, writer) = Connection::local(client).split();
let writer = Arc::new(Mutex::new(Some(writer)));
let (send, events) = channel();
let cookies = Cookies::default();
let (thread_writer, thread_cookies) = (writer.clone(), cookies.clone());
let thread = std::thread::spawn(move || {
receive_play(
&mut reader,
&thread_writer,
&AtomicBool::new(false),
&Language::new(),
&send,
&thread_cookies,
)
});
let mut server = Connection::local(server);
let ask = |server: &mut Connection, key: &str| {
let request = play::Clientbound::CookieRequest(cfg::CookieRequest { key: key.into() });
server.send(&request.encode()).unwrap();
let (id, body) = server.recv().unwrap();
play::Serverbound::decode(id, &body).unwrap()
};
// handleRequestCookie answers an unknown key with no payload.
assert_eq!(
ask(&mut server, "proxy:session"),
play::Serverbound::CookieResponse(cfg::CookieResponse {
key: "proxy:session".into(),
payload: None,
})
);
let store = play::Clientbound::StoreCookie(cfg::StoreCookie {
key: "proxy:session".into(),
payload: vec![1, 2, 3],
});
server.send(&store.encode()).unwrap();
assert_eq!(
ask(&mut server, "proxy:session"),
play::Serverbound::CookieResponse(cfg::CookieResponse {
key: "proxy:session".into(),
payload: Some(vec![1, 2, 3]),
})
);
let transfer = play::Clientbound::Transfer(cfg::Transfer {
host: "example.net".into(),
port: 25566,
});
server.send(&transfer.encode()).unwrap();
assert!(thread.join().unwrap().is_ok());
assert!(matches!(
events.try_iter().last(),
Some(Event::Transfer { host, port: 25566 }) if host == "example.net"
));
// The stored cookie stays for the next connection (TransferState).
assert_eq!(
cookies.lock().unwrap().get("proxy:session"),
Some(&vec![1, 2, 3])
);
}
#[test]
fn server_brand_payload_is_decoded_and_invalid_or_unrelated_payloads_are_ignored() {
let (send, events) = channel();
+19 -15
View File
@@ -267,20 +267,21 @@ impl Opt {
Opt::HighContrastBlockOutlines => "options.accessibility.high_contrast_block_outline",
Opt::ForceUnicode => "options.forceUnicodeFont",
Opt::JapaneseGlyphs => "options.japaneseGlyphVariants",
// Horizon's own options: English captions (no vanilla keys).
Opt::PadEnabled => "Controller",
Opt::PadVibration => "Vibration",
Opt::PadAdaptiveTriggers => "Adaptive Triggers",
Opt::PadLightbar => "Lightbar",
Opt::PadLookSensitivity => "Look Sensitivity",
Opt::PadInvertX => "Invert Look X",
Opt::PadInvertY => "Invert Look Y",
Opt::PadToggleSneak => "Sneak Button",
Opt::PadMoveDeadzone => "Move Deadzone",
Opt::PadLookDeadzone => "Look Deadzone",
Opt::PadGlyphs => "Button Glyphs",
Opt::PadMenuNav => "Menu Navigation",
Opt::PadKeyboard => "On-Screen Keyboard",
// Horizon's own options: vanilla has no keys for them, so they read
// the namespace the client bundles (assets/horizon/lang).
Opt::PadEnabled => "horizon.options.controller",
Opt::PadVibration => "horizon.options.vibration",
Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers",
Opt::PadLightbar => "horizon.options.lightbar",
Opt::PadLookSensitivity => "horizon.options.look_sensitivity",
Opt::PadInvertX => "horizon.options.invert_x",
Opt::PadInvertY => "horizon.options.invert_y",
Opt::PadToggleSneak => "horizon.options.sneak_button",
Opt::PadMoveDeadzone => "horizon.options.move_deadzone",
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
Opt::PadGlyphs => "horizon.options.button_glyphs",
Opt::PadMenuNav => "horizon.options.menu_navigation",
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
}
}
pub(crate) fn kind(self, s: &Settings) -> Kind {
@@ -1291,7 +1292,10 @@ impl OptionsUi {
open("Graphics...", Page::Graphics),
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
),
Row::Buttons(open("Controller Settings...", Page::Controller), None),
Row::Buttons(
open("Controller Settings...", Page::Controller),
Some(self.extra_button(s, crate::extras::Item::DiscordPresence)),
),
Row::Buttons(
open("Extras...", Page::Extras),
Some(open("Mods...", Page::Mods)),
+5 -1
View File
@@ -517,7 +517,11 @@ fn build_overlay_uncached(
}
}
} else if touched.contains(&child)
|| (vanilla.join(&child).is_dir() && !filters.is_empty())
// Windows cannot link a directory the way unix does (`link` above), so an
// untouched one has to be mirrored: skipping it hid textures/font and the
// title panorama from the overlay, and a font json that names those sheets
// then failed to load (#1688).
|| (vanilla.join(&child).is_dir() && (cfg!(not(unix)) || !filters.is_empty()))
{
mirror(
&child,
+25 -3
View File
@@ -61,6 +61,25 @@ pub enum ProjectileKind {
color: u32,
},
}
impl ProjectileKind {
/// Its entity type.
pub fn id(&self) -> &'static str {
match self {
Self::Arrow { .. } => "minecraft:arrow",
Self::Snowball => "minecraft:snowball",
Self::Egg => "minecraft:egg",
Self::EnderPearl => "minecraft:ender_pearl",
Self::SmallFireball => "minecraft:small_fireball",
Self::LargeFireball { .. } => "minecraft:fireball",
Self::LlamaSpit => "minecraft:llama_spit",
Self::WindCharge => "minecraft:wind_charge",
Self::BreezeWindCharge => "minecraft:breeze_wind_charge",
Self::ShulkerBullet { .. } => "minecraft:shulker_bullet",
Self::WitherSkull { .. } => "minecraft:wither_skull",
Self::Potion { .. } => "minecraft:splash_potion",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Projectile {
/// The entity id a server tracks it by (0 until it is tracked).
@@ -83,6 +102,8 @@ pub struct Projectile {
pub bonus: crate::mobs::gear::ArrowBonus,
/// Arrows' `AbstractArrow` state (ticked by `arrows::tick`).
pub arrow: Option<Box<crate::arrows::ArrowState>>,
/// `AbstractHurtingProjectile.accelerationPower`.
pub acceleration: f64,
}
/// What a projectile struck this tick.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -146,6 +167,7 @@ impl Projectile {
stuck: false,
left_owner: false,
bonus: Default::default(),
acceleration: 0.1,
arrow: kind_is_arrow.then(|| {
// Entity.random: seeded unpredictably, the position stands in.
Box::new(crate::arrows::ArrowState::new(
@@ -571,9 +593,9 @@ impl Projectile {
let v = self.velocity;
let len = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt().max(1.0e-9);
self.velocity = Vec3::new(
(v.x + v.x / len * 0.1) * inertia,
(v.y + v.y / len * 0.1) * inertia,
(v.z + v.z / len * 0.1) * inertia,
(v.x + v.x / len * self.acceleration) * inertia,
(v.y + v.y / len * self.acceleration) * inertia,
(v.z + v.z / len * self.acceleration) * inertia,
);
}
// Arrows tick with `arrows::tick`.
+6 -4
View File
@@ -787,13 +787,15 @@ impl Settings {
}
(displays, added)
}
/// `ServerRecipeBook.removeRecipes`: the displays removed.
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> usize {
let mut displays = 0;
/// `ServerRecipeBook.removeRecipes`: the display ids removed, which its
/// `ClientboundRecipeBookRemovePacket` lists (a transmute recipe has one
/// per material count).
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> Vec<i32> {
let mut displays = Vec::new();
for id in ids {
if self.recipes.remove(id) {
self.to_be_displayed.remove(id);
displays += index.displays(id);
displays.extend(index.display_ids(id));
}
}
displays
+2 -1
View File
@@ -1913,7 +1913,8 @@ pub fn write_dat(path: &Path, tag: Tag) -> Result<()> {
Some(stem) => path.with_file_name(format!("{stem}.dat_old")),
None => path.with_file_name(format!("{file}_old")),
};
let _ = fs::rename(path, old);
// Util.safeReplaceFile: no backup, no replacement.
fs::rename(path, old).map_err(|e| format!("{}: {e}", path.display()))?;
}
fs::rename(temporary, path)?;
Ok(())
@@ -34,7 +34,21 @@ pub fn headers(name: &str, uuid: u128) -> Vec<(&'static str, String)> {
]
}
type Job = (Vec<Request>, Vec<(&'static str, String)>);
/// `HttpUtil.DownloadProgressListener`, on the channel a batch was given.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Progress {
/// `requestStart`: the batch's next request begins.
RequestStart,
/// `downloadStart`: the response came, with its Content-Length if it
/// said one.
DownloadStart(Option<u64>),
/// `downloadedBytes`: what this request has written so far.
Bytes(u64),
/// `requestFinished`: whether it succeeded.
RequestFinished(bool),
}
type Job = (Vec<Request>, Vec<(&'static str, String)>, Sender<Progress>);
/// `DownloadQueue`: batches download in order, on one thread.
pub struct Queue {
@@ -50,16 +64,24 @@ impl Queue {
std::thread::Builder::new()
.name("download-queue".into())
.spawn(move || {
for (requests, headers) in todo {
if finished.send(run(&dir, &requests, &headers)).is_err() {
for (requests, headers, progress) in todo {
if finished
.send(run(&dir, &requests, &headers, &progress))
.is_err()
{
break;
}
}
})?;
Ok(Self { jobs, done })
}
pub fn download(&self, requests: Vec<Request>, headers: Vec<(&'static str, String)>) {
let _ = self.jobs.send((requests, headers));
pub fn download(
&self,
requests: Vec<Request>,
headers: Vec<(&'static str, String)>,
progress: Sender<Progress>,
) {
let _ = self.jobs.send((requests, headers, progress));
}
/// The batches finished since the last call.
pub fn finished(&self) -> Vec<Batch> {
@@ -68,11 +90,18 @@ impl Queue {
}
/// `DownloadQueue.runDownload`: each pack into `<dir>/<id>/`, logged.
fn run(dir: &Path, requests: &[Request], headers: &[(&'static str, String)]) -> Batch {
fn run(
dir: &Path,
requests: &[Request],
headers: &[(&'static str, String)],
progress: &Sender<Progress>,
) -> Batch {
let mut batch = Batch::default();
for request in requests {
let id = horizon_protocol::crypto::uuid_string(request.id);
let result = download_file(&dir.join(&id), request, headers, MAX_SIZE);
let _ = progress.send(Progress::RequestStart);
let result = download_file(&dir.join(&id), request, headers, MAX_SIZE, progress);
let _ = progress.send(Progress::RequestFinished(result.is_ok()));
// DownloadQueue.LogEntry, its fields in the codec's order.
let mut entry = vec![
("id", serde_json::Value::from(id)),
@@ -165,6 +194,7 @@ pub fn download_file(
request: &Request,
headers: &[(&'static str, String)],
max_size: u64,
progress: &Sender<Progress>,
) -> Result<PathBuf, String> {
let target = request.sha1.as_ref().map(|sha1| dir.join(sha1));
if let (Some(target), Some(sha1)) = (&target, &request.sha1) {
@@ -198,6 +228,7 @@ pub fn download_file(
.header("Content-Length")
.and_then(|v| v.trim().parse::<u64>().ok());
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let _ = progress.send(Progress::DownloadStart(size));
if let Some(size) = size
&& size > max_size
{
@@ -206,9 +237,13 @@ pub fn download_file(
));
}
let mut input = response.into_reader();
let mut report = |written: u64| {
let _ = progress.send(Progress::Bytes(written));
};
match (&target, &request.sha1) {
(Some(target), Some(sha1)) => {
let actual = save_hashed(&mut input, target, max_size).map_err(|e| e.to_string())?;
let actual = save_hashed(&mut input, target, max_size, &mut report)
.map_err(|e| e.to_string())?;
if &actual != sha1 {
return Err(format!(
"Hash of downloaded file ({actual}) did not match requested ({sha1})"
@@ -218,7 +253,7 @@ pub fn download_file(
}
_ => {
let tmp = dir.join(format!("download{:016x}.tmp", rand_suffix()));
let result = save_hashed(&mut input, &tmp, max_size).and_then(|actual| {
let result = save_hashed(&mut input, &tmp, max_size, &mut report).and_then(|actual| {
let path = dir.join(&actual);
if matches(&path, &actual) {
touch(&path);
@@ -237,7 +272,12 @@ fn rand_suffix() -> u64 {
RandomState::new().hash_one(std::time::Instant::now())
}
/// `downloadAndHash`: write what comes, at most `max_size` bytes.
fn save_hashed(input: &mut dyn Read, path: &Path, max_size: u64) -> io::Result<String> {
fn save_hashed(
input: &mut dyn Read,
path: &Path,
max_size: u64,
progress: &mut dyn FnMut(u64),
) -> io::Result<String> {
let mut out = fs::File::create(path)?;
let mut hasher = Sha1::new();
let mut buffer = [0u8; 8196];
@@ -248,6 +288,7 @@ fn save_hashed(input: &mut dyn Read, path: &Path, max_size: u64) -> io::Result<S
break;
}
read_so_far += n as u64;
progress(read_so_far);
if read_so_far > max_size {
return Err(io::Error::other(format!(
"Filesize was bigger than maximum allowed (got >= {read_so_far}, limit was {max_size})"
@@ -366,7 +407,14 @@ mod tests {
url,
sha1: Some(sha1(&body)),
};
let path = download_file(&dir, &request, &headers("Alex", 0xab), MAX_SIZE).unwrap();
let path = download_file(
&dir,
&request,
&headers("Alex", 0xab),
MAX_SIZE,
&channel().0,
)
.unwrap();
assert_eq!(path, dir.join(sha1(&body)));
assert_eq!(fs::read(&path).unwrap(), body);
let sent = server.join().unwrap();
@@ -377,7 +425,10 @@ mod tests {
url: "http://127.0.0.1:9/gone.zip".into(),
..request
};
assert_eq!(download_file(&dir, &cached, &[], MAX_SIZE).unwrap(), path);
assert_eq!(
download_file(&dir, &cached, &[], MAX_SIZE, &channel().0).unwrap(),
path
);
fs::remove_dir_all(dir).unwrap();
}
@@ -390,7 +441,7 @@ mod tests {
url,
sha1: Some("0".repeat(40)),
};
let err = download_file(&dir, &wrong, &[], MAX_SIZE).unwrap_err();
let err = download_file(&dir, &wrong, &[], MAX_SIZE, &channel().0).unwrap_err();
assert!(err.contains("did not match requested"), "{err}");
server.join().unwrap();
let dead = Request {
@@ -398,7 +449,7 @@ mod tests {
url: "http://127.0.0.1:9/pack.zip".into(),
sha1: None,
};
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE).is_err());
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE, &channel().0).is_err());
assert!(!dir.join("dead").exists());
let (url, server) = serve("200 OK", vec![0; 100]);
let large = Request {
@@ -406,7 +457,7 @@ mod tests {
url,
sha1: None,
};
let err = download_file(&dir, &large, &[], 10).unwrap_err();
let err = download_file(&dir, &large, &[], 10, &channel().0).unwrap_err();
assert!(err.contains("bigger than maximum"), "{err}");
let _ = server.join();
let (url, server) = serve("404 Not Found", Vec::new());
@@ -415,7 +466,7 @@ mod tests {
url,
sha1: None,
};
assert!(download_file(&dir, &missing, &[], MAX_SIZE).is_err());
assert!(download_file(&dir, &missing, &[], MAX_SIZE, &channel().0).is_err());
server.join().unwrap();
fs::remove_dir_all(dir).unwrap();
}
@@ -425,6 +476,7 @@ mod tests {
let dir = scratch();
let (url, server) = serve("200 OK", b"zip".to_vec());
let queue = Queue::open(dir.clone()).unwrap();
let (progress, events) = channel();
let id = 0x5b2c_7a4e_1f3d_4c8b_9a6e_2d4f_8b1c_3e5a;
queue.download(
vec![Request {
@@ -433,6 +485,7 @@ mod tests {
sha1: None,
}],
Vec::new(),
progress,
);
let batch = loop {
if let Some(b) = queue.finished().pop() {
@@ -445,6 +498,16 @@ mod tests {
.join("5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a")
.join(sha1(b"zip"));
assert_eq!(batch.downloaded, [(id, file)]);
// HttpUtil.downloadFile's listener calls, in order.
assert_eq!(
events.iter().collect::<Vec<_>>(),
[
Progress::RequestStart,
Progress::DownloadStart(Some(3)),
Progress::Bytes(3),
Progress::RequestFinished(true),
]
);
let log = fs::read_to_string(dir.join("log.json")).unwrap();
let entry: serde_json::Value = serde_json::from_str(log.trim()).unwrap();
assert_eq!(entry["id"], "5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a");
+4 -4
View File
@@ -638,7 +638,7 @@ impl Streaming {
});
self.shared.ready.notify_one();
}
fn receive_decorated(&mut self, x: i32, z: i32, region: Box<[Proto; 9]>) {
fn receive_decorated(&mut self, x: i32, z: i32, region: [Proto; 9]) {
self.generation_scan.exhausted = false;
self.decorating -= 1;
self.decorated.insert((x, z));
@@ -825,7 +825,7 @@ impl Streaming {
}
}
}
Done::Decorated(x, z, region) => self.receive_decorated(x, z, region),
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::Lit(x, z, sections, seq) => {
@@ -1307,7 +1307,7 @@ impl Streaming {
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.results.recv_timeout(left) {
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, region),
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
Ok(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Ok(Done::Saved(protos)) => self.receive_saved(protos),
Ok(other) => self.deferred.push(other),
@@ -1335,7 +1335,7 @@ impl Streaming {
let started = std::time::Instant::now();
while let Ok(done) = self.results.try_recv() {
match done {
Done::Decorated(x, z, region) => self.receive_decorated(x, z, region),
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
other => self.deferred.push(other),
+169
View File
@@ -0,0 +1,169 @@
//! Display/TextDisplay synced data (26.3), checked against vanilla accessors
//! and ClientboundSetEntityDataPacket's own stream codec.
use crate::text::Text;
use horizon_protocol::entity_data::Value;
use serde::Serialize;
use std::sync::Arc;
/// `TextDisplay.Align`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Align {
Center,
Left,
Right,
}
/// `Display` and `TextDisplay`'s synced data (ids 8 to 27); the default is
/// what their `defineSynchedData` starts with.
#[derive(Clone, Debug, Serialize)]
pub struct TextDisplayData {
pub interpolation_delay: i32,
pub interpolation_duration: i32,
pub teleport_duration: i32,
pub translation: [f32; 3],
pub scale: [f32; 3],
pub left_rotation: [f32; 4],
pub right_rotation: [f32; 4],
pub billboard: u8,
pub brightness: i32,
pub view_range: f32,
pub shadow_radius: f32,
pub shadow_strength: f32,
pub width: f32,
pub height: f32,
pub glow_color: i32,
/// Shared: a frame's interpolated copy of the data must not copy it.
#[serde(skip)]
pub text: Arc<Text>,
pub line_width: i32,
pub background: i32,
pub opacity: i8,
pub flags: u8,
}
impl Default for TextDisplayData {
fn default() -> Self {
Self {
interpolation_delay: 0,
interpolation_duration: 0,
teleport_duration: 0,
translation: [0.0; 3],
scale: [1.0; 3],
left_rotation: [0.0, 0.0, 0.0, 1.0],
right_rotation: [0.0, 0.0, 0.0, 1.0],
billboard: 0,
brightness: -1,
view_range: 1.0,
shadow_radius: 0.0,
shadow_strength: 1.0,
width: 0.0,
height: 0.0,
glow_color: -1,
text: Arc::new(Text::empty()),
line_width: 200,
background: 0x4000_0000,
opacity: -1,
flags: 0,
}
}
}
impl TextDisplayData {
/// DisplayRenderer's billboard orientation followed by Transformation's T*L*S*R.
pub fn matrix(&self, entity: [f32; 2], camera: [f32; 2]) -> glam::Mat4 {
let [yaw, pitch] = match self.billboard {
1 => [camera[0] - 180.0, entity[1]],
2 => [entity[0], -camera[1]],
3 => [camera[0] - 180.0, -camera[1]],
_ => entity,
};
let orientation = glam::Quat::from_euler(
glam::EulerRot::YXZ,
-yaw * 0.017_453_292,
pitch * 0.017_453_292,
0.0,
);
glam::Mat4::from_quat(orientation)
* glam::Mat4::from_translation(self.translation.into())
* glam::Mat4::from_quat(glam::Quat::from_array(self.left_rotation))
* glam::Mat4::from_scale(self.scale.into())
* glam::Mat4::from_quat(glam::Quat::from_array(self.right_rotation))
}
pub fn blend(&self, from: &Self, t: f32) -> Self {
let mut out = self.clone();
for i in 0..3 {
out.translation[i] =
from.translation[i] + t * (self.translation[i] - from.translation[i]);
out.scale[i] = from.scale[i] + t * (self.scale[i] - from.scale[i]);
}
out.left_rotation = glam::Quat::from_array(from.left_rotation)
.slerp(glam::Quat::from_array(self.left_rotation), t)
.to_array();
out.right_rotation = glam::Quat::from_array(from.right_rotation)
.slerp(glam::Quat::from_array(self.right_rotation), t)
.to_array();
let int = |a: i32, b: i32| a + (t * (b as f32 - a as f32)).floor() as i32;
out.opacity = int(i32::from(from.opacity), i32::from(self.opacity)) as i8;
out.background = (0..4).fold(0, |color, channel| {
color
| (int(
(from.background as u32 >> (channel * 8) & 255) as i32,
(self.background as u32 >> (channel * 8) & 255) as i32,
) << (channel * 8))
});
out
}
/// Omitted fields retain their previous values.
pub fn apply(&mut self, values: &[(u8, i32, Value)]) {
for (id, _, value) in values {
match (id, value) {
(8, Value::Int(v)) => self.interpolation_delay = *v,
(9, Value::Int(v)) => self.interpolation_duration = *v,
(10, Value::Int(v)) => self.teleport_duration = *v,
(11, Value::Floats(v)) if v.len() == 3 => self.translation.copy_from_slice(v),
(12, Value::Floats(v)) if v.len() == 3 => self.scale.copy_from_slice(v),
(13, Value::Floats(v)) if v.len() == 4 => self.left_rotation.copy_from_slice(v),
(14, Value::Floats(v)) if v.len() == 4 => self.right_rotation.copy_from_slice(v),
(15, Value::Byte(v)) => self.billboard = *v as u8,
(16, Value::Int(v)) => self.brightness = *v,
(17, Value::Float(v)) => self.view_range = *v,
(18, Value::Float(v)) => self.shadow_radius = *v,
(19, Value::Float(v)) => self.shadow_strength = *v,
(20, Value::Float(v)) => self.width = *v,
(21, Value::Float(v)) => self.height = *v,
(22, Value::Int(v)) => self.glow_color = *v,
(23, Value::Text(v)) => self.text = Arc::new(crate::entity_nbt::name_text(v)),
(24, Value::Int(v)) => self.line_width = *v,
(25, Value::Int(v)) => self.background = *v,
(26, Value::Byte(v)) => self.opacity = *v,
(27, Value::Byte(v)) => self.flags = *v as u8,
_ => {}
}
}
}
/// `TextDisplay.getAlign`: the left flag wins over the right one.
pub fn align(&self) -> Align {
if self.flags & 8 != 0 {
Align::Left
} else if self.flags & 16 != 0 {
Align::Right
} else {
Align::Center
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn partial_updates_retain_values_and_bad_vectors_do_not_panic() {
let mut d = TextDisplayData::default();
d.apply(&[(24, 1, Value::Int(80)), (27, 0, Value::Byte(24))]);
d.apply(&[(11, 0, Value::Floats(vec![1.0]))]);
assert_eq!(d.line_width, 80);
assert_eq!(d.align(), Align::Left);
assert_eq!(d.translation, [0.0; 3]);
}
}
+62 -51
View File
@@ -581,23 +581,57 @@ impl Title {
let cx = (w / 2.0).floor();
let y = (h / 4.0).floor() + 48.0;
let mut buttons = vec![
b(cx - 100.0, y, 200.0, "Singleplayer", true, 1),
b(cx - 100.0, y + 24.0, 200.0, "Multiplayer", true, 4),
b(cx - 100.0, y + 48.0, 200.0, "Minecraft Realms", false, 0),
b(cx - 100.0, y + 96.0, 98.0, "Options...", true, 3),
b(cx + 2.0, y + 96.0, 98.0, "Quit Game", true, 2),
];
for (i, (label, enabled, id, icon, size)) in [
// Horizon's Join a Friend (an invite code) behind
// vanilla's Friends button.
("Friends", true, 5, "friends/friends", 15.0),
("Language", true, 6, "icon/language", 15.0),
(
"Accessibility Settings",
b(
cx - 100.0,
y,
200.0,
&lang::get_or("menu.singleplayer", "Singleplayer"),
true,
1,
),
b(
cx - 100.0,
y + 24.0,
200.0,
&lang::get_or("menu.multiplayer", "Multiplayer"),
true,
4,
),
b(
cx - 100.0,
y + 48.0,
200.0,
&lang::get_or("menu.online", "Minecraft Realms"),
false,
0,
),
b(
cx - 100.0,
y + 96.0,
98.0,
&lang::get_or("menu.options", "Options..."),
true,
3,
),
b(
cx + 2.0,
y + 96.0,
98.0,
&lang::get_or("menu.quit", "Quit Game"),
true,
2,
),
];
// CommonButtons' icon buttons; Horizon's Join a Friend (an
// invite code) sits behind vanilla's Friends button.
for (i, (key, plain, id, icon)) in [
("gui.friends.open", "Friends", 5, "friends/friends"),
("options.language", "Language...", 6, "icon/language"),
(
"options.accessibility",
"Accessibility Settings...",
7,
"icon/accessibility",
15.0,
),
]
.into_iter()
@@ -607,11 +641,11 @@ impl Title {
cx - 34.0 + i as f32 * 24.0,
y + 72.0,
20.0,
label,
enabled,
&lang::get_or(key, plain),
true,
id,
);
button.icon = Some((icon, size));
button.icon = Some((icon, 15.0));
buttons.push(button);
}
buttons
@@ -621,7 +655,7 @@ impl Title {
(w / 2.0).floor() - 154.0,
h - 52.0,
150.0,
"Play Selected World",
&lang::get_or("selectWorld.select", "Play Selected World"),
selected.is_some(),
10,
),
@@ -629,7 +663,7 @@ impl Title {
(w / 2.0).floor() + 4.0,
h - 52.0,
150.0,
"Create New World",
&lang::get_or("selectWorld.create", "Create New World"),
true,
11,
),
@@ -657,7 +691,14 @@ impl Title {
selected.is_some(),
15,
),
b((w / 2.0).floor() + 83.0, h - 28.0, 71.0, "Back", true, 12),
b(
(w / 2.0).floor() + 83.0,
h - 28.0,
71.0,
&lang::get_or("gui.back", "Back"),
true,
12,
),
],
Mode::Create {
creative, options, ..
@@ -1150,7 +1191,7 @@ impl Title {
+ 30.0;
// Toast.xPos: in from the right edge by the visible portion.
let x = w - width * slide.portion;
toast_background(atlas, batch, x, width);
crate::toasts::background(atlas, batch, x, 0.0, width);
batch.text(atlas, &title, x + 18.0, 7.0, rgba(255, 255, 0, 255), false);
batch.text(atlas, &message, x + 18.0, 19.0, u32::MAX, false);
}
@@ -1553,36 +1594,6 @@ impl Title {
}
}
}
/// SystemToast's 32-pixel background keeps the notice icon and borders intact.
fn toast_background(atlas: &HudAtlas, batch: &mut HudBatch, x: f32, width: f32) {
let Some(rect) = atlas.sprites.get("gui/sprites/toast/system").copied() else {
return;
};
let source_x = [0.0, 17.0, rect.w as f32 - 4.0, rect.w as f32];
let target_x = [x, x + 17.0, x + width - 4.0, x + width];
for col in 0..3 {
for (sy, sh, y, h) in [
(0.0, 28.0, 0.0, 28.0),
(rect.h as f32 - 4.0, 4.0, 28.0, 4.0),
] {
let mut piece = rect;
let du = (rect.u1 - rect.u0) / rect.w as f32;
let dv = (rect.v1 - rect.v0) / rect.h as f32;
piece.u0 = rect.u0 + source_x[col] * du;
piece.u1 = rect.u0 + source_x[col + 1] * du;
piece.v0 = rect.v0 + sy * dv;
piece.v1 = rect.v0 + (sy + sh) * dv;
batch.rect_uv(
target_x[col],
y,
target_x[col + 1] - target_x[col],
h,
piece,
u32::MAX,
);
}
}
}
fn centered(atlas: &HudAtlas, batch: &mut HudBatch, text: &str, y: f32, color: u32) {
batch.text(
atlas,
+102
View File
@@ -1,5 +1,6 @@
//! `ToastManager`'s slide, shared by the game's toasts and the title
//! screens' system toasts.
use crate::hud::{HudAtlas, HudBatch};
use std::time::Instant;
/// `ToastManager.ToastInstance`: slides a toast in and out over 600 ms,
@@ -56,3 +57,104 @@ impl Slide {
self.update(origin.elapsed().as_millis() as i64, wants_show)
}
}
/// `SystemToast`: a yellow title over a white message (or the title alone,
/// lower down) on the system background. It stays until its display time
/// has passed since its last change, or until it is hidden.
pub struct SystemToast {
pub slide: Slide,
title: String,
message: Option<String>,
/// `changed`: the next update takes its time as `lastChanged`.
changed: bool,
last_changed: i64,
force_hide: bool,
}
impl SystemToast {
/// `SystemToastId.displayTime`'s default, in milliseconds.
const DISPLAY_TIME: f64 = 5000.0;
pub fn new(title: String, message: Option<String>) -> Self {
Self {
slide: Slide::default(),
title,
message,
changed: false,
last_changed: 0,
force_hide: false,
}
}
/// `reset`: new text, and the display time starts again.
pub fn reset(&mut self, title: String, message: Option<String>) {
self.title = title;
self.message = message;
self.changed = true;
}
pub fn force_hide(&mut self) {
self.force_hide = true;
}
/// `ToastInstance.update` with `SystemToast.update`, the display time
/// scaled by the Notification Time option: the visibility it switched
/// to (the sound to play), and whether it has slid out.
pub fn update(&mut self, multiplier: f64) -> (Option<bool>, bool) {
self.slide.update_now(|fully| {
if std::mem::take(&mut self.changed) {
self.last_changed = fully;
}
!self.force_hide
&& ((fully - self.last_changed) as f64) < Self::DISPLAY_TIME * multiplier
})
}
/// `extractRenderState` at `y`, in from the right edge by the visible
/// portion (`Toast.xPos`).
pub fn draw(&self, atlas: &HudAtlas, batch: &mut HudBatch, y: f32) {
let width = std::iter::once(&self.title)
.chain(&self.message)
.map(|line| HudBatch::text_width(atlas, line))
.fold(0.0, f32::max)
.ceil()
.max(160.0)
+ 30.0;
let x = batch.width - width * self.slide.portion;
background(atlas, batch, x, y, width);
let yellow = crate::hud::rgba(255, 255, 0, 255);
match &self.message {
Some(message) => {
batch.text(atlas, &self.title, x + 18.0, y + 7.0, yellow, false);
batch.text(atlas, message, x + 18.0, y + 19.0, u32::MAX, false);
}
None => {
batch.text(atlas, &self.title, x + 18.0, y + 12.0, yellow, false);
}
}
}
}
/// SystemToast's 32-pixel background keeps the notice icon and borders intact.
pub fn background(atlas: &HudAtlas, batch: &mut HudBatch, x: f32, y: f32, width: f32) {
let Some(rect) = atlas.sprites.get("gui/sprites/toast/system").copied() else {
return;
};
let source_x = [0.0, 17.0, rect.w as f32 - 4.0, rect.w as f32];
let target_x = [x, x + 17.0, x + width - 4.0, x + width];
for col in 0..3 {
for (sy, sh, y, h) in [
(0.0, 28.0, y, 28.0),
(rect.h as f32 - 4.0, 4.0, y + 28.0, 4.0),
] {
let mut piece = rect;
let du = (rect.u1 - rect.u0) / rect.w as f32;
let dv = (rect.v1 - rect.v0) / rect.h as f32;
piece.u0 = rect.u0 + source_x[col] * du;
piece.u1 = rect.u0 + source_x[col + 1] * du;
piece.v0 = rect.v0 + sy * dv;
piece.v1 = rect.v0 + (sy + sh) * dv;
batch.rect_uv(
target_x[col],
y,
target_x[col + 1] - target_x[col],
h,
piece,
u32::MAX,
);
}
}
}
+59
View File
@@ -278,6 +278,65 @@ fn run<T: Send + 'static>(
handle.join().unwrap().unwrap()
}
#[test]
fn sprint_commands_survive_keyboard_input_changes() {
let Some(resources) = resources() else {
return;
};
let mut s = server(&resources);
let port = s.hub.lan.port;
let _bot = run(&mut s, move || connect(port, "Sprinter", true));
let deadline = Instant::now() + Duration::from_secs(5);
while s.hub.guests.is_empty() {
s.tick(&[]);
assert!(Instant::now() < deadline, "guest did not join");
thread::yield_now();
}
let id = s.hub.guests[0].entity_id;
s.hub.guests[0].loaded = true;
s.hub.guests[0]
.inbox
.push_back(S::PlayerCommand(play::PlayerCommand {
id,
action: 1,
data: 0,
}));
s.tick(&[]);
assert!(s.hub.guests[0].sprinting);
// LocalPlayer keeps sprinting after Ctrl is released while W remains
// down. Double-tap sprint also sends forward without the sprint key.
for flags in [65, 1, 0, 33] {
s.hub.guests[0]
.inbox
.push_back(S::PlayerInput(play::PlayerInput { flags }));
s.tick(&[]);
let g = &s.hub.guests[0];
assert!(
g.sprinting,
"keyboard input {flags} cleared the sprint flag"
);
assert_eq!(g.input, flags);
assert_eq!(g.sneaking, flags & 32 != 0);
}
s.hub.guests[0]
.inbox
.push_back(S::PlayerCommand(play::PlayerCommand {
id,
action: 2,
data: 0,
}));
s.tick(&[]);
assert!(!s.hub.guests[0].sprinting);
s.hub.guests[0]
.inbox
.push_back(S::PlayerInput(play::PlayerInput { flags: 65 }));
s.tick(&[]);
assert!(
!s.hub.guests[0].sprinting,
"keyboard input started a sprint"
);
}
#[test]
fn status_and_join() {
let Some(resources) = resources() else {
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "horizon-mod-registry"
version = "0.1.0"
edition = "2024"
license = "MIT"
rust-version = "1.90"
publish = false
description = "The registry's submission service: GitHub sign-in, the file's checks, staging, and a pull request (docs/api/mod-index.md)"
# Its own workspace, as the telemetry server's: the game's workspace never builds a web server.
[workspace]
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "query", "multipart"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs", "io-util"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1"
# GitHub's API, blocking, on tokio's blocking pool: the launcher's HTTP client.
ureq = { version = "2", default-features = false, features = ["json", "tls"] }
hmac = "0.12"
sha2 = "0.10"
base64 = "0.22"
getrandom = "0.3"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
[profile.release]
strip = true
lto = "thin"
Loaded 100 of 283 files, more files were not shown because too many files have changed in this diff. Show more