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
Ludovic Marie f59bb8b8d5 Merge pull request #1738 from rdbtCVS/fix/pre-push-ref-history
Scope pre-push history checks to pushed refs
2026-10-25 07:54:48 +01:00
Ludovic Marie 00705d8431 Merge pull request #1630 from some-du6e/fix/ipv4-first 2026-10-25 07:10:05 +01:00
Ludovic Marie 0564ab0bcd Merge pull request #1748 from ludovic111/fix/server-player-targets-barrier
server_player_targets harness: end each step by the server's clock, not 2.5 s
2026-10-25 06:39:50 +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 Marie eb1fdf5fb4 Merge pull request #1745 from ludovic111/fix/command-exec-random-count
command_exec harness: start the script without random sequences rolled before the setup
2026-10-25 06:35:31 +01:00
Ludovic Marie 2ee008f8fa Merge pull request #1646 from ludovic111/feat/mod-registry-tools 2026-10-25 06:34:00 +01:00
Ludovic Marie 8a0303ed62 Merge pull request #1642 from ludovic111/feat/mod-check 2026-10-25 06:32:11 +01: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 Marie e988b9b833 Merge pull request #1641 from ludovic111/perf/face-cull-cr1 2026-10-25 06:22:47 +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 6acf7fb6ab server_player_targets harness: end each step by the server's clock
Each step waited a fixed 2.5 s. When the machine is loaded the vanilla server stalls for longer than that (CI on
#1571 and #1585: after Bob's trip to the Nether, vanilla's `tp Bob 4.5 -60 0.5` from the console ran a step late),
so its packets were filed under the next step and the transcripts differed although both servers sent the same.

A step now ends once the server has done its work:
- the console's own `time query gametime` comes back, so the step's console commands have run (the console runs
  its queue in order);
- LocatorBarClient's new `sync NAME` runs `time query gametime` as that player and waits for the reply (not
  recorded), so that player's commands have run and every packet sent before the reply is recorded;
- the server's game time has advanced STEP_TICKS (40) more ticks, then each player sends its position reminder
  and syncs once more.

game_time() moves out of settle() in test_dedicated_server_parity.py so both use it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:46:22 +01:00
Ludovic MarieandClaude Opus 5.5 afb3ae79a2 command_exec harness: start the script without the random sequences of mobs that died before it
/random reset * in the ticking group counted 12 sequences in vanilla against Horizon's 11 on one CI run (#1585).
Locally 1 oracle run in 24 did the same, and its extra sequence was minecraft:entities/bat. Before the setup's
gamerules (spawn_mobs and mob_drops off), the oracle ticks until the entities around the player have loaded, a
number of ticks that depends on the machine; mobs spawn meanwhile, and a bat that died then rolled its loot table.
Once mob_drops is off no mob death rolls one (LivingEntity.shouldDropLoot), so the setup now ends with an
unrecorded /random reset *: every run starts the script with no sequence, and the later count holds only what the
corpus itself rolled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:44:02 +01:00
Ludovic Marie 4a675206b1 Merge pull request #1740 from ludovic111/refactor/port-pack-legacy-structure-mods
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 05:41:33 +01:00
Ludovic MarieandClaude Sonnet 5.5 486df91b2d refactor(port-pack): move YUNG, Lithostitched and CTOV conversion into legacy_structure_mods
A pure move, no behaviour change. The conversion of YUNG's API and its mods,
Lithostitched and ChoiceTheorem's Overhauled Village was mixed with the general
conversion code in worldgen.rs and processors.rs. It now lives in
crates/horizon-port-pack/src/legacy_structure_mods/{yung,lithostitched,ctov}.rs,
and the general code reaches it through a few entry points, so a clean-room
rewrite can replace the module without touching the rest.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 05:35:36 +01:00
rdbtCVSandOpenCode gpt-6.1-sol 2c80009c70 Scope pre-push history checks to pushed refs
Read Git's pre-push ref updates and inspect the full reachable history of each non-deleted local object ID. Ignore unrelated refs while rejecting private artifacts in deleted files, merge-only paths, and stored history masked by local replacements.

Add regression coverage for branches, tags, raw revisions, multiple updates, deletion-only pushes, and artifact detection.

Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 04:15:14 +00: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
Ludovic Marie 1ef5b82b7d Merge pull request #1571 from ludovic111/fix/home-leak-tests
Tools and harnesses start the game in scratch folders, not a player's
2026-10-25 05:07:12 +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
Ludovic Marie f2f58aca94 Merge pull request #1690 from ludovic111/fix/remote-items-fall 2026-10-25 03:45:53 +01:00
Ludovic Marie f9e3244281 Merge pull request #1715 from ludovic111/fix/launcher-remove-async
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 03:30:13 +01: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
Ludovic MarieandClaude Sonnet 5.5 87f377b29c launcher: remove a version on a background thread
Clicking Remove deleted the version's thousands of files on the UI thread, so
the whole launcher froze for seconds (more on Windows). The removal now runs
on a thread and ends with Msg::Removed. The version leaves the installed list
at once (nothing can launch, select or prepare it) and its row shows
"Removing…" with a moving bar; installing it again is refused until it is gone.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 03:24:05 +01: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
Ludovic Marie 4bb7f96f81 Merge pull request #1634 from ludovic111/feat/mod-index 2026-10-25 02:52:31 +01: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
Ludovic Marie 89f426c48f Merge pull request #1648 from ludovic111/fix/remote-fall-hurt 2026-10-25 02:38:44 +01: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
Ludovic Marie 947d65e64d Merge pull request #1678 from ludovic111/fix/no-play-packets-in-reconfig 2026-10-25 02:38:40 +02:00
Ludovic MarieandClaude Opus 5.5 fe253fe534 Server clients: dropped items and falling blocks fall by themselves between updates
A server sends an item's and a falling block's position only every 20 ticks.
As a client (multiplayer, a LAN guest, single player on its server thread)
Horizon moved them only when a position arrived, three steps towards it, so a
dropped item or falling sand hung in the air and then jumped down once a
second (#1568).

Vanilla runs ItemEntity.tick and FallingBlockEntity.tick on the client too
(gravity, collisions, drag), takes their motion from AddEntity
(recreateFromPacket) and SetEntityMotion (Entity.lerpMotion), and snaps them to
each position packet: neither overrides Entity's NO_OP InterpolationHandler.
The client now does the same, with the same ticks single player in-frame uses;
only the server removes them or places the block.

lan_e2e --check falling: the guest samples both every 2 ticks over 16 ticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:36:48 +02:00
some-du6eandClaude Opus 5.5 96b6ed4916 Test that a connection reaches IPv4 first and report the first address's error
A loopback test listens on both [::1] and 127.0.0.1 and checks the
connection lands on IPv4 although IPv6 is listed first; it fails without
the sort. Another checks the fallback to IPv6 when IPv4 refuses. When every
address fails, the error is now the first address's (the one vanilla tries)
rather than an IPv6 fallback's, which is often only "no route to host".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:33:22 -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 Marie 149864c3b4 Merge remote-tracking branch 'origin/main' into perf/face-cull-cr1
# Conflicts:
#	crates/horizon-client/src/mesher/leaf_culling_tests.rs
2026-10-25 02:15:14 +02:00
Ludovic MarieandClaude Opus 5.5 7c27b85fbd server_pack_e2e: start the game and the servers through lab.game_env
The new home-leak test rejects scripts that set HORIZON_NO_AUDIO by hand; this one, added on main
since, did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:08:31 +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 Marie 75d70a1a74 Merge pull request #1584 from ludovic111/feat/shader-packs-read 2026-10-25 01:55:19 +02:00
Ludovic Marie 856f0c2e40 Merge pull request #1583 from ludovic111/docs/platform-apis 2026-10-25 01:53:57 +02: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
Ludovic Marie 213f1bbb2e Merge pull request #1582 from ludovic111/docs/server-api-reference 2026-10-25 01:52:31 +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 3e3ab7a413 Merge main into fix/ipv4-first: IPv4 first for SRV targets too
Main's SRV resolver (#1578) added Connection::connect_server; both connect paths now
go through connect_addresses, which puts IPv4 first, as ServerAddressResolver.SYSTEM's
InetAddress.getByName does for the original host and the SRV target alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:24:52 +02:00
Ludovic MarieandClaude Opus 5.5 0253d9bc47 Multiplayer: no play packets after the client acknowledges a reconfiguration
A proxy switching servers (Velocity, BungeeCord) sends the client back to
configuration. The network thread acknowledged it, but the game thread kept
ticking the old level and its client_tick_end went out right after the
acknowledgement, where the server reads it as configuration packet 13.
Velocity drops the client: "A packet did not decode successfully", shown as
"An internal error occurred in your connection".

Vanilla's handleConfigurationStart clears the level before it acknowledges,
so nothing of play follows. Session::send now drops play packets while the
connection is in configuration, checked under the writer lock that the
acknowledgement is sent with.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:23:59 +02:00
Ludovic MarieandClaude Opus 5.5 fab71df185 Mesher: sort solid quads in the meshing workers, not in Mesher::mesh
Mesher::mesh keeps model order, which the leaf-culling test pairs with
the baked faces; the section and tile workers sort what they upload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:21:23 +02: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 Marie 76638aaf80 Merge pull request #1658 from rdbtCVS/t3/remove-unused-load-block-section
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 01:15:24 +02:00
Ludovic Marie eaa9f3c203 Merge pull request #1651 from rdbtCVS/t3/remove-orphaned-baseline-loader-profile
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 01:14:55 +02:00
Ludovic Marie 395c256d11 Merge pull request #1657 from rdbtCVS/t3/remove-unused-launcher-release-wrapper
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 01:14:39 +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
Ludovic Marie 75983739ec Merge branch 'main' into fix/home-leak-tests 2026-10-25 01:09:27 +02:00
Ludovic Marie a4fa1a770d Merge pull request #1667 from ludovic111/fix/release-page-launcher-first
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 01:07:03 +02:00
Ludovic Marie 8a7831e58b Merge pull request #1662 from ludovic111/docs/how-horizon-is-built
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 01:01:52 +02:00
Ludovic MarieandClaude Opus 5.5 3612f51812 Game releases send people to the launcher: notes open with it, archives labelled
GitHub's "Latest release" is the newest game release, so new players download the game's archive, which
does not start on its own (#1647). Making a launcher release the latest instead would break every
installed horizon-server, install.sh and install.ps1: they ask for releases/latest and expect the game's
archives, and a server that can't update can't fetch a fix either. So the game stays the latest and says
where to go:

- The notes open with "Want to play? Download the Horizon launcher, not the files below", linking
  playhorizon.dev and the launcher releases. It is raw HTML on one line, which GitHub renders and the
  launcher's notes view and one-line summary skip (pulldown-cmark HTML events), so launchers already
  installed don't tell players to download themselves. The game footer that said the same at the bottom
  goes. The site's Releases page leaves the block out.
- release.yml gives each archive a label ("Game files the launcher installs: Linux (...)", "Dedicated
  server for Linux (...)"), which GitHub shows instead of the file name. Everything that downloads
  matches the asset's name, which is unchanged.

Fixes #1647

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:00:21 +02:00
Ludovic Marie d6e5fb5331 Merge pull request #1606 from rdbtCVS/t3/vectorize-perlin-noise
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 00:59:02 +02:00
Ludovic Marie 3b3590ea03 Merge pull request #1580 from levzzz5154/feat/boat-passengers
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 00:54:21 +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 Marie 75b7812f68 Merge pull request #1595 from Domanffe/fix/paused-player-tick
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 00:49:56 +02:00
Ludovic Marie e0344f1f79 Merge pull request #1597 from Domanffe/fix/remote-mob-fluid-contact
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 00:48:10 +02:00
Ludovic MarieandClaude Opus 5.5 6774b2b3e7 Docs: "How Horizon is built", a page on who writes it and why it's safe to run
New players ask on Discord whether Horizon is legit and whether it is vibecoded. One plain page answers
both, every claim checked against the repository today: agents write most of the code, what checks it
instead of a human reading every line (harnesses, verify, parity, the contribution gate, the release
smoke test), signed checksums, the macOS and Windows signing status as it is (not notarized, not
code-signed), the mod sandbox, opt-in telemetry, no Mojang files and no other project's code.

On the website (docs nav under Start here, a card in the home page's community section, the footers) and
as docs/how-horizon-is-built.md. CONTRIBUTING.md gets the public "Only original code" rule the page links.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 00:47:49 +02: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
rdbtCVSandOpenAI Codex 7072a40701 Remove orphaned baseline loader benchmark
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-24 23:25:02 +01:00
rdbtCVSandOpenAI Codex 2a6347dc55 Remove unused saved-chunk section loader
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-24 23:25:01 +01:00
rdbtCVSandOpenAI Codex f1cfb3069f Remove unused launcher release wrapper
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-24 23:25:01 +01:00
Ludovic Marie de840da0e5 Merge pull request #1594 from Domanffe/feat/bundled-languages
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 00:15:26 +02:00
Ludovic MarieandClaude Opus 5.5 8800d1878a Perlin volume: one loop with a BATCH switch instead of a test copy
The batched loop and the one-at-a-time reference were two copies of the
same 100 lines; a const generic picks the path, so the test compares the
code that ships with itself minus the batching. Drops the ignored
microkernel timer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 00:03:46 +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 7d76923a04 Mod registry tools: the registry's entries, build and review
- `horizon_mods::index::registry`: the registry repository's
  `mods/<id>.toml` and `revoked.toml`, `build` (every entry and file
  checked, every problem listed, the index written) and `review` (a
  published file never changes or disappears, a kind never changes;
  non-owner, owner and key changes pointed out).
- `cargo horizon-mod registry build|review|check|list` for the registry's CI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:42:57 +02:00
Ludovic MarieandClaude Opus 5.5 59281c5c73 Mod registry: cargo horizon-mod check, each kind's own checks
- `horizon_mods::index::inspect`: each kind's own check (a mod's manifest
  and content, a modpack's manifest and overrides, a resource pack's
  pack.mcmeta), returning what the file says; `check_file` now compares
  those facts with the index. Shader packs and unknown kinds are refused,
  and `index::open` says which kinds the registry takes: shader
  submissions stay closed until Horizon's own format exists.
- `cargo horizon-mod check <file>`: those checks plus a trial run of a mod
  in the sandbox (ticks, frames, a chat line, each command), text or JSON.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:42:17 +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 Marie 7334bebc1b Merge pull request #1578 from RVZO6/fix/minecraft-srv-resolution
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 23:23:21 +02:00
Ludovic MarieandClaude Opus 5.5 d604409be2 A client's own fall no longer hurts it: the server's hurt starts the tilt
As a client (multiplayer, a LAN guest, single player on its server
thread) Horizon ran its own fall damage, fire, freezing, berry bushes and
starvation through the local hurt, so the camera's hurt tilt started even
when the server cancelled the damage (lobbies that turn fall damage off).
Vanilla's client never hurts itself: Entity.hurt does nothing outside a
ServerLevel and FoodData.tick is ServerPlayer's. Its hurt animation comes
from the server: LocalPlayer.hurtTo on a health drop, and
ClientboundDamageEventPacket / ClientboundHurtAnimationPacket for itself.

- PlayerMotion.client_side: hurt_blocking and food_tick do nothing;
  landing sounds and fall-distance tracking still run.
- remote: SetHealth flashes on a drop after the first health, and the
  player's own damage events and hurt animations restart its hurt time.
- horizon-ctl state reports the player's hurt_time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:17:14 +02:00
Ludovic MarieandClaude Opus 5.5 2716ad41be Renderer: Metal's flat draws and occlusion culling switch on by sections in view
Both switched on past 4,096 section draws, measured when a section had
2.2 to 2.5 draws. A section's draw count now depends on the sides it shows
the eye, so the bars count sections in view: 1,600, where 4,096 draws fell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:16:05 +02:00
Ludovic Marie 791eaca45f Merge pull request #1629 from ludovic111/fix/parity-trim-own-dirs
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 23:15:30 +02:00
Ludovic MarieandClaude Opus 5.5 cb1c9e6935 Parity: a pass trims only its own .local directory
decorated_region compiles its oracle into chunk-generation-parity; when it
passed while chunk_generation was still running, the trim deleted
chunk_generation's dumps mid-run (FileNotFoundError on
*-987654321987-full.txt in CI on #1569, #1571 and #1584).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:09:48 +02:00
Ludovic MarieandClaude Opus 5.5 0849283a9d Pause: drop the state-packet gates, Game::tick doesn't run while paused
The frame loop skips Game::tick while the game is paused, so these never
fired; the server thread's paused pass is what moved the player on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:04:59 +02:00
Ludovic Marie 40eb3e202a Merge branch 'cg8-1595-new' into cg8-1595 2026-10-24 23:04:58 +02:00
some-du6eandClaude Opus 5.5 be34f4cd5f Connect to a server's IPv4 address first, as vanilla does
Vanilla resolves a server with InetAddress.getByName, and Java's default
(java.net.preferIPv6Addresses=false) puts IPv4 addresses first. Horizon
tried addresses in the system resolver's order, which on macOS puts IPv6
first: a server whose IPv6 address resets the connection showed
"Connection reset by peer (os error 54)" where vanilla joined.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:04:45 -06: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
Ludovic MarieandClaude Opus 5.5 bffb68b0c5 Mod registry index: schema 1, .hpack and the checks before install
horizon_mods::index (feature index): index.json's types and rules (unique
ids, https URLs, plain file names, hex SHA-256, minisign keys and
signatures, a modpack's mods present in the index), open-ended kinds
(mod, modpack, resourcepack, shader; unknown kinds kept, not offered),
revocations, and check_file: size, hash, revocation, signature and the
file's own manifest against its entry. horizon_mods::modpack reads
.hpack files: horizon-modpack.toml and an allow-list of overrides.
docs/api/mod-index.md documents both, with a sample index the tests read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:52 +02:00
Ludovic Marie 65d7c4b027 Merge pull request #1563 from ludovic111/pa1/remove-cull-leaves 2026-10-24 22:50:34 +02:00
Ludovic Marie 7becd7fdca Merge pull request #1559 from ludovic111/pa1/remove-mod-surveys 2026-10-24 22:48:55 +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 c6980fb2b7 Drop the lang.rs test: layers_selected_language_over_english covers it
It reads the same files the same way (assets/pack.mcmeta's language
section, a language offered only with its file, Language::load over
en_us). Leaving the client out also keeps this PR to horizon-prepare, so
its check doesn't rerun the client's harnesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:43:26 +02: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 6c6c08f4d9 Fluid contact on a client: only for the entities that tick
A frozen level (TickRateManager.isEntityFrozen) ticks only the players, so
their fluid contact is the only one that moves on, as in the loop below.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:39:13 +02:00
Alexander KrylovandHermes Agent 7d06aca17a Player: also stop sending the state packet while the game is paused
Measured on the built branch: it does not stop the drowning from #1592 on
its own. Kept because it is what forcing the level to stand still looks
like, and the negative result is in the PR.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-24 23:29:55 +03:00
Ludovic MarieandClaude Opus 5.5 0e607404d1 Pause: the server thread stops ticking its players, not the window
The window's own tick doesn't run while paused (the frame loop skips
Game::tick), so the two gates added there never fired. What kept going was
the server thread: its paused pass still ran tick_guests, the players'
doTick, so air, hunger and drowning damage moved on behind the pause menu.
Vanilla's IntegratedServer.tickPaused only ticks the connections, and
ServerGamePacketListenerImpl.tick skips tickPlayer while the server is
paused. tick_lan now does the same: the paused pass keeps the connections
and skips tick_guests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:28:34 +02:00
rdbtCVSandCodex gpt-6.1-sol 66070b918e Batch cached Perlin Y samples for portable SIMD
Evaluate four same-cell Y samples together while preserving coordinate
setup, gradient caching and each sample's floating-point operation order.
Safe arrays let LLVM vectorize the interpolation on the portable target;
cell boundaries and tails keep scalar evaluation.

Add bitwise differential coverage against the original volume loop.
Two paired chunk-generation batches reproduce a 4.6–4.7% Nether wall-time
reduction, with smaller or unresolved effects in the other dimensions.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-10-24 21:25:27 +01: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
Ludovic MarieandClaude Opus 5.5 37c36b548a Merge main into docs/server-api-reference
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:09:34 +02:00
Ludovic MarieandClaude Opus 5.5 6e8a96a734 Boats push the player, and startRiding's quiet failures
- AbstractBoat.push(player) moves the player too: the boat's own tick and
  the player's pushEntities run on the player's client in vanilla
  (pushableBy lets a client push only its local player), so the window's
  player is pushed in-frame and a server's client pushes itself in
  remote_pushes, as it does for mobs.
- A mob the boat would take that can't get in (boarding cooldown, boat
  under water) is left alone, not pushed: the tick calls startRiding,
  which fails quietly, instead of push.
- Mob.startRiding drops the mob's lead.
- look_at_entity reuses the guest's eye position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:03:53 +02:00
Alexander KrylovandHermes Agent 508cb2df9f Fish: the window tracks the fluid contact of the mobs it only displays
`m.fluids` is filled by `Mob::update_fluids`, which only runs in the mob tick
on the server. A window that is a client of a server never runs that tick, so
every mob it draws kept the default `Fluids { in_water: false }` for its whole
life. Cod, salmon and tropical fish rotate onto their side while `!in_water`
(`mobs/aquatic.rs`), so they were drawn lying on their flank in water, while
their position and heading, which come from the server, looked correct (#1591).

`tick_remote_entities` now runs vanilla's `EntityFluidInteraction.update` for
the displayed mobs before it ticks their animations, so the renderers read the
water contact the same way they do for a mob the window simulates itself.

Fixes #1591.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-24 23:01:31 +03:00
Alexander KrylovandHermes Agent 4c3abd86de Player: the window stops ticking its player while the game is paused
Air kept draining behind the Escape menu in singleplayer and the drowning
damage landed on closing it. The server pauses (`IntegratedServer::set_paused`
reads the flag), but the window's own copy of the player kept running
`controlled_simulation_tick` every frame, and that is where
`tick_environment_damage` decrements the air and pushes `MotionEvent::Drown`.

`Minecraft.tick` skips the level while the game is paused, so both local paths
now skip the player's simulation: the client of a server (`tick_remote_level`)
and in-frame single player. Keys are already neutral when a screen is open, so
this only stops the environment, hunger and fall prediction from running ahead
of the paused server.

Fixes #1592.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-24 22:53:12 +03: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
Alexander KrylovandHermes Agent adcfe810f2 rustfmt
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-24 22:46:35 +03:00
Alexander KrylovandHermes Agent 3802a97dc9 Language: the prepared resources carry every bundled language and the list
The language screen offered English alone. The client jar holds only
`en_us.json` and `deprecated.json` under `assets/minecraft/lang`, and the list
the screen shows comes from `pack.mcmeta`'s `language` section, which the jar
does not carry at all. Both live in the asset index, so `horizon-prepare
languages` copies `pack.mcmeta` and every `minecraft/lang/*.json` from the
asset store the way the sounds, the panorama and Unifont already are, and full
preparation runs it with the rest.

Every bundle the client declares is then offered, `Русский` among them, and
`lang` in options.txt selects one (#1272).

Fixes #1272.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-24 22:42:39 +03: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
Ludovic MarieandClaude Opus 5.5 ff91907057 SRV redirects: ask the name servers directly instead of hickory + tokio
Vanilla's ServerRedirectHandler asks through JNDI's DNS client: the
system's name servers in turn, one 1 s UDP try each (retries = 1), TCP when
the reply is truncated, the first SRV record of the answer. resolver/dns.rs
does that by hand in ~200 lines with wire-format tests and a local stub
server, so the game, the dedicated server and the bots no longer pull in
tokio, hickory and ~25 other crates (none of them were in the game before).

Name servers come from resolv.conf on Unix, as JNDI reads them, and from
the network adapters on Windows (ipconfig, Windows only; the crate keeps
forbid(unsafe_code)). Each server waits 1 s, as vanilla does, instead of
the whole connect timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:30:32 +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 Marie fe128948ba Merge pull request #1575 from RobertTGreat/fix/launcher-startup-windows
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 21:16:44 +02:00
Ludovic Marie 33c2512cbd Merge pull request #1576 from RobertTGreat/fix/launcher-mod-lists
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 21:14:20 +02:00
Ludovic Marie 2e35028930 Merge pull request #1574 from RobertTGreat/fix/launcher-window-layout
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 21:13:52 +02:00
Ludovic Marie d0b7c20371 Merge pull request #1577 from RVZO6/fix/server-text-debug-shortcuts
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 21:13:24 +02:00
Ludovic MarieandClaude Opus 5.5 7c1dd49810 Renderer: leave out solid quads that face away from the eye
Meshing sorts each section's and distant tile's solid quads into seven
buckets: one per axis direction (all four corners in one axis plane, both
triangles wound the same way) and one for everything else, and keeps each
axis bucket's extreme plane. A quad facing +X at x = p is a back face from
an eye at x <= p, which the solid pipeline culls anyway, so the frame draws
only the buckets that can face the eye, merged into runs of adjacent
buckets: one to three indirect draws per section on the existing page-run,
occlusion-culling and flat paths. The eye comes from the frame's
view-projection, so view bobbing is exact. The picture does not change;
`horizon-ctl section-sides on|off` switches it for A/B checks.

Written from scratch from the general back-face technique; no other
renderer's code or Horizon's removed code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:13:00 +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
Ludovic MarieandClaude Opus 5.5 afcb4062cb Merge main into pa1/remove-mod-surveys (keep the surveys deleted)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:07:46 +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 aee0b8b589 Format the endpoint table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:07:26 +02:00
Ludovic Marie 7f995af677 Merge pull request #1570 from ludovic111/feat/server-packs-client 2026-10-24 21:02:56 +02:00
Ludovic Marie 61e044e67c Merge pull request #1569 from ludovic111/feat/server-packs-manager 2026-10-24 21:01:05 +02:00
Ludovic Marie 18b63d7acb Merge pull request #1581 from ludovic111/docs/mod-api-reference 2026-10-24 20:58:01 +02:00
Ludovic Marie e683cdf322 Merge pull request #1579 from ludovic111/fix/builtin-pack-icons 2026-10-24 20:56:28 +02:00
Ludovic MarieandClaude Opus 5.5 e5bfce3b37 Horizon APIs: ways in for mods, server mods as api 0.3, who promotes to stable
docs/api/mods.md gets "Ways in": one API (the WIT world) and one sandbox
(WebAssembly) for every language. Rust is first-class; Lua scripts are
planned, run by an interpreter that is itself a WebAssembly mod linked with
the script's capabilities only, with hot reload of the script; C, C++, Zig,
TinyGo, AssemblyScript and JavaScript/TypeScript are to be evaluated, each
listed with its cost measured against Rust.

Server mods are their own line (api 0.3); block entities are no longer
tied to a draft. Promotion to stable: the orchestrator proposes with the
evidence, Ludovic approves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:53:43 +02:00
Ludovic MarieandClaude Opus 5.5 0a1d3a7534 Horizon APIs: the docs portal and the stability and versioning policy
docs/api/ gets one page per surface other programs build on (mods,
shaders, agent control, servers, the launcher, data formats), each with an
overview measured from the source, the guide, the generated reference, examples,
its stability, what is not there yet and a changelog, plus the policy every
API follows (docs/api/stability.md): a version per API, semver lines,
experimental / preview / stable, deprecation windows, compatibility tests and
the level of every part today.

The website gets a "Build on Horizon" page (website/docs/apis.md) that
summarises them and links the repository's pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:50:20 +02:00
levzzzandClaude Opus 5.5 66235cc981 Let mobs ride boats, and make boats collide with and push entities
A moving boat now stops at other boats, minecarts, mobs and players
(AbstractBoat.canVehicleCollide), and on the server it pushes what it
touches and is pushed back (AbstractBoat.push, LivingEntity.pushEntities).
A boat no player steers picks up the mobs that touch it, two seats in a
boat and one in a chest boat, with the player always in front
(getPassengerAttachmentPoint, clampRotation). Mobs get out when the boat
breaks or stays under water, are saved under the boat's Passengers, and
are seated by the client, which is sent no passenger's position.

A boat can't be placed where it would overlap an entity (BoatItem.use's
noCollision), and the player sits at the raft's and chest boat's vanilla
seat. A server player breaking a boat with a mob in it hits the boat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:49:37 +05:00
Ludovic MarieandClaude Opus 5.5 f80d00802e Server reference generated from horizon-server's source
docs/api/server-reference.md lists horizon-server's commands (its help
text), every server.toml key (config::TEMPLATE: default, the
server.properties key it writes, what it does) and the control API's
endpoints. Tests fail when the page is out of date (HORIZON_WRITE_DOCS=1
rewrites it), when the template and Config disagree on the keys, when a key
has no comment, or when api::ROUTES and the endpoints route() answers
differ.

api::ROUTES replaces the endpoint list in api.rs's module comment and the
hand-kept path list of the 405 arm. The template's uncommented keys get a
comment (what init writes for new servers; no setting changes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:42:51 +02: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
Ludovic MarieandClaude Opus 5.5 07e46daa59 Shader packs, clean room 1: read packs and their lang files
A new crate, horizon-shaders, the first piece of shader pack support written
again from a specification of the format. It opens a pack (a folder or a zip
whose root holds a shaders folder; decoy folders and files beside it are
ignored), lists the packs in a shaderpacks folder with the reason a broken
one can't be used, and reads a pack's lang files: English under the player's
locale, file names matched ignoring case (packs ship en_US.lang).

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:41:16 +02:00
Ludovic MarieandClaude Opus 5.5 f1ff81e874 launcher: spacers carry only their height; the version verdict leaves the check thread plainly
- `Span::Space` kept the range of rows it hid, written and never read: it is just a height.
- The check thread sent the project page's verdict from inside a `map` closure; it is now an
  explicit step before the result goes to the results list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:38:34 +02:00
Ludovic MarieandClaude Opus 5.5 1b23a14ee7 Mod API reference generated from the WIT contract
docs/api/mods-reference.md lists every capability, world, interface, type,
function and hook of horizon:mods, generated from
crates/horizon-mod-sdk/wit/horizon.wit and the manifest's capabilities by a
test in horizon-mods (feature pack, as CI runs it). The test fails when the
page is out of date (HORIZON_WRITE_DOCS=1 rewrites it), when a function has
no doc comment, or when a capability grants an interface the contract lacks.

The WIT's undocumented functions and fields get doc comments (no change to
the contract itself).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:38:18 +02:00
Ludovic Marie 16d49fe660 Merge pull request #1558 from rdbtCVS/t3/retire-java-optimization-mod
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 20:33:21 +02:00
Ludovic Marie fccac1e744 Merge pull request #1560 from rdbtCVS/t3/remove-voxel-kernels
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 20:31:50 +02:00
Ludovic MarieandClaude Opus 5.5 42714deca6 Merge origin/main into fix/server-text-debug-shortcuts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:27:43 +02:00
Ludovic MarieandClaude Opus 5.5 1a4a2ec120 Debug keys: check the title's focused field inline
The helper's release check repeated the `pressed &&` guard below it, and
its test only covered the helper. The comment now says why text screens
take presses first: vanilla types through charTyped, which debug keys
cannot swallow, while here the character comes with the key press.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:27:37 +02:00
Ludovic MarieandClaude Opus 5.5 ac43cdea70 launcher, server: one hidden-command helper per crate, server children keep its terminal
- horizon-bench reads the machine with horizon-telemetry's `command_output` (now public) instead
  of a second copy of it; the console flag lives in one place.
- horizon-server's `process::command` hides the console only when the server has none (started
  by the launcher). Run from a terminal, `schtasks`, `tar` and `mklink` share it as before, so
  their output still reaches it: a CREATE_NO_WINDOW child can't write to its parent's console.
- horizon-prepare's java/javac keep the stdin they had; only the console flag is new.
- launcher `process::hide_console` returns the command (no `let _ =` on other systems); the
  machine-details thread failing just leaves the report without them, as a slow one does.
- README: the Windows subsystem's effect on the command line (interactive cmd/PowerShell don't
  wait or set the exit code) under "Command line", the deferred probe under "Performance log".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:26:50 +02:00
Ludovic MarieandClaude Opus 5.5 6d81b98f18 launcher: keep one landscape per width step, free replaced ones, keep the top bar's wordmark off Windows
- A resize re-baked Home's landscape every 6 px and never removed the old images from the
  window's sprite atlas (GPUI keeps an image there until `drop_image`): dragging a window wider
  piled up textures of up to ~14 MB each. The landscape now grows in steps of 64 texels, is kept
  when the window narrows, and the one it replaces leaves the atlas on the next frame.
- The landscape was laid out from the width (sun at 0.68 w, one random stream for every part), so
  it reshuffled hills, trees and the sun at each step of a resize. Each part now has its own
  stream drawn left to right: a wider landscape only adds columns.
- macOS and Linux keep their system title bar, so the top bar keeps the mark and "HORIZON"
  there (docs/design-system.md, Navigation); only the Windows title bar carries them.
- The before/after screenshots were for the pull request, not the launcher's docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:22:10 +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
Ludovic MarieandClaude Opus 5.5 1dffadeea3 Pack screen: draw each pack's own pack.png instead of unknown_pack
PackSelectionScreen.loadPackIcon registers every pack's pack.png as a
texture; Horizon never loaded them, so every pack (Default, Programmer Art,
High Contrast and the player's own) fell back to unknown_pack, the
cobblestone. The icons now load from each pack when the screen opens and are
written into 64 new 128x128 HUD atlas slots, one per icon on screen. The
vanilla pack's icon is the client JAR's pack.png, which horizon-prepare now
copies into the prepared resources.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:19:05 +02:00
Ludovic Marie 29c5e65d17 Merge pull request #1573 from Domanffe/fix/placed-block-entity-keeps-state
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 20:12:22 +02:00
Ryan BandOpenAI Codex gpt-6.1-sol 83657f3bbf fix: resolve Minecraft DNS SRV redirects for ping and login
Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:04:46 -04:00
Ryan BandOpenAI Codex gpt-6.1-sol 094d5c8e16 fix: let server text fields receive debug-bound characters
Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:03:32 -04:00
RobertTGreatandOpenAI Codex 98639d1471 fix(launcher): virtualize long mod lists from the first frame
Render nearby rows in the existing page scrollbar, defer hidden icons and cached verdict reads, and preserve bottom anchoring as row heights are measured.

Co-Authored-By: OpenAI Codex (GPT-6.1 Sol) <[email protected]>
2026-10-24 18:49:37 +01:00
Ludovic MarieandClaude Opus 5.5 0c6816811c Commands: fold the block check into place_state's condition; cover /fill in the corpus
The comment names vanilla's rule (LevelChunk.setBlockState keeps or creates
the block entity when the block, not the state, is still there after
onPlace). The corpus checks the `enabled=false` flip, drops the cleanup lines
the group's closing /fill already does, and adds a powered hopper placed by
/fill, which goes through the same place_state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 19:49:37 +02:00
RobertTGreatandOpenAI Codex 1be8a4caea fix(launcher): defer hardware probes and hide Windows consoles
Collect performance-report hardware after the first frame and preserve logs, input and CLI pipes while suppressing background terminal windows.

Co-Authored-By: OpenAI Codex (GPT-6.1 Sol) <[email protected]>
2026-10-24 18:34:08 +01:00
RobertTGreatandOpenAI Codex 4ebba25280 fix(launcher): fill window width and add Windows title bar
Keep page content and pixel art responsive, align navigation left, and use native Windows control hit regions in themed chrome.

Co-Authored-By: OpenAI Codex (GPT-6.1 Sol) <[email protected]>
2026-10-24 18:30:36 +01:00
Ludovic Marie 7fca3c2f97 Merge pull request #1572 from ludovic111/fix/mac-dock-icon 2026-10-24 19:11:53 +02:00
Ludovic Marie db758a622f Merge remote-tracking branch 'origin/main' into pa1/remove-face-groups
# Conflicts:
#	crates/horizon-client/src/gfx.rs
2026-10-24 18:54:45 +02:00
Ludovic Marie 2ad81884f9 Merge remote-tracking branch 'origin/main' into pa1/remove-cull-leaves
# Conflicts:
#	crates/horizon-client/src/graphics.rs
2026-10-24 18:54:18 +02:00
Ludovic Marie 84bdd1d0b0 Merge remote-tracking branch 'origin/main' into pa1/remove-mod-surveys
# Conflicts:
#	docs/research/java-mod-compat.md
2026-10-24 18:53:51 +02:00
Alexander KrylovandHermes Agent 493e5f8d3a Commands: a placed block keeps its block entity when a neighbour update changes its state
/setblock created the block entity only when the state that landed matched the
requested state exactly. HopperBlock.onPlace sets enabled=false while the block is
being placed, so a hopper put into power had no inventory: /data get block answered
"not a block entity", items dropped into it stayed item entities, and
/setblock hopper{Items:[...]} dropped the NBT as well.

Compare the block at the position with the block that was placed, the way vanilla
does when it creates the entity from the placed state, before the neighbour updates
run.

Fixes #1506.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-24 19:50:45 +03:00
Ludovic Marie ccb827282b Merge remote-tracking branch 'origin/main' into pa1/gate-1558
# Conflicts:
#	docs/vulkan.md
#	mods/horizon/src/main/java/dev/horizonlab/client/HorizonClient.java
#	mods/horizon/src/test/java/dev/horizonlab/client/DistantTerrainSettingsTest.java
#	mods/horizon/src/test/java/dev/horizonlab/metrics/ClientGraphicsIntegration.java
#	packs/START-HERE.txt
#	packs/mods.lock.json
#	tests/test_fixtures.py
#	tests/test_packs.py
#	tools/Compare-Benchmarks.html
#	tools/build_pack.py
#	tools/compare_benchmarks.py
#	tools/test_client_graphics.py
2026-10-24 18:47:57 +02:00
Ludovic Marie 56aa5f9549 Merge remote-tracking branch 'origin/main' into pa1/gate-1560
# Conflicts:
#	Cargo.toml
2026-10-24 18:47:16 +02:00
Ludovic MarieandClaude Sonnet 5.5 d271176f23 Tools and harnesses start the game in scratch folders, not a player's
The game logs into HORIZON_HOME and falls back to the player's own game folder when it is unset. Harnesses
that set only HORIZON_NO_AUDIO (and test_native_client, which set nothing) wrote logs, and on a world run
advancements, into ~/Library/Application Support/horizon.

lab.game_env gives every game a fresh HORIZON_HOME, HORIZON_LAUNCHER_HOME, XDG_DATA_HOME and XDG_CONFIG_HOME,
no audio and no launcher self-update; a folder the caller passes is kept. Every launcher of the game uses it,
parity.py gives each harness its own HORIZON_HOME, and tests/test_game_home.py fails for a script in tools/
that starts the game without it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 18:34:32 +02:00
Ludovic Marie d65282fb0f Merge pull request #1566 from ludovic111/fix/baby-villager-type-texture 2026-10-24 18:34:08 +02:00
Ludovic MarieandClaude Opus 5.5 6a698c40f0 Launcher: start the game from a Horizon.app on macOS, so the Dock and Cmd+Tab show Horizon
Started as a bare executable, the game showed in the Dock and Cmd+Tab as
"horizon-client" with the generic executable icon: winit's window icon does
nothing on macOS. The launcher now keeps a small Horizon.app in each version
folder (Info.plist, the launcher's icon, hard links to the version's
horizon-client and horizon-ctl) and starts the game from it. Server setup
leaves the bundle out of its copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:34:04 +02:00
Ludovic Marie 71292d2ed7 Merge pull request #1562 from ludovic111/feat/server-pack-status 2026-10-24 18:12:16 +02:00
Ludovic Marie 46f4237b67 Merge pull request #1565 from ludovic111/fix/remote-villager-data 2026-10-24 18:03:07 +02:00
Ludovic Marie 643f331f8c Merge pull request #1561 from ludovic111/fix/remove-shader-packs 2026-10-24 17:42:17 +02:00
Ludovic MarieandClaude Opus 5.5 186ab70305 Server resource packs: prompt, Server Resource Packs setting, apply and remove
Servers that push a resource pack now work (reported by scanfee on Discord:
"Servers requiring custom resource packs do not work yet"; net.rs declined
every push).

- net.rs forwards resource_pack_push and _pop in configuration and play, and
  answers in the phase the connection is in.
- The prompt is vanilla's PackConfirmScreen on a shared ConfirmScreen
  (confirm_screen.rs, now also used by the game rules screen): the required
  or optional wording, the server's message, Proceed/Disconnect or Yes/No,
  Escape for no. Declining a required pack disconnects with vanilla's reason.
- The server list's Edit screen has the Server Resource Packs cycle button
  (Enabled, Disabled, Prompt), saved as acceptTextures; the prompt's answer
  is saved there too.
- Downloaded packs go on top of the player's packs, in push order, through
  the same reload as the Resource Packs screen; they are removed on pop and
  when leaving the server.

tools/server_pack_e2e.py drives the real client against vanilla's dedicated
server or Horizon's, with a test pack (blue dirt, lang strings) from a local
HTTP server, and checks the statuses the client sends through net_capture:
decline, proceed, the cache, a wrong SHA-1, an optional pack declined and
accepted, a dead URL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 17:20:35 +02:00
Ludovic MarieandClaude Opus 5.5 cdfcc3d4d4 Server resource packs: the client's pack manager and downloads
Vanilla's ServerPackManager, DownloadedPackSource and DownloadQueue, without
the game around them yet: the prompt state, each pack's download and
activation, the statuses in vanilla's order (onSuccess repeats
SUCCESSFULLY_LOADED, a failed reload recovers with the packs that loaded),
pop, and leaving.

Downloads follow HttpUtil.downloadFile: downloads/<pack id>/<sha1>, a cached
file whose hash matches is reused, a wrong hash fails, no hash downloads to a
temporary file renamed to its SHA-1, 250 MiB at most, vanilla's X-Minecraft-*
headers, a log.json entry per download, and 20 files kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 17:15:35 +02:00
Ludovic MarieandClaude Opus 5.5 7a1638a190 Renderer: remove the section face groups modelled on another renderer
#503 grouped each section's solid quads by the axis side they face and
skipped the groups facing away from the eye. Its code and PR named an
internal class of another project as the model. Horizon doesn't build on
another project's code, so the grouping goes and every section draws its
solid quads in one draw, as vanilla does; the GPU's back-face culling
discards the back faces, so the picture is unchanged.

This costs frame time, measured on the Mac mini in the PR. A clean-room
version can come back later, designed from vanilla and from first
principles only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:24:46 +02:00
Ludovic MarieandClaude Opus 5.5 ab8b379097 Dedicated server: offer server.properties' resource pack as vanilla does
ServerResourcePackConfigurationTask: after the tags, the configuration phase
sends the pack (id from the URL when none is given, hash, required, prompt)
and waits for a final answer, with keepalives every 15 s. A declined required
pack disconnects with multiplayer.requiredTexturePrompt.disconnect, in
configuration or in play, with vanilla's log lines.

tools/test_server_resource_pack_parity.py drives a vanilla 26.3 server and
Horizon's with the same five players (declines, accepts then declines in
play, fails the download, answers after 20 s, declines an optional pack) and
compares what each one receives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:21:53 +02:00
Ludovic MarieandClaude Opus 5.5 a3b4ed57b2 runtime.py: two blank lines between functions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:21:21 +02:00
Ludovic MarieandClaude Opus 5.5 4e7c25bb55 Remove shader packs
A developer of Iris showed that Horizon's shader pack code was written from
Iris's source. This removes it: the horizon-shaderpack crate, Java pack
loading and its render stages, Horizon's own WGSL packs and the example
packs, the Shader Packs screen and its option, the launcher's shader pack
browsing and installing, and the docs about them. Shader packs may come
back later as a clean-room rewrite.

The vanilla renderer and its post-processing (resolution scale, FXAA) are
unchanged. Options files that name a pack load and drop the key; the
launcher reads manifests that list a shader pack, and files in
`shaderpacks/` are never deleted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:15:39 +02:00
Ludovic MarieandClaude Opus 5.5 ba9b876e3e website: the mods page no longer links the removed Java mod notes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:12:39 +02:00
Ludovic MarieandClaude Opus 5.5 e1052e1bd3 Baby villagers wear their biome's clothes again
VillagerProfessionLayer takes a baby's type texture from
`entity/villager/baby/<type>` (and `entity/zombie_villager/baby/<type>`),
not `type/baby/<type>`: that path names no texture, so baby villagers and
baby zombie villagers of every biome drew without their type layer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:06:50 +02:00
Ludovic MarieandClaude Opus 5.5 afb638b81c lan_e2e: a guest sees villagers' types, professions, levels and new jobs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:06:50 +02:00
Ludovic MarieandClaude Opus 5.5 f1369b4efe Clients show villagers' professions, types and levels
A client of a server (single player on its server thread, a LAN guest, a
dedicated server's player) never read Villager.DATA_VILLAGER_DATA or
ZombieVillager's from SetEntityData, so every villager drew as a plains
villager with no profession and no level badge, and a villager that took a
job never changed. The client now reads the type and the profession by
their built-in registry ids, and the level, which VillagerRenderer reads
from the synched data in vanilla.

horizon-ctl entities reports a villager's type, profession and level as the
game knows them, so a client's view can be checked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:06:50 +02:00
rdbtCVSandOpenAI Codex gpt-6.1-sol d49b0eb98b Name the profiling documentation for its current use
Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:55:53 +01:00
rdbtCVSandOpenAI Codex gpt-6.1-sol 68bc9f82b6 Remove documentation of the retired Java product
Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:55:32 +01:00
Ludovic MarieandClaude Opus 5.5 717acb07af Graphics: remove Cull Leaves, which was adapted from the CullLeaves mod
The opt-in Cull Leaves setting (#420, from #387) was adapted from the
CullLeaves mod's code (MIT, credited in THIRD_PARTY_NOTICES.md). Horizon
doesn't build on another mod's code, so it goes, with its notice.

Leaves now follow vanilla only: with opaque leaves (cutoutLeaves off) the
faces between two leaf blocks are skipped (LeavesBlock.skipRendering),
as before #420 and as the setting's Off did. Mangrove roots keep every
face. An old options file's cullLeaves line is ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:55:06 +02:00
Ludovic Marie e423e570c9 Merge pull request #1557 from ludovic111/fix/launcher-cmd-q
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 15:52:15 +02:00
rdbtCVSandOpenAI Codex gpt-6.1-sol 06ec15f3a0 Retire the legacy Java optimization mod and performance packs
Preserve Java parity oracles and comparison benchmark instrumentation. Move their remaining dependency locks under tools/benchmark-deps and remove legacy product packaging, fixtures, and CI checks.

Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:50:55 +01:00
rdbtCVSandOpenAI Codex gpt-6.1-sol 2a9e0ab28d Remove unused voxel-kernels research experiment
Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:49:46 +01:00
Ludovic MarieandClaude Sonnet 5.5 87aa88de54 Launcher: Cmd+Q quits and Cmd+W closes the window on macOS
The launcher had no app menu and no key bindings, so Cmd+Q did nothing.
Add Quit and CloseWindow actions, bind them to cmd-q and cmd-w on macOS,
and set an app menu with "Quit Horizon Launcher" and a Window menu with
"Close Window". Quit shares one shut_down path with closing the window
(stop the servers this launcher started, send telemetry, exit), so a
running game is treated exactly as it is today.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 15:45:29 +02:00
Ludovic MarieandClaude Opus 5.5 7efdbdb39a docs: remove the surveys of other mods; mod code is never read
Removes docs/performance/java-perf-mods.md (a survey of eight Java
performance mods' techniques) and docs/research/java-mod-compat.md (notes
on how Fabric, NeoForge and Mixin work, with a list of mods to port).
Links to them now point at mods.md, which says in two lines why Java jars
don't run in Horizon.

docs/modpacks.md no longer lists a mod's decompiled code as an input of a
port: a mod's code is never decompiled, read, quoted or paraphrased, and
ports are written from the mod's observed behaviour (the oracle and the
harness), its own data files and its public documentation only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:42:44 +02:00
Ludovic Marie 535f2d2a04 Merge pull request #1556 from ludovic111/docs/agents-server-thread-default
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 15:09:23 +02:00
Ludovic MarieandClaude Opus 5.5 7eb7791240 AGENTS.md: the server thread is single player's default (#1514), releases ship in batches (#1554)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:08:04 +02:00
Ludovic Marie ca90a20992 Merge pull request #1551 from levzzz5154/fix/vehicles-server-thread
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 14:32:57 +02:00
Ludovic Marie 923368f4df Merge pull request #1553 from ludovic111/cg3/pr1534-fixup 2026-10-24 14:31:42 +02:00
Ludovic Marie 25bdaf5321 Merge pull request #1552 from ludovic111/cg3/pr1502-fixup 2026-10-24 14:30:12 +02:00
Ludovic MarieandClaude Opus 5.5 8a04e18b91 Measure the minecart push with the player standing half in it
Walking through the cart pushes it back once past its middle, so the
check stands the player half into the cart from the north and waits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 14:23:40 +02:00
Ludovic Marie 5d54f28c42 Merge pull request #1547 from ludovic111/fix/kb1-cooldown-knockback 2026-10-24 14:12:58 +02:00
Ludovic Marie 818d1515c6 Merge pull request #1554 from ludovic111/fix/release-batches
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 13:53:46 +02:00
Ludovic MarieandClaude Opus 5.5 e61e6345bc Releases: in batches of 5 pull requests, or when the oldest has waited 6 hours
One release per merge made about 40 releases in 31 hours, hard for players to follow (asked on Discord).
release-now still releases at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 13:47:19 +02:00
Ludovic Marie 02039fed5b Merge pull request #1539 from ludovic111/fix/launcher-builtin-packs 2026-10-24 13:43:38 +02:00
Ludovic Marie 976956fc3e Merge pull request #1546 from ludovic111/fix/kb1-enchantment-ids 2026-10-24 13:41:22 +02:00
Ludovic Marie dfae12cfe2 Merge pull request #1550 from ludovic111/fix/kb1-knockback-yaw 2026-10-24 13:34:45 +02:00
Ludovic MarieandClaude Opus 5.5 8a8348c93f Merge origin/main into fix/vehicles-server-thread
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 13:29:05 +02:00
Ludovic MarieandClaude Opus 5.5 0a3965a7b1 Check vehicles on both sides of a LAN game
lan_e2e's vehicles check: a creative break whose block is put back stays,
a player stands on a boat, lava breaks a ridden boat and lets the rider
out, a minecart in lava breaks and a TNT one goes off, and walking into a
minecart pushes it, for the host and the guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 13:28:50 +02:00
Ludovic MarieandClaude Opus 5.5 704931ed14 Keep horizon-ctl getblock on a client's copy, run server ones in the owner's level
On a LAN guest or any remote client, getblock and container read what
that client has again (test_friends waits on a guest's getblock). On the
server thread the block commands run in the owner's level, not whichever
level the server ticked last.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 13:27:50 +02:00
Ludovic MarieandClaude Opus 5.5 a713dbd46d Break vehicles in lava on their first tick, as vanilla does
AbstractBoat.tick runs applyEffectsFromBlocks twice, and a minecart takes
lavaHurt from its behaviour's applyEffectsFromBlocks and again from its
own isInLava check: two 40-point hurts a tick, so lava breaks either on
its first tick inside (a TNT minecart primes instead). A minecart has no
baseTick and a boat never outlives lava, so the fire countdown is gone.

Minecart pushing takes the player's own box and returns at once when
there are no carts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 13:27:50 +02:00
Ludovic Marie 1868fc39ae Merge pull request #1549 from ludovic111/fix/kb1-sprint-reset 2026-10-24 13:18:50 +02:00
Ludovic Marie 50f2894ca8 Merge pull request #1548 from ludovic111/fix/kb1-server-velocity 2026-10-24 13:13:08 +02:00
Ludovic Marie 4b44399242 Merge pull request #1545 from ludovic111/fix/kb1-knockback-harness 2026-10-24 13:11:32 +02:00
Ludovic Marie 43e6bfcf98 Merge pull request #1542 from Excurs1ons/fix/java-pack-bob-in-view
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 13:07:33 +02:00
levzzzandClaude Opus 5.5 f688726b34 Let server players push minecarts, without pushing the player back
Guests on the server thread (and LAN) now push carts with vanilla's push
math. The push back on the player is dropped in both modes: vanilla only
syncs it to other players, so the pusher's own client never feels it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:56:12 +05:00
levzzzandClaude Opus 5.5 826cf8ffe6 Burn boats and minecarts in lava, and place boats on lava
Vehicles take lava damage and keep burning for 15 seconds like vanilla
(lavaHurt, remainingFireTicks), breaking and dropping their items; a TNT
minecart primes from fire. Boat placement stops at lava as well as water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:56:12 +05:00
levzzzandClaude Opus 5.5 e2dd6b67df Make boats solid to players
Boats join happy ghasts in the player's entity collision list, so a
player can't walk through a boat and steps up onto it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:56:12 +05:00
levzzzandClaude Opus 5.5 ebd25abd0e Run horizon-ctl block commands in the server's world
With the integrated server thread on, setblock, fill, getblock and
container ran on the client's copy only. They now run on the server, and
on a remote server setblock and fill go out as chat commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:56:11 +05:00
levzzzandClaude Opus 5.5 9624e99801 Clear a creative player's dig after the block breaks on the server
A creative START destroys the block at once and its client never sends
STOP, so the kept dig broke whatever was placed in that cell next, every
destroy delay, until the player broke something else.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:56:11 +05:00
Ludovic MarieandClaude Opus 5.5 6a5c749f7c Aim extra knockback with Mth's sine table
Player.causeExtraKnockback pushes along Mth.sin/Mth.cos of the attacker's
yaw, which come from vanilla's 65536-entry table: facing east, the push
has no z at all. Horizon took f32 sin/cos, which left -4.4e-8 of z per
block of push (a sprinting or Knockback hit, on mobs and players).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 12:13:31 +02:00
Ludovic MarieandClaude Opus 5.5 c33acfdd05 Stop a guest's sprint when its hit knocks back
Player.causeExtraKnockback stops the attacker's sprint on the server, and
the shared flag reaches the attacker's own client (ServerEntity sends a
player its own dirty entity data). A guest's sprint was only stopped on
the server copy for the length of the hit and never sent, so a guest (and,
on the server thread, the window) kept sprinting: every hit while
sprinting had the sprint knockback, where vanilla's needs the sprint to
start again.

- leave_guest keeps the sprint stop a guest's hit made; a guest hitting a
  player stops its sprint too.
- The hub sends each guest its own shared flags (crouching, sprinting,
  gliding) when they change.
- A remote client takes the sprinting bit of its own shared flags (the
  flag only, as vanilla's assignValues: the speed modifier and the
  wasSprinting it compares stay, so it sends START_SPRINTING again only
  after it has stopped).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 12:13:31 +02:00
Ludovic MarieandClaude Opus 5.5 7c83fbb6df Travel a guest's server-side velocity, and send hurts after it
A guest's (and, on the server thread, the window's) knockback started from
the velocity the last hit left on its server copy, which nothing ever
slowed down, and each hit went out at once. So every hit after the first
pushed harder (0.72, then 0.76 blocks a tick where vanilla sends 0.4 each
time), netherite resisted from the wrong base, and a zombie's hit reached
the client before the friction of the tick it landed in.

Vanilla's server simulates a player too (MoveSimulationType
AUTHORITATIVE_SIDE_AND_SERVER): ServerPlayer travels every tick with no
input, and ServerEntity.sendChanges sends a velocity a hurt marked
(Entity.markHurt) as it is after that travel. A player's melee hit is the
exception (Player.causeExtraKnockback): it sends at once, then puts the
server copy back to its velocity from before the hit.

- MotionWorld::server_travel: Player.travel with no input, keeping only
  the velocity (the client's moves place the player). The server's copy
  checks no fall damage on its own moves (isLocalInstanceAuthoritative).
- guest_server_tick runs it last, then sends the velocity if a hurt
  marked it since the last tick.
- guest_hit_by leaves a marked velocity to that; guest_struck_by (melee
  from a player) sends at once and restores.
- lan_e2e `knockback`: three hits each way push the victim as far each
  time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 12:13:30 +02:00
Ludovic MarieandClaude Opus 5.5 c26ced533e Merge origin/main into fix/worldgen-clippy-bool-20261023
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:31:38 +02:00
Ludovic MarieandClaude Opus 5.5 e0d29dc233 Drop the aquifer helper and correct the fire burnout comment
Stable Clippy (1.99, what CI runs) does not flag q < -2. || q > 2., so the
helper and its test only served Rust 1.90's Clippy. Fire burnout is not
'intentionally disabled': vanilla's FireBlock.tick reads the
INCREASED_FIRE_BURNOUT environment attribute, set by eight overworld biomes,
which Horizon does not read yet; the comment now says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:31:38 +02:00
Ludovic MarieandClaude Opus 5.5 6eb34eb868 Merge origin/main into fix/client-clippy-baseline-20261023
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:31:10 +02:00
Ludovic MarieandClaude Opus 5.5 b21423ae01 Keep only the Clippy rewrites that read as well on stable
CI runs stable Clippy (1.99), which is clean on main. The #[expect(clippy::boxed_local)]
in streaming.rs is unfulfilled there and fails CI under -D warnings, so it goes.
Also back out the rewrites that only satisfied Rust 1.90's Clippy and read
worse: the dragon's range helper and its test, the axolotl test's restated
formula, and two De Morgan expansions (first_person_probe, explosion).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:31:10 +02:00
Ludovic MarieandClaude Opus 5.5 a6e9374f88 Java shader packs: say where vanilla and Iris put the view bob
Vanilla 26.3 multiplies bobHurt/bobView into the projection
(GameRenderer.renderLevel); Iris's MixinModelViewBobbing moves them into
the model-view when a pack is in use. The comment said vanilla keeps the
bob in the PoseStack, which is Iris's behaviour, not vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:30:12 +02:00
Ludovic MarieandClaude Opus 5.5 50aee22996 Add a knockback harness: a dedicated server against vanilla's
tools/test_server_knockback_parity.py runs vanilla's dedicated server and
Horizon's (`--dedicated --nogui`) on one flat world and joins both with two
vanilla-codec players (KnockbackClient):

- Alice hits a cow and a zombie (plain, sprinting, Knockback II, critical,
  in the air); the game is frozen around the hit and stepped a tick at a
  time, and the mob's Motion and Pos over 20 ticks are compared exactly;
- Alice hits Bob (the same, a shield, netherite, two hits, two sprinting
  hits, a mace smash, a stronger hit inside the hurt cooldown): Bob's own
  motion packets, Alice's and her sprint flag are compared;
- a zombie hits Bob twice: his motion packets.

7 of 21 cases match on main. KNOWN lists the other 14 with their causes:
the guest's server-side velocity never travels (knockback adds up from hit
to hit), the server never sends a guest its own sprint flag, the extra
knockback's direction uses f32 sin/cos instead of Mth's table, a hit in
the hurt cooldown still knocks back, and a mace smash on a player runs
none of MaceItem.hurtEnemy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:23:04 +02:00
Ludovic MarieandClaude Opus 5.5 01133fac82 No default knockback from a hit inside the hurt cooldown
A hit that lands while the player is still in its hurt cooldown, harder
than the last one, only hurts by the difference (LivingEntity.hurtServer):
it marks nothing and deals no default knockback. Horizon knocked the
player back 0.4 for it anyway, so a quick second hit (a weak swing, then a
charged one) pushed twice.

The hurt now reports whether its knockback hook ran (a fresh hit, not
fully blocked), and mob_hits_player knocks back only then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:13:27 +02:00
Ludovic MarieandClaude Opus 5.5 4507ded0e4 Read enchantment ids without a namespace as minecraft ones
`/give @s wooden_sword[enchantments={knockback:2}]` (or an item saved
that way) kept the key `knockback`, which no enchantment lookup matched:
the sword showed no enchantment and knocked back like a plain one.
Vanilla reads the keys as Identifiers, so `knockback` is
`minecraft:knockback`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:13:27 +02:00
Ludovic Marie 46304cc1a7 Merge pull request #1489 from rdbtCVS/docs/generic-agent-attribution
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 11:10:16 +02:00
Ludovic MarieandClaude Opus 5.5 377d81a329 AGENTS.md: keep Ludovic's markers, let a contributor's agent name its own person and tool
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:07:44 +02:00
Ludovic Marie 905acfe7cd Merge remote-tracking branch 'origin/main' into pr-1489 2026-10-24 11:07:43 +02:00
Ludovic Marie c18f3dbfde Merge pull request #1536 from ludovic111/fix/dg1-villager-doors 2026-10-24 09:50:38 +02:00
JasonGuandDeepSeek V4.1 Flash 9c843819b7 Java shader packs: keep the view bob in the view, not the projection
A pack rebuilds view positions from the depth buffer with
`inverse_projection` and then multiplies by `inverse_view`
(`gbufferModelViewInverse`), and Horizon's own packs do the same through
`world_offset` and `frame.inverse_view`. Passing
`world_projection(fov, aspect, far) * bob` as the projection while `view`
stayed unbobbed left the two halves disagreeing by the walk bob, so
everything a pack reconstructed — reflections, ambient occlusion,
godrays — turned with the bob. That reads as reflections wobbling in
step with the first-person hand, and changing the FOV made it worse.

Vanilla and Iris carry the bob in the world's pose stack (the model-view
matrix), not in the projection. Pass `bob * view` as the view and the
plain world projection instead: `projection * view` is unchanged, so
terrain, entities and the pack's own geometry programs draw as before,
while depth reconstruction is now consistent. The hand's uniforms
already carried the bob in its pose and are untouched.

Checked with Complementary Reimagined r5.9.3 over the depth-feedback
passes at 70 and 110 FOV.

Co-Authored-By: DeepSeek V4.1 Flash (OpenCode agent) <[email protected]>
2026-10-24 15:49:14 +08:00
Ludovic Marie c466c5ddad Merge remote-tracking branch 'origin/main' into fix/dg1-villager-doors 2026-10-24 09:07:55 +02:00
Ludovic Marie d5bd35025e Merge pull request #1541 from ludovic111/fix/probe-target-names 2026-10-24 08:57:26 +02:00
Ludovic Marie 2cd8b88a5b Merge pull request #1540 from ludovic111/fix/villager-golem-summon 2026-10-24 08:18:46 +02:00
Ludovic MarieandClaude Opus 5.5 0b581f9ffe Give each crate's probe example its own target name
horizon-client, horizon-world and horizon-protocol each had an example
named `probe`. Cargo copies every crate's examples into one
<profile>/examples/ directory, so parallel parity harnesses sharing a
target dir could run another crate's probe (CI parity run 40055656303:
command_parse ran the horizon-world dispatcher). The examples are now
client_probe, world_probe and protocol_probe ([[example]] in each
Cargo.toml; the files stay where they are), and every harness builds
and runs its own crate's name.

tools/parity.py recognises the new names when it counts the probes a
harness ran, and renames the old `probe` in recorded artifacts to the
crate's target (from the record's -p, else its probe sources), so the
prebuild still builds them instead of dropping them as unknown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 08:08:18 +02:00
Ludovic Marie f67b2b3204 Merge pull request #1537 from ludovic111/fix/mob-path-max-visited 2026-10-24 07:52:25 +02:00
Ludovic Marie d139f9bee6 Merge pull request #1514 from ludovic111/feat/st-default
🤖 Agent: merged after verify and CI parity passed, built merged with current main. Single player runs on its server thread by default, as vanilla does; `--in-frame` brings back the old mode. Evidence: the smoke session passes 48/48 in both modes. ThinkPad (HD 620, Mesa) RD 16: 651 vs 185 FPS. M4 Pro, v0.2.25 release build, forest, offscreen, alternating runs: 1% lows 224–231 → 399–476 at RD 16 and 120–124 → 210–213 at RD 32; average FPS within 4% at RD 32.
2026-10-24 07:27:50 +02:00
Ludovic Marie 03d93d79b2 Merge pull request #1513 from ludovic111/fix/st-cursor-close 2026-10-24 07:26:18 +02:00
Ludovic MarieandClaude Opus 5.5 1b14e0d171 Villagers summon iron golems the vanilla way and remember them
Villager.spawnGolemIfNeeded set GOLEM_DETECTED_RECENTLY on the villagers
near a new golem, but Horizon wrote only villager::Ext, and the next brain
tick copied the empty memory back over it: the same villagers could summon
another golem before their sensor saw the first. golem_detected now sets the
brain memory (599 ticks).

IronGolem.checkSpawnObstruction checks the floor's top face (no golems on
dirt paths, farmland or slabs) and three blocks, the feet whatever fluid is
there; Horizon checked four blocks and no floor, so a roof three blocks up
kept golems out. The search's open block is air or a liquid block, not any
waterlogged one, and the villagers asked are those whose boxes meet the
caller's box grown by 10. The golem is created (yaw, follow range bonus,
left-handedness from the level random) before the obstruction check, as
trySpawnMob does.

The search and checks move to mobs::villager so the mob_ai probe runs the
game's code: four new scenarios (gossip, glass roof, dirt paths, panic) match
vanilla; with the old checks all four diverge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 07:25:46 +02:00
Ludovic Marie f8e4e31b12 Merge pull request #1512 from ludovic111/fix/st-hits 2026-10-24 07:24:47 +02:00
Ludovic Marie 67e07f3faa Merge pull request #1511 from ludovic111/fix/st-player-body 2026-10-24 07:23:17 +02:00
Ludovic Marie 2bbbc70620 Merge pull request #1510 from ludovic111/fix/st-maps 2026-10-24 07:19:37 +02:00
Ludovic Marie 9c134f54ac Merge pull request #1509 from ludovic111/fix/st-fishing 2026-10-24 07:17:50 +02:00
Ludovic Marie a00dc868ec Merge pull request #1471 from ludovic111/perf/glyph-cache-fxhash 2026-10-24 07:14:41 +02:00
Ludovic Marie 08306d1586 Merge pull request #1470 from ludovic111/fix/entity-shadow-height 2026-10-24 07:12:59 +02:00
Ludovic Marie 3b70dc9c16 Merge pull request #1469 from ludovic111/fix/creative-search-hotbar-keys 2026-10-24 07:11:26 +02:00
Ludovic MarieandClaude Opus 5.5 13c14563c4 Launcher adds Programmer Art and High Contrast to resources prepared without them
Resources prepared before #1538, and every version that reused them, lack vanilla's built-in
packs, so players had to click Prepare again. The launcher now copies them itself, with
horizon-prepare's `packs` (the asset index's SHA-1 checked), when it starts, installs a version
or starts the game: only for prepared versions that lack a zip, once per launcher run. Without
them in any Minecraft folder's asset store it logs one line and the game starts as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 07:07:28 +02:00
Ludovic Marie 99b058442c Merge pull request #1538 from ludovic111/fix/builtin-packs
🤖 Agent: merged after verify and CI parity passed, built merged with current main (goldendays-1). Prepare copies vanilla's built-in Programmer Art and High Contrast packs from the player's own launcher asset store (SHA-1 checked; nothing ships with Horizon), so packs made to sit on Programmer Art, like Golden Days, show their classic textures.
2026-10-24 06:54:08 +02:00
Ludovic MarieandClaude Opus 5.5 8cce2e2f8a Prepare: import Programmer Art and High Contrast from the asset index
Vanilla lists its built-in resource packs from minecraft:resourcepacks of
the vanilla pack (BuiltInPackSource.populatePackList), and those two zips
live only in the asset index, not in the client JAR. Preparation never
copied them, so Horizon's pack list had no Programmer Art. Packs made to sit
on it, like Golden Days ("This pack is meant to be put over Programmer
Art"), then showed the modern textures for the ~720 textures they leave to
it: furnaces, stone bricks, crafting tables, pumpkins, TNT, logs, tools,
buckets, paper.

Full preparation now imports both after extracting the JAR, like the
panorama and Unifont, and `horizon-prepare packs` adds them to prepared
resources.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 06:34:00 +02:00
Ludovic Marie 582856aac2 Merge pull request #1326 from ludovic111/feat/java-shadow-programs
🤖 Agent: merged after verify and CI parity passed (shaders stack: Java packs draw their own terrain, water and shadow map, as in Iris). BSL shows real shadows on Vulkan and Metal; frame times and screenshots are on #1326. Without a pack, nothing changes.
2026-10-24 06:11:42 +02:00
Ludovic Marie 0a95a78e03 Merge pull request #1290 from ludovic111/feat/java-terrain-cull
🤖 Agent: merged after verify and CI parity passed (shaders stack: Java packs draw their own terrain, water and shadow map, as in Iris). BSL shows real shadows on Vulkan and Metal; frame times and screenshots are on #1326. Without a pack, nothing changes.
2026-10-24 06:09:33 +02:00
Ludovic MarieandClaude Opus 5.5 5205b909f7 Villagers, llamas, bees, allays and copper golems search the full path budget
PathNavigation.setRequiredPathLength calls updatePathfinderMaxVisitedNodes, so a
villager's PathFinder visits up to max(follow range, 48) * 16 = 768 nodes, not the
256 of its follow range alone. Horizon always used follow range * 16, so a villager
gave up on any job site, bed or bell whose path needed more than 256 nodes: in the
seed 12345 village it never found the way round to the mason's stonecutter door.

Navigation now keeps PathFinder.maxVisitedNodes: the base follow range times 16 when
the mob is made, then max(follow range, required path length) * 16 after
setRequiredPathLength and after any follow range change (Mob.onAttributeUpdated).
A happy ghast that grows up or becomes a baby gets a new navigation, as in vanilla.

mob_ai: villager_job_in_box, a composter inside a stone box whose doorway faces away
from the villager.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 05:33:52 +02:00
Ludovic Marie 5ec40c8004 Merge remote-tracking branch 'origin/main' into feat/java-terrain-cull 2026-10-24 05:15:42 +02:00
Ludovic Marie 82bba24f43 Merge remote-tracking branch 'origin/fix/st-cursor-close' into feat/st-default 2026-10-24 05:14:33 +02:00
Ludovic Marie 7b0316e6a4 Merge remote-tracking branch 'origin/fix/st-hits' into fix/st-cursor-close
# Conflicts:
#	tools/lan_e2e.py
2026-10-24 05:13:15 +02:00
Ludovic Marie a29e10ef9b Merge remote-tracking branch 'origin/fix/st-player-body' into fix/st-hits
# Conflicts:
#	tools/lan_e2e.py
2026-10-24 05:11:21 +02:00
Ludovic Marie 92050712fa Merge remote-tracking branch 'origin/fix/st-maps' into fix/st-player-body
# Conflicts:
#	tools/lan_e2e.py
2026-10-24 05:09:46 +02:00
Ludovic Marie 122e3bf287 Merge remote-tracking branch 'origin/fix/st-fishing' into fix/st-maps
# Conflicts:
#	tools/lan_e2e.py
2026-10-24 05:08:17 +02:00
Ludovic Marie 65031a7739 Merge remote-tracking branch 'origin/main' into fix/st-fishing
# Conflicts:
#	tools/lan_e2e.py
2026-10-24 05:06:43 +02:00
Ganer 82218db860 Merge commit '59f018eabe26093274d34942365d31010c3e3e95' into fix/worldgen-clippy-bool-20261023 2026-10-23 21:58:59 -05:00
Ganer 040ac1bec4 Merge commit '59f018eabe26093274d34942365d31010c3e3e95' into fix/client-clippy-baseline-20261023 2026-10-23 21:58:59 -05:00
Ludovic Marie 59f018eabe Merge pull request #1535 from ludovic111/fix/dg1-mining
🤖 Agent: merged after verify and CI parity passed, built merged with current main (discord-gate-1: player reports checked against vanilla 26.3; a lan_e2e check fails on main and passes here in both modes; evidence in the PR body).
2026-10-24 04:49:25 +02:00
Ludovic MarieandClaude Opus 5.5 969bcec6c9 Villagers open and close doors on their paths
InteractWithDoor was a stub, so a villager whose path went through a
closed oak door walked into it and stayed there. In generated villages,
villagers who were inside a house never reached a job site, a bed or
the meeting point.

Like vanilla, a villager now opens the closed mob-interactable doors on
its next path step (DoorBlock.setOpen: both halves, the door sound and
the block_open game event), remembers the door it leaves and the doors
it opened, and closes them once it is more than one step past them,
unless another villager within 2 blocks is walking through. A villager
going to sleep closes them too.

The mob events apply after the mob loop, so the door opens one tick
later than in vanilla, where DoorBlock.setOpen changes the world during
the villager's brain tick.

lan_e2e's new doors check shuts a villager in a room with a closed
door and a free composter outside. Before, it never opened the door;
now it opens it, becomes a farmer, and the door is closed again on the
server and on the guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 04:47:39 +02:00
Ludovic Marie ca0637da80 Merge pull request #1508 from ludovic111/fix/st-wind-charge
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-2: server-thread gaps checked against vanilla, each with a lan_e2e check that fails before and passes after, host and guest, both modes; numbers in the PR body).
2026-10-24 04:45:07 +02:00
Ludovic Marie 47459b0b19 Merge pull request #1455 from ludovic111/chore/mob-brain-shared
🤖 Agent: merged after verify and CI parity passed, built merged with current main (review-2-deps-dupes; numbers and harness proof are in the PR body).
2026-10-24 04:22:35 +02:00
Ludovic Marie 9b16d37e80 Merge pull request #1452 from ludovic111/chore/dedupe-side-mask-kind
🤖 Agent: merged after verify and CI parity passed, built merged with current main (review-2-deps-dupes; numbers and harness proof are in the PR body).
2026-10-24 04:20:53 +02:00
Ludovic Marie 03be14bb59 Merge remote-tracking branch 'origin/fix/st-cursor-close' into feat/st-default 2026-10-24 04:12:23 +02:00
Ludovic Marie 45e5b64520 Merge remote-tracking branch 'origin/fix/st-hits' into fix/st-cursor-close 2026-10-24 04:11:25 +02:00
Ludovic Marie d65be4183f Merge remote-tracking branch 'origin/fix/st-player-body' into fix/st-hits 2026-10-24 04:10:32 +02:00
Ludovic Marie e307f03684 Merge remote-tracking branch 'origin/fix/st-maps' into fix/st-player-body 2026-10-24 04:09:39 +02:00
Ludovic Marie 26a91e3a08 Merge remote-tracking branch 'origin/fix/st-fishing' into fix/st-maps 2026-10-24 04:08:43 +02:00
Ludovic Marie 8c81cc63f7 Merge remote-tracking branch 'origin/fix/st-wind-charge' into fix/st-fishing 2026-10-24 04:07:54 +02:00
Ludovic Marie 221545e92b Merge remote-tracking branch 'origin/main' into fix/st-wind-charge 2026-10-24 04:07:01 +02:00
Ludovic Marie 88b27070bd Merge pull request #1476 from ludovic111/fix/st-held-use
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:43:11 +02:00
Ludovic Marie 499f8bddfc Merge pull request #1475 from ludovic111/fix/st-elytra
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:41:32 +02:00
Ludovic Marie 5904922865 Merge pull request #1466 from ludovic111/fix/st-books
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:39:56 +02:00
Ludovic Marie 49e706e8be Merge pull request #1465 from ludovic111/fix/st-signs
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:38:08 +02:00
Ludovic Marie 5e63b0512d Merge pull request #1464 from ludovic111/fix/st-keep-inventory
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:36:22 +02:00
Ganer d66466db81 Merge commit '22b2711cf9557d53d21080828c895dbff6027151' into fix/worldgen-clippy-bool-20261023 2026-10-23 20:25:14 -05:00
Ganer 6b55abd66a Merge commit '22b2711cf9557d53d21080828c895dbff6027151' into fix/client-clippy-baseline-20261023 2026-10-23 20:25:14 -05:00
Ludovic Marie 22b2711cf9 Merge pull request #1277 from ludovic111/feat/java-gbuffers-terrain
🤖 Agent: merged after verify and CI parity passed (shaders stack: Java packs draw their own terrain, water and shadow map, as in Iris). BSL shows real shadows on Vulkan and Metal; frame times and screenshots are on #1326. Without a pack, nothing changes.
2026-10-24 03:18:46 +02:00
Ganer 6ea7167cbb Merge commit 'd8dd2023dd9c6129e9e628a66d61c1b10e1435cf' into fix/worldgen-clippy-bool-20261023 2026-10-23 20:17:24 -05:00
Ganer 51de8afe95 Merge commit 'd8dd2023dd9c6129e9e628a66d61c1b10e1435cf' into fix/client-clippy-baseline-20261023 2026-10-23 20:08:07 -05:00
Ganer 3e04df41c8 Merge commit 'd84d7a7e6178ec1a6afab302c4b8ce80e5d2d9ee' into fix/client-clippy-baseline-20261023 2026-10-23 19:55:56 -05:00
Ganer 7371b2cccd Merge commit 'd84d7a7e6178ec1a6afab302c4b8ce80e5d2d9ee' into fix/worldgen-clippy-bool-20261023 2026-10-23 19:55:55 -05:00
Ganer 077c4c9214 Merge remote-tracking branch 'upstream/main' into fix/client-clippy-baseline-20261023 2026-10-23 19:53:00 -05:00
Ganer 8f081ae460 Merge remote-tracking branch 'upstream/main' into fix/client-clippy-baseline-20261023 2026-10-23 19:44:31 -05:00
Ganer 14461c1d96 fix(client): preserve behavior while clearing Rust 1.90 Clippy errors 2026-10-23 19:44:13 -05:00
Ludovic MarieandClaude Opus 5.5 ea424ebd52 LAN and dedicated servers wait for the player's client to finish breaking a block
ServerPlayerGameMode breaks a digging player's block only when the
player's client says it is done (STOP_DESTROY_BLOCK, or the delayed
destroy), or at once when it is instant. The host ran the guest's
tick_mining with its own count and broke the block when that reached 1.
A guest whose client ran even a tick behind lost the block before it
predicted the break: no destroy particles, no break sound, and the
dropped item could appear before the block vanished.

lan_e2e's new digging check holds attack across six oak logs and hears
six breaks, then stalls the guest for a second halfway through stone:
the stone is still there on both sides when it resumes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 02:38:07 +02:00
Ganer fc9822b241 Merge commit 'd6dc08098a30a08be7ec168fa3e6b756aa3ac493' into fix/worldgen-clippy-bool-20261023 2026-10-23 17:12:21 -05:00
Ganer 4b1e298a44 fix(worldgen): resolve upstream Rust 1.90 Clippy boolean warnings 2026-10-23 16:55:08 -05:00
Ludovic MarieandClaude Opus 5.5 a4a101ca9c Single player runs on its server thread by default again; --in-frame is the opt-out
#1428 made the in-frame world the default while a server's players lagged
behind it (signs, books, totems, keep_inventory and the other [server-thread]
issues). With the fixes of this stack a scripted single player session (walk,
mine, place, inventory, combat, eating, boats, minecarts, chests, furnaces,
beds, chat, signs, books, totems, keep_inventory, fishing, maps, saving and
reopening) gives the same results on the server thread as in the frame.

`--in-frame`, `--no-server-thread` or HORIZON_SERVER_THREAD=0 runs the world
in the frame; `--server-thread` still works. LAN guests, dedicated servers and
the command harness are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 23:30:14 +02:00
Ludovic MarieandClaude Opus 5.5 c6ed3112e7 Closing a menu puts a server's player's cursor stack back into their inventory
AbstractContainerMenu.removed hands the stack a closing player holds on the
cursor to placeItemBackInInventory (dropped only what doesn't fit, or for a
player who left), and broadcastChanges then empties the client's cursor. The
LAN host threw it out at the player's eyes instead, for the inventory and
every container, crafting, station, mount and merchant menu, and the client
kept showing a copy on its cursor (#1283).

The three ContainerClose arms now place it back through lan_place_back and
send the inventory, carried stack included.

tools/lan_e2e.py gains a cursor check: each player picks up a stack in their
inventory and closes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 23:30:08 +02:00
Ludovic MarieandClaude Opus 5.5 f96101cd47 Strikes and mob effects reach a server's players: lightning, evoker fangs, poison, axolotls
Lightning (Entity.thunderHit) and evoker fangs (EvokerFangs.dealDamageTo) hit
every entity in their box, but thunder_hit and fangs_bite only looked at the
window's own player, so a server's players (guests, and single player on the
server thread) were never struck (#1480, #1481). Both now run their player
part for each guest too, through Game::each_guest_hit.

The effects a mob gives the player it acts on (cave spider and bee poison, husk
hunger, wither skeleton wither, the nautilus's breath) and an axolotl's help
after a kill stayed with the server's own player: acts_on_player now routes
MobEvent::EffectPlayer and MobEvent::AxolotlSupportPlayer to the guest the mob
sees, like its blows (#1484, #1485).

tools/lan_e2e.py gains a strikes check: a bolt hurts each player standing in
water, and a cave spider poisons them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 23:30:08 +02:00
Ludovic MarieandClaude Opus 5.5 fa4ea80d32 A server's player's client sets its body as in frame: look pitch, use slowdown, boots on powder snow
On a server (guests, and single player on the server thread) the window ticks
its player through tick_remote_level, which copied the yaw, the Respiration
level, the wings and FAST_LAVA but not the rest of what the in-frame tick sets
from the window's own state. Both now call Game::sync_player_body:

- the look pitch: Player.travel steers sprint-swimming up and down by it (#1303)
- the slowdown of an item in use, LocalPlayer.modifyInput (#1278)
- leather boots, PowderSnowBlock.canEntityWalkOnPowderSnow (#1296)
- freeze-immune wear, LivingEntity.canFreeze
- the Auto Jump option, LocalPlayer.sendPosition (#1279)

tools/lan_e2e.py gains a body check: each player walks while drawing a bow,
stands on powder snow with leather boots and without, and sprint-swims looking
down.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 23:30:08 +02:00
Ludovic Marie 0bbe50c6e8 Merge remote-tracking branch 'origin/main' into feat/java-terrain-cull 2026-10-23 23:10:12 +02:00
Ludovic Marie c924894624 Merge remote-tracking branch 'origin/main' into feat/java-gbuffers-terrain 2026-10-23 23:08:59 +02:00
Ludovic MarieandClaude Opus 5.5 287d7a7eb5 Maps on a server's players: each holder's map ticks on the server and its data reaches them
A map a server's player (a LAN guest, or single player's window on its
server thread) made stayed blank and saved nothing (#1382): only the
game's own player ever updated a map, and no map data went to the guests.
Each guest's turn now runs MapItem's ticks as theirs (the marker of every
carried map, the reveal of the held ones), and the server sends each guest
ClientboundMapItemDataPacket for the maps it carries, and every 10 ticks
for the maps framed in the level (ServerPlayer.synchronizeSpecialItemUpdates,
ServerEntity.sendChanges).

As in vanilla, a map keeps its holders (MapItemSavedData.HoldingPlayer):
each has its own column sweep and its own record of what changed since its
last packet, so the packet carries only the changed pixels' box, and the
decorations at most every 5th tick while they changed. Player markers are
kept per holder, so each player's marker shows on a shared map, and go
when that player stops carrying it. A server's client now applies the
packet's decorations.

lan_e2e maps: host and guest each make and hold a map, which draws the
terrain around them and their marker, in both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 22:58:28 +02:00
Ludovic MarieandClaude Opus 5.5 c86996c234 Fishing on a server's players: a hook per player, ticked on the server and drawn by its clients
A server's players (LAN guests, and single player's window on its server
thread) cast no lasting hook and caught nothing (#1348): the guest's cast
went into the game's own hook, which the server's tick then dropped for
want of a local player. Each guest now keeps its own hook (Player.fishing),
swapped in with the rest of its state for its turns, ticked as theirs every
server tick, and dropped when they die. Reeling in retrieves it on the
server: a catch, the rod's wear, the experience.

The server tracks every hook to its players as a fishing_bobber entity
(FishingHook.getAddEntityPacket: the owner's id; DATA_HOOKED_ENTITY and
DATA_BITING). A client takes the one its own player cast as its hook, so
the rod shows cast and the line runs to its hand, and draws the others'
bobbers on lines to their owners' hands.

A hook resting on a block stays on the ground (Entity.move keeps onGround
when nothing moves vertically); it used to flip every tick, so reeling it
in cost the rod 2 only half the time.

lan_e2e fishing: host and guest, both modes: the hook stays out and both
sides draw it, reeling in from the ground costs 2, and in open water with
Lure III a fish bites and is caught for 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 22:58:18 +02:00
rdbtCVS d7b5cd8c85 docs: make agent identity instructions user independent
🤖 Generated with Codex
2026-10-23 21:28:06 +01:00
Ludovic MarieandClaude Opus 5.5 4daa91cf52 Wind charges push a server's players: the burst's packet carries each one's push
A wind charge's burst (ServerExplosion with TRIGGER_BLOCK) pushed only the
game's own player, so a LAN guest, and single player's window on its server
thread, never rose (#1386). The burst now pushes every player in reach, each
swapped in as the player, and sends ClientboundExplodePacket to the players
within 64 blocks with the push it gave them (hitPlayers), which their client
adds (Entity.pushFromExplosion); onExplosionHit's fall-damage grace goes on
the server's body. A burst from a guest's own turn (their charge landing,
their mace's wind burst) waits for the turn to end, so that it reaches the
host and the other guests too.

The packet's centre particle is the gust emitter and it throws no block
particles, as WindCharge.explode asks; the client now draws the packet's
own particle. Explosion knockback resistance lowers the player's push as in
vanilla, and a guest's body knows whether it flies (no push while flying in
creative).

lan_e2e wind: host and guest each throw a charge at their feet, rise about
six blocks and land unhurt, in both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 22:15:06 +02:00
Ludovic Marie 5595db9077 Merge remote-tracking branch 'origin/main' into chore/dedupe-side-mask-kind 2026-10-23 22:10:53 +02:00
Ludovic Marie fe70ff90b4 Merge remote-tracking branch 'origin/main' into fix/st-wind-charge 2026-10-23 21:43:45 +02:00
Ludovic Marie 4a442f7250 Merge remote-tracking branch 'origin/main' into perf/glyph-cache-fxhash 2026-10-23 20:53:30 +02:00
Ludovic Marie f47f6ee472 Merge remote-tracking branch 'origin/main' into fix/entity-shadow-height 2026-10-23 20:53:28 +02:00
Ludovic Marie 85c6103d45 Merge remote-tracking branch 'origin/main' into fix/creative-search-hotbar-keys 2026-10-23 20:53:27 +02:00
Ludovic Marie 60611ed5a3 Merge remote-tracking branch 'origin/main' into mm1-1455
# Conflicts:
#	crates/horizon-client/src/mobs/villager_brain.rs
2026-10-23 20:16:35 +02:00
Ludovic MarieandClaude Opus 5.5 4fa5966338 Text: the glyph cache hashes chars with Fx instead of SipHash
Every drawn character looks its glyph up in the font set's cache, and with
std's SipHash the hashing was the top cost of drawing sign text (#1461:
100 text signs in view). FxHash for those char keys cuts the frame's
prepare time with 100 text signs from 0.32 ms to 0.22 ms on the Mac mini
(Metal, 1280x720): 1920 to 2250 fps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:58:48 +02:00
Ludovic MarieandClaude Opus 5.5 eb79cfdb01 Entity shadows: no more z-fighting with chicken feet and other model feet
Shadow quads were lifted 0.001 above the block top and drawn in the entity
batch with depth writes. LivingEntityRenderer's translate(0, -1.501, 0)
puts a model's feet 0.001 above the ground too, so a chicken's feet and the
shadow under it were coplanar and flickered (#1440).

ShadowFeatureRenderer puts the quads on the block top itself
(relativeY + shapeBelow.minY), and RenderPipelines.ENTITY_SHADOW tests depth
without writing it. Shadows are now their own entity material (overlay 14)
drawn by such a pipeline after the entities. A small depth bias, constant
plus 1.5% of the slope, keeps them over the terrain under them, whose
vertices snap to the subpixel grid differently (without it whole shadows
vanished at some camera positions), while staying under feet 0.001 higher.
The shader pack shadow map no longer draws entity shadows as casters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:58:48 +02:00
Ludovic MarieandClaude Opus 5.5 717f54bf28 Creative search tab: number keys over a slot move its item to the hotbar
CreativeModeInventoryScreen.keyPressed on the search tab tries the hotbar
keys on the hovered slot before the search box: a digit or keypad key bound
to key.hotbar.N swaps when the cursor is over a slot holding an item, or any
slot that is not a display slot, and the cursor stack is empty
(checkHotbarKeyPressed). Over an empty display slot, outside every slot or
with a stack held, the key still types. Horizon sent every key to the search
box (#1460).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:58:47 +02:00
Ludovic MarieandClaude Opus 5.5 ad76f911bd Shields, tridents, crossbows and spyglasses end their use on a server: the client starts it too
Item.use runs on the client as well (MultiPlayerGameMode.useItem), and
only a use the client started sends RELEASE_USE_ITEM when the button
lets go. A server's client started one for bows and food only, so a
shield kept blocking after release, a charged trident never left the
hand, a loaded crossbow stayed "in use" on the server and never fired,
and a spyglass never scoped. The client now starts what the window's
own Item.use does: a bow or crossbow with ammunition, a trident, a
spyglass, a shield off cooldown, and keeps it until the button lets go.

The server's RELEASE_USE_ITEM let go of a main-hand bow only. It now
runs ServerPlayer.releaseUsingItem with the used item's hand, through
the release the window's own tick shares (bow, trident, spyglass).

A loaded crossbow reached its client without its ammunition: the
network stack gets minecraft:charged_projectiles (registry id 51,
ItemStackTemplate list), and a server's client decodes it.

lan_e2e gets a helduse check, for the host and the guest, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:50:56 +02:00
Ludovic MarieandClaude Opus 5.5 5ed8181adc Elytra glides on a server's players: the client sends START_FALL_FLYING and the server checks it
A server's client never took the chest slot's Elytra into its own
movement (player.glider) and never told the server it started gliding,
so on the server thread and as a LAN guest the player just fell. The
client now sets the glider as the window's own tick does and sends
ServerboundPlayerCommandPacket START_FALL_FLYING when a glide starts.

The hub used to accept that command blindly and never clear it. The game
now answers it as handlePlayerCommand does (Player.tryToStartFallFlying,
else stopFallFlying), and each tick runs LivingEntity.updateFallFlying
for a gliding guest: the glide ends once canGlide fails (landed, riding,
flying, levitation, a glider about to break), every 20th tick wears the
Elytra, every 10th is an elytra_glide game event, and a glide that isn't
diving keeps the fall distance at 1 (checkFallDistanceAccumulation).

lan_e2e gets an elytra check, for the host and the guest, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:12:44 +02:00
Ludovic MarieandClaude Opus 5.5 5617d702cc Books open on a server's players, and what they write and sign reaches the server
A server's player (a LAN guest, or single player's window on its server
thread) could neither write in a book and quill nor read a written book:
the server opened the screens in the guest's turn and dropped them (#1366).
As in vanilla, the client opens a book and quill's editor itself when it
uses one (LocalPlayer.openItemGui) and sends ServerboundEditBookPacket
when it saves or signs; the server writes the pages or makes the written
book, signed with the player's name, in that hotbar slot or the off hand.
A written book opens on the client when the server sends
ClientboundOpenBookPacket for its hand (ServerPlayer.openItemGui).

horizon-protocol: OpenBook and EditBook (pages a list of at most 100).
horizon-ctl: book sign presses Sign, then Sign and Close; book done on a
book and quill saves it, as its Done button does.

lan_e2e books: host and guest each write, sign, read and drop a book,
both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:10:13 +02:00
Ludovic MarieandClaude Opus 5.5 52d8ed6721 Signs open their editor on a server's players, and what they write reaches the server
A sign a server's player placed (a LAN guest, or single player's window on
its server thread) opened no editor: its turn's editor was dropped with
the rest of the guest's screens (#1380). The server now does as
ServerPlayer.openTextEdit: the block, then ClientboundOpenSignEditorPacket,
and the player may write on that sign. The client opens its editor on the
sign it has and sends ServerboundSignUpdatePacket when it closes; the
server takes the lines (formatting codes stripped) unless the sign is
waxed, the player was not the one allowed or went out of reach, and sends
the sign to everyone. The window's own sign now reaches its guests too
(sendBlockUpdated, not only a save).

horizon-protocol: OpenSignEditor and SignUpdate, with SignTextSlot as a
VarInt and the lines as a fixed list of four (26.3's stream codecs).
horizon-ctl: type and key reach an open sign editor; block shows a sign's
lines.

lan_e2e signs: host and guest each place, write and see, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:10:13 +02:00
Ludovic MarieandClaude Opus 5.5 de358cd721 A server's players drop their inventory on death unless keep_inventory is on
The guest death path (LAN guests, dedicated players and single player's
window on its server thread) gave the experience reward but never ran
Player.dropEquipment, so a player died with keep_inventory off and kept
everything (#1284). Both paths now share one drop_inventory:
destroyVanishingCursedItems, then Inventory.dropAll from the eyes less 0.3.

ServerPlayer.die skips the loot for spectators only; the window's own
death skipped it in creative too, which vanilla doesn't.

lan_e2e keepinv: host and guest, keep_inventory off and on, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:10:13 +02:00
Ludovic MarieandClaude Opus 5.5 9acc68f09f Merge feat/java-terrain-cull into feat/java-shadow-programs
The shadow samplers bind with main's custom textures (texture.* first,
then the shadow map, then the rest); the shadow layers are drawn from the
shadow list on the flat path too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:00:56 +02:00
Ludovic MarieandClaude Opus 5.5 a8c21c27df Merge feat/java-gbuffers-terrain into feat/java-terrain-cull
With main's Metal culling: the pack's depth test in OpenGL's layout runs
forced early on Vulkan (fs_box_early_depth) and in the shader on Metal
(fs_box_gl, the pack's depth attached read only and bound), and the flat
path culls too (phase 1 calls, flatten_phase2 after test_gl, then
draw_phase2 with each page's group 3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 17:55:04 +02:00
Ludovic MarieandClaude Opus 5.5 0f3a92ac2b Merge feat/java-packs-photon-chocapic into feat/java-gbuffers-terrain
Brings main's shader api 2 (#1414) up the stack. One path for blending:
the terrain programs' blend.* now go through Pass.blend/buffer_blends like
composite and final (java_pack::pass_blends). depthtex1/depthtex2 are one
source, DepthSolid: Horizon's depth before translucent geometry, then the
pack's solid terrain's. Horizon's world goes to the pack's first terrain
target (colortex1 for MakeUp).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 17:46:12 +02:00
Ludovic MarieandClaude Opus 5.5 a79077b30e Mob brains share Timed, compute_path, reached and the living-entity scan
Behavior's Timed was copied into the villager, animal, monster and warden
brains: one copy now, in brain.rs, drawing its duration in Java's int
arithmetic (two copies used plain +, the same for every duration they
have). MoveToTargetSink's tryComputePath and reachedTarget, copied into
the villager, monster and warden brains on the same WalkTarget and Slot
types, are the villager brain's, given each brain's
CANT_REACH_WALK_TARGET_SINCE. NearestLivingEntitySensor's scan, copied
into the villager, animal and monster brains, is brain.rs's living_near.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:33:59 +02:00
Ludovic MarieandClaude Opus 5.5 ac811d4708 One side_mask for the two datafixes, one test MobType for two mobs.rs tests
datafix: LeavesFix's getSideMask is ChunkPalettedStorageFix's, line for
line; chunk_leaves uses chunk_flatten's. mobs.rs: push_tests takes
sensor_scan_tests' 46-line cow MobType instead of its own copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:46 +02:00
Ludovic MarieandClaude Opus 5.5 5858cb113e Java shader packs: the shadow map leaves out sections whose shadow can't reach the view
A section's shadow is its box swept away from the light; when the box
is outside one of the camera frustum's planes and the sweep never
crosses it, nothing it casts can be seen (Iris's advanced shadow
culling, conservatively). The picture is the same; BSL's 256-block
shadow distance draws far fewer sections.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:21:16 +02:00
Ludovic MarieandClaude Opus 5.5 fe6ae937ef Java shader packs: the pack's shadow programs draw the shadow map
A Java pack's shadow program (shadow_cutout, shadow or shadow_solid for
the solid layer, shadow_water or shadow for the translucent one) draws
Horizon's terrain from the light after begin, as Iris's ShadowRenderer:

- shadowModelView and shadowProjection are Iris's ShadowMatrices, with
  the pack's shadowMapResolution, shadowDistance, near and far planes,
  shadowIntervalSize and sunPathRotation; shadow programs get them as
  their model view and projection.
- The renderer picks every resident section inside the light's box (no
  cave culling, no face culling) and draws it instanced, solid then
  translucent, with origins after the camera's.
- shadowtex0 holds everything, shadowtex1 the depth before translucent
  terrain, bound as depth textures for comparisons and as float copies
  for plain reads; shadowcolor0..7 take the pack's formats and clears
  (white by default).

A shadow program that fails to compile leaves the pack without its
shadow map, with a warning; its terrain programs still run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:41:18 +02:00
Ludovic MarieandClaude Opus 5.5 c344a26e80 Java shader packs: occlusion culling while the pack draws the terrain
The pack's terrain program draws the solid sections the last test saw,
the boxes of the others are tested against the pack's depth in a pass
of their own (OpenGL's layout: y flipped, depth 0 near, so a second
depth-only culling pipeline), and the program then draws the sections
in sight from the culling's phase 2 draws. The flat path, which has no
occlusion culling, is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:05:26 +02:00
Ludovic MarieandClaude Opus 5.5 6a58fc20da Java shader packs: the pack's gbuffers_terrain and gbuffers_water draw the terrain
When a Java pack has a terrain program (gbuffers_terrain, or the program
Iris falls back to), Horizon's world pass skips its own terrain and the
pack's program draws every visible section into its render targets,
tested against the depth of the rest of the world; gbuffers_water draws
the translucent layer before the first composite with the pack's blend
modes. depthtex0/depthtex1 get the depth before and after water.

- translate: a geometry mode (group 2, attributes as globals a glue
  function fills before main, alpha test) and a pass that ends switch
  cases which return, which naga's WGSL writer otherwise rejects
  (Photon).
- java_terrain: the glue decoding Horizon's terrain vertices from the
  arena pages into Iris's attributes, the block.properties id table by
  sprite name, and a 2D atlas built from the sprite arrays (animated
  sprites copied in).
- RGBA16/RG16/R16 targets are 16-bit unorm where the GPU renders them:
  Chocapic13' packs its G-buffer into them.

MakeUp Ultra Fast, Chocapic13' and BSL draw their own terrain and water
on the M4 Pro; Photon still draws black (compute and .dat textures).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:50:37 +02:00
898 changed files with 47793 additions and 28799 deletions

No files matched your search

+1
View File
@@ -3,3 +3,4 @@
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+1 -1
View File
@@ -1,2 +1,2 @@
#!/bin/sh
exec python3 tools/check_public_tree.py --history
exec python3 tools/check_public_tree.py --pre-push
+1 -95
View File
@@ -430,7 +430,7 @@ jobs:
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_float_log_parity.py
portable-mod:
jfr-analysis:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
@@ -442,101 +442,7 @@ jobs:
with:
distribution: temurin
java-version: '25'
- name: Compile original mod and test metrics
run: python tools/build_mod.py
- name: Verify streaming JFR analysis against the JDK exporter
run: python tools/test_jfr.py
- name: Verify ZGC pause and allocation event interpretation
run: python tools/test_jfr.py --gc zgc
- name: Build pinned portable packs
run: python tools/build_pack.py
- name: Measure isolated worker pacing on this platform
run: python -c "from tools.build_mod import benchmark_workers; benchmark_workers()"
- name: Measure isolated debounce queue polling
run: python -c "from tools.build_mod import benchmark_debounce; benchmark_debounce()"
- name: Measure wakeable idle queue latency
run: python -c "from tools.build_mod import benchmark_idle; benchmark_idle()"
- name: Measure object allocations with compact headers off and on
run: python -c "from tools.build_mod import benchmark_headers; benchmark_headers()"
- name: Test pack integrity and comparisons
run: python -m unittest discover -s tests -v
- name: Start recommended OpenGL macOS clients and verify framebuffer readback
if: runner.os == 'macOS'
run: |
python tools/test_client_graphics.py --native-display
cp dist/client-graphics-smoke.json dist/client-graphics-macos-title.json
python tools/test_client_graphics.py --native-display --world
cp dist/client-graphics-smoke.json dist/client-graphics-macos-world.json
- name: Compile the private dispatch fixture
run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()"
- name: Compile the private integrated-server graphics fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('server-graphics', 'dev.horizonlab.metrics.ServerGraphicsIntegration', fixture_dependencies={})"
- name: Compile the private tick-manager state fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
- name: Compile the private terrain tick membership fixture
run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()"
- name: Compile the private culling whitelist fixture
run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()"
- name: Compile the private culling ray geometry fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('culling-rays', 'dev.horizonlab.dispatch.CullingRayIntegration', ['entityculling'], ['CullingRayAccess'])"
- name: Compile the private terrain position lookup fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-position', 'dev.horizonlab.dispatch.PositionIntegration')"
- name: Compile the private terrain quad reuse fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('quad-reuse', 'dev.horizonlab.dispatch.QuadReuseIntegration')"
- name: Compile the private object-size agent and terrain quad fixture
run: python -c "from tools.test_quad_sizes import build_agent; from tools.build_mod import build_integration_fixture; build_agent(); build_integration_fixture('quad-size', 'dev.horizonlab.dispatch.QuadSizeIntegration')"
- name: Compile the private terrain read shutdown fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')"
- name: Install headless graphics libraries for applied-hook checks
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0
- name: Verify integrated-server graphics report against the actual device
run: python tools/test_server_graphics.py ${{ runner.os == 'macOS' && '--native-display' || '' }}
- name: Exercise the applied dispatcher hooks and upstream rejection path
if: runner.os == 'Linux'
run: python tools/test_dispatch.py
- name: Verify applied tick membership transitions and allocation
if: runner.os == 'Linux'
run: python tools/test_tick_dedup.py
- name: Verify live culling whitelist registration and empty iteration
if: runner.os == 'Linux'
run: python tools/test_culling_empty.py
- name: Compare applied culling ray geometry and provider queries
if: runner.os == 'Linux'
run: python tools/test_culling_rays.py
- name: Verify actual full-data position lookup equivalence
if: runner.os == 'Linux'
run: python tools/test_terrain_position.py
- name: Verify actual terrain quad geometry and allocation
if: runner.os == 'Linux'
run: python tools/test_quad_reuse.py
- name: Measure actual builder and quad shallow sizes in both header modes
if: runner.os == 'Linux'
run: python tools/test_quad_sizes.py
- name: Verify interrupted DH reads and preserved shared futures
if: runner.os == 'Linux'
run: python tools/test_terrain_read.py
- name: Upload original mod and pack manifests only
uses: actions/upload-artifact@v4
with:
name: horizon-linux
path: |
dist/*.mrpack
dist/horizon-*.jar
dist/worker-pacing-benchmark.txt
dist/debounce-queue-benchmark.txt
dist/idle-queue-benchmark.txt
dist/object-header-benchmark.txt
dist/dispatch-integration.json
dist/tick-dedup-integration.json
dist/culling-empty-integration.json
dist/terrain-read-integration.json
dist/culling-rays-integration.json
dist/terrain-position-integration.json
dist/quad-reuse-integration.json
dist/quad-size-integration.json
dist/server-graphics-smoke.json
dist/client-graphics-smoke.json
dist/client-graphics-macos-title.json
dist/client-graphics-macos-world.json
retention-days: 3
+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
+10 -5
View File
@@ -1,8 +1,8 @@
name: release
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
# a release when pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago (the oldest 20 minutes ago, if merges never pause), or at once for a pull request labelled release-now.
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
@@ -367,10 +367,15 @@ jobs:
TAG: ${{ needs.decide.outputs.tag }}
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# The release and the latest at once: there is one channel (docs/release-flow.md).
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
--notes-file notes/body.md --latest \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
+21 -18
View File
@@ -12,17 +12,20 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
with that agent's own "Generated with" line. Ludovic's agents use
`Co-Authored-By: Claude <model> <[email protected]>` and
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
they would change the vanilla look.
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
([docs/release-flow.md](docs/release-flow.md)).
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
6 hours ([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
## Crates
@@ -40,12 +43,13 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.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 |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
@@ -123,7 +127,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
@@ -148,19 +151,19 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## The two single-player paths
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `game/integrated.rs` runs a second
`Game` (`serving`) on a "Server thread" with one level per dimension; the window joins it as a client
through memory (`remote`), exactly as a LAN guest or a dedicated server's player does. Not at parity yet:
open `[server-thread]` issues list the gaps.
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
python3 tools/parity.py dedicated_server server_entities server_player_damage
+16
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`).
@@ -39,6 +44,17 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
had been written from another project's source; they come back only as a clean-room rewrite from a written
description of the pack format.
## Before asking for review
Generated
+78 -387
View File
@@ -155,7 +155,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -178,15 +178,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
[[package]]
name = "ascii-canvas"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891"
dependencies = [
"term",
]
[[package]]
name = "ash"
version = "0.38.0+1.3.281"
@@ -214,30 +205,15 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec 0.8.0",
]
[[package]]
name = "bit-set"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
dependencies = [
"bit-vec 0.9.1",
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
@@ -283,16 +259,6 @@ dependencies = [
"objc2 0.6.5",
]
[[package]]
name = "borsh"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
dependencies = [
"bytes",
"cfg_aliases",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -462,6 +428,8 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width",
]
@@ -484,15 +452,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "coords-hud"
version = "0.1.0"
@@ -797,41 +756,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.119",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn 2.0.119",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
@@ -849,29 +773,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
"unicode-xid",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -958,15 +859,6 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "ena"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
dependencies = [
"log",
]
[[package]]
name = "encoding_rs"
version = "0.8.42"
@@ -1230,58 +1122,6 @@ version = "0.30.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
[[package]]
name = "glsl-lang"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "983a18d0c2ecad18ac9ff3c94b069d6bd03b47405a200836d9d8d63def8e34de"
dependencies = [
"glsl-lang-lexer",
"glsl-lang-types",
"lalrpop",
"lalrpop-util",
"lang-util",
"once_cell",
"thiserror 2.0.21",
]
[[package]]
name = "glsl-lang-lexer"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abdbc0bdfbc81d557f4865e695457eb23d68031bfb061fda3444f56511b26eee"
dependencies = [
"glsl-lang-pp",
"glsl-lang-types",
"lalrpop-util",
"lang-util",
"thiserror 2.0.21",
]
[[package]]
name = "glsl-lang-pp"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88677876fb27f116015d0ecebda6d77bdf65f3a6fa3f5dfeed10b8c8f04cb2c5"
dependencies = [
"arrayvec",
"derive_more",
"lang-util",
"string_cache",
"string_cache_codegen",
"thiserror 2.0.21",
]
[[package]]
name = "glsl-lang-types"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd8b1532b424d40df634cf2dc2be56d59f994b9ac07fa7c1e3183fd33db9cb07"
dependencies = [
"lang-util",
"thiserror 2.0.21",
]
[[package]]
name = "gpu-allocator"
version = "0.28.0"
@@ -1428,7 +1268,7 @@ dependencies = [
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
"horizon-shaderpack",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1441,9 +1281,9 @@ dependencies = [
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"symphonia",
"toml",
"ureq",
"wgpu",
"winit",
@@ -1478,7 +1318,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"socket2",
"socket2 0.5.10",
"spake2",
]
@@ -1494,8 +1334,10 @@ name = "horizon-mods"
version = "0.1.0"
dependencies = [
"log",
"minisign-verify",
"serde",
"serde_json",
"sha2",
"toml",
"wasmtime",
"wit-component 0.239.0",
@@ -1541,6 +1383,7 @@ dependencies = [
"getrandom 0.3.4",
"horizon-core",
"horizon-world",
"ipconfig",
"serde_json",
"sha1",
"sha2",
@@ -1595,12 +1438,24 @@ dependencies = [
]
[[package]]
name = "horizon-shaderpack"
name = "horizon-shader-api"
version = "0.1.0"
dependencies = [
"glsl-lang",
"bytemuck",
"naga",
"serde",
"serde_json",
"sha2",
"toml",
"zip",
]
[[package]]
name = "horizon-shaders"
version = "0.1.0"
dependencies = [
"horizon-core",
"naga",
"pp-rs",
"zip",
]
@@ -1755,12 +1610,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@@ -1833,6 +1682,19 @@ dependencies = [
"generic-array",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2 0.6.5",
"widestring",
"windows-registry",
"windows-result 0.4.1",
"windows-sys 0.61.2",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -1943,73 +1805,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures",
]
[[package]]
name = "lalrpop"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501"
dependencies = [
"ascii-canvas",
"bit-set 0.8.0",
"ena",
"itertools",
"lalrpop-util",
"petgraph 0.7.1",
"pico-args",
"regex",
"regex-syntax",
"sha3",
"string_cache",
"term",
"unicode-xid",
"walkdir",
]
[[package]]
name = "lalrpop-util"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733"
dependencies = [
"regex-automata",
"rustversion",
]
[[package]]
name = "lang-util"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1715a1e56998f119cd6d49ac8a69802dcbe4fca96d8291243d6dae87abeb913"
dependencies = [
"derive_more",
"lalrpop-util",
"lang-util-derive",
"line-span",
"smol_str 0.3.6",
"text-size",
]
[[package]]
name = "lang-util-derive"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2acc66aedf0e445534e0a6b3f0f6ee20ef6ebb9647dbe10735e52ea8d6a096"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
@@ -2066,12 +1861,6 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "line-span"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fc123b2f6600099ca18248f69e3ee02b09c4188c0d98e9a690d90dec3e408a"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -2150,6 +1939,12 @@ dependencies = [
"libc",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2183,7 +1978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set 0.10.0",
"bit-set",
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
@@ -2245,12 +2040,6 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
@@ -2750,16 +2539,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap",
]
[[package]]
name = "petgraph"
version = "0.8.3"
@@ -2771,31 +2550,6 @@ dependencies = [
"indexmap",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2923,12 +2677,6 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "presser"
version = "0.3.1"
@@ -3022,15 +2770,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3369,16 +3108,6 @@ dependencies = [
"digest",
]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest",
"keccak",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -3407,12 +3136,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "slab"
version = "0.4.12"
@@ -3462,16 +3185,6 @@ dependencies = [
"serde",
]
[[package]]
name = "smol_str"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
dependencies = [
"borsh",
"serde_core",
]
[[package]]
name = "socket2"
version = "0.5.10"
@@ -3482,6 +3195,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "spake2"
version = "0.4.0"
@@ -3521,37 +3244,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -3668,15 +3360,6 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "term"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -3686,12 +3369,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "text-size"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -3852,7 +3529,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph 0.8.3",
"petgraph",
]
[[package]]
@@ -3968,10 +3645,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voxel-kernels"
version = "0.1.0"
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4552,6 +4225,7 @@ dependencies = [
"hashbrown 0.17.1",
"js-sys",
"log",
"naga",
"parking_lot",
"portable-atomic",
"profiling",
@@ -4573,8 +4247,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
dependencies = [
"arrayvec",
"bit-set 0.10.0",
"bit-vec 0.9.1",
"bit-set",
"bit-vec",
"bitflags 2.13.2",
"bytemuck",
"cfg_aliases",
@@ -4624,7 +4298,7 @@ dependencies = [
"android_system_properties",
"arrayvec",
"ash",
"bit-set 0.10.0",
"bit-set",
"bitflags 2.13.2",
"block2 0.6.2",
"bytemuck",
@@ -4688,6 +4362,12 @@ dependencies = [
"web-sys",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -4820,6 +4500,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result 0.4.1",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.1.2"
@@ -5122,7 +4813,7 @@ dependencies = [
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit",
"smol_str 0.2.2",
"smol_str",
"tracing",
"unicode-segmentation",
"wasm-bindgen",
+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/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "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-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]
+7 -2
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>
@@ -45,7 +46,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
@@ -57,7 +58,7 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
</tr>
<tr>
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics, Advanced Graphics and Shader Packs"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
@@ -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)
-30
View File
@@ -1,35 +1,5 @@
# Third-party notices
## CullLeaves
The optional leaf and mangrove-root face-culling behavior is adapted from
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
Its MIT license is reproduced below.
```text
MIT License
Copyright (c) 2020 MidnightDust
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
@@ -131,8 +131,8 @@ average) and reached the ThinkPad over the tailnet.
It ran with Paper's defaults except `connection-throttle: -1` in `bukkit.yml`. Every bot connects from one
address, and the default throttle (4 s) refused all but the first. Paperclip patched the pinned server jar
from its cache. Same Java and heap.
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`packs/fabric.lock.json`) with Lithium
`mc26.3-0.26.2-fabric` (`packs/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`tools/benchmark-deps/fabric.lock.json`) with Lithium
`mc26.3-0.26.2-fabric` (`tools/benchmark-deps/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
Same Java and heap.
The Paper and Fabric jars are dev-only downloads into `.local/server-bench/jars` and are never committed or
+2 -2
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
serde_json = "1.0.151"
[lints]
+278
View File
@@ -0,0 +1,278 @@
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
//! when given a key, and for a mod a trial run in the sandbox: it must start,
//! survive ticks, frames, a chat line and each of its commands, and import
//! no more than its capabilities.
use horizon_mods::{
Clock, Mods, Output, Package, Player, Snapshot, Status,
index::{self, Facts, Kind},
manifest::component_imports,
};
use serde_json::{Value, json};
use std::path::Path;
/// How long the trial run plays: two seconds of game.
const TICKS: u64 = 40;
const FRAMES_PER_TICK: u64 = 3;
pub struct Report {
pub kind: Kind,
pub facts: Facts,
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.
pub commands: Vec<String>,
/// What the mod showed during the trial run.
pub output: Vec<String>,
}
impl Report {
pub fn json(&self) -> Value {
let requires: Vec<Value> = self
.facts
.requires
.iter()
.map(|r| json!({"id": r.id, "version": r.version, "sha256": r.sha256}))
.collect();
json!({
"kind": self.kind.name(),
"id": self.facts.id,
"version": self.facts.version,
"api": self.facts.api,
"side": self.facts.side,
"capabilities": self.facts.capabilities,
"game": self.facts.game,
"requires": requires,
"sha256": self.sha256,
"size": self.size,
"signed": self.signed,
"code": self.code,
"imports": self.imports,
"commands": self.commands,
"output": self.output,
})
}
pub fn text(&self) -> String {
let f = &self.facts;
let mut out = format!(
"{} {} ({}",
f.id.as_deref().unwrap_or("resource pack"),
f.version.as_deref().unwrap_or(""),
self.kind.name()
);
if let Some(side) = f.side {
let side = serde_json::to_value(side).unwrap_or_default();
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
} else if !f.api.is_empty() {
out += &format!(", format {}", f.api);
}
if let Some(game) = &f.game {
out += &format!(", for {game}");
}
let signed = if self.signed {
"signature verified"
} else {
"unsigned"
};
out += &format!(
")\n {} bytes, SHA-256 {}, {signed}\n",
self.size, self.sha256
);
for c in &f.capabilities {
out += &format!(" may {}\n", c.describe());
}
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
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 {
format!(", /{}", self.commands.join(", /"))
};
out += &format!(
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
TICKS * FRAMES_PER_TICK
);
}
out
}
}
/// The kind a file's extension says.
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" => Some(Kind::Mod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
_ => None,
}
}
/// Checks the file at `path` as `kind`; `key` and `signature` (the
/// `.minisig` text) check its signature.
pub fn check(
path: &Path,
kind: Kind,
key: Option<&str>,
signature: Option<&str>,
) -> Result<Report, String> {
let size = std::fs::metadata(path)
.map_err(|e| format!("{}: {e}", path.display()))?
.len();
if size > index::MAX_FILE {
return Err(format!("over {} MiB", index::MAX_FILE >> 20));
}
index::open(&kind)?;
let facts = index::inspect(&kind, path)?;
let signed = match (key, signature) {
(Some(key), Some(sig)) => {
index::verify_signature(key, sig, path)?;
true
}
(None, None) => false,
_ => return Err("--key and --signature go together".into()),
};
let mut report = Report {
kind,
facts,
sha256: index::sha256_file(path)?,
size,
signed,
code: false,
imports: Vec::new(),
commands: Vec::new(),
output: Vec::new(),
};
if report.kind == Kind::Mod {
trial_run(path, &mut report)?;
}
Ok(report)
}
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
/// 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 Some(wasm) = package.wasm.as_deref() else {
return Ok(());
};
report.code = true;
report.imports = component_imports(wasm)?;
package
.manifest
.check_imports(report.imports.iter().map(String::as_str))?;
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let copy = dir.join("mod.hmod");
let result = std::fs::copy(path, &copy)
.map_err(|e| e.to_string())
.and_then(|_| play(&copy, report));
let _ = std::fs::remove_dir_all(&dir);
result
}
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
let failed = |mods: &Mods| match &mods.entries()[0].status {
Status::Failed(reason) => Some(reason.clone()),
_ => None,
};
if let Some(reason) = failed(&mods) {
return Err(format!("it didn't start: {reason}"));
}
report.commands = mods.commands().map(str::to_owned).collect();
for t in 0..TICKS {
mods.set_snapshot(Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
});
mods.tick(t);
for _ in 0..FRAMES_PER_TICK {
mods.frame(1.0 / 60.0);
}
}
mods.chat("<Steve> hello");
for name in report.commands.clone() {
mods.command(&name);
}
for out in mods.drain() {
report.output.push(match out {
Output::Chat { text, .. } => format!("chat: {text}"),
Output::Feedback { text, error } => {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
});
}
match failed(&mods) {
Some(reason) => Err(format!("it stopped: {reason}")),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-mods/tests/index")
.join(name)
}
#[test]
fn checks_each_kind_its_own_way() {
let pack = fixture("cozy-1.0.0.hpack");
let report = check(&pack, kind_of(&pack).unwrap(), None, None).unwrap();
assert_eq!(report.facts.requires[0].id, "coords_hud");
assert!(
report
.text()
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
);
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
let hmod = fixture("coords_hud-0.1.0.hmod");
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
let key = key.lines().last().unwrap();
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
.err()
.unwrap();
assert!(e.contains("didn't start"), "{e}");
let e = check(
&hmod,
Kind::Mod,
Some(key),
Some("untrusted comment: x\nRUQ=\n"),
)
.err()
.unwrap();
assert!(e.contains("signature"), "{e}");
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
assert!(e.contains("shader packs open"), "{e}");
assert!(check(&pack, Kind::Mod, None, None).is_err());
}
}
+186 -21
View File
@@ -1,13 +1,21 @@
//! `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;
mod registry;
use std::{
path::{Path, PathBuf},
process::{Command, ExitCode, Stdio},
@@ -28,7 +36,9 @@ fn main() -> ExitCode {
println!("Built {}", p.display());
}),
Some("run") => run(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => 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,
@@ -82,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()
@@ -102,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",
@@ -137,17 +235,39 @@ 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
/// when one does.
fn check(args: &[String]) -> Result<(), String> {
let file = args
.first()
.filter(|a| !a.starts_with("--"))
.ok_or("cargo horizon-mod check <file>")?;
let path = Path::new(file);
let kind = match flag(args, "--kind") {
Some(k) => horizon_mods::index::Kind::from(k.to_string()),
None => check::kind_of(path)
.ok_or("pass --kind mod|modpack|resourcepack: the extension doesn't say")?,
};
let signature = flag(args, "--signature")
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
.transpose()?;
let report = check::check(path, kind, flag(args, "--key"), signature.as_deref());
if args.iter().any(|a| a == "--json") {
let json = match &report {
Ok(r) => serde_json::json!({"ok": true, "report": r.json()}),
Err(e) => serde_json::json!({"ok": false, "error": e}),
};
println!("{json}");
}
let report = report?;
if !args.iter().any(|a| a == "--json") {
print!("{}", report.text());
}
Ok(())
}
fn run(args: &[String]) -> Result<(), String> {
@@ -198,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();
}
}
+159
View File
@@ -0,0 +1,159 @@
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! workflow downloads the files and publishes what this writes.
use horizon_mods::index::{
FILE_HOST,
registry::{self, Sources},
};
use std::path::Path;
/// Where the registry's site is published (its icons).
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
cargo horizon-mod registry list <checkout>";
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
super::flag(args, name)
}
fn positional(args: &[String]) -> Vec<&str> {
let mut out = Vec::new();
let mut skip = false;
for a in args {
if skip {
skip = false;
} else if a == "--json" {
} else if a.starts_with("--") {
skip = true;
} else {
out.push(a.as_str());
}
}
out
}
fn lines(errors: Vec<String>) -> String {
format!("{} problem(s):\n {}", errors.len(), errors.join("\n "))
}
pub fn main(args: &[String]) -> Result<(), String> {
let rest = args.get(1..).unwrap_or_default();
let pos = positional(rest);
match args.first().map(String::as_str) {
Some("build") => {
let [checkout] = pos[..] else {
return Err(USAGE.into());
};
let (files, out) = (flag(rest, "--files"), flag(rest, "--out"));
let (Some(files), Some(out)) = (files, out) else {
return Err(USAGE.into());
};
let generated = flag(rest, "--generated")
.map(str::to_owned)
.or_else(|| std::env::var("REGISTRY_GENERATED").ok())
.unwrap_or_default();
let index = registry::build(&Sources {
registry: Path::new(checkout),
files: Path::new(files),
release: flag(rest, "--release").unwrap_or(FILE_HOST),
site: flag(rest, "--site").unwrap_or(SITE),
generated,
})
.map_err(lines)?;
let text = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
std::fs::write(out, text + "\n").map_err(|e| format!("{out}: {e}"))?;
println!(
"Wrote {out}: {} project(s), {} taken down",
index.projects.len(),
index.revoked.len()
);
Ok(())
}
Some("review") => {
let [base, head] = pos[..] else {
return Err(USAGE.into());
};
let author = flag(rest, "--author").ok_or(USAGE)?;
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review(&base, &head, author);
if rest.iter().any(|a| a == "--json") {
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
println!("{json}");
} else {
for n in &review.notes {
println!("note: {n}");
}
for e in &review.errors {
println!("error: {e}");
}
}
if review.errors.is_empty() {
Ok(())
} else {
Err(format!(
"{} change(s) the registry refuses",
review.errors.len()
))
}
}
// `check` (and its trial run) on each file `head` lists and `base`
// doesn't: a pull request's new files.
Some("check") => {
let [base, head] = pos[..] else {
return Err(USAGE.into());
};
let files = Path::new(flag(rest, "--files").ok_or(USAGE)?);
let old: Vec<String> = match registry::load(Path::new(base)) {
Ok((entries, _)) => entries
.iter()
.flat_map(|e| e.versions.iter().map(|v| v.sha256.clone()))
.collect(),
// The first pull request has no entries to compare with.
Err(_) => Vec::new(),
};
let (entries, _) = registry::load(Path::new(head)).map_err(lines)?;
let mut failed = 0;
for e in &entries {
for v in e.versions.iter().filter(|v| !old.contains(&v.sha256)) {
println!("{} {} ({}):", e.id, v.version, v.file);
let report = crate::check::check(
&files.join(&v.sha256),
e.kind.clone(),
e.key.as_deref(),
v.signature.as_deref(),
);
match report {
Ok(r) => print!("{}", r.text()),
Err(err) => {
failed += 1;
println!(" error: {err}");
}
}
}
}
if failed == 0 {
Ok(())
} else {
Err(format!("{failed} file(s) failed their check"))
}
}
// The files CI must download for `build`: `<sha256> <id> <version> <file>`.
Some("list") => {
let [checkout] = pos[..] else {
return Err(USAGE.into());
};
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
for e in &entries {
for v in &e.versions {
println!("{} {} {} {}", v.sha256, e.id, v.version, v.file);
}
}
Ok(())
}
_ => Err(USAGE.into()),
}
}
@@ -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));
}
}
}
+1 -8
View File
@@ -1,8 +1,7 @@
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
//! come from the program that rendered (the game knows its adapter, the launcher its window).
use std::process::Command;
use horizon_telemetry::system::command_output as output;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -173,12 +172,6 @@ pub fn cpu_seconds(pid: u32) -> Option<f64> {
Some(ticks / 100.0)
}
fn output(program: &str, args: &[&str]) -> Option<String> {
let out = Command::new(program).args(args).output().ok()?;
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
(out.status.success() && !text.is_empty()).then_some(text)
}
#[cfg(target_os = "macos")]
fn os_version() -> Option<String> {
output("sw_vers", &["-productVersion"])
+7 -1
View File
@@ -245,13 +245,19 @@ pub struct Report {
impl Report {
/// An empty report for this machine, created now.
pub fn new(kind: Kind, app: App) -> Report {
Self::with_hardware(kind, app, Hardware::detect())
}
/// Starts a report using supplied hardware, without running system probes.
/// Interactive apps can collect the remaining machine details after their first frame.
pub fn with_hardware(kind: Kind, app: App, hardware: Hardware) -> Report {
Report {
format: FORMAT.to_owned(),
version: VERSION,
kind,
created: utc_now(),
app,
hardware: Hardware::detect(),
hardware,
conditions: Conditions::default(),
metrics: BTreeMap::new(),
frames: Vec::new(),
+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("-") {
+14 -5
View File
@@ -16,26 +16,26 @@ 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"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
toml = "1.1"
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
# Java (OptiFine/Iris) shader packs, translated to WGSL.
horizon-shaderpack = { path = "../horizon-shaderpack" }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
@@ -85,5 +85,14 @@ mods = ["horizon-mods/runtime"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
[[example]]
name = "client_probe"
path = "examples/probe/main.rs"
[lints]
workspace = true
[dev-dependencies]
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -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(())
}
@@ -41,7 +41,7 @@ pub fn main(names: &[&str], run: fn(&str) -> Option<ExitCode>) -> ExitCode {
let name = std::env::args().nth(1).unwrap_or_default();
run(&name).unwrap_or_else(|| {
eprintln!(
"usage: probe <name> [arguments...]\nprobes: {}",
"usage: client_probe <name> [arguments...]\nprobes: {}",
names.join(" ")
);
ExitCode::from(2)
@@ -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,
@@ -293,6 +293,47 @@ fn spawn_seed(index: usize) -> i64 {
1000 + 7919 * index as i64
}
/// `Villager.spawnGolemIfNeeded` for villager `a`, the game's way: the golem
/// draws from the level's random and joins the scenario's mobs next tick,
/// seeded as the oracle seeds what a tick spawned.
fn summon_golem(
mobs: &mut [Mob],
a: usize,
needed: usize,
w: &MobWorld,
types: &MobTypes,
born: &mut Vec<Mob>,
level_random: &mut LegacyRandom,
) {
use horizon_client::mobs::{villager, villager_brain};
let Some(near) = villager::golem_summoners(mobs, a, w.game_time, needed) else {
return;
};
let kind = Arc::new(
types
.types
.get("minecraft:iron_golem")
.expect("iron golem")
.clone(),
);
let index = mobs.len() + born.len();
let p = mobs[a].position;
let start = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let golem = villager::summon_golem(w, start, level_random, |random, at, yaw| {
let mut m = Mob::new(100_000 + index as u32, kind.clone(), at, yaw);
villager::finalize_summoned_golem(&mut m, random);
m
});
let Some(mut golem) = golem else {
return;
};
golem.random = LegacyRandom::new(spawn_seed(index));
born.push(golem);
for j in near {
villager_brain::golem_detected(&mut mobs[j], w.game_time);
}
}
/// Villager events that act at once in vanilla (`TradeWithVillager` and
/// `VillagerMakeLove` write the other villager directly).
fn villager_events(
@@ -304,6 +345,7 @@ fn villager_events(
items: &Items,
born: &mut Vec<Mob>,
day_time: i64,
level_random: &mut LegacyRandom,
) {
use horizon_client::mobs::villager;
let index = |mobs: &[Mob], id: u32| {
@@ -314,6 +356,7 @@ fn villager_events(
while i < events.len() {
match events[i] {
MobEvent::VillagerMeet { mob, other } => {
events.remove(i);
if let (Some(a), Some(b)) = (index(mobs, mob), index(mobs, other)) {
let meeting = villager::meet(mobs, a, b, w.game_time, |item| {
items.get(item).map_or(64, |i| i.max_stack)
@@ -322,8 +365,16 @@ fn villager_events(
meeting.thrown.is_empty(),
"villagers toss items: not modelled here"
);
if meeting.gossiped {
summon_golem(mobs, a, 5, w, types, born, level_random);
}
}
}
MobEvent::GolemCheck { mob, needed } => {
events.remove(i);
if let Some(a) = index(mobs, mob) {
summon_golem(mobs, a, needed, w, types, born, level_random);
}
}
MobEvent::VillagerBirth {
parent,
@@ -1167,6 +1218,7 @@ pub fn main() -> horizon_client::Result<()> {
&items,
&mut born,
s.time,
&mut scratch,
);
horizon_client::mobs::warden::hear_mob_events(&mut mobs, &events, from, &w);
// Melee hits on other mobs land during the attacker's tick
@@ -1323,6 +1375,31 @@ pub fn main() -> horizon_client::Result<()> {
world.set(at[0], at[1], at[2], registry.resolve(&st)?)?;
}
}
// Doors villagers opened or closed (DoorBlock.setOpen), both halves.
let doors: Vec<([i32; 3], bool)> = events
.iter()
.filter_map(|e| match e {
MobEvent::SetDoorOpen { pos, open } => Some((*pos, *open)),
_ => None,
})
.collect();
for (at, open) in doors {
let Some(half) = registry
.state(world.block(at[0], at[1], at[2]))
.and_then(|s| s.properties.get("half").cloned())
else {
continue;
};
let other = if half == "lower" { 1 } else { -1 };
for y in [at[1], at[1] + other] {
if let Some(mut st) = registry.state(world.block(at[0], y, at[2])).cloned()
&& st.properties.contains_key("open")
{
st.properties.insert("open".to_owned(), open.to_string());
world.set(at[0], y, at[2], registry.resolve(&st)?)?;
}
}
}
mobs.append(&mut born);
mobs.append(&mut added);
for e in &heart_events {
@@ -5,7 +5,7 @@
//!
//! usage: pack_screen_fixture RESOURCES CASES OUTPUT
use horizon_client::{
hud::HudAtlas,
hud::{HudAtlas, PACK_ICON_SIZE},
lang,
pack_screen::{Compat, PackInfo, PackScreen, Source},
packs::{self, Position},
@@ -13,7 +13,7 @@ use horizon_client::{
text::Text,
};
use serde_json::{Value, json};
use std::{fmt::Write as _, fs, path::PathBuf};
use std::{fmt::Write as _, fs, path::PathBuf, sync::Arc};
/// Styled runs: [text, rgb or null, bold, italic, underlined, strikethrough, obfuscated].
pub fn runs(t: &Text) -> Value {
@@ -105,7 +105,9 @@ pub fn main() -> horizon_client::Result<()> {
Some("unknown") => Compat::Unknown,
_ => Compat::Compatible,
},
icon: p["icon"].as_bool().unwrap_or(false),
icon: p["icon"].as_bool().unwrap_or(false).then(|| {
Arc::from(vec![0; (PACK_ICON_SIZE * PACK_ICON_SIZE * 4) as usize])
}),
required: p["required"].as_bool().unwrap_or(false),
position: if p["position"] == "top" {
Position::Top
@@ -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() {
@@ -136,7 +136,6 @@ fn scene(
blend_radius,
smooth_lighting,
cutout_leaves,
cull_leaves: false,
};
let section_y = int(&row["section_y"])?;
let started = Instant::now();
@@ -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);
}
@@ -147,7 +147,6 @@ const PAGES: Ty = Ty::Enum(&[
"graphics-advanced",
"extras",
"horizon",
"shader-packs",
"agent",
]);
+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());
}
+24
View File
@@ -133,6 +133,30 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
border.collision_shape().map(Some).map_err(io::Error::other)
}
}
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
/// `SceneCollisions::resolve` that also stops at the entity boxes.
pub fn resolve(
&mut self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = self.blocks.context(bounds);
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context,
bounds,
movement,
max_step,
on_ground,
candidate_budget: 1_000_000,
},
)?)
}
}
impl SceneCollisions<'_, '_> {
/// Position contexts permit geometric movement probes without inventing a
/// player, inventory, effects or fluid-standing behavior; a lava walker
+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,75 @@
//! `ConfirmScreen`: a title, a message wrapped to `width - 50` (15 lines at
//! most) and two 150-wide buttons 4 apart and 16 below it, the whole column
//! centred; Escape answers no.
use crate::{
font::Font,
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
pub struct Confirm {
/// Each line and where it is drawn.
pub lines: Vec<(Text, f32, f32)>,
/// The yes and no buttons' corners.
pub buttons: [(f32, f32); 2],
}
impl Confirm {
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.map(|(line, _)| line)
.take(15)
.collect();
let tw = font.width_text(title);
let mw = message
.iter()
.map(|l| font.width_text(l))
.fold(0.0, f32::max);
let mh = 9.0 * message.len() as f32;
let col = tw.max(mw).max(304.0);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut lines = vec![(title.clone(), cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
Self {
lines,
buttons: [(bx, by), (bx + 154.0, by)],
}
}
/// The answer a click at `cursor` gives: true for yes, false for no.
pub fn answer_at(&self, cursor: (f32, f32)) -> Option<bool> {
let [yes, no] = self.buttons;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if on(yes) {
Some(true)
} else if on(no) {
Some(false)
} else {
None
}
}
/// The lines and the buttons (yes first), over whatever is behind.
pub fn draw(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
) {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
}
}
}
+7 -7
View File
@@ -382,7 +382,7 @@ impl Providers {
);
Self { ints, floats }
}
fn eval(&self, v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
if depth > 16 {
return 0.0;
}
@@ -390,10 +390,10 @@ impl Providers {
Value::Number(n) => n.as_f64().unwrap_or(0.0),
Value::String(name) => table
.get(name)
.map_or(0.0, |v| self.eval(v, fast, table, depth + 1)),
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
Value::Object(o) => {
let f =
|k: &str| self.eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
match o.get("type").and_then(Value::as_str).unwrap_or("") {
"minecraft:div" => {
let r = f("right");
@@ -425,14 +425,14 @@ impl Providers {
}
/// A plain `context_int_provider` value or reference (no context conditions).
pub fn int_value(&self, v: &Value) -> i32 {
self.eval(v, false, &self.ints, 0) as i32
Self::eval(v, false, &self.ints, 0) as i32
}
/// A plain `context_float_provider` value or reference; `default` when absent.
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
if v.is_null() {
default
} else {
self.eval(v, false, &self.floats, 0) as f32
Self::eval(v, false, &self.floats, 0) as f32
}
}
#[cfg(test)]
@@ -446,14 +446,14 @@ impl Providers {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(0, |(time, _)| self.eval(time, fast, &self.ints, 0) as i32)
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
}
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(1.0, |(_, speed)| {
self.eval(speed, fast, &self.floats, 0) as f32
Self::eval(speed, fast, &self.floats, 0) as f32
})
}
}
+39
View File
@@ -425,6 +425,29 @@ fn split_id(id: &str) -> (String, String) {
None => ("minecraft".to_owned(), id.to_owned()),
}
}
/// The slot under the cursor, for the search tab's keys.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Hover {
Nothing,
/// A display slot (`isCreativeSlot`), showing an item or past the end.
Display {
filled: bool,
},
/// Any other slot: the hotbar row, or the inventory tab's slots.
Slot,
}
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: whether a
/// key is a hotbar swap on the hovered slot instead of text for the search
/// box. `numeric` is `getNumericKeyValue().isPresent()` (the digit and keypad
/// keys); `hotbar_key` is a `keyHotbarSlots` key. Over an empty display slot
/// the key types (`doQuickSwap` is false), and `checkHotbarKeyPressed` needs
/// a hovered slot and an empty cursor.
pub fn search_key_swaps(numeric: bool, hotbar_key: bool, hover: Hover, carrying: bool) -> bool {
let quick_swap = hover != Hover::Display { filled: false };
quick_swap && numeric && hotbar_key && !carrying && hover != Hover::Nothing
}
/// What a click on an item tab's display slot does besides changing the
/// carried stack.
#[derive(Clone, PartialEq, Debug)]
@@ -529,6 +552,22 @@ mod tests {
(carried, effect)
}
#[test]
fn search_tab_number_keys_swap_over_a_slot_before_typing() {
let filled = Hover::Display { filled: true };
let empty = Hover::Display { filled: false };
// Over an item, the hotbar row or another slot: the hotbar swap.
assert!(search_key_swaps(true, true, filled, false));
assert!(search_key_swaps(true, true, Hover::Slot, false));
// Over an empty display slot, nothing or with a carried stack: text.
assert!(!search_key_swaps(true, true, empty, false));
assert!(!search_key_swaps(true, true, Hover::Nothing, false));
assert!(!search_key_swaps(true, true, filled, true));
// Letters always type; so do the digits no hotbar key is bound to.
assert!(!search_key_swaps(false, false, filled, false));
assert!(!search_key_swaps(true, false, filled, false));
}
#[test]
fn display_slot_clicks_follow_slot_clicked() {
use crate::menu::Input::*;
@@ -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,
+3 -2
View File
@@ -102,7 +102,8 @@ enum Provider {
/// One font (`FontSet`): its providers and the glyphs resolved so far.
struct FontSet {
providers: Vec<Provider>,
glyphs: HashMap<char, Glyph>,
/// Every char drawn looks up here, so the hasher is Fx, not SipHash.
glyphs: crate::fast_hash::FxHashMap<char, Glyph>,
/// `glyphsByWidth` for obfuscated text (built on first use).
by_width: Option<HashMap<u32, Vec<char>>>,
}
@@ -647,7 +648,7 @@ impl Font {
.collect();
FontSet {
providers,
glyphs: HashMap::new(),
glyphs: Default::default(),
by_width: None,
}
}
File diff suppressed because it is too large. Load diff
+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);
+26 -2
View File
@@ -9,6 +9,10 @@ use horizon_client::{
block_entity_anim::Ticker,
chunk::{MIN_Y, SECTIONS},
items::ItemStack,
mobs::{
Species,
render::{PROFESSIONS, VILLAGER_TYPES},
},
widgets,
};
use horizon_core::geometry::Vec3;
@@ -335,6 +339,12 @@ impl Game<'_> {
Some(Ticker::Shulker { .. }) => v["lid"] = json!(self.block_anim(pos, 1.0).shulker.0),
_ => {}
}
// The lines of a sign, as this client has them.
if let Some(horizon_client::containers::BlockEntity::Sign(sign)) =
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
{
v["sign"] = json!({ "front": sign.front.lines, "back": sign.back.lines });
}
v
}
pub(super) fn stack_value(&self, s: &ItemStack) -> Value {
@@ -457,10 +467,14 @@ impl Game<'_> {
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
"on_ground": p.ground.on_ground,
"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).
"hurt_time": p.health.hurt_animation_time,
"air": p.air,
"food": p.health.food.food,
"saturation": p.health.food.saturation,
@@ -585,10 +599,20 @@ impl Game<'_> {
}
};
for m in self.mobs.iter().filter(|m| m.alive()) {
let mut v = json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time });
// VillagerData as this game knows it: on a client, what the
// server's synched data says.
if matches!(m.species(), Species::Villager | Species::ZombieVillager) {
v["villager"] = json!({
"type": VILLAGER_TYPES[m.extra.variant as usize % VILLAGER_TYPES.len()],
"profession": PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()],
"level": m.extra.variant3,
});
}
push(
[m.position.x, m.position.y, m.position.z],
m.kind.id.as_ref(),
json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time }),
v,
);
}
for item in &self.dropped {
@@ -59,6 +59,30 @@ impl Game<'_> {
}
/// Block commands; None when `words[0]` is not one of them.
pub(super) fn block_command(&mut self, words: &[&str]) -> Result<Option<Value>> {
let ours = matches!(
words.first(),
Some(&("setblock" | "fill" | "getblock" | "container"))
);
// Single player on its server thread: the world is the server's, so
// the command runs there, in the owner's level, and the client hears
// of it like any change.
if ours && let Some(server) = &self.integrated {
let owned: Vec<String> = words.iter().map(|w| (*w).to_owned()).collect();
return server
.call(move |g| {
let words: Vec<&str> = owned.iter().map(String::as_str).collect();
g.in_owner_level(|g| g.block_command(&words).map_err(|e| e.to_string()))
})
.ok_or("the integrated server stopped")?
.map_err(Into::into);
}
// Another server's world changes only through its own commands, which
// it checks as the player's; `getblock` and `container` read what this
// client has of it.
if self.remote.is_some() && matches!(words.first(), Some(&("setblock" | "fill"))) {
self.send_chat(&format!("/{}", words.join(" ")));
return Ok(Some(json!({ "ok": true, "sent": true })));
}
let int = |i: usize| -> Result<i32> {
Ok(words
.get(i)
@@ -0,0 +1,453 @@
//! Boats and the entities around them: what a boat runs into
//! (`AbstractBoat.canVehicleCollide`), the pushes between a boat and what
//! touches it (`AbstractBoat.push`, `LivingEntity.pushEntities`), the mobs
//! it picks up (the end of `AbstractBoat.tick`), where they sit
//! (`positionRider`, `clampRotation`) and how they get out.
//! Saving them under the boat's `Passengers` lives in `persist.rs`.
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
use horizon_client::{
camera::wrap_degrees,
mobs::{Mob, Species, Who, drops::is_animal, equine, riders},
scoreboard::pushable_by,
};
use horizon_core::geometry::{Aabb, Vec3};
/// `Entity.boardingCooldown` once a mob leaves its boat.
const BOARDING_COOLDOWN: i32 = 60;
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
/// feet are.
const PLAYER_RIDING_OFFSET: f64 = 0.6;
/// `Entity.isVehicle`: a player or a mob rides it.
fn carries(m: &Mob) -> bool {
m.rider.is_some() || m.extra.rider.is_some()
}
/// `isPushable`, as `pushableBy` and `canVehicleCollide` ask it:
/// `Mob::pushable` plus the shulkers it leaves out (they take no push, but
/// count), less a ridden horse (`AbstractHorse.isPushable`).
fn pushable(m: &Mob) -> bool {
!m.removed
&& (m.pushable() || m.species() == Species::Shulker && m.alive())
&& !(equine::is_equine(m.species()) && carries(m))
}
/// Whether `Entity.push` moves the mob: it is pushable and carries no one.
fn moved_by_push(m: &Mob) -> bool {
!m.removed && m.pushable() && !carries(m)
}
/// `Entity.push(Entity)`: the push `other` gets from `this` (which takes the
/// opposite one), or None when they stand on nearly the same spot.
pub(super) fn push_impulse(this: Vec3, other: Vec3) -> Option<Vec3> {
let (mut xa, mut za) = (other.x - this.x, other.z - this.z);
let mut dd = xa.abs().max(za.abs());
if dd < f64::from(0.01_f32) {
return None;
}
dd = dd.sqrt();
xa /= dd;
za /= dd;
let pow = (1.0 / dd).min(1.0);
let k = f64::from(0.05_f32);
Some(Vec3::new(xa * pow * k, 0.0, za * pow * k))
}
/// `AbstractBoat.push`: another boat is pushed while it is lower than this
/// boat's top, anything else while its feet are no higher than the boat's.
pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool {
if other_is_boat {
other.min_position().y < boat.max_position().y
} else {
other.min_position().y <= boat.min_position().y
}
}
impl Game<'_> {
/// The players in boat `id`: the window's player (or the server player
/// acting as it) and the server's other players.
pub(super) fn boat_players(&self, id: u32) -> usize {
let guests = self.lan.as_ref().map_or(0, |h| {
h.hub
.guests
.iter()
.filter(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(id))
.count()
});
usize::from(self.riding == Some(id)) + guests
}
/// The mobs in boat `id`, in their seat order after the players
/// (`Entity.addPassenger` puts a player first).
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
(0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
m.extra.mount == Some(id) && !m.removed && m.alive()
})
.collect()
}
/// `getPassengers().size()` of boat `id`.
pub(super) fn boat_passengers(&self, id: u32) -> usize {
self.boat_players(id) + self.boat_mobs(id).len()
}
/// `AbstractBoat.canAddPassenger`: a seat is free and the boat is not
/// under water.
pub(super) fn boat_has_room(&self, i: usize) -> bool {
let b = &self.boats[i];
self.boat_passengers(b.id) < b.max_passengers() && !b.under_water()
}
/// Where the player in boat `i` sits: in the middle alone, in front of a
/// mob.
pub(super) fn player_boat_seat(&self, i: usize) -> Vec3 {
let b = &self.boats[i];
let count = self.boat_players(b.id).max(1) + self.boat_mobs(b.id).len();
b.seat(0, count, false, PLAYER_RIDING_OFFSET, false)
}
/// `getRootVehicle` of mob `j`: the mob or boat at the bottom of what it
/// rides, None when it rides nothing.
fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
let mut root = None;
for _ in 0..8 {
let Some(v) = self.mobs[j].extra.mount else {
break;
};
root = Some(v);
match self.mobs.iter().position(|m| m.id == v) {
Some(k) => j = k,
None => break,
}
}
root
}
/// The players outside boat `id` that push and block it (living, not
/// spectators), with the window player's team.
fn players_outside(&self, id: u32) -> Vec<(Aabb, bool)> {
let mut out = Vec::new();
if let Some(p) = self.player.as_ref()
&& self.riding != Some(id)
&& !self.spectator()
&& !p.health.dead_or_dying()
{
out.push((p.bounds(), true));
}
if let Some(host) = self.lan.as_ref() {
for g in host.hub.guests.iter().filter(|g| {
Some(g.conn) != self.lan_acting
&& host.hub.here(g)
&& g.loaded
&& !g.dead
&& !g.spectator()
&& g.vehicle != Some(id)
}) {
out.push((guest_bounds(g), false));
}
}
out
}
/// `getEntityCollisions` of boat `id` within `area`, with
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
/// shulkers and pushable mobs and players, but not its own passengers.
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
let mut out: Vec<Aabb> = (0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
pushable(m)
&& m.bounds().intersects(area)
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
})
.map(|j| self.mobs[j].bounds())
.collect();
out.extend(
self.boats
.iter()
.filter(|b| b.id != id)
.map(Boat::bounds)
.chain(self.carts.iter().map(|c| c.bounds()))
.chain(self.players_outside(id).into_iter().map(|(bb, _)| bb))
.filter(|bb| bb.intersects(area)),
);
out
}
/// What boat `b` may run into this tick.
pub(super) fn boat_move_obstacles(&self, b: &Boat) -> Vec<Aabb> {
let reach = b.velocity.length() + 1.0;
self.boat_obstacles(b.id, b.bounds().inflate(Vec3::new(reach, reach, reach)))
}
/// `noEntityCollision(boat, box)` for a new boat (`BoatItem.use`).
pub(super) fn boat_entity_collision(&self, bb: Aabb) -> bool {
// EntityGetter.getEntityCollisions: the box grown by 1e-7.
let area = bb.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
!self.boat_obstacles(u32::MAX, area).is_empty()
}
/// The end of `AbstractBoat.tick` on the server for boat `i`: each
/// entity it touches gets in (a mob, while no player steers) or is
/// pushed apart from it, and `LivingEntity.pushEntities` of each mob and
/// player touching it pushes the two apart once more. Only the window's
/// own player is moved here: a server's players are pushed by their
/// clients, which tick the boats near them (`remote_pushes`).
pub(super) fn boat_contacts(&mut self, i: usize) {
let (id, bb, max) = {
let b = &self.boats[i];
(b.id, b.bounds(), b.max_passengers())
};
let area = bb.inflate(Vec3::new(
f64::from(0.2_f32),
f64::from(-0.01_f32),
f64::from(0.2_f32),
));
let mut count = self.boat_passengers(id);
// getControllingPassenger is a player: one rides it (players sit first).
let pick_up = self.boat_players(id) == 0;
// canAddPassenger, which startRiding asks after the checks below.
let room = !self.boats[i].under_water();
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
let teams = self.mob_teams();
let mobs: Vec<(usize, bool, bool)> = (0..self.mobs.len())
.filter_map(|j| {
let m = &self.mobs[j];
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
return None;
}
let mb = m.bounds();
let team = teams.of(Who::Mob(m.id));
let touched = mb.intersects(area) && pushable_by(None, team, true);
// Bats push nothing (Bat.pushEntities).
let pushes = m.species() != Species::Bat
&& mb.intersects(bb)
&& pushable_by(team, None, true);
(touched || pushes).then_some((j, touched, pushes))
})
.collect();
let player_team = teams.of(Who::Player);
let players: Vec<(Aabb, bool, bool, bool)> = self
.players_outside(id)
.into_iter()
.map(|(pb, own)| {
let team = if own { player_team } else { None };
(
pb,
own,
pb.intersects(area) && pushable_by(None, team, true),
pb.intersects(bb) && pushable_by(team, None, true),
)
})
.collect();
drop(teams);
for (j, touched, pushes) in mobs {
if touched {
let m = &self.mobs[j];
let boards = pick_up
&& count < max
&& m.extra.mount.is_none()
&& m.width() < super::boats::WIDTH as f32
&& !self
.res
.mob_data()
.entity_has("minecraft:cannot_be_pushed_onto_boats", &m.kind.id);
if boards {
// startRiding fails quietly (no push) while the mob's
// boarding cooldown runs (Entity.canRide) or the boat
// is under water.
if room && m.extra.boarding_cooldown <= 0 {
let m = &mut self.mobs[j];
m.extra.mount = Some(id);
m.extra.passenger = true;
m.ai.nav.stop();
let mob = m.id;
count += 1;
// Mob.startRiding drops its lead.
self.break_leash(mob, true, false);
}
continue;
}
self.boat_push_mob(i, j, count);
}
if pushes {
self.boat_push_mob(i, j, count);
}
}
for (pb, own, touched, pushes) in players {
let Some(v) = push_impulse(self.boats[i].position, pb.center())
.filter(|_| boat_pushes(bb, pb, false))
else {
continue;
};
for _ in 0..usize::from(touched) + usize::from(pushes) {
if count == 0 {
let b = &mut self.boats[i];
b.velocity = b.velocity.subtract(v);
}
if own && let Some(p) = self.player.as_mut() {
p.velocity = p.velocity.add_vector(v);
}
}
}
for k in 0..self.boats.len() {
let ob = self.boats[k].bounds();
if k == i || !ob.intersects(area) || !boat_pushes(bb, ob, true) {
continue;
}
let Some(v) = push_impulse(self.boats[i].position, self.boats[k].position) else {
continue;
};
if count == 0 {
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
}
if self.boat_passengers(self.boats[k].id) == 0 {
self.boats[k].velocity = self.boats[k].velocity.add_vector(v);
}
}
let guest_carts = self.guest_vehicles();
for c in 0..self.carts.len() {
let cb = self.carts[c].bounds();
if !cb.intersects(area) || !boat_pushes(bb, cb, false) {
continue;
}
let Some(v) = push_impulse(self.boats[i].position, self.carts[c].position) else {
continue;
};
if count == 0 {
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
}
let cid = self.carts[c].id;
let ridden = self.riding == Some(cid)
|| guest_carts.contains(&cid)
|| self.mobs.iter().any(|m| m.extra.mount == Some(cid));
if !ridden {
self.carts[c].velocity = self.carts[c].velocity.add_vector(v);
}
}
}
/// `AbstractBoat.push(mob)` from boat `i`, which has `count` passengers.
fn boat_push_mob(&mut self, i: usize, j: usize, count: usize) {
let (b, m) = (&self.boats[i], &self.mobs[j]);
if !boat_pushes(b.bounds(), m.bounds(), false) {
return;
}
let Some(v) = push_impulse(b.position, m.position) else {
return;
};
if count == 0 {
let b = &mut self.boats[i];
b.velocity = b.velocity.subtract(v);
}
let m = &mut self.mobs[j];
if moved_by_push(m) {
m.velocity = m.velocity.add_vector(v);
m.needs_sync = true;
}
}
/// `positionRider` and `clampRotation` for the mobs in boats: each sits
/// in its seat facing the way the boat does, an animal in a full boat
/// sideways, its head kept within its reach of its body.
pub(super) fn seat_boat_mobs(&mut self) {
for i in 0..self.boats.len() {
let id = self.boats[i].id;
let mobs = self.boat_mobs(id);
if mobs.is_empty() {
continue;
}
let players = self.boat_players(id);
let count = players + mobs.len();
let b = &self.boats[i];
let full = count == b.max_passengers();
let delta = b.delta_rotation;
let seats: Vec<_> = mobs
.iter()
.enumerate()
.map(|(n, &j)| {
let m = &self.mobs[j];
let animal = is_animal(m.species());
// Another player, on a server's client.
let offset = if &*m.kind.id == "minecraft:player" {
PLAYER_RIDING_OFFSET
} else {
riders::vehicle_attachment(m)
};
let index = players + n;
// calculatePassengerBodyYRot.
let turn = match (animal && full, m.id.is_multiple_of(2)) {
(false, _) => 0.0,
(true, true) => 90.0,
(true, false) => 270.0,
};
let turns = !self
.res
.mob_data()
.entity_has("minecraft:can_turn_in_boats", &m.kind.id);
(
j,
b.seat(index, count, animal, offset, false),
b.seat(index, count, animal, offset, true),
turns.then_some((b.yaw + turn, b.previous_yaw + turn)),
)
})
.collect();
for (j, at, previous, facing) in seats {
let m = &mut self.mobs[j];
m.position = at;
m.previous = previous;
m.velocity = Vec3::ZERO;
m.fall_distance = 0.0;
m.on_ground = false;
m.extra.passenger = true;
m.ai.nav.stop();
let Some((body, previous_body)) = facing else {
continue;
};
m.head_yaw += delta;
m.body_yaw = body;
m.previous_body_yaw = previous_body;
m.yaw = body;
m.previous_yaw = previous_body;
// Mob.clampHeadRotationToBody.
let limit = m.kind.max_head_y_rot;
let d = wrap_degrees(body - m.head_yaw);
m.head_yaw += d - d.clamp(-limit, limit);
}
}
}
/// `ejectPassengers` for the mobs in boat `i`: each gets out where
/// `getDismountLocationForPassenger` puts it (beside the boat when it
/// broke: `LivingEntity.dismountVehicle`) and can't board for a while.
pub(super) fn eject_boat_mobs(&mut self, i: usize, broken: bool) {
let b = &self.boats[i];
let (id, y) = (b.id, b.position.y);
for j in self.boat_mobs(id) {
let m = &self.mobs[j];
let at = if broken {
Vec3::new(m.position.x, m.position.y.max(y), m.position.z)
} else {
self.boat_dismount_target(i, f64::from(m.width()), m.yaw)
};
let m = &mut self.mobs[j];
m.extra.mount = None;
m.extra.passenger = false;
m.extra.boarding_cooldown = BOARDING_COOLDOWN;
m.position = at;
m.previous = at;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_is_capped_and_scaled() {
// One block apart on x: the full 0.05 along x.
let v = push_impulse(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)).unwrap();
assert!((v.x - f64::from(0.05_f32)).abs() < 1e-12 && v.z == 0.0);
// Almost on the same spot: no push.
assert!(push_impulse(Vec3::ZERO, Vec3::new(0.005, 0.0, 0.0)).is_none());
}
#[test]
fn boats_push_by_height() {
let boat = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(1.0, 64.5625, 1.0));
let at = |y: f64| Aabb::new(Vec3::new(0.0, y, 0.0), Vec3::new(1.0, y + 1.0, 1.0));
assert!(boat_pushes(boat, at(64.0), false));
assert!(!boat_pushes(boat, at(64.1), false));
assert!(boat_pushes(boat, at(64.5), true));
assert!(!boat_pushes(boat, at(64.5625), true));
}
}
+169 -27
View File
@@ -5,10 +5,10 @@
use super::{Game, Keys, entity_culling::at, view_vector};
use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
collisions::{EntityCollisions, SceneCollisions},
items::ItemStack,
mesher::Vertex,
mobs::{Part, anim::Poses},
mobs::{Part, anim::Poses, riders},
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -54,10 +54,8 @@ pub(super) struct Boat {
}
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
const WIDTH: f64 = 1.375;
pub(super) const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
pub(super) const SEAT: f64 = 0.1875 - 0.6;
impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
@@ -98,6 +96,54 @@ impl Boat {
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
)
}
/// `isUnderWater`; it stands in for `canAddPassenger`'s
/// `isEyeInFluid(WATER)` too.
pub fn under_water(&self) -> bool {
matches!(self.status, Status::UnderWater | Status::UnderFlowingWater)
}
/// `getMaxPassengers`: two, one beside a chest.
pub fn max_passengers(&self) -> usize {
if self.chest { 1 } else { 2 }
}
/// `rideHeight`: a third of a boat's height, 8/9 of a raft's.
fn ride_height(&self) -> f64 {
let height = HEIGHT as f32;
f64::from(if &*self.wood == "bamboo" {
height * 0.888_888_9
} else {
height / 3.0
})
}
/// Where the passenger at `index` of `count` stands, at the boat's current
/// (or, with `previous`, last) place: `getPassengerAttachmentPoint`
/// turned with the boat, less the passenger's `rider_offset`.
pub fn seat(
&self,
index: usize,
count: usize,
animal: bool,
rider_offset: f64,
previous: bool,
) -> Vec3 {
let mut z = if self.chest { 0.15f32 } else { 0.0 };
if count > 1 {
z = if index == 0 { 0.2 } else { -0.6 };
if animal {
z += 0.2;
}
}
let (at, yaw) = if previous {
(self.previous, self.previous_yaw)
} else {
(self.position, self.yaw)
};
riders::seat(
[0.0, self.ride_height(), f64::from(z)],
rider_offset,
at,
yaw,
)
}
/// `getRowingTime`.
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
if self.paddling[side] {
@@ -183,13 +229,18 @@ impl BoatModels {
}
impl Game<'_> {
fn fluid_height(&self, x: i32, y: i32, z: i32) -> Option<(f32, bool)> {
self.fluid_height_of(x, y, z, Some(Fluid::Water))
}
/// `FluidState.getHeight` and whether it is a source, for `fluid` or,
/// with `None`, any fluid (`ClipContext.Fluid.ANY`).
fn fluid_height_of(&self, x: i32, y: i32, z: i32, fluid: Option<Fluid>) -> Option<(f32, bool)> {
let f = self.res.facts().get(self.world.block(x, y, z));
if f.fluid != Fluid::Water {
if f.fluid == Fluid::None || fluid.is_some_and(|want| f.fluid != want) {
return None;
}
let above = self.res.facts().get(self.world.block(x, y + 1, z));
// FluidState.getHeight: 1 under the same fluid, else amount / 9.
let height = if above.fluid == Fluid::Water {
let height = if above.fluid == f.fluid {
1.0
} else {
f32::from(f.fluid_level) / 9.0
@@ -315,6 +366,7 @@ impl Game<'_> {
let mut vspeed = -0.04;
let mut buoyancy = 0.0;
let inv_friction: f32;
let obstacles = self.boat_move_obstacles(&b);
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
@@ -396,6 +448,11 @@ impl Game<'_> {
b.paddling = [right && !left || up, left && !right || up];
}
// Entity.move: collisions zero the blocked components.
let mut scene = EntityCollisions {
blocks: scene,
entities: &obstacles,
border: None,
};
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
b.position = Vec3::new(
b.position.x + moved.x,
@@ -461,6 +518,72 @@ impl Game<'_> {
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
}
// AbstractBoat.tick's applyEffectsFromBlocks, on the server for every
// boat, the client-driven ones too.
let mut i = 0;
while i < self.boats.len() {
let p = self.boats[i].position;
let skip = Some(self.boats[i].id) == self.riding
|| !ticking(p.x.floor() as i32, p.z.floor() as i32);
if skip || !self.boat_lava_tick(i) {
i += 1;
}
}
// The rest of AbstractBoat.tick on the server, for every boat: the
// passengers of one under water too long get out, and it picks up or
// pushes what it touches.
for i in 0..self.boats.len() {
let p = self.boats[i].position;
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
continue;
}
if self.boats[i].out_of_control >= 60.0 {
self.eject_boat_mobs(i, false);
}
self.boat_contacts(i);
}
}
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
/// damage, so lava breaks a boat on its first tick inside, and the fire
/// `lavaIgnite` lights never gets to burn it. True when the boat broke.
fn boat_lava_tick(&mut self, i: usize) -> bool {
if !self.in_lava(self.boats[i].bounds()) {
return false;
}
for _ in 0..2 {
let p = self.boats[i].position;
let broke = self.hurt_boat(i, 4.0, false);
self.lava_hurt_sound([p.x, p.y, p.z]);
if broke {
return true;
}
}
false
}
/// `Entity.lavaHurt`'s sound once the hurt went through.
pub(super) fn lava_hurt_sound(&mut self, at: [f64; 3]) {
let pitch = 2.0 + self.world.random.next_float() * 0.4;
self.audio
.play("minecraft:entity.generic.burn", Some(at), 0.4, pitch);
}
/// Whether a vehicle's box, deflated by 1e-5, reaches into lava below its
/// surface (`Entity.collidedWithFluid`).
pub(super) fn in_lava(&self, bb: Aabb) -> bool {
let (lo, hi) = (bb.min_position(), bb.max_position());
let e = 1.0e-5;
for x in (lo.x + e).floor() as i32..=(hi.x - e).floor() as i32 {
for y in (lo.y + e).floor() as i32..=(hi.y - e).floor() as i32 {
for z in (lo.z + e).floor() as i32..=(hi.z - e).floor() as i32 {
if let Some((height, _)) = self.fluid_height_of(x, y, z, Some(Fluid::Lava))
&& lo.y + e < f64::from(y) + f64::from(height)
{
return true;
}
}
}
}
false
}
/// The player's tick while riding: steer, tick the boat, sit in it.
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
@@ -482,22 +605,35 @@ impl Game<'_> {
self.yaw += b.delta_rotation;
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
self.yaw += delta.clamp(-105.0, 105.0) - delta;
self.boats[i] = b;
let seat = self.player_boat_seat(i);
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = Vec3::new(b.position.x, b.position.y + SEAT, b.position.z);
p.position = seat;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
self.boats[i] = b;
if self.boat_lava_tick(i) {
self.riding = None;
}
Ok(())
}
/// `getDismountLocationForPassenger`: in front of the player if there is
/// floor there, else on top of the boat.
pub(super) fn dismount(&mut self, i: usize) {
self.riding = None;
let target = self.boat_dismount_target(i, 0.6, self.yaw);
if let Some(p) = self.player.as_mut() {
p.position = target;
p.velocity = Vec3::ZERO;
}
}
/// `getDismountLocationForPassenger` for a passenger `width` wide
/// facing `yaw`.
pub(super) fn boat_dismount_target(&self, i: usize, width: f64, yaw: f32) -> Vec3 {
let b = &self.boats[i];
let r = self.yaw.to_radians();
let distance = (WIDTH * std::f64::consts::SQRT_2 + 0.6 + 1.0e-5) / 2.0;
let r = yaw.to_radians();
let distance = (WIDTH * std::f64::consts::SQRT_2 + width + 1.0e-5) / 2.0;
let (s, c) = (-(r.sin()) as f64, r.cos() as f64);
let m = s.abs().max(c.abs());
let (x, z) = (
@@ -508,17 +644,13 @@ impl Game<'_> {
let (bx, by, bz) = (x.floor() as i32, top.floor() as i32, z.floor() as i32);
let below = self.res.facts().get(self.world.block(bx, by - 1, bz));
let here = self.res.facts().get(self.world.block(bx, by, bz));
let target = if below.fluid != Fluid::Water
if below.fluid != Fluid::Water
&& !here.full_collision
&& (below.full_collision || below.legacy_solid)
{
Vec3::new(x, f64::from(by), z)
} else {
Vec3::new(b.position.x, top, b.position.z)
};
if let Some(p) = self.player.as_mut() {
p.position = target;
p.velocity = Vec3::ZERO;
}
}
/// `BoatItem.use`: aim at a block or the water surface.
@@ -534,7 +666,8 @@ impl Game<'_> {
};
let dir = view_vector(self.yaw, self.pitch);
let reach = self.reach();
// ClipContext.Fluid.ANY: the first water surface or block along the ray.
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
// block along the ray.
let block = self.pick().map(|h| {
(
h.point,
@@ -544,7 +677,7 @@ impl Game<'_> {
.sqrt(),
)
});
let mut water = None;
let mut fluid = None;
let mut t = 0.0;
while t < reach {
let p: [f64; 3] = std::array::from_fn(|a| eye[a] + dir[a] * t);
@@ -553,15 +686,15 @@ impl Game<'_> {
p[1].floor() as i32,
p[2].floor() as i32,
);
if let Some((h, _)) = self.fluid_height(x, y, z)
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
&& p[1] <= f64::from(y) + f64::from(h)
{
water = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
fluid = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
break;
}
t += 0.05;
}
let hit = match (block, water) {
let hit = match (block, fluid) {
(Some(b), Some(w)) => {
if w.1 < b.1 {
w.0
@@ -594,7 +727,7 @@ impl Game<'_> {
binding: self.collision,
subject: None,
};
if !scene.unobstructed(inner).unwrap_or(false) {
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
return false;
}
self.next_entity += 1;
@@ -649,7 +782,7 @@ impl Game<'_> {
return false;
}
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
let b = &self.boats[i];
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
self.world.block_entities.insert(
@@ -666,6 +799,7 @@ impl Game<'_> {
|| self.boats[i].out_of_control >= 60.0
|| self.riding.is_some()
|| self.ridden_by_guest(self.boats[i].id)
|| !self.boat_has_room(i)
{
return false;
}
@@ -682,15 +816,24 @@ impl Game<'_> {
return false;
}
self.vehicle_attack_sounds();
self.hurt_boat(i, damage, self.creative);
self.swing_arm();
true
}
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
/// (`creative`: a creative player's hit removes it without drops).
/// True when the boat was removed.
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
let b = &mut self.boats[i];
b.hurt_dir = -b.hurt_dir;
b.hurt_time = 10;
b.damage += damage * 10.0;
let destroyed = self.creative || b.damage > 40.0;
let destroyed = creative || b.damage > 40.0;
if destroyed {
self.eject_boat_mobs(i, true);
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
if !self.creative && self.ui.rules.bool("entity_drops") {
if !creative && self.ui.rules.bool("entity_drops") {
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
for stack in b.items.iter().flatten() {
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
@@ -709,8 +852,7 @@ impl Game<'_> {
);
}
}
self.swing_arm();
true
destroyed
}
/// AbstractBoatRenderer.
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
+124 -29
View File
@@ -3,9 +3,10 @@
//! pixels, 1024 characters, 100 pages), signing it into a written book,
//! reading written books, and lecterns (placing a book, turning its pages
//! with a redstone pulse, the comparator output and taking the book).
use super::Game;
use super::{Game, lan::LanHost};
use horizon_client::{
containers::{BlockEntity, Lectern},
first_person::Hand,
hud::{HudBatch, rgba},
item_data::WrittenBook,
items::ItemStack,
@@ -14,6 +15,7 @@ use horizon_client::{
ui,
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use horizon_world::level::flags as level_flags;
use std::time::Instant;
use winit::{
@@ -48,9 +50,32 @@ impl Game<'_> {
fn book_lines(&self, page: &str) -> Vec<Text> {
self.gui.atlas.font.split(&Text::literal(page), 114.0)
}
/// `WritableBookItem.use` / `WrittenBookItem.use`.
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
/// A server's player writes in its own client's editor
/// (`LocalPlayer.openItemGui`), and reads on its own client once told to
/// (`ServerPlayer.openItemGui`'s ClientboundOpenBookPacket).
pub(super) fn use_book(&mut self, stack: &ItemStack) -> bool {
let screen = match &*stack.item {
let written = match &*stack.item {
"minecraft:writable_book" => false,
"minecraft:written_book" => true,
_ => return false,
};
if self.lan_acting.is_some() {
if written {
let hand = i32::from(self.hand == Hand::Off);
self.guest_packets
.push(C::OpenBook(play::OpenBook { hand }));
}
} else if let Some(screen) = self.book_screen(stack) {
self.open_book(screen);
}
self.swing_arm();
true
}
/// The screen a book in hand opens: `BookEditScreen` for a book and
/// quill, `BookViewScreen` for a written book.
fn book_screen(&self, stack: &ItemStack) -> Option<BookScreen> {
Some(match &*stack.item {
"minecraft:writable_book" => {
let mut pages = stack
.data()
@@ -77,12 +102,31 @@ impl Game<'_> {
page: 0,
lectern: None,
},
_ => return false,
};
self.open_book(screen);
self.swing_arm();
_ => return None,
})
}
/// `MultiPlayerGameMode.useItem`'s own use of a book and quill on a
/// server's client: its editor opens here at once.
pub(super) fn remote_use_book(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:writable_book" {
return false;
}
if let Some(screen) = self.book_screen(stack) {
self.open_book(screen);
}
true
}
/// `ClientPacketListener.handleOpenBook`: the written book in that hand.
pub(super) fn remote_open_book(&mut self, hand: i32) {
let hand = if hand == 1 { Hand::Off } else { Hand::Main };
let screen = self.with_hand(hand, |g| {
let stack = g.held().filter(|s| &*s.item == "minecraft:written_book")?;
g.book_screen(&stack.clone())
});
if let Some(screen) = screen {
self.open_book(screen);
}
}
fn open_book(&mut self, screen: BookScreen) {
self.book = Some(screen);
self.keys.clear();
@@ -104,7 +148,9 @@ impl Game<'_> {
self.book = None;
self.capture(true);
}
/// `BookEditScreen.saveChanges`: trailing empty pages dropped.
/// `BookEditScreen.saveChanges` and `BookSignScreen`'s Sign and Close:
/// trailing empty pages dropped, the book in hand updated here and, on a
/// server's client, sent (ServerboundEditBookPacket).
fn save_book(&mut self, sign: bool) {
let Some(BookScreen::Edit {
slot, pages, title, ..
@@ -116,29 +162,59 @@ impl Game<'_> {
while pages.len() > 1 && pages.last().is_some_and(String::is_empty) {
pages.pop();
}
if let Some(stack) = self.stack_in_mut(slot).as_mut()
&& &*stack.item == "minecraft:writable_book"
{
if sign {
let mut written = ItemStack::new("minecraft:written_book", 1);
written.data_mut().written = Some(WrittenBook {
title: title.trim().to_owned(),
author: super::chat::PLAYER_NAME.to_owned(),
generation: 0,
pages,
});
*stack = written;
} else {
stack.data_mut().writable_pages = if pages.iter().all(String::is_empty) {
Vec::new()
} else {
pages
};
stack.tidy();
}
let title = sign.then(|| title.trim().to_owned());
if self.remote.is_some() {
self.send(S::EditBook(play::EditBook {
slot: slot as i32,
pages: pages.clone(),
title: title.clone(),
}));
}
self.write_book(slot, pages, title);
self.capture(true);
}
/// The book and quill at `slot` takes the pages, or with a title becomes
/// the written book its writer signed.
fn write_book(&mut self, slot: usize, pages: Vec<String>, title: Option<String>) {
let author = self
.lan_acting_name
.clone()
.unwrap_or_else(|| super::chat::PLAYER_NAME.to_owned());
let Some(stack) = self
.stack_in_mut(slot)
.as_mut()
.filter(|s| &*s.item == "minecraft:writable_book")
else {
return;
};
if let Some(title) = title {
let mut written = ItemStack::new("minecraft:written_book", 1);
written.data_mut().written = Some(WrittenBook {
title,
author,
generation: 0,
pages,
});
*stack = written;
} else {
stack.data_mut().writable_pages = if pages.iter().all(String::is_empty) {
Vec::new()
} else {
pages
};
stack.tidy();
}
}
/// `ServerGamePacketListenerImpl.handleEditBook`: a guest's book in a
/// hotbar slot or its off hand (`updateBookContents`, `signBook`).
pub(super) fn lan_edit_book(&mut self, host: &mut LanHost, conn: u32, p: play::EditBook) {
if !((0..9).contains(&p.slot) || p.slot == super::shield::OFFHAND_SLOT as i32) {
return;
}
self.as_guest(host, conn, |g| {
g.write_book(p.slot as usize, p.pages, p.title)
});
}
pub(super) fn book_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
return;
@@ -519,7 +595,26 @@ impl Game<'_> {
Some(key) => self.lectern_button(key, 3),
None => return Err("not a lectern".into()),
},
Some("done") => self.close_book(),
// The book and quill's buttons: Sign, then Sign and Close; Done
// (which saves), then Cancel.
Some("sign") => match self.book.as_mut() {
Some(BookScreen::Edit {
signing: true,
title,
..
}) if !title.trim().is_empty() => self.save_book(true),
Some(BookScreen::Edit { signing, .. }) => *signing = true,
_ => return Err("not a book and quill".into()),
},
Some("done") => match self.book.as_mut() {
Some(BookScreen::Edit { signing: true, .. }) => {
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = false;
}
}
Some(BookScreen::Edit { .. }) => self.save_book(false),
_ => self.close_book(),
},
Some(other) => return Err(format!("unknown book button {other}").into()),
}
Ok(match &self.book {
+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,
}
+15 -8
View File
@@ -1,6 +1,6 @@
//! Chat, the command registries and the per-world state behind commands
//! (game rules, the world clock, difficulty), bound to the game.
use super::{Dim, Game, Screen};
use super::{Game, Screen};
use horizon_client::{
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
command::Registries,
@@ -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;
}
@@ -695,10 +701,7 @@ impl Game<'_> {
/// Advance the overworld clock (ServerClockManager.tick) when
/// `advance_time` allows it.
pub(super) fn advance_clock(&mut self) {
if !self.ui.rules.bool("advance_time")
|| self.ui.clock.paused
|| self.dim == Dim::Nether && false
{
if !self.ui.rules.bool("advance_time") || self.ui.clock.paused {
return;
}
let c = &mut self.ui.clock;
@@ -819,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).
+34 -20
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 {
@@ -1783,8 +1794,11 @@ impl Game<'_> {
self.level_set(pos, state)
.map_err(|e| CommandError::new(Text::literal(e.to_string())))?;
}
// LevelChunk.setBlockState creates it when the block, not the state, is still
// there after onPlace: a powered hopper turns itself `enabled=false`.
let landed = self.world.block(pos[0], pos[1], pos[2]);
if old != new
&& self.world.block(pos[0], pos[1], pos[2]) == state
&& self.res.facts().get(landed).name == new
&& let Some(entity) = horizon_client::containers::BlockEntity::for_block(&new)
{
self.world.block_entities.insert(key, entity);
@@ -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",
+1 -1
View File
@@ -128,7 +128,7 @@ impl Game<'_> {
if self.state_in_tag(avoid, ground) {
return false;
}
let entities = self.happy_ghast_player_collisions();
let entities = self.player_entity_collisions();
let Some(player) = self.player.as_mut() else {
return false;
};
+28 -3
View File
@@ -226,12 +226,29 @@ impl Game<'_> {
}
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
}
"section-sides" => {
let r = self.renderer.as_mut().ok_or("no renderer")?;
match words.get(1).copied() {
None => {}
Some("on") => r.set_section_sides(true),
Some("off") => r.set_section_sides(false),
Some(other) => return Err(format!("unknown section sides mode {other}").into()),
}
Ok(Some(json!({ "ok": true, "sides": r.section_sides() })))
}
"sculk" => Ok(Some(self.sculk_command(&words)?)),
"status" => Ok(Some(self.control_status())),
"pad" => Ok(Some(self.pad_command(&words)?)),
"key" => {
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
self.require_world()?;
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
if self.sign_edit.is_some() {
if arg(2)? != "up" {
self.sign_input(Some(key), None);
}
return ok();
}
// A held edit modifier (`key super down`) and A/C/V/X: the
// open book's Select All, Copy, Paste or Cut.
if self.book.is_some() && arg(2)? != "up" {
@@ -496,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)
}
@@ -524,6 +544,10 @@ impl Game<'_> {
self.book_type(text);
return ok();
}
if self.sign_edit.is_some() {
self.sign_input(None, Some(text));
return ok();
}
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
@@ -549,7 +573,7 @@ impl Game<'_> {
}
_ => {
return Err(
"type needs the chat screen (key t tap), the language search, the creative search, the anvil or World Options".into(),
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
);
}
}
@@ -578,7 +602,6 @@ impl Game<'_> {
"credits" => Page::Credits,
"horizon" => Page::Horizon,
"agent" => Page::Agent,
"shader-packs" => Page::ShaderPacks,
other => return Err(format!("unknown options page {other}").into()),
};
if self.screen == Screen::Playing && self.title.is_none() {
@@ -971,12 +994,14 @@ 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],
"flashes": self.lightning_seen[1], "flash": self.flash});
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
status["projectiles"] = json!(self.projectiles_status());
status["maps"] = json!(self.maps_status());
// The particles made so far, by type.
status["particles"] = json!(self.particles.made());
// The world border this side knows of.
+21 -8
View File
@@ -47,6 +47,8 @@ pub(super) struct Creatures {
pub pearl_cooldown: u32,
/// The wind charge's use cooldown (ticks).
pub wind_cooldown: u32,
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
pub wind_bursts: Vec<super::wind::Burst>,
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
@@ -976,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);
@@ -1005,6 +1009,11 @@ impl Game<'_> {
MobEvent::SetBlock { pos, state } => {
self.world.set(pos[0], pos[1], pos[2], state)?
}
MobEvent::SetDoorOpen { pos, open } => {
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
self.process_level_events()?;
}
MobEvent::SetBlockName { pos, name, effect } => {
if effect {
let old = self.world.block(pos[0], pos[1], pos[2]);
@@ -1489,12 +1498,16 @@ impl Game<'_> {
o.hurt
}
{
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
self.knock_back_player(f64::from(0.4_f32), xd, zd);
if let Some(p) = self.player.as_mut()
&& let Some(r) = p.hurt_log.last_mut()
{
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
// hurt cooldown (a stronger hit there only adds its difference).
if o.knockback {
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
self.knock_back_player(f64::from(0.4_f32), xd, zd);
if let Some(p) = self.player.as_mut()
&& let Some(r) = p.hurt_log.last_mut()
{
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
}
}
self.hurt_armor(amount);
self.thorns(from);
@@ -1778,7 +1791,7 @@ impl Game<'_> {
_ => self.projectile_hit(&p, hit)?,
}
}
Ok(())
self.flush_wind_bursts()
}
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
/// as the host's when they have left.
+13 -6
View File
@@ -38,7 +38,7 @@ impl DebugHost for Host<'_, '_> {
// the menu as it always has (key_pressed).
let g = &mut *self.game;
if without_menu && g.screen == Screen::Playing {
g.pause_menu = !(g.has_singleplayer_server() && !g.is_published());
g.pause_menu = !g.has_singleplayer_server() || g.is_published();
g.screen = Screen::Paused;
g.capture(false);
g.mining = None;
@@ -82,8 +82,10 @@ impl Game<'_> {
}
return true;
}
// Screens with a text box take every key press first
// (EditBox.keyPressed); releases still reach KeyboardHandler.
// Vanilla types into a focused EditBox through charTyped, which no
// debug key can swallow. Here the character comes with the key press,
// so a screen with a focused text box takes every press first;
// releases still reach KeyboardHandler.
let typing = matches!(
self.screen,
Screen::Chat | Screen::Options | Screen::WorldOptions | Screen::CommandBlock
@@ -91,7 +93,11 @@ impl Game<'_> {
|| self.book.is_some()
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|| self.recipe_search_focused()
|| self.station_typing();
|| self.station_typing()
|| self
.title
.as_mut()
.is_some_and(|t| t.focused_field().is_some());
// Container screens use Escape, the inventory key and the hotbar and
// drop keys first; any other screen closes on Escape.
let screen_key = match self.screen {
@@ -179,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());
}
}
@@ -593,7 +601,6 @@ impl Game<'_> {
DebugAction::ReloadResourcePacks => {
self.debug_translated("debug.reload_resourcepacks.message");
self.reload_packs();
self.load_shader_pack();
true
}
DebugAction::CopyLocation if player => {
+15 -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;
}
@@ -608,6 +609,7 @@ impl Game<'_> {
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
log_ips: props.log_ips,
}),
resource_pack: props.resource_pack.clone(),
};
let config = HubConfig {
default_game_mode: game_mode,
+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 {
@@ -121,11 +121,13 @@ impl Game<'_> {
let bonus = if knockback_attack { 0.5 } else { 0.0 };
(enchanted / 2.0 + bonus, knockback_attack)
}
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)`: the target
/// flies where the attacker looks, not away from it.
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)` from Mth's
/// sine table: the target flies where the attacker looks, not away from
/// it (straight along an axis when the attacker faces one).
pub(super) fn extra_knockback_direction(&self) -> (f64, f64) {
let yaw = self.yaw.to_radians();
(f64::from(yaw.sin()), -f64::from(yaw.cos()))
use horizon_core::geometry::trig;
let yaw = f64::from(self.yaw * trig::DEG_TO_RAD);
(f64::from(trig::sin(yaw)), -f64::from(trig::cos(yaw)))
}
/// The attacker's side of `causeExtraKnockback` as the attacking client
/// sees it: the server's player stops sprinting, and its flag reaches the
@@ -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));
}
}
}
+12 -6
View File
@@ -69,12 +69,18 @@ impl Game<'_> {
let lo = Vec3::new(f.pos[0] - half, f.pos[1], f.pos[2] - half);
let hi = Vec3::new(f.pos[0] + half, f.pos[1] + 0.8, f.pos[2] + half);
let b = horizon_core::geometry::Aabb::new(lo, hi);
if let Some(p) = self.player_view()
&& p.alive
&& p.bounds.intersects(b)
{
self.mob_hits_player_kind(6.0, f.pos, "minecraft:indirect_magic");
}
// Every player in the box, the server's players too.
let pos = f.pos;
let bite = move |g: &mut Self| {
if let Some(p) = g.player_view()
&& p.alive
&& p.bounds.intersects(b)
{
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
}
};
bite(self);
self.each_guest_hit(bite);
for m in &mut self.mobs {
let ally = matches!(
m.species(),
+241 -41
View File
@@ -2,6 +2,7 @@
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
use super::{Game, view_vector};
use horizon_client::lan::{entity::States, hub::Hub};
use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
@@ -15,7 +16,10 @@ use horizon_core::{
geometry::{Aabb, Vec3, trig},
random::RandomSource,
};
use std::sync::Arc;
use horizon_protocol::entity_data::{Value, serializer};
use std::{collections::HashMap, sync::Arc};
use super::lan_actions::GuestPlay;
#[derive(Clone, Copy, Debug, PartialEq)]
enum State {
@@ -24,6 +28,8 @@ enum State {
Hooked(u32),
}
pub(super) struct Hook {
/// Its entity id, which a server's players track it under.
id: u32,
position: Vec3,
previous: Vec3,
velocity: Vec3,
@@ -114,7 +120,9 @@ impl Game<'_> {
);
// A spawned hook's start position is its feet: centre the box on the eye.
let start = Vec3::new(start.x, start.y - SIZE / 2.0, start.z);
self.next_entity += 1;
self.hook = Some(Hook {
id: self.next_entity,
position: start,
previous: start,
velocity: v,
@@ -562,12 +570,17 @@ impl Game<'_> {
lerp(h.previous.z, h.position.z),
))
}
/// FishingHookRenderer: the bobber, a camera-facing half-block quad
/// centred on the hook.
/// FishingHookRenderer: the bobbers (this player's and the others' a
/// server tracks), camera-facing half-block quads centred on the hooks.
pub(super) fn fishing_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let Some(p) = self.hook_position(alpha) else {
let hooks: Vec<glam::DVec3> = self
.hook_position(alpha)
.into_iter()
.chain(self.remote_hook_ends(alpha).into_iter().map(|(h, _)| h))
.collect();
if hooks.is_empty() {
return;
};
}
let Some(r) = self
.gui
.atlas
@@ -577,56 +590,57 @@ impl Game<'_> {
else {
return;
};
let light = self
.world
.light(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let forward = glam::DVec3::from_array(view_vector(self.yaw, self.pitch));
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
let up = right.cross(forward).normalize_or_zero();
let s = 0.25;
let corners = [
p - right * s - up * s,
p + right * s - up * s,
p + right * s + up * s,
p - right * s + up * s,
];
let uv = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
for (v, uv) in corners.iter().zip(uv) {
out.push(Vertex {
normal: [0; 4],
pos: (*v - camera).as_vec3().to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
});
for p in hooks {
let light =
self.world
.light(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let corners = [
p - right * s - up * s,
p + right * s - up * s,
p + right * s + up * s,
p - right * s + up * s,
];
for (v, uv) in corners.iter().zip(uv) {
out.push(Vertex {
normal: [0; 4],
pos: (*v - camera).as_vec3().to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
});
}
}
}
/// FishingHookRenderer: the line, 16 black `lines` segments sagging from
/// a quarter block above the hook to the rod in the player's hand,
/// FishingHookRenderer: each line, 16 black `lines` segments sagging
/// from a quarter block above the hook to the rod in its owner's hand,
/// `width` pixels wide (`Window.getAppropriateLineWidth`).
pub(super) fn fishing_line(&self, alpha: f64, size: [u32; 2]) -> Vec<Line> {
let (Some(hook), Some(hand)) = (
self.hook_position(alpha),
self.rod_hand_position(alpha, size),
) else {
return Vec::new();
};
let offset = (hand - (hook + glam::DVec3::new(0.0, 0.25, 0.0))).as_vec3();
let point = |a: f32| {
let y = offset.y * (a * a + a) * 0.5 + 0.25;
hook + glam::Vec3::new(offset.x * a, y, offset.z * a).as_dvec3()
};
let own = self
.hook_position(alpha)
.zip(self.rod_hand_position(alpha, size));
let width = horizon_client::gfx::outline_width(size[0]);
(0..16)
.map(|i| Line {
let mut lines = Vec::new();
for (hook, hand) in own.into_iter().chain(self.remote_hook_ends(alpha)) {
let offset = (hand - (hook + glam::DVec3::new(0.0, 0.25, 0.0))).as_vec3();
let point = |a: f32| {
let y = offset.y * (a * a + a) * 0.5 + 0.25;
hook + glam::Vec3::new(offset.x * a, y, offset.z * a).as_dvec3()
};
lines.extend((0..16).map(|i| Line {
start: point(i as f32 / 16.0).to_array(),
end: point((i + 1) as f32 / 16.0).to_array(),
color: 0xFF00_0000,
width,
})
.collect()
}));
}
lines
}
/// `FishingHookRenderer.getPlayerHandPos`: on the near plane in first
/// person, beside the body otherwise.
@@ -694,6 +708,20 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// The hooks drawn here, with their owners' ids (0 for this player's own).
pub(super) fn hooks_status(&self) -> Vec<serde_json::Value> {
let at = |p: Vec3| [p.x, p.y, p.z];
let own = self.hook.iter().map(|h| (h.id, 0, h.position));
let others = self
.remote_hooks
.iter()
.map(|h| (h.id, h.owner, h.position));
own.chain(others)
.map(|(id, owner, p)| {
serde_json::json!({"id": id, "kind": "minecraft:fishing_bobber", "owner": owner, "position": at(p)})
})
.collect()
}
/// The hook for `horizon-ctl status`.
pub(super) fn hook_status(&self) -> Option<serde_json::Value> {
let h = self.hook.as_ref()?;
@@ -706,3 +734,175 @@ impl Game<'_> {
}))
}
}
/// A hook a server tracks to this client (`FishingHook.recreateFromPacket`)
/// that another player cast: drawn where the server puts it, its line
/// running to that player's hand.
pub(super) struct RemoteHook {
id: u32,
owner: u32,
position: Vec3,
previous: Vec3,
}
impl Hook {
/// This client's own hook as its server tracks it: drawn, never ticked here.
fn remote(id: u32, at: Vec3) -> Self {
Self {
id,
position: at,
previous: at,
velocity: Vec3::ZERO,
state: State::Flying,
life: 0,
on_ground: false,
nibble: 0,
time_until_lured: 0,
time_until_hooked: 0,
fish_angle: 0.0,
out_of_water: 0,
open_water: true,
luck: 0,
lure: 0,
}
}
/// `FishingHook`'s entity for the players tracking it: the owner's id in
/// its add packet, DATA_HOOKED_ENTITY (the hooked id plus one) and
/// DATA_BITING when they differ from their defaults.
fn track(&self, owner: i32, out: &mut States) {
let p = self.position;
let e = out.add(
self.id as i32,
super::lan::entity_uuid(self.id),
"minecraft:fishing_bobber",
);
e.position = [p.x, p.y, p.z];
e.velocity = [self.velocity.x, self.velocity.y, self.velocity.z];
e.on_ground = self.on_ground;
e.data = owner;
if let State::Hooked(id) = self.state {
e.meta.push((8, serializer::INT, Value::Int(id as i32 + 1)));
}
if self.nibble > 0 {
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
}
}
}
impl Game<'_> {
/// The hooks guests track: the host's and each guest's in the level.
pub(super) fn lan_hook_states(
&self,
host_entity: Option<i32>,
play: &HashMap<u32, GuestPlay>,
hub: &Hub,
out: &mut States,
) {
if let (Some(h), Some(owner)) = (&self.hook, host_entity) {
h.track(owner, out);
}
for g in hub.guests.iter().filter(|g| hub.here(g)) {
if let Some(h) = play.get(&g.conn).and_then(|p| p.hook.as_ref()) {
h.track(g.entity_id, out);
}
}
}
/// `handleAddEntity` for a fishing bobber: this player's own becomes its
/// hook (`Player.fishing`), another player's is drawn on its line to them.
pub(super) fn spawn_remote_hook(&mut self, id: u32, at: Vec3, owner: i32) {
let own = self.remote.as_ref().map(|r| r.entity_id);
if own == Some(owner) {
self.hook = Some(Hook::remote(id, at));
} else {
self.remote_hooks.push(RemoteHook {
id,
owner: owner as u32,
position: at,
previous: at,
});
}
}
/// One tick of interpolation for the hooks a server tracks.
pub(super) fn step_remote_hooks(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
if self.remote.is_none() {
return;
}
if let Some(h) = self.hook.as_mut() {
h.previous = h.position;
h.position = step(h.id, h.position);
}
for h in &mut self.remote_hooks {
h.previous = h.position;
h.position = step(h.id, h.position);
}
}
/// `FishingHook.onSyncedDataUpdated`: this player's hook is biting
/// (DATA_BITING), which `status` shows as a nibble.
pub(super) fn remote_hook_data(&mut self, id: u32, values: &[(u8, i32, Value)]) {
let Some(h) = self
.hook
.as_mut()
.filter(|h| h.id == id && self.remote.is_some())
else {
return;
};
for (index, _, value) in values {
if let (9, Value::Bool(biting)) = (index, value) {
h.nibble = i32::from(*biting);
}
}
}
pub(super) fn remove_remote_hook(&mut self, id: u32) {
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
self.hook = None;
}
self.remote_hooks.retain(|h| h.id != id);
}
pub(super) fn clear_remote_hooks(&mut self) {
if self.remote.is_some() {
self.hook = None;
}
self.remote_hooks.clear();
}
/// Other players' hooks: the bobber and its line to the owner's hand
/// (`FishingHookRenderer.getPlayerHandPos` in third person), as
/// (hook, hand) this frame.
fn remote_hook_ends(&self, alpha: f64) -> Vec<(glam::DVec3, glam::DVec3)> {
let lerp = |a: Vec3, b: Vec3| {
glam::DVec3::new(
a.x + (b.x - a.x) * alpha,
a.y + (b.y - a.y) * alpha,
a.z + (b.z - a.z) * alpha,
)
};
self.remote_hooks
.iter()
.filter_map(|h| {
let owner = self.mobs.iter().find(|m| m.id == h.owner)?;
let feet = lerp(owner.previous, owner.position);
let body = horizon_client::camera::lerp(
alpha as f32,
owner.previous_body_yaw,
owner.body_yaw,
);
let angle = f64::from(body * (std::f64::consts::PI / 180.0) as f32);
let (sin, cos) = (f64::from(trig::sin(angle)), f64::from(trig::cos(angle)));
// The right arm; a crouching owner's eyes are lower.
let eye = owner.eye_y() - owner.position.y;
let hand = feet
+ glam::DVec3::new(
-cos * 0.35 - sin * 0.8,
eye - 0.45,
-sin * 0.35 + cos * 0.8,
);
Some((lerp(h.previous, h.position), hand))
})
.collect()
}
}
+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,
+1 -1
View File
@@ -200,7 +200,7 @@ impl Game<'_> {
}
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the Shader Packs page opens its folder.
// The system browser, as the folder buttons open their folders.
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_folder(std::path::Path::new(url));
@@ -3,6 +3,7 @@
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
//! Leaving with changes asks first (ConfirmScreen).
use horizon_client::{
confirm_screen::Confirm,
font::Font,
gamerules::{self, GameRules, RULES, Rule, Value},
hud::{HudAtlas, HudBatch},
@@ -66,9 +67,6 @@ fn parse(r: &Rule, s: &str) -> Option<i32> {
.flatten()
.filter(|v| (r.min..=r.max).contains(v))
}
fn widest(font: &Font, lines: &[Text]) -> f32 {
lines.iter().map(|l| font.width_text(l)).fold(0.0, f32::max)
}
fn lines(font: &Font, t: &Text, width: f32) -> Vec<Text> {
font.split_lines(t, width)
.into_iter()
@@ -95,38 +93,11 @@ fn yellow() -> u32 {
named_color("yellow").unwrap_or(0xFFFF55)
}
/// ConfirmScreen: the title, the message (at most `width - 50` wide), and
/// Yes / No 16 below, 4 apart, all centred.
struct Confirm {
lines: Vec<(Text, f32, f32)>,
buttons: [(f32, f32); 2],
}
impl Confirm {
fn new(font: &Font, (w, h): (f32, f32)) -> Self {
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
let message: Vec<Text> = lines(font, &message, w - 50.0)
.into_iter()
.take(15)
.collect();
let (tw, mw) = (font.width_text(&title), widest(font, &message));
let mh = 9.0 * message.len() as f32;
let col = tw.max(mw).max(304.0);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut out = vec![(title, cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
out.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
Self {
lines: out,
buttons: [(bx, by), (bx + 154.0, by)],
}
}
/// ConfirmScreen "Game Rule Changes".
fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
Confirm::new(font, size, &title, &message)
}
impl RulesScreen {
@@ -301,13 +272,10 @@ impl RulesScreen {
let (w, h) = size;
let on = |x: f32, y: f32, bw: f32, bh: f32| ui::hovered(cursor, x, y, bw, bh);
if self.confirm {
let c = Confirm::new(&atlas.font, size);
let [(yx, yy), (nx, ny)] = c.buttons;
if on(yx, yy, 150.0, 20.0) {
return Some(Exit::Discard);
}
if on(nx, ny, 150.0, 20.0) {
self.confirm = false;
match confirm(&atlas.font, size).answer_at(cursor) {
Some(true) => return Some(Exit::Discard),
Some(false) => self.confirm = false,
None => {}
}
return None;
}
@@ -367,7 +335,7 @@ impl RulesScreen {
let (w, h) = size;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if self.confirm {
return Confirm::new(&atlas.font, size).buttons.into_iter().any(on);
return confirm(&atlas.font, size).answer_at(cursor).is_some();
}
let [done, cancel] = footer(w, h);
if (on(done) && !self.invalid()) || on(cancel) {
@@ -384,15 +352,11 @@ impl RulesScreen {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
if self.confirm {
let c = Confirm::new(&atlas.font, (w, h));
for (line, x, y) in &c.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), key) in c.buttons.into_iter().zip(["gui.yes", "gui.no"]) {
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
let label = Text::translate(key, &[]);
ui::button(batch, atlas, bx, by, 150.0, 20.0, &label, true, hover);
}
let labels = [
Text::translate("gui.yes", &[]),
Text::translate("gui.no", &[]),
];
confirm(&atlas.font, (w, h)).draw(batch, atlas, cursor, [&labels[0], &labels[1]]);
return;
}
let rows = self.rows();
+10 -3
View File
@@ -10,9 +10,10 @@ use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::registry::BlockStateId;
impl Game<'_> {
/// Client `EntityGetter.getEntityCollisions`: only solid happy ghasts,
/// excluding the player's own vehicle.
pub(super) fn happy_ghast_player_collisions(&self) -> Vec<Aabb> {
/// Client `EntityGetter.getEntityCollisions` for the player: the
/// entities that `canBeCollidedWith` it, solid happy ghasts and boats
/// (`AbstractBoat.canBeCollidedWith`), except the player's own vehicle.
pub(super) fn player_entity_collisions(&self) -> Vec<Aabb> {
let Some(p) = &self.player else {
return Vec::new();
};
@@ -24,6 +25,12 @@ impl Game<'_> {
&& happy_ghast::collides_with_player(m, p.position.y)
})
.map(|m| m.bounds())
.chain(
self.boats
.iter()
.filter(|b| Some(b.id) != self.riding)
.map(super::boats::Boat::bounds),
)
.collect()
}
/// `HappyGhast.mobInteract`. Returns whether the click was used.
+26 -15
View File
@@ -6,15 +6,15 @@
//! the world's options) with [`Integrated::call`], as vanilla's client calls
//! into its `IntegratedServer`.
//!
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`) until a
//! server's players reach parity with the in-frame world: on the server
//! thread, signs open no editor, books don't open, a totem doesn't save its
//! holder and death keeps the inventory (#1380, #1366, #1292, #1284 and the
//! other `[server-thread]` issues). LAN guests and dedicated servers use the
//! same code either way.
//! On by default. `--in-frame` (or `--no-server-thread`, or
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead, the
//! escape hatch. LAN guests and dedicated servers use the same code either
//! way.
use super::{
Game, Options, PackResources, Resources, UiResources,
execution_role::ExecutionRole,
lan::{LanSettings, Owner},
world_loading::WorldRequest,
};
use horizon_client::{
Result,
@@ -28,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},
@@ -172,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.
@@ -217,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;
@@ -289,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).
@@ -498,6 +493,22 @@ impl Game<'_> {
out
}
/// Run `f` with the owner's level in place (the server holds one per
/// dimension), then put back the level that was.
pub(super) fn in_owner_level<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
let owner = self.serving.as_ref().map(|s| s.owner);
let dim = self.lan.as_ref().and_then(|h| {
let g = h.hub.guests.iter().find(|g| Some(g.uuid) == owner)?;
Some(super::Dim::from_id(&g.dimension))
});
let back = dim.and_then(|d| self.enter_command_level(d));
let out = f(self);
if let Some(back) = back {
self.enter_command_level(back);
}
out
}
/// The window's requests, between two ticks.
fn run_tasks(&mut self) {
let tasks: Vec<Task> = match &self.serving {
+17 -3
View File
@@ -411,7 +411,12 @@ impl Game<'_> {
"game_mode": g.game_mode,
// ItemCooldowns: the ticks the shield an axe disabled stays down.
"shield_cooldown": h.play.get(&g.conn).map_or(0, |p| p.shield_cooldown),
// The item the server has this guest using: its slot and ticks.
"using": h.play.get(&g.conn).and_then(|p| p.using())
.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
"sneaking": g.sneaking,
// The server's Elytra glide (shared flag 7).
"fall_flying": g.fall_flying,
"sleeping": g.bed,
"health": g.health,
// The server's body: its air supply and golden hearts.
@@ -829,8 +834,11 @@ impl Game<'_> {
self.lan_object_states(host_entity, hub, out);
}
/// Run the hub for this tick and apply what the guests did.
pub(super) fn tick_lan(&mut self) {
/// Run the hub for this tick and apply what the guests did. While the
/// single player game is `paused` only the connections run
/// (`IntegratedServer.tickPaused`): `ServerGamePacketListenerImpl.tick`
/// skips `tickPlayer`, so no player's air, hunger or damage moves on.
pub(super) fn tick_lan(&mut self, paused: bool) {
let Some(mut host) = self.lan.take() else {
return;
};
@@ -870,6 +878,7 @@ impl Game<'_> {
.collect();
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
} else {
states.clear();
}
@@ -994,7 +1003,9 @@ impl Game<'_> {
host.states = states;
}
self.lan_flush_effects(&mut host);
self.tick_guests(&mut host);
if !paused {
self.tick_guests(&mut host);
}
self.lan_living_flags(&mut host);
self.tick_server_travel(&mut host);
self.lan_swings(&mut host);
@@ -1006,6 +1017,8 @@ impl Game<'_> {
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
// A mace's wind burst from a guest's attack.
let _ = self.flush_wind_bursts();
}
fn lan_guest_joined(
@@ -1061,6 +1074,7 @@ impl Game<'_> {
}
// A dedicated server has no host player.
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
theirs.as_slice()
} else {
entities
+234 -34
View File
@@ -30,6 +30,8 @@ use winit::keyboard::KeyCode;
/// Per-guest state of the gameplay code that lives outside `Guest`.
#[derive(Default)]
pub(super) struct GuestPlay {
/// The sign this guest may write on (`SignBlockEntity.playerWhoMayEdit`).
pub(super) sign_editing: Option<[i32; 3]>,
mining: Option<Mining>,
destroy_delay: u32,
using: Option<(usize, u32)>,
@@ -80,6 +82,8 @@ pub(super) struct GuestPlay {
/// (`LivingEntity.collectEquipmentChanges`).
modifiers: Vec<(String, String)>,
equipped: Option<Vec<Option<ItemStack>>>,
/// The guest's cast fishing hook (`Player.fishing`).
pub(super) hook: Option<super::fishing::Hook>,
}
/// A guest's command waiting for the hub (`LanHost::commands`).
@@ -259,6 +263,7 @@ impl Game<'_> {
motion.pitch = g.pitch;
motion.sprinting = g.sprinting;
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
motion.flying = g.flying;
motion.invulnerable = motion.may_fly;
let _ = motion.set_health(g.health, false);
motion.health.food.food = g.food;
@@ -342,6 +347,7 @@ impl Game<'_> {
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
std::mem::swap(&mut self.stats, &mut play.stats);
std::mem::swap(&mut self.hook, &mut play.hook);
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
@@ -513,7 +519,8 @@ impl Game<'_> {
std::mem::swap(&mut self.mount, &mut play.mount);
self.container = container;
self.screen = screen;
self.sign_edit = host_sign;
// A sign the guest's placement opened (SignBlock.openTextEdit).
let guest_sign = std::mem::replace(&mut self.sign_edit, host_sign);
// A lectern the guest's use opened (LecternBlock.openScreen).
let guest_lectern = match std::mem::replace(&mut self.book, host_book) {
Some(super::books::BookScreen::View {
@@ -548,6 +555,7 @@ impl Game<'_> {
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
std::mem::swap(&mut self.stats, &mut play.stats);
std::mem::swap(&mut self.hook, &mut play.hook);
self.adv.loaded = adv_loaded;
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
let stat_events = std::mem::take(&mut play.stats.events);
@@ -587,6 +595,11 @@ impl Game<'_> {
// A teleport (commands, a pearl) moves the guest; a pearl's is
// sent even to where they stand (ServerPlayer.teleport).
let at = [m.position.x, m.position.y, m.position.z];
// A hit with extra knockback stopped the guest's sprint
// (Player.causeExtraKnockback's setSprinting(false)).
if !m.sprinting {
g.sprinting = false;
}
m.sprinting = false;
let teleported = std::mem::take(&mut m.teleported);
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
@@ -652,6 +665,9 @@ impl Game<'_> {
if let Some(station) = opened_station {
self.lan_station_opened(host, conn, station);
}
if let Some(edit) = guest_sign {
self.lan_open_sign_editor(host, conn, edit);
}
if let Some(key) = guest_lectern {
self.lan_open_lectern(host, conn, key);
}
@@ -865,14 +881,20 @@ impl Game<'_> {
self.as_guest(host, conn, |g| g.drop_held(a.action == 4));
}
}
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
// hand of the item in use acting.
6 => {
self.as_guest(host, conn, |g| {
if let Some((slot, ticks)) = g.using.take()
&& g.stack_in(slot)
.is_some_and(|s| &*s.item == "minecraft:bow")
{
g.release_bow(ticks);
}
let Some(hand) = g.used_hand() else {
return;
};
g.with_hand(hand, |g| {
if let Some((slot, ticks)) = g.using.take()
&& let Some(stack) = g.stack_in(slot).cloned()
{
g.release_used_item(&stack, ticks, false);
}
});
});
}
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
@@ -1029,30 +1051,27 @@ impl Game<'_> {
g.open_ridden_mount_menu();
});
}
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
S::ContainerButtonClick(p) => {
self.lan_station_button(host, conn, p.container, p.button);
}
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
S::SignUpdate(p) => self.lan_sign_update(host, conn, p),
S::EditBook(p) => self.lan_edit_book(host, conn, p),
S::ContainerSlotStateChanged(p) => {
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
}
S::SetBeacon(p) => self.lan_station_beacon(host, conn, p.primary, p.secondary),
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
let g = &mut host.hub.guests[gi];
if let Some(c) = g.carried.take() {
self.drop_guest_items(host, gi, vec![c]);
}
self.guest_carried_back(host, gi);
}
S::ContainerClose(p)
if self.lan_crafting_closed(host, conn, Some(p.container))
|| self.lan_station_closed(host, conn, Some(p.container))
|| self.lan_mount_closed(host, conn, Some(p.container)) =>
{
let g = &mut host.hub.guests[gi];
if let Some(c) = g.carried.take() {
self.drop_guest_items(host, gi, vec![c]);
}
self.guest_carried_back(host, gi);
}
S::ContainerClick(p) => {
let rules = ItemRules(self.res.items());
@@ -1068,10 +1087,7 @@ impl Game<'_> {
self.guest_container_closed(host, conn);
}
host.hub.container_closed(conn, p.container);
let g = &mut host.hub.guests[gi];
if let Some(c) = g.carried.take() {
self.drop_guest_items(host, gi, vec![c]);
}
self.guest_carried_back(host, gi);
}
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
@@ -1218,6 +1234,17 @@ impl Game<'_> {
Some(out)
}
/// `AbstractContainerMenu.removed`: what a closing player held on the
/// cursor goes back into their inventory (`placeItemBackInInventory`,
/// dropped only if it doesn't fit), and their client's cursor empties
/// with the inventory it is sent.
fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
if let Some(c) = host.hub.guests[gi].carried.take() {
self.lan_place_back(host, gi, vec![c]);
host.hub.send_inventory(gi);
}
}
pub(super) fn drop_guest_items(&mut self, host: &LanHost, gi: usize, stacks: Vec<ItemStack>) {
let g = &host.hub.guests[gi];
let eye = [
@@ -1250,10 +1277,24 @@ impl Game<'_> {
id: i32,
location: Option<[f64; 3]>,
) -> Option<([f64; 3], [f64; 3])> {
let eye = {
let g = &host.hub.guests[gi];
[g.position[0], g.position[1] + g.eye_height(), g.position[2]]
};
let object = || {
let (lo, hi, origin) = self.object_target(id)?;
let middle = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
let Some(l) = location else {
// A boat's mobs sit over its middle: aim low at its near
// side, so the ray meets the boat before them (the server
// attacks the entity it is sent).
let carries = u32::try_from(id).is_ok_and(|v| {
self.boats.iter().any(|b| b.id == v) && !self.boat_mobs(v).is_empty()
});
if carries {
let near = |k: usize| eye[k].clamp(lo[k] + 0.01, hi[k] - 0.01);
return Some([near(0), lo[1] + 0.05, near(2)]);
}
return Some(middle);
};
let point = [0, 1, 2].map(|k| origin[k] + l[k]);
@@ -1284,7 +1325,6 @@ impl Game<'_> {
}
};
let g = &mut host.hub.guests[gi];
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let d = [centre[0] - eye[0], centre[1] - eye[1], centre[2] - eye[2]];
let horizontal = (d[0] * d[0] + d[2] * d[2]).sqrt();
g.yaw = (-d[0].atan2(d[2])).to_degrees() as f32;
@@ -1300,8 +1340,11 @@ impl Game<'_> {
return;
}
if let Some(victim) = Self::pvp_victim(host, conn, id) {
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike()) {
self.pvp_land(host, victim, &strike);
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
&& self.pvp_land(host, victim, &strike)
&& strike.stops_sprint()
{
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
}
return;
}
@@ -1683,6 +1726,17 @@ impl Game<'_> {
g.use_delay -= 1;
}
});
// ServerPlayerGameMode.handleBlockBreakAction: a creative
// player's START breaks the block once (destroyAndAck) and
// leaves nothing being destroyed; its client sends no STOP.
// Kept, the dig broke whatever was put there next, every
// destroy delay, for as long as the player broke nothing else.
if digging.is_some()
&& host.hub.guests[gi].creative()
&& let Some(play) = host.play.get_mut(&conn)
{
play.digging = None;
}
}
// Player.tick: a different main-hand item resets the attack
// strength, and detectEquipmentUpdates gives the guest its modifiers.
@@ -1704,16 +1758,23 @@ impl Game<'_> {
// PlayerAdvancements.flushDirty.
let sleeping = host.hub.guests[gi].sleeping;
self.as_guest(host, conn, |g| {
g.tick_fishing();
g.tick_maps();
g.count_tick_stats(sleeping);
g.tick_advancements();
g.update_player_attributes();
});
self.lan_map_updates(host, conn);
host.hub.flush_progress(conn);
self.guest_dig_feedback(host, conn);
self.guest_pickups(host, gi);
self.guest_orbs(host, gi);
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
self.guest_forget_digging(host, conn);
// FishingHook.shouldStopFishing: its owner is dead.
if let Some(play) = host.play.get_mut(&conn) {
play.hook = None;
}
}
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
@@ -1727,6 +1788,9 @@ impl Game<'_> {
}
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
let (name, uuid) = (g.name.clone(), g.uuid);
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
let drops = (g.game_mode != SPECTATOR)
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
let message = match cause {
// ServerPlayer.die: the death message, to everyone unless
@@ -1760,6 +1824,9 @@ impl Game<'_> {
);
// LivingEntity.die: the death animation, the dying player included.
host.hub.entity_event(id, 3, at);
if let Some(eyes) = drops {
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
}
self.spawn_orbs(at, reward);
}
}
@@ -1846,6 +1913,13 @@ impl Game<'_> {
/// sounds are heard at the guest.
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
self.guest_vehicle_tick(host, conn);
// The last move's fall stands in for the server's delta movement.
let fell = host
.hub
.guest_mut(conn)
.and_then(|g| g.moves.last().map(|m| m.0[1]))
.unwrap_or(0.0);
self.guest_glide_tick(host, conn, fell);
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
@@ -1865,11 +1939,11 @@ 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 {
return Vec::new();
return (Vec::new(), None);
};
let mut w = MotionWorld {
entities: &[],
@@ -1945,22 +2019,122 @@ impl Game<'_> {
game.border_damage();
game.tick_mob_effects();
let Some(player) = game.player.as_mut() else {
return Vec::new();
return (Vec::new(), None);
};
let _ = player.peaceful_regeneration(rules);
let _ = player.food_tick(health, rules);
events.append(&mut player.events);
events
.into_iter()
.filter_map(|e| match e {
MotionEvent::HurtSound(sound) => Some(sound),
_ => None,
})
.collect::<Vec<_>>()
let _ = MotionWorld {
entities: &[],
player,
scene: &mut game.world,
assets: &game.pack.assets,
collision_binding: game.collision,
resources: game.motion,
}
.server_travel();
// 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);
(events, marked.then_some(player.velocity))
});
for sound in sounds.into_iter().flatten() {
self.player_hurt_sound(sound, at);
let Some((events, marked)) = events else {
return;
};
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 {
id: g.entity_id,
velocity: [v.x, v.y, v.z],
}));
}
}
/// ServerGamePacketListenerImpl.handlePlayerCommand START_FALL_FLYING:
/// `Player.tryToStartFallFlying`, or else `stopFallFlying` (a second
/// start while gliding ends the glide, as in vanilla).
fn lan_start_gliding(&mut self, host: &mut LanHost, conn: u32) {
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
let start = !g.fall_flying && self.guest_can_glide(host, conn) == Some(true);
let in_liquid = self
.as_guest(host, conn, |game| {
game.player
.as_ref()
.is_some_and(|p| p.fluids.was_touching_water || p.is_in_lava())
})
.unwrap_or(true);
if let Some(g) = host.hub.guest_mut(conn) {
g.fall_flying = start && !in_liquid;
}
}
/// `Player.canGlide`: not flying, airborne, not riding, no levitation, and
/// a glider that its next point of wear won't break.
fn guest_can_glide(&mut self, host: &mut LanHost, conn: u32) -> Option<bool> {
let g = host.hub.guest_mut(conn)?;
if g.flying || g.on_ground || g.vehicle.is_some() {
return Some(false);
}
self.as_guest(host, conn, |game| {
game.glider_worn() && game.potions.effects.get("levitation").is_none()
})
}
/// `LivingEntity.updateFallFlying` on the server, once a tick for a
/// gliding guest: the glide ends once `canGlide` fails; otherwise every
/// 20th tick wears the glider by one, and every 10th is an
/// `elytra_glide` game event. Entity.checkFallDistanceAccumulation runs
/// first: a glide that isn't diving keeps the fall distance at 1.
fn guest_glide_tick(&mut self, host: &mut LanHost, conn: u32, fell: f64) {
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
if !g.fall_flying {
if let Some(m) = g.motion.as_mut() {
m.fall_fly_ticks = 0;
}
return;
}
let creative = g.creative();
if let Some(m) = g.motion.as_mut()
&& fell > -0.5
&& m.fall_distance > 1.0
{
m.fall_distance = 1.0;
}
if self.guest_can_glide(host, conn) != Some(true) {
if let Some(g) = host.hub.guest_mut(conn) {
g.fall_flying = false;
}
return;
}
self.as_guest(host, conn, |game| {
let Some(p) = game.player.as_mut() else {
return;
};
p.fall_fly_ticks += 1;
let next = p.fall_fly_ticks + 1;
let at = [p.position.x, p.position.y, p.position.z];
if next % 10 != 0 {
return;
}
if (next / 10) % 2 == 0
&& !creative
&& let Some(elytra) = game.armor[1].as_mut()
{
elytra.damage += 1;
}
let ctx = game.player_context(None);
game.post_game_event("minecraft:elytra_glide", at, ctx);
});
}
/// `Player.isInWall`: a suffocating block in the 0.48-wide box at the
@@ -2275,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);
+59 -5
View File
@@ -78,6 +78,8 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
target: Who::Player,
..
}
| MobEvent::EffectPlayer { .. }
| MobEvent::AxolotlSupportPlayer
)
}
@@ -115,6 +117,25 @@ impl Game<'_> {
views
}
/// Runs `f` as each guest in play here, for what strikes every player in
/// an area (`Level.getEntities`), not only the window's own.
pub(super) fn each_guest_hit(&mut self, mut f: impl FnMut(&mut Self)) {
let Some(mut host) = self.lan.take() else {
return;
};
let conns: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.map(|g| g.conn)
.collect();
for conn in conns {
self.guest_hit_by(&mut host, conn, &mut f);
}
self.lan = Some(host);
}
/// Mob events aimed at a guest, run as that guest (`guest_hit_by`).
pub(super) fn lan_guest_mob_events(&mut self, events: Vec<(u32, MobEvent)>) {
if events.is_empty() {
@@ -192,7 +213,9 @@ impl Game<'_> {
impl Game<'_> {
/// Hurt a guest: `f` runs as them, and the knockback it gave reaches
/// their client (`hurtMarked`: ClientboundSetEntityMotionPacket).
/// their client. A hurt marks the velocity (Entity.markHurt), which goes
/// out once the guest's server copy has travelled (`guest_server_tick`);
/// a push with no hurt goes out at once.
pub(super) fn guest_hit_by<R>(
&mut self,
host: &mut LanHost,
@@ -202,16 +225,47 @@ impl Game<'_> {
let (result, pushed) = self.as_guest(host, conn, |g| {
let before = g.player.as_ref().map(|p| p.velocity);
let result = f(g);
let after = g.player.as_ref().map(|p| p.velocity);
(result, after.filter(|v| Some(*v) != before))
let pushed = g
.player
.as_ref()
.filter(|p| !p.health.sync_velocity && Some(p.velocity) != before)
.map(|p| p.velocity);
(result, pushed)
})?;
if let (Some(v), Some(g)) = (pushed, host.hub.guest(conn)) {
self.send_guest_motion(host, conn, pushed);
Some(result)
}
/// A player's melee hit on a guest (Player.causeExtraKnockback on a
/// ServerPlayer): a hurt that marked the velocity sends it at once, and
/// the server's copy goes back to the velocity it had before the hit.
pub(super) fn guest_struck_by<R>(
&mut self,
host: &mut LanHost,
conn: u32,
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
let (result, sent) = self.as_guest(host, conn, |g| {
let before = g.player.as_ref().map(|p| p.velocity);
let result = f(g);
let sent = g.player.as_mut().zip(before).and_then(|(p, before)| {
std::mem::take(&mut p.health.sync_velocity)
.then(|| std::mem::replace(&mut p.velocity, before))
});
(result, sent)
})?;
self.send_guest_motion(host, conn, sent);
Some(result)
}
/// ClientboundSetEntityMotionPacket to a guest about themselves.
pub(super) fn send_guest_motion(&self, host: &LanHost, conn: u32, velocity: Option<Vec3>) {
if let (Some(v), Some(g)) = (velocity, host.hub.guest(conn)) {
g.send(&C::SetEntityMotion(play::SetEntityMotion {
id: g.entity_id,
velocity: [v.x, v.y, v.z],
}));
}
Some(result)
}
}
+111 -33
View File
@@ -3,7 +3,7 @@
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
use super::Game;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::random::RandomSource;
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
entity_data::{Particle, ParticleData},
packets::play::{self, Clientbound as C},
@@ -154,7 +154,7 @@ impl Game<'_> {
}
/// `ClientPacketListener.handleExplosion`: the blast's particles (and its
/// sound, when the packet carries it).
/// sound, when the packet carries it), then the player's push.
pub(super) fn remote_explode(&mut self, p: &play::Explode) {
if p.play_sound {
let pitch = (1.0
@@ -168,37 +168,88 @@ impl Game<'_> {
pitch,
);
}
let (world, facts) = (&self.world, self.res.facts());
self.particles
.explosion(p.center, p.radius, p.block_count.max(0) as usize, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
if let Some(options) = self.received_particle(&p.particle, p.center) {
self.particles.add(&options, p.center, [1.0, 0.0, 0.0]);
}
// A wind charge's burst throws none.
if !p.block_particles.is_empty() {
let (world, facts) = (&self.world, self.res.facts());
self.particles.explosion_blocks(
p.center,
p.radius,
p.block_count.max(0) as usize,
&|b| facts.is_air(world.block(b[0], b[1], b[2])),
);
}
// Entity.pushFromExplosion.
if let (Some(push), Some(player)) = (p.knockback, self.player.as_mut()) {
player.velocity = Vec3::new(
player.velocity.x + push[0],
player.velocity.y + push[1],
player.velocity.z + push[2],
);
}
}
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// players' motion), so the packet carries neither.
/// motion a hurt player is sent), so the packet carries neither.
pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) {
let Some(host) = self.lan.as_ref() else {
return;
};
let particle = |name: &str| {
Some(Particle {
id: host.hub.registry_id("minecraft:particle_type", name)?,
data: None,
})
};
let emitter = if radius < 2.0 {
"minecraft:explosion"
} else {
"minecraft:explosion_emitter"
};
let (Some(at_center), Some(poof), Some(smoke)) = (
particle(emitter),
particle("minecraft:poof"),
particle("minecraft:smoke"),
) else {
return;
let sound = "minecraft:entity.generic.explode";
if let Some(packet) =
self.explosion_packet(center, radius, block_count, emitter, sound, true)
{
self.send_explosion(packet, &[]);
}
}
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
/// no block particles, and for each player it pushed the push
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
/// nothing hurt them.
pub(super) fn lan_wind_burst(
&self,
center: [f64; 3],
radius: f32,
sound: &str,
block_count: usize,
pushed: &[(u32, [f64; 3])],
) {
let emitter = if radius < 2.0 {
"minecraft:gust_emitter_small"
} else {
"minecraft:gust_emitter_large"
};
if let Some(packet) =
self.explosion_packet(center, radius, block_count, emitter, sound, false)
{
self.send_explosion(packet, pushed);
}
}
/// `ClientboundExplodePacket` without a push, or None while not serving.
/// The sound is the level's, which the players hear already, so it isn't
/// played from the packet.
fn explosion_packet(
&self,
center: [f64; 3],
radius: f32,
block_count: usize,
emitter: &str,
sound: &str,
block_particles: bool,
) -> Option<play::Explode> {
let hub = &self.lan.as_ref()?.hub;
let particle = |name: &str| {
Some(Particle {
id: hub.registry_id("minecraft:particle_type", name)?,
data: None,
})
};
// Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES.
let info = |particle, scaling| play::ExplosionParticle {
@@ -207,22 +258,49 @@ impl Game<'_> {
speed: 1.0,
weight: 1,
};
let packet = C::Explode(play::Explode {
let block_particles = if block_particles {
vec![
info(particle("minecraft:poof")?, 0.5),
info(particle("minecraft:smoke")?, 1.0),
]
} else {
Vec::new()
};
Some(play::Explode {
center,
radius,
block_count: block_count as i32,
knockback: None,
particle: at_center,
sound: match host
.hub
.registry_id("minecraft:sound_event", "minecraft:entity.generic.explode")
{
particle: particle(emitter)?,
sound: match hub.registry_id("minecraft:sound_event", sound) {
Some(id) => (id, None),
None => (-1, Some(("minecraft:entity.generic.explode".into(), None))),
None => (-1, Some((sound.into(), None))),
},
block_particles: vec![info(poof, 0.5), info(smoke, 1.0)],
block_particles,
play_sound: false,
});
host.hub.broadcast_near(center, 64.0, &packet, None);
})
}
/// `packet` to each player within 64 blocks of its centre, with the push
/// `pushed` holds for them.
fn send_explosion(&self, mut packet: play::Explode, pushed: &[(u32, [f64; 3])]) {
let Some(host) = self.lan.as_ref() else {
return;
};
let center = packet.center;
let plain = C::Explode(packet.clone()).encode();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
let d: f64 = (0..3).map(|k| (g.position[k] - center[k]).powi(2)).sum();
if d >= 4096.0 {
continue;
}
match pushed.iter().find(|(conn, _)| *conn == g.conn) {
Some((_, push)) => {
packet.knockback = Some(*push);
g.send(&C::Explode(packet.clone()));
}
None => g.sender.send(plain.clone()),
}
}
}
}
+10 -2
View File
@@ -45,6 +45,14 @@ pub(super) struct Strike {
team: Option<horizon_client::scoreboard::Team>,
}
impl Strike {
/// Whether it has extra knockback, which stops the attacker's sprint
/// once it lands (Player.causeExtraKnockback).
pub(super) fn stops_sprint(&self) -> bool {
self.knockback.0 > 0.0
}
}
impl Game<'_> {
/// The guest on the host's crosshair and how far, within reach.
pub(super) fn guest_hit(&self) -> Option<(u32, f64)> {
@@ -74,7 +82,7 @@ impl Game<'_> {
};
if let Some(strike) = self.pvp_strike()
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
&& strike.knockback.0 > 0.0
&& strike.stops_sprint()
{
self.stop_sprint_after_extra_knockback();
}
@@ -240,7 +248,7 @@ impl Game<'_> {
&& harm
&& match victim {
Victim::Host => land(self),
Victim::Guest(conn) => self.guest_hit_by(host, conn, land).unwrap_or(false),
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false),
};
let sound = match (hurt, s.crit, s.scale > 0.9) {
(false, ..) => "minecraft:entity.player.attack.nodamage",
+16 -11
View File
@@ -588,17 +588,7 @@ impl Game<'_> {
let [x, y, z] = gs.pos;
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
let block = StationKind::from_block(name) == Some(gs.kind);
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
// The block interaction range (4.5, creative 5) and the buffer.
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
let near = (0..3)
.map(|k| {
let lo = f64::from(gs.pos[k]);
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
})
.sum::<f64>()
< reach * reach;
if !(block && near) {
if !(block && within_reach(g, gs.pos)) {
let window = gs.window;
self.lan_station_closed(host, conn, Some(window));
host.hub.send_to(
@@ -670,3 +660,18 @@ pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
.unwrap_or_default()
}
/// `Player.isWithinBlockInteractionRange(pos, 4)`: the guest's eyes within
/// its block interaction range (4.5, creative 5) and a 4-block buffer of the
/// block's box.
pub(super) fn within_reach(g: &horizon_client::lan::guest::Guest, pos: [i32; 3]) -> bool {
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
(0..3)
.map(|k| {
let lo = f64::from(pos[k]);
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
})
.sum::<f64>()
< reach * reach
}
@@ -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);
+13 -10
View File
@@ -9,7 +9,7 @@
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, boats::SEAT, lan::LanHost};
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
use horizon_client::collisions::SceneCollisions;
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
@@ -120,8 +120,8 @@ impl Game<'_> {
game.dismount(i);
return None;
}
let p = game.boats[i].position;
return Some([p.x, p.y + SEAT, p.z]);
let p = game.player_boat_seat(i);
return Some([p.x, p.y, p.z]);
}
// The minecart moves with the rider's input, and lets it out when
// it sneaks or the cart breaks. An animal takes the rider's input
@@ -161,19 +161,19 @@ impl Game<'_> {
let at = self
.boats
.iter()
.find(|b| b.id == vehicle)
.map(|b| b.position)
.position(|b| b.id == vehicle)
.map(|i| (self.boats[i].position, self.player_boat_seat(i)))
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == vehicle)
.map(|c| c.position)
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
});
if let Some(p) = at {
if let Some((p, seat)) = at {
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([p.x, p.y, p.z]);
g.previous_position = g.position;
g.position = [p.x, p.y + SEAT, p.z];
g.position = [seat.x, seat.y, seat.z];
g.on_ground = false;
return;
}
@@ -205,7 +205,9 @@ impl Game<'_> {
return;
};
let [x, y, z] = p.position;
if !(x.is_finite() && y.is_finite() && z.is_finite())
if !x.is_finite()
|| !y.is_finite()
|| !z.is_finite()
|| !p.yaw.is_finite()
|| !p.pitch.is_finite()
{
@@ -287,10 +289,11 @@ impl Game<'_> {
b.previous_yaw = b.yaw;
b.yaw = p.yaw;
b.on_ground = p.on_ground;
let seat = self.player_boat_seat(i);
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([target.x, target.y, target.z]);
g.previous_position = g.position;
g.position = [target.x, target.y + SEAT, target.z];
g.position = [seat.x, seat.y, seat.z];
}
/// `handlePaddleBoat`: the paddles of the boat the player drives.
+3 -3
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;
@@ -296,7 +296,7 @@ impl Game<'_> {
self.update_dedicated_streaming();
self.update_world();
result = if paused {
self.tick_lan();
self.tick_lan(true);
Ok(())
} else if k == 0 {
self.tick()
+22 -16
View File
@@ -280,11 +280,12 @@ impl Game<'_> {
}
/// `thunderHit` for entities within 3 blocks (up to 9 above).
fn thunder_hit(&mut self, b: &mut Bolt) {
let inside = |p: [f64; 3]| {
(p[0] - b.at[0]).abs() <= 3.0
&& (p[2] - b.at[2]).abs() <= 3.0
&& p[1] >= b.at[1] - 3.0
&& p[1] <= b.at[1] + 9.0
let at = b.at;
let inside = move |p: [f64; 3]| {
(p[0] - at[0]).abs() <= 3.0
&& (p[2] - at[2]).abs() <= 3.0
&& p[1] >= at[1] - 3.0
&& p[1] <= at[1] + 9.0
};
let damage = Damage {
kind: DamageKind::Generic,
@@ -349,19 +350,24 @@ impl Game<'_> {
if !events.is_empty() {
let _ = self.handle_mob_events(events);
}
if let Some(p) = self.player.as_ref().map(|p| p.position)
&& inside([p.x, p.y, p.z])
{
if let Some(p) = self.player.as_mut() {
p.fire_ticks += 1;
if p.fire_ticks == 0
&& let Err(error) = p.ignite_for_ticks(160)
{
self.error = Some(error);
// Every player in the box, the server's players too.
let strike = move |g: &mut Self| {
if let Some(p) = g.player.as_ref().map(|p| p.position)
&& inside([p.x, p.y, p.z])
{
if let Some(p) = g.player.as_mut() {
p.fire_ticks += 1;
if p.fire_ticks == 0
&& let Err(error) = p.ignite_for_ticks(160)
{
g.error = Some(error);
}
}
g.hurt_player_by("minecraft:lightning_bolt", 5.0);
}
self.hurt_player_by("minecraft:lightning_bolt", 5.0);
}
};
strike(self);
self.each_guest_hit(strike);
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
self.dropped.retain(|e| {
!inside([e.position.x, e.position.y, e.position.z])
+321 -50
View File
@@ -4,7 +4,7 @@
//! shading, the dithered edge), the player marker, `data/minecraft/maps/<id>.dat` and
//! `maps/last_id.dat`, the 128x128 texture in an atlas slot, the two-handed
//! first-person view and maps in item frames.
use super::Game;
use super::{Game, lan::LanHost};
use horizon_client::{
blocks::{Fluid, MAP_COLORS},
hud::{MAP_SLOTS, Rect},
@@ -14,13 +14,48 @@ use horizon_client::{
use horizon_world::nbt::Tag;
use std::collections::HashMap;
/// A decoration: sprite, map position (-128..127, half pixels) and rotation (0..15).
/// A decoration: sprite, map position (-128..127, half pixels) and rotation
/// (0..15), and the player it marks (an item's own decorations mark none).
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Decoration {
pub sprite: &'static str,
pub x: i8,
pub y: i8,
pub rot: u8,
pub holder: Option<u32>,
}
/// The host among a map's holders (the guests are their connections).
const HOST: u32 = u32::MAX;
/// A player carrying a map, or seeing it framed
/// (`MapItemSavedData.HoldingPlayer`): the column sweep's step, and what
/// changed since the last `ClientboundMapItemDataPacket` it was sent.
struct Holder {
who: u32,
/// The last tick it carried the map, and saw it framed.
carried: i64,
framed: i64,
step: i32,
/// The changed pixels' box (min x, min y, max x, max y).
dirty: Option<[i32; 4]>,
decorations: bool,
tick: u32,
}
impl Holder {
fn new(who: u32) -> Self {
Self {
who,
carried: 0,
framed: 0,
step: 0,
dirty: Some([0, 0, 127, 127]),
decorations: true,
tick: 0,
}
}
/// Still carrying the map, or seeing it framed (frames tick every 10).
fn holds(&self, now: i64) -> bool {
self.carried + 1 >= now || self.framed + 10 >= now
}
}
pub(super) struct MapData {
pub center: [i32; 2],
@@ -31,8 +66,7 @@ pub(super) struct MapData {
pub unlimited: bool,
pub colors: Vec<u8>,
pub decorations: Vec<Decoration>,
/// HoldingPlayer.step for the column sweep.
step: i32,
holders: Vec<Holder>,
/// Changed since the last save / texture upload.
dirty: bool,
texture_dirty: bool,
@@ -51,7 +85,7 @@ impl MapData {
unlimited: false,
colors: vec![0; 128 * 128],
decorations: Vec::new(),
step: 0,
holders: Vec::new(),
dirty: true,
texture_dirty: true,
}
@@ -64,8 +98,128 @@ impl MapData {
self.colors[i] = color;
self.dirty = true;
self.texture_dirty = true;
// setColorsDirty: every holder's next packet carries it.
for h in &mut self.holders {
h.dirty = Some(match h.dirty {
Some([x0, y0, x1, y1]) => [x0.min(x), y0.min(y), x1.max(x), y1.max(y)],
None => [x, y, x, y],
});
}
true
}
/// `tickCarriedBy`'s holder for `who`, added when new.
fn holder(&mut self, who: u32) -> &mut Holder {
let i = match self.holders.iter().position(|h| h.who == who) {
Some(i) => i,
None => {
self.holders.push(Holder::new(who));
self.holders.len() - 1
}
};
&mut self.holders[i]
}
/// The holders that no longer carry the map nor see it framed go, and
/// their markers with them (`tickCarriedBy`'s removeDecoration).
fn drop_holders(&mut self, now: i64) {
self.holders.retain(|h| h.holds(now));
let holders = &self.holders;
let before = self.decorations.len();
self.decorations
.retain(|d| d.holder.is_none_or(|w| holders.iter().any(|h| h.who == w)));
if self.decorations.len() != before {
self.decorations_changed();
}
}
/// setDecorationsDirty.
fn decorations_changed(&mut self) {
for h in &mut self.holders {
h.decorations = true;
}
}
/// `addDecoration` / `removeDecoration` for `holder`'s marker: in place,
/// so that the order (the drawing order) stays.
fn set_marker(&mut self, holder: u32, marker: Option<Decoration>) {
let i = self
.decorations
.iter()
.position(|d| d.holder == Some(holder));
let changed = match (i, marker) {
(Some(i), Some(d)) if self.decorations[i] == d => false,
(Some(i), Some(d)) => {
self.decorations[i] = d;
true
}
(Some(i), None) => {
self.decorations.remove(i);
true
}
(None, Some(d)) => {
self.decorations.push(d);
true
}
(None, None) => false,
};
if changed {
self.decorations_changed();
}
}
/// `HoldingPlayer.nextUpdatePacket` for `who`: the changed pixels, and
/// the decorations (at most every 5th call while they changed).
fn update_packet(
&mut self,
id: i32,
who: u32,
kind: impl Fn(&str) -> Option<i32>,
) -> Option<horizon_protocol::packets::play::MapItemData> {
use horizon_protocol::packets::play::{MapDecoration, MapItemData, MapPatch};
let h = self.holders.iter_mut().find(|h| h.who == who)?;
let patch = h.dirty.take().map(|[x0, y0, x1, y1]| {
let (width, height) = (x1 + 1 - x0, y1 + 1 - y0);
let mut colors = Vec::with_capacity((width * height) as usize);
for y in 0..height {
for x in 0..width {
colors.push(self.colors[(x0 + x + (y0 + y) * 128) as usize]);
}
}
MapPatch {
start_x: x0,
start_y: y0,
width,
height,
colors,
}
});
let decorations = if h.decorations && {
let due = h.tick % 5 == 0;
h.tick += 1;
due
} {
h.decorations = false;
Some(
self.decorations
.iter()
.filter_map(|d| {
Some(MapDecoration {
kind: kind(decoration_type(d.sprite)?)?,
x: d.x,
y: d.y,
rot: d.rot as i8,
name: None,
})
})
.collect(),
)
} else {
None
};
(patch.is_some() || decorations.is_some()).then_some(MapItemData {
map_id: id,
scale: self.scale as i8,
locked: self.locked,
decorations,
patch,
})
}
fn to_nbt(&self) -> Tag {
let flag = |b: bool| Tag::Byte(i8::from(b));
Tag::compound([
@@ -112,7 +266,7 @@ impl MapData {
unlimited: int("unlimitedTracking").unwrap_or(0) != 0,
colors,
decorations: Vec::new(),
step: 0,
holders: Vec::new(),
dirty: false,
texture_dirty: true,
})
@@ -143,36 +297,48 @@ fn packed_rgba(packed: u8) -> [u8; 4] {
pub(super) fn map_id(stack: &ItemStack) -> Option<i32> {
stack.data().and_then(|d| d.map_id)
}
/// `MapDecorationTypes`: the sprite of a decoration type that a
/// `minecraft:map_decorations` entry can name.
/// `MapDecorationTypes` Horizon draws: (type, sprite).
const DECORATION_TYPES: [(&str, &str); 24] = [
("player", "map/decorations/player"),
("frame", "map/decorations/frame"),
("red_marker", "map/decorations/red_marker"),
("blue_marker", "map/decorations/blue_marker"),
("target_x", "map/decorations/target_x"),
("target_point", "map/decorations/target_point"),
("player_off_map", "map/decorations/player_off_map"),
("player_off_limits", "map/decorations/player_off_limits"),
("mansion", "map/decorations/woodland_mansion"),
("monument", "map/decorations/ocean_monument"),
("red_x", "map/decorations/red_x"),
("village_desert", "map/decorations/desert_village"),
("village_plains", "map/decorations/plains_village"),
("village_savanna", "map/decorations/savanna_village"),
("village_snowy", "map/decorations/snowy_village"),
("village_taiga", "map/decorations/taiga_village"),
("jungle_temple", "map/decorations/jungle_temple"),
("swamp_hut", "map/decorations/swamp_hut"),
("trial_chambers", "map/decorations/trial_chambers"),
("abandoned_camp", "map/decorations/abandoned_camp"),
("ancient_city", "map/decorations/ancient_city"),
("desert_pyramid", "map/decorations/desert_pyramid"),
("mineshaft", "map/decorations/mineshaft"),
("ocean_ruin_warm", "map/decorations/warm_ocean_ruins"),
];
/// The sprite of a decoration type that a `minecraft:map_decorations`
/// entry or a server's packet names.
fn decoration_sprite(id: &str) -> Option<&'static str> {
Some(match id.strip_prefix("minecraft:").unwrap_or(id) {
"player" => "map/decorations/player",
"frame" => "map/decorations/frame",
"red_marker" => "map/decorations/red_marker",
"blue_marker" => "map/decorations/blue_marker",
"target_x" => "map/decorations/target_x",
"target_point" => "map/decorations/target_point",
"player_off_map" => "map/decorations/player_off_map",
"player_off_limits" => "map/decorations/player_off_limits",
"mansion" => "map/decorations/woodland_mansion",
"monument" => "map/decorations/ocean_monument",
"red_x" => "map/decorations/red_x",
"village_desert" => "map/decorations/desert_village",
"village_plains" => "map/decorations/plains_village",
"village_savanna" => "map/decorations/savanna_village",
"village_snowy" => "map/decorations/snowy_village",
"village_taiga" => "map/decorations/taiga_village",
"jungle_temple" => "map/decorations/jungle_temple",
"swamp_hut" => "map/decorations/swamp_hut",
"trial_chambers" => "map/decorations/trial_chambers",
"abandoned_camp" => "map/decorations/abandoned_camp",
"ancient_city" => "map/decorations/ancient_city",
"desert_pyramid" => "map/decorations/desert_pyramid",
"mineshaft" => "map/decorations/mineshaft",
"ocean_ruin_warm" => "map/decorations/warm_ocean_ruins",
_ => return None,
})
let name = id.strip_prefix("minecraft:").unwrap_or(id);
DECORATION_TYPES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, sprite)| *sprite)
}
/// The type a decoration's sprite stands for, as its registry names it.
fn decoration_type(sprite: &str) -> Option<&'static str> {
DECORATION_TYPES
.iter()
.find(|(_, s)| *s == sprite)
.map(|(name, _)| *name)
}
/// `minecraft:map_decorations` of a map item: (sprite, x, z, rotation), in
/// the component's order.
@@ -275,12 +441,29 @@ impl Game<'_> {
}
self.maps.data.get_mut(&id)
}
/// `ClientboundMapItemDataPacket.handle`: the server's colours for a map
/// this client does not own (lobby map art, a framed map on a wall).
/// `ClientboundMapItemDataPacket.handle`: the server's colours and
/// decorations for a map (one carried or framed on a server).
pub(super) fn remote_map_item_data(
&mut self,
p: &horizon_protocol::packets::play::MapItemData,
) {
let decorations = p.decorations.as_ref().map(|list| {
let types = self
.remote
.as_ref()
.and_then(|r| r.builtin.get("minecraft:map_decoration_type"));
list.iter()
.filter_map(|d| {
Some(Decoration {
sprite: decoration_sprite(types?.name(d.kind)?)?,
x: d.x,
y: d.y,
rot: (d.rot & 15) as u8,
holder: None,
})
})
.collect()
});
let dimension = self.dimension_id();
let map = self
.maps
@@ -288,6 +471,9 @@ impl Game<'_> {
.entry(p.map_id)
.or_insert_with(|| MapData::fresh(0.0, 0.0, p.scale.max(0) as u8, dimension));
map.locked = p.locked;
if let Some(decorations) = decorations {
map.decorations = decorations;
}
let Some(patch) = &p.patch else {
map.dirty = false;
return;
@@ -566,10 +752,14 @@ impl Game<'_> {
.collect();
let (yaw, dim) = (self.yaw, self.dimension_id());
let (nether, game_time) = (self.dim == super::Dim::Nether, self.world.game_time);
// The guest whose turn it is, else the host.
let me = self.lan_acting.unwrap_or(HOST);
for (id, targets) in carried {
let Some(m) = self.map(id) else { continue };
m.holder(me).carried = game_time;
m.drop_holders(game_time);
// tickCarriedBy: the player marker while tracking in this dimension.
m.decorations.clear();
let mut marker = None;
if m.tracking && m.dimension == dim {
let s = f64::from(1 << m.scale);
let dx = ((p.x - f64::from(m.center[0])) / s) as f32;
@@ -596,18 +786,19 @@ impl Game<'_> {
} else {
None
};
if let Some(sprite) = sprite {
m.decorations.push(Decoration {
sprite,
x: clamp(dx),
y: clamp(dz),
rot,
});
}
marker = sprite.map(|sprite| Decoration {
sprite,
x: clamp(dx),
y: clamp(dz),
rot,
holder: Some(me),
});
}
m.set_marker(me, marker);
// The item's MAP_DECORATIONS (an explorer map's target), added
// after the players as addDecoration would.
let s = (1 << m.scale) as f32;
let mut items: Vec<Decoration> = Vec::new();
for (sprite, x, z, rotation) in targets {
let dx = (x - f64::from(m.center[0])) as f32 / s;
let dz = (z - f64::from(m.center[1])) as f32 / s;
@@ -620,16 +811,95 @@ impl Game<'_> {
x: clamp_map_coordinate(dx),
y: clamp_map_coordinate(dz),
rot: decoration_rotation(nether, game_time, f64::from(rotation)),
holder: None,
};
if !m.decorations.contains(&d) {
m.decorations.push(d);
if !items.contains(&d) {
items.push(d);
}
}
if !m
.decorations
.iter()
.filter(|d| d.holder.is_none())
.eq(items.iter())
{
m.decorations.retain(|d| d.holder.is_some());
m.decorations.extend(items);
m.decorations_changed();
}
}
for id in held {
self.update_map(id);
}
}
/// The maps the player carries, for `status`: how many of their pixels
/// are drawn, and their decorations.
pub(super) fn maps_status(&self) -> Vec<serde_json::Value> {
self.inventory
.iter()
.chain(std::iter::once(&self.offhand))
.flatten()
.filter_map(map_id)
.filter_map(|id| {
let m = self.maps.data.get(&id)?;
Some(serde_json::json!({
"id": id,
"pixels": m.colors.iter().filter(|&&c| c != 0).count(),
"decorations": m.decorations.iter().map(|d| d.sprite).collect::<Vec<_>>(),
}))
})
.collect()
}
/// `ServerPlayer.synchronizeSpecialItemUpdates` for each map the guest
/// carries, and every 10 ticks (`ServerEntity.sendChanges`) for each map
/// framed in the level: a `ClientboundMapItemDataPacket` when something
/// changed since the guest's last one.
pub(super) fn lan_map_updates(&mut self, host: &LanHost, conn: u32) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let mut ids: Vec<i32> = g
.inventory
.iter()
.chain(std::iter::once(&g.offhand))
.flatten()
.filter_map(map_id)
.collect();
let now = self.world.game_time;
if now % 10 == 0 && host.hub.here(g) {
let framed: Vec<i32> = self
.item_frames
.iter()
.filter_map(|f| f.item.as_ref().and_then(map_id))
.collect();
for id in framed {
if let Some(m) = self.map(id) {
m.holder(conn).framed = now;
ids.push(id);
}
}
}
ids.sort_unstable();
ids.dedup();
let kind = |name: &str| {
host.hub.registry_id(
"minecraft:map_decoration_type",
&format!("minecraft:{name}"),
)
};
for id in ids {
if let Some(p) = self
.maps
.data
.get_mut(&id)
.and_then(|m| m.update_packet(id, conn, kind))
{
g.send(&horizon_protocol::packets::play::Clientbound::MapItemData(
p,
));
}
}
}
/// `MapItem.update` for the player holding map `id`.
fn update_map(&mut self, id: i32) {
let dim = self.dimension_id();
@@ -651,8 +921,9 @@ impl Game<'_> {
if nether {
radius /= 2;
}
m.step += 1;
let step = m.step;
let h = m.holder(self.lan_acting.unwrap_or(HOST));
h.step += 1;
let step = h.step;
let mut changes: Vec<(i32, i32, u8)> = Vec::new();
let mut consecutive = false;
for img_x in px - radius + 1..px + radius {
+73 -33
View File
@@ -95,7 +95,7 @@ fn display_offset(kind: CartKind) -> f64 {
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
}
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
const SEAT: f64 = 0.1875 - 0.6;
pub(super) const SEAT: f64 = 0.1875 - 0.6;
/// The placeholder container key under which an open cart's items live.
pub(super) const CART_KEY_Y: i32 = -100_000;
@@ -686,40 +686,53 @@ impl Game<'_> {
}
}
}
/// `AbstractMinecart.push(player)` when the player walks into a cart.
/// `AbstractMinecart.push` from each player walking into a cart
/// (`LivingEntity.pushEntities`): the window's player and every server
/// player (single player's own on its server thread, LAN guests). A
/// player in a vehicle pushes nothing here. The quarter push the player
/// gets back only moves the server's copy of it (`Entity.push` marks
/// `needsSync`, which `ServerEntity` sends to the other players only),
/// so no player feels it.
fn push_carts(&mut self) {
let player = self.player.as_ref().map(|p| p.position);
let riding = self.riding;
for i in 0..self.carts.len() {
if Some(self.carts[i].id) == riding {
continue;
if self.carts.is_empty() {
return;
}
// (feet, box)
let mut pushers: Vec<(Vec3, Aabb)> = Vec::new();
if self.riding.is_none()
&& let Some(p) = self.player.as_ref()
{
pushers.push((p.position, p.bounds()));
}
if let Some(host) = self.lan.as_ref() {
for g in host.hub.guests.iter().filter(|g| {
host.hub.here(g) && g.loaded && !g.dead && !g.spectator() && g.vehicle.is_none()
}) {
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
let bb = Aabb::new(
Vec3::new(x - 0.3, y, z - 0.3),
Vec3::new(x + 0.3, y + height, z + 0.3),
);
pushers.push((Vec3::new(x, y, z), bb));
}
if let Some(pp) = player {
let bb = self.carts[i].bounds();
let (lo, hi) = (bb.min_position(), bb.max_position());
let touching = pp.x + 0.3 > lo.x
&& pp.x - 0.3 < hi.x
&& pp.z + 0.3 > lo.z
&& pp.z - 0.3 < hi.z
&& pp.y + 1.8 > lo.y
&& pp.y < hi.y;
if touching && riding.is_none() {
let c = &mut self.carts[i];
let (mut xa, mut za) = (pp.x - c.position.x, pp.z - c.position.z);
let dd = xa * xa + za * za;
if dd >= 1.0e-4 {
let d = dd.sqrt();
let pow = (1.0 / d).min(1.0);
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
za = za / d * pow * f64::from(0.1f32) * 0.5;
c.velocity.x -= xa;
c.velocity.z -= za;
if let Some(p) = self.player.as_mut() {
p.velocity.x += xa / 4.0;
p.velocity.z += za / 4.0;
}
}
}
for (feet, bb) in pushers {
for c in &mut self.carts {
if !bb.intersects(c.bounds()) {
continue;
}
let (mut xa, mut za) = (feet.x - c.position.x, feet.z - c.position.z);
let dd = xa * xa + za * za;
if dd < f64::from(1.0e-4f32) {
continue;
}
let d = dd.sqrt();
let pow = (1.0 / d).min(1.0);
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
za = za / d * pow * f64::from(0.1f32) * 0.5;
c.velocity.x -= xa;
c.velocity.z -= za;
}
}
}
@@ -797,7 +810,7 @@ impl Game<'_> {
let ridden = self.guest_vehicles();
// By id: an exploding cart can destroy or prime others mid-loop.
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
for id in ids {
for &id in &ids {
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
};
@@ -823,6 +836,33 @@ impl Game<'_> {
self.carts.remove(i);
}
}
// On the server for every cart, the ridden ones too.
for id in ids {
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let p = self.carts[i].position;
if ticking(p.x.floor() as i32, p.z.floor() as i32) {
self.cart_lava_tick(id);
}
}
}
}
/// `lavaHurt` twice a tick in lava: from `LavaFluid.entityInside` in the
/// behaviour's `applyEffectsFromBlocks`, then from `AbstractMinecart.tick`'s
/// own `isInLava`. The second breaks the cart, or primes a TNT minecart
/// (`MinecartTNT.destroy` for a fire source). A cart has no `baseTick`,
/// so the fire `lavaIgnite` lights never burns it.
fn cart_lava_tick(&mut self, id: u32) {
for _ in 0..2 {
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
return;
};
if !self.in_lava(self.carts[i].bounds()) {
return;
}
let p = self.carts[i].position;
self.hurt_cart(i, 4.0, false, true);
self.lava_hurt_sound([p.x, p.y, p.z]);
}
}
/// `ClientPacketListener.postAddEntitySoundInstance` for the carts that
/// joined the level since the last tick, and `LocalPlayer.startRiding`'s
-16
View File
@@ -237,15 +237,7 @@ impl Game<'_> {
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::OpenShaderPackFolder => {
let dir = horizon_client::shader_pack::folder();
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
Event::ReloadShaderPack => {
self.load_shader_pack();
}
}
}
Ok(())
@@ -294,14 +286,6 @@ impl Game<'_> {
font.reset_cache();
self.ui.chat.rescale(&self.gui.atlas);
}
// A new renderer (resource reload, first window) takes the loaded pack.
let stale = self
.renderer
.as_ref()
.is_some_and(|r| r.shader_pack_id() != self.shader_packs.id());
if stale && let Err(e) = self.apply_shader_pack() {
log::warn!("Horizon: shader pack: {e}");
}
}
/// `Window.updateFullscreenState`: a fullscreen change made by the window
/// system is adopted by the option (`Minecraft.fullscreenStateChanged`).
+30 -17
View File
@@ -4,7 +4,7 @@ use super::Game;
use horizon_client::{
hud::HudAtlas,
lang,
pack_screen::{Compat, PackInfo, Source as PackSource},
pack_screen::{self, Compat, PackInfo, Source as PackSource},
packs::{self, Pack, Position},
text::{Style, Text},
};
@@ -34,10 +34,16 @@ pub fn open_folder(dir: &Path) {
log::warn!("Horizon: could not open {}: {e}", dir.display());
}
}
/// The resource root to load from, given the vanilla root and the selection.
pub fn startup_root(vanilla: &Path, selected: &[String]) -> std::io::Result<PathBuf> {
/// The resource root to load from, given the vanilla root, the selection and
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
pub fn startup_root(
vanilla: &Path,
selected: &[String],
server: &[Pack],
) -> std::io::Result<PathBuf> {
let all = packs::discover(vanilla, &packs_dir());
let enabled = packs::enabled(&all, selected);
let mut enabled = packs::enabled(&all, selected);
enabled.extend_from_slice(server);
packs::resource_root(vanilla, &enabled, &cache_dir())
}
fn info(p: &Pack) -> PackInfo {
@@ -53,14 +59,15 @@ fn info(p: &Pack) -> PackInfo {
PackSource::Default
},
compat: p.compatibility,
icon: p.icon,
icon: p.icon_png().as_deref().and_then(pack_screen::load_icon),
required: false,
position: Position::Top,
fixed: false,
}
}
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`).
fn vanilla_info() -> PackInfo {
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`), with the
/// client JAR's `pack.png` that the prepared resources hold.
fn vanilla_info(vanilla: &Path) -> PackInfo {
PackInfo {
id: "vanilla".into(),
title: Text::translate("resourcePack.vanilla.name", &[]),
@@ -68,7 +75,10 @@ fn vanilla_info() -> PackInfo {
description_style: Style::default(),
source: PackSource::BuiltIn,
compat: Compat::Compatible,
icon: true,
icon: std::fs::read(vanilla.join("pack.png"))
.ok()
.as_deref()
.and_then(pack_screen::load_icon),
required: true,
position: Position::Bottom,
fixed: false,
@@ -80,7 +90,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
.iter()
.map(info)
.collect();
infos.push(vanilla_info());
infos.push(vanilla_info(vanilla));
infos
}
impl Game<'_> {
@@ -113,14 +123,7 @@ impl Game<'_> {
self.pending_pack_reload = true;
return;
}
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let result = startup_root(&vanilla, &self.settings.resource_packs)
.map_err(Into::into)
.and_then(|root| self.reload_resources(&root));
if let Err(e) = result {
if let Err(e) = self.reload_selected_packs() {
log::warn!("Horizon: resource reload failed: {e}");
if self.screen != super::Screen::Options {
self.open_options_at(
@@ -131,6 +134,16 @@ impl Game<'_> {
self.ui.options.resource_error = Some(e.to_string());
}
}
/// Reload from the selected packs and the server's.
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let server = &self.server_packs.active;
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
self.reload_resources(&root)
}
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
+26 -5
View File
@@ -38,9 +38,16 @@ impl Game<'_> {
m.extra.rider = Some(rider);
}
}
/// Riders sit on their vehicles (`Entity.positionRider`), and let go when
/// the vehicle dies or is gone.
/// Riders sit on their vehicles (`Entity.positionRider`), mobs or boats,
/// and let go when the vehicle dies or is gone.
pub(super) fn sync_riders(&mut self) {
// Entity.baseTick.
for m in &mut self.mobs {
if m.extra.boarding_cooldown > 0 {
m.extra.boarding_cooldown -= 1;
}
}
let boats: std::collections::HashSet<u32> = self.boats.iter().map(|b| b.id).collect();
let index: HashMap<u32, usize> = self
.mobs
.iter()
@@ -54,9 +61,10 @@ impl Game<'_> {
};
let valid = self.mobs[i].alive()
&& !self.mobs[i].removed
&& index
.get(&v)
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed);
&& (boats.contains(&v)
|| index
.get(&v)
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed));
if valid {
riding.insert(v, self.mobs[i].id);
} else {
@@ -68,6 +76,19 @@ impl Game<'_> {
for m in &mut self.mobs {
m.extra.rider = riding.get(&m.id).copied();
}
self.position_riders();
}
/// `Entity.positionRider` for every mob passenger: in its boat, then on
/// its mob. A server's client places them itself too, as the server sends
/// no passenger's position.
pub(super) fn position_riders(&mut self) {
self.seat_boat_mobs();
let index: HashMap<u32, usize> = self
.mobs
.iter()
.enumerate()
.map(|(i, m)| (m.id, i))
.collect();
// Riders of riders settle over a few passes.
for _ in 0..3 {
for i in 0..self.mobs.len() {
+99 -56
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)
@@ -243,6 +250,8 @@ impl Game<'_> {
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
{
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
let mut tag = tag;
let riders = self.take_mob_passengers(&mut tag);
self.next_entity += 1;
let id = self.next_entity;
let (wood, chest) =
@@ -263,6 +272,9 @@ impl Game<'_> {
b.velocity = velocity;
self.remember(id, uuid, tag);
self.boats.push(b);
for rider in riders {
self.load_mob(rider, Some(id));
}
}
}
k if super::minecarts::CartKind::from_id(k).is_some() => {
@@ -341,25 +353,10 @@ impl Game<'_> {
// Structure entities placed with finalizeEntities.
let mut tag = tag;
let mut finalize = false;
let mut riders = Vec::new();
let mut unknown = Vec::new();
if let Tag::Compound(c) = &mut tag {
finalize = c.remove(&"horizon:finalize".into()).is_some();
if let Some(Tag::List(list)) = c.remove(&"Passengers".into()) {
for p in list {
let known =
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
if known {
riders.push(p);
} else {
unknown.push(p);
}
}
}
if !unknown.is_empty() {
c.insert("Passengers".into(), Tag::List(unknown));
}
}
let riders = self.take_mob_passengers(&mut tag);
let mut m = entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), self.next_entity)?;
m.extra.mount = mount;
m.extra.passenger = mount.is_some();
@@ -401,6 +398,49 @@ impl Game<'_> {
}
Some(id)
}
/// The mobs under an entity's `Passengers`, taken out of it; the
/// passengers Horizon doesn't simulate stay.
fn take_mob_passengers(&self, tag: &mut Tag) -> Vec<Tag> {
let mut riders = Vec::new();
if let Tag::Compound(c) = tag
&& let Some(Tag::List(list)) = c.remove(&"Passengers".into())
{
let mut unknown = Vec::new();
for p in list {
let known =
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
if known {
riders.push(p);
} else {
unknown.push(p);
}
}
if !unknown.is_empty() {
c.insert("Passengers".into(), Tag::List(unknown));
}
}
riders
}
/// The mobs riding entity `id` under `tag`'s `Passengers`, after the
/// passengers it kept from loading.
fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
let riders: Vec<usize> = (0..self.mobs.len())
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
.collect();
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
if let Tag::Compound(c) = tag {
let mut list = match c.get(&"Passengers".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
list.extend(riders);
if list.is_empty() {
c.remove(&"Passengers".into());
} else {
c.insert("Passengers".into(), Tag::List(list));
}
}
}
/// An entity of a type Horizon does not simulate, kept as its compound.
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
let position = entity_nbt::position(&tag)?;
@@ -476,22 +516,7 @@ impl Game<'_> {
let uuid = self.uuid_for(id);
let mut tag = entity_nbt::mob_to_nbt(&self.mobs[i], uuid, self.persist.raw.get(&id));
self.write_leash(&mut tag, i);
let riders: Vec<usize> = (0..self.mobs.len())
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
.collect();
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
if let Tag::Compound(c) = &mut tag {
let mut list = match c.get(&"Passengers".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
list.extend(riders);
if list.is_empty() {
c.remove(&"Passengers".into());
} else {
c.insert("Passengers".into(), Tag::List(list));
}
}
self.put_mob_passengers(&mut tag, id);
tag
}
/// Forget an entity that left the world (its UUID and loaded compound).
@@ -569,10 +594,15 @@ impl Game<'_> {
let mut out: HashMap<(i32, i32), Vec<Tag>> =
keys.iter().map(|k| (*k, Vec::new())).collect();
let player_uuid = self.level.player_uuid;
// A rider is saved inside its root vehicle, in the root's chunk.
// A rider is saved inside its root vehicle, in the root's chunk; a
// boat's passengers inside the boat.
let mobs: Vec<usize> = (0..self.mobs.len())
.filter(|&i| {
self.mob_root(i) == i
&& self.mobs[i]
.extra
.mount
.is_none_or(|v| self.boats.iter().all(|b| b.id != v))
&& keys.contains(&chunk_of(self.mobs[i].position))
&& self.mobs[i].alive()
})
@@ -619,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);
}
}
@@ -650,25 +682,29 @@ impl Game<'_> {
.push(with_raw(tag, raw.as_ref()));
}
}
for b in &self.boats {
if keys.contains(&chunk_of(b.position)) {
let uuid = self
.persist
.uuids
.get(&b.id)
.copied()
.unwrap_or_else(random_uuid);
let tag = entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
uuid,
self.persist.raw.get(&b.id),
b.chest.then_some(&b.items[..]),
);
out.entry(chunk_of(b.position)).or_default().push(tag);
}
let boats: Vec<usize> = (0..self.boats.len())
.filter(|&i| keys.contains(&chunk_of(self.boats[i].position)))
.collect();
for i in boats {
let b = &self.boats[i];
let (id, at) = (b.id, b.position);
let uuid = self
.persist
.uuids
.get(&id)
.copied()
.unwrap_or_else(random_uuid);
let mut tag = entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
uuid,
self.persist.raw.get(&id),
b.chest.then_some(&b.items[..]),
);
self.put_mob_passengers(&mut tag, id);
out.entry(chunk_of(at)).or_default().push(tag);
}
if let Some(d) = &self.dragon
&& keys.contains(&chunk_of(d.position))
@@ -833,9 +869,16 @@ impl Game<'_> {
}
keep
});
// Riders leave with their root vehicle.
// Riders leave with their root vehicle, a boat's with the boat.
let leaving: HashSet<u32> = (0..self.mobs.len())
.filter(|&i| gone(self.mobs[self.mob_root(i)].position))
.filter(|&i| {
let r = self.mob_root(i);
match self.mobs[r].extra.mount {
Some(v) if removed.contains(&v) => true,
Some(v) if self.boats.iter().any(|b| b.id == v) => false,
_ => gone(self.mobs[r].position),
}
})
.map(|i| self.mobs[i].id)
.collect();
self.mobs.retain(|m| {
Loaded 100 of 898 files, more files were not shown because too many files have changed in this diff. Show more