Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 660a170f61 Count packets per second on F3 and show the server line
The F3 tps line printed a literal "0 tx, 0 rx" and was hidden on servers.
Horizon now averages the connection's packet counts once a second as
Connection.tick does, shows them on the integrated line when single player
runs through its server connection, and shows vanilla's "<brand>" server
line on a remote server with the brand the server sent.

Fixes #564

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 03:23:56 +02:00
Ludovic Marie ad5f05a8c7 Merge pull request #606 from Online5277/fix/inventory-break-progress
Clear block breaking progress when opening inventory
2026-10-17 02:39:14 +02:00
Cosmic 1fc3f6724e Cancel block mining immediately when opening screens 2026-10-16 19:59:23 -04:00
Ludovic Marie e422bd138a Merge pull request #595 from ludovic111/fix/prepare-again-wording
Name the launcher's real button in the incomplete-resources error
2026-10-17 01:46:40 +02:00
Ludovic MarieandClaude Opus 5.5 2b1cd3e96e Name the launcher's real button in the incomplete-resources error
The launcher offers "Prepare again", not a Repair action.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 01:42:02 +02:00
Ludovic Marie 64a4c72ebd Merge pull request #574 from Online5277/fix/direct-connect-address
Remember Direct Connection server addresses
2026-10-17 01:37:47 +02:00
Ludovic Marie 69d03e546a Merge pull request #575 from Online5277/fix/quit-button-sound
Preserve quit button sounds when leaving a world
2026-10-17 01:37:43 +02:00
Ludovic Marie 0f3a9e1508 Merge pull request #576 from Online5277/fix/prepared-registry-reports
Package registry reports and show recoverable server resource errors
2026-10-17 01:37:39 +02:00
Cosmic d652b25ead Package registry reports and recover from incomplete server resources 2026-10-16 19:12:40 -04:00
Cosmic 43d5ed8246 Play menu button clicks after world teardown 2026-10-16 18:52:00 -04:00
Cosmic e6068b1246 Remember Direct Connection addresses in both menus 2026-10-16 18:51:25 -04:00
Ludovic Marie f535d1b48c Merge pull request #556 from ludovic111/feat/horizon-bots
horizon-bots and tools/server_bench.py: the first server benchmark against vanilla
2026-10-17 00:47:54 +02:00
Ludovic Marie ee3a160cbd Merge pull request #555 from ludovic111/feat/horizon-server-panel
horizon-server: the web panel
2026-10-17 00:47:51 +02:00
Ludovic Marie 652e547b53 Merge pull request #554 from ludovic111/feat/horizon-server-packaging
horizon-server: install scripts, a Docker image, and boot services
2026-10-17 00:47:47 +02:00
Ludovic Marie f3f2301dce Merge pull request #553 from ludovic111/feat/horizon-server-backups
horizon-server: backups, restore and automatic backups
2026-10-17 00:47:43 +02:00
Ludovic Marie f04562c640 Merge pull request #552 from ludovic111/feat/horizon-server-control
horizon-server: start, stop, status, cmd and logs through a local control API
2026-10-17 00:47:39 +02:00
Ludovic Marie 000fafa9bd Merge pull request #551 from ludovic111/feat/horizon-server
horizon-server: server.toml, init, and a supervisor that runs the game
2026-10-17 00:47:35 +02:00
Ludovic Marie 7d57523c63 Merge pull request #572 from ludovic111/fix/falling-damage-pack
Fix main build: falling anvil wear reads blocks from self.pack
2026-10-17 00:47:32 +02:00
Ludovic MarieandClaude Opus 5.5 2e2645ba9d Fix main build: falling anvil wear reads blocks from self.pack
#528 was tested before #537 moved the asset registry from Resources to
the reloadable pack, so main stopped compiling after both merged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:35:45 +02:00
Ludovic Marie b324ee7e3a Merge pull request #528 from Online5277/fix/falling-anvil-damage
Apply falling anvil impact damage and wear
2026-10-17 00:31:30 +02:00
Ludovic Marie 19aac92bb7 Merge pull request #545 from ludovic111/perf/integrated-memory-connection
Integrated server: join through memory instead of a socket
2026-10-17 00:11:50 +02:00
Ludovic Marie 44c3ebb0d8 Merge pull request #544 from ludovic111/perf/integrated-server-thread
Run single player on an integrated server thread (opt-in)
2026-10-17 00:11:47 +02:00
Ludovic Marie 4e9495175c Merge pull request #538 from ludovic111/perf/game-constructor
Build the game in Game::new instead of inside run()
2026-10-17 00:11:42 +02:00
Ludovic Marie 9726af184b Merge pull request #537 from ludovic111/perf/resources-shared-split
Split Resources into shared read-only data and per-game pack state
2026-10-17 00:11:38 +02:00
Ludovic Marie 5dac64f770 Merge pull request #561 from ludovic111/perf/section-culling-columns
Renderer: cull sections by chunk column from a dense list (section walk 5x faster, exact)
2026-10-16 23:04:53 +02:00
Ludovic Marie 89740d39e0 Merge pull request #560 from ludovic111/perf/skip-indirect-validation
Renderer: skip wgpu's indirect-draw validation in release builds (−0.05..0.11 ms/frame of render thread at RD 32)
2026-10-16 23:04:01 +02:00
Ludovic MarieandClaude Opus 5.5 167339c03c Renderer: cull sections by chunk column, from a dense list
The per-frame section walk iterated a SipHash map of every resident
section, then looked each visible one up twice more by key. Resident
sections now live in a dense list grouped by chunk column: one distance
and frustum test rejects a whole column (exact: a section's box lies in
its column's box and the plane test rounds monotonically), the rest are
reached by slot, and the visible list sorts on integer keys with ties
broken by section key, so the order no longer depends on hash order.
FaceGroups looks its axes up in a table instead of searching for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:54:17 +02:00
Ludovic Marie 9b2825df34 Merge pull request #517 from Online5277/fix/world-list-text-details
Correct Classic world-list search hints and text placement
2026-10-16 22:32:01 +02:00
Ludovic Marie 7a27781ab5 Merge pull request #512 from Online5277/fix/classic-graphics-duplicates
Remove duplicated settings from Classic graphics submenus
2026-10-16 22:31:55 +02:00
Ludovic Marie af15fb1a98 Merge pull request #511 from Online5277/fix/cloud-face-alpha
Preserve cloud opacity when shading faces
2026-10-16 22:31:49 +02:00
Ludovic Marie 4ff55f7a99 Merge pull request #510 from Online5277/fix/fullscreen-all-screens
Handle fullscreen shortcut across menus and text fields
2026-10-16 22:31:42 +02:00
Ludovic MarieandClaude Sonnet 5.5 6b86fa9bef Merge feat/horizon-server-panel into feat/horizon-bots
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:30:45 +02:00
Ludovic MarieandClaude Sonnet 5.5 b9d4ef3194 Merge feat/horizon-server-packaging into feat/horizon-server-panel
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:28:38 +02:00
Ludovic MarieandClaude Sonnet 5.5 949751656a Merge feat/horizon-server-backups into feat/horizon-server-packaging
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:28:35 +02:00
Ludovic MarieandClaude Sonnet 5.5 500241cbc6 Merge feat/horizon-server-control into feat/horizon-server-backups
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:28:18 +02:00
Ludovic MarieandClaude Sonnet 5.5 2483f90779 Merge feat/horizon-server into feat/horizon-server-control
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:28:06 +02:00
Ludovic Marie 07f7e3aeab Merge remote-tracking branch 'origin/main' into srv-fix
# Conflicts:
#	Cargo.toml
2026-10-16 22:27:25 +02:00
Ludovic Marie a1b0d95db1 Merge pull request #557 from ludovic111/docs/server-design
docs/server.md: hosting a Horizon server, the design
2026-10-16 22:25:05 +02:00
Ludovic MarieandClaude Opus 5.5 9c03535011 docs/server.md: hosting a Horizon server, the design
What the dedicated server does today (measured against vanilla 26.3), the horizon-server supervisor and
its packaging, server.toml, the CLI and TUI, the web panel, backups and updates, the control API and MCP,
the bot benchmark plan against vanilla and Paper, the working version for 2026-10-23, and the PRs that
build it (#551 to #556).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:22:56 +02:00
Ludovic MarieandClaude Opus 5.5 820936b975 horizon-bots and tools/server_bench.py: the first server benchmark against vanilla
horizon-bots is a load client on horizon-protocol for any 26.3 server: offline login, configuration,
keep-alives, pings, teleports, chunk-batch acknowledgements, and movement at 20 Hz. The bots play in
creative and fly above their spawn point, so they need no world of their own and no server refuses their
moves; idle, walk, explore and mixed behaviours are deterministic from a seed.

tools/server_bench.py runs the same bots on Horizon's server (horizon-server run) and on the pinned vanilla
jar, each fresh from the same server.properties, and measures MSPT from each server's own /tick query,
RSS and CPU of the process tree, the chunks the bots receive, and the start time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:21:19 +02:00
Ludovic MarieandClaude Opus 5.5 7f77772e15 horizon-server: the web panel
One page embedded in the binary (crates/horizon-server/panel: HTML, CSS and JS, no framework or CDN): status,
live TPS, MSPT, RAM and CPU graphs over the last 10 minutes, players with op and kick, the console, and
backups with restore, plus restart and stop.

It listens on [panel] bind, 127.0.0.1:8130 by default; a tailnet address reaches it from the owner's
devices, and any other address is refused unless allow_public is set. `horizon-server panel` prints a
one-time login link (10 minutes) that becomes an HttpOnly, SameSite=Strict session cookie. The Host header
must name the panel, every POST needs X-Horizon: 1, failed logins are rate-limited, and the page is served
with a strict Content-Security-Policy. The control API gains GET /api/v1/metrics and, with the token
only, POST /api/v1/panel/login.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:18:46 +02:00
Ludovic Marie 36b3fef0ed Merge pull request #533 from ludovic111/feat/port-pack-carry
horizon-port-pack carry: a jar's data and assets as a content mod
2026-10-16 22:13:21 +02:00
Ludovic Marie e21919c114 Merge pull request #532 from ludovic111/feat/port-pack
horizon-port-pack: import a modpack and classify its mods
2026-10-16 22:13:13 +02:00
Ludovic Marie cff7a66881 Merge pull request #531 from ludovic111/feat/mod-content-game
Content mods in the game: loot, recipes, models, names and icons from mod namespaces
2026-10-16 22:13:05 +02:00
Ludovic Marie 0c532fc1b0 Merge pull request #530 from ludovic111/feat/mod-content-root
Content mods in the resource root: template rows appended to every block table
2026-10-16 22:12:57 +02:00
Ludovic Marie cbb8c6a239 Merge pull request #523 from ludovic111/feat/mod-content
Content mods (API 0.2): .hmod assets/ and data/, blocks and items from vanilla templates
2026-10-16 22:12:48 +02:00
Ludovic MarieandClaude Opus 5.5 db45b6f87a Integrated server: join through memory instead of a socket
The window now joins its server thread through an in-memory pipe, as
vanilla's integrated server player does (LocalChannel), rather than a TCP
socket on 127.0.0.1. The server opens no port until it is opened to the LAN.

- horizon-protocol: Connection runs over TCP or one end of a Pipe (a
  blocking byte pipe with read timeouts and shutdown; dropping an end
  closes it).
- Lan: NetConfig.listen false starts no listener; connect_local serves the
  other end of a pipe on its own thread, through the same handshake, login
  and configuration as a socket.
- Session::connect_local joins through a pipe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:06:13 +02:00
Ludovic MarieandClaude Opus 5.5 56dc4352d2 Run single player on an integrated server thread (opt-in)
With --server-thread or HORIZON_SERVER_THREAD=1, opening a single-player
world starts a game of its own on a "Server thread" over the shared
resources, as vanilla's IntegratedServer does, and the window joins it as a
player over a local connection. The world tick then never runs inside a
frame.

- The server listens on 127.0.0.1 only, lets in the window's profile alone,
  without compression, and makes it an operator when the world allows
  commands.
- The window's player is the world's own player: it loads from and saves to
  players/data/<singleplayer_uuid>.dat, so the world opens the same way in
  either mode.
- A pause screen pauses the server (IntegratedServer.tickServer), and
  leaving the world or quitting waits for the server to save.
- The integrated server does not mesh sections it never draws.
- Worlds whose player is outside the Overworld open the usual way for now.
- The bench summary reports the 0.1% low.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:06:12 +02:00
Ludovic MarieandClaude Opus 5.5 c2f4ecc188 Build the game in Game::new instead of inside run()
run() loads the shared resources and the pack, then hands them to a
constructor that builds a game with no world open. The same constructor will
build an integrated server's game on its own thread. The struct literal moves
unchanged; Options derives Clone so a server can start from the client's.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:06:12 +02:00
Ludovic MarieandClaude Opus 5.5 c57c409f40 Split Resources into shared read-only data and per-game pack state
Resources now holds only what is loaded once and never changes (blocks,
items, recipes, biomes, mobs, collision, motion) and is Send + Sync behind
an Arc, so an integrated server thread can share it with the client.

What a resource pack reload replaces moves to PackResources (assets,
meshing context, item renderer, names, panorama, clouds, forcefield), built
by one loader used at startup and on reload. The client-only mutable state
(the HUD atlas with its font cache and skin slots, fog state, screen effects)
becomes the game's own fields.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:06:12 +02:00
Ludovic Marie a08add231a Merge pull request #521 from Online5277/fix/villager-hat-metadata
Honor villager and zombie villager hat metadata
2026-10-16 21:33:06 +02:00
Ludovic Marie c7ee8baa25 Merge pull request #522 from Online5277/fix/classic-option-tooltips
Show translated hover tooltips in Classic options
2026-10-16 21:32:59 +02:00
Ludovic Marie 47182eccbf Merge pull request #546 from Online5277/fix/chest-slot-nine
Show the tenth storage slot in chests
2026-10-16 21:32:52 +02:00
Ludovic MarieandClaude Opus 5.5 05d1b92d56 Renderer: skip wgpu's indirect-draw validation in release builds
wgpu validates every indirect draw with a compute pass and a per-draw
batcher before the render pass, for untrusted WebGPU content. The section
draws are the only indirect draws and are in bounds by construction; the
validation cost about 0.1 ms of the render thread per frame at render
distance 32 on Metal. Debug builds keep it, and
WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 21:04:17 +02:00
Ludovic Marie cce44ad97d Merge pull request #514 from ludovic111/perf/stronghold-rings-on-all-workers
Stronghold ring searches run on every worker that needs them
2026-10-16 20:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 24bb3f97aa Stronghold ring test: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:35:59 +02:00
Cosmic bfb6a0458d Restrict crafter result rendering to crafter menus 2026-10-16 14:33:44 -04:00
Ludovic MarieandClaude Opus 5.5 ed6af00ff8 horizon-server: install scripts, a Docker image, and boot services
- packaging/server/install.sh (Linux, macOS) and install.ps1 (Windows) install the latest release (or
  --version) from GitHub, check the SHA-256 GitHub publishes for it, and keep releases side by side:
  <dir>/current points at the one in use, so an update or a rollback is one link, and the game files
  prepared from the owner's Minecraft stay in <dir>/game-files across updates.
- packaging/server/Dockerfile builds the server and the engine; the prepared game files are mounted
  read-only, and the first start writes server.toml from HORIZON_EULA, HORIZON_WORLD, HORIZON_SEED,
  HORIZON_PORT, HORIZON_WHITELIST, HORIZON_PLAYERS and HORIZON_OP (`init` reads them too).
- `horizon-server service install|uninstall|print` writes and loads a systemd unit (user, or --system),
  a launchd agent, or a Windows task at logon. It runs the installer's launcher, so updates apply on
  the next restart.
- Releases build horizon-server and ship it beside horizon-ctl and horizon-prepare; PLAY.txt says how
  to run a server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:20:09 +02:00
Ludovic MarieandClaude Opus 5.5 3510764cc6 horizon-server: backups, restore and automatic backups
`backup [NAME]` zips the world and the settings into backups/; on a running server it turns saving
off, flushes, zips and turns saving back on, so the game stays up. `restore BACKUP` puts a backup's
world in place and keeps the current one in backups/replaced-<time>/; a running server restarts with
it. `[backups] every = "6h"`, `keep = 12` in server.toml back up on a schedule and prune the
automatic backups only. The control API gains GET/POST /api/v1/backups and POST /api/v1/restore.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:20:09 +02:00
Ludovic MarieandClaude Opus 5.5 e8832190d1 horizon-server: start, stop, status, cmd and logs through a local control API
The supervisor serves a small HTTP API on 127.0.0.1 (a token in .horizon/api.json, mode 0600, and a
Host check): status, logs, console commands, stop and restart. `start` runs the server in the
background and returns once the game is up; `restart` and `cmd` wait for it too. A sampler reads the
game's tick rate, tick times, players and chunks every second over the existing control endpoint
(`--control-socket`), and RAM and CPU from the system.

The dedicated loop now records its tick times in the game clock, so `/tick query` reports real
numbers on a dedicated server, as it does in single player. The changes to game/dedicated.rs are
additive: the log capture for command replies, the tick counter and the control requests
(game/dedicated/status.rs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:20:09 +02:00
Ludovic MarieandClaude Opus 5.5 9d21209ae8 horizon-server: server.toml, init, and a supervisor that runs the game
The first part of hosting a Horizon server with one small program
(docs/server.md). horizon-server is a new 1.3 MB binary with no window,
GPU, audio or controller code; it runs the game headless
(horizon-client --dedicated) as its engine.

- horizon-server-files: server.properties, eula.txt and the user lists,
  moved out of horizon-client unchanged so both programs share vanilla's
  exact reading and writing; horizon_client::server re-exports it.
- server.toml: typed settings for what owners change, [properties] for
  any other vanilla key, vanilla 26.3's defaults (a test checks them
  against ServerProperties). It is written into server.properties before
  every start; keys the game changes there itself (/whitelist on) are
  copied back afterwards, keeping the owner's comments (toml_edit).
  `import` turns a vanilla server.properties into server.toml.
- init: the EULA (only a yes writes eula=true), world, seed, port (checked
  free), whitelist and first operator; every question has a flag, so it
  also runs unattended.
- run: the console on stdin, a clean `stop` on Ctrl-C, SIGTERM or SIGHUP
  (the game runs in its own process group), and a restart when the game
  crashes: 5 s, doubling to 5 min, giving up after 5 crashes in 10 min.
  One supervisor per folder (a file lock).
- op, deop and whitelist for a stopped server, with vanilla's messages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:20:09 +02:00
Ludovic Marie b4ad65386c Merge pull request #507 from ludovic111/perf/collision-scan-empty
Collision scans: skip blocks with no shape, build the entity shape lazily (−6 % tick time)
2026-10-16 20:01:11 +02:00
Ludovic Marie 9d75d839aa Merge pull request #524 from ludovic111/perf/datapack-walk
Data packs: walk only the folders functions, tags and predicates live in
2026-10-16 20:00:55 +02:00
Ludovic Marie 38640fc845 Merge pull request #509 from ludovic111/perf/poi-state-table
Villagers: each block state's POI type from a table built once (−11 % CPU at world load)
2026-10-16 20:00:49 +02:00
Ludovic Marie 586acadd81 Merge pull request #508 from ludovic111/perf/mob-push-grid
Mobs: find the mobs to push through a grid, not a scan of every mob (−14 % tick time)
2026-10-16 20:00:42 +02:00
Ludovic Marie 72ec93a089 Merge pull request #506 from ludovic111/perf/supporting-block-cells
Mobs: shape only the cells that can support them (−11 % tick time)
2026-10-16 20:00:36 +02:00
Ludovic Marie 020e01746f Merge pull request #516 from ludovic111/perf/streaming-bench
streaming_bench: the chunk pipeline on its worker pool, without a window
2026-10-16 19:32:58 +02:00
Ludovic MarieandClaude Opus 5.5 be6a9d2cb5 Stronghold ring test: wrap the seed + 1 for i64::MAX
The threaded test added seed + 1 for seed = i64::MAX, which overflows in debug
builds (CI's verify).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:32:45 +02:00
Ludovic Marie 6754c52e82 Merge pull request #520 from ludovic111/perf/frame-uploads
Per-frame uploads through the staging belt; skip unchanged lightmaps
2026-10-16 19:27:32 +02:00
Ludovic MarieandClaude Opus 5.5 673ac84724 horizon-port-pack carry: a jar's data and assets as a content mod
carry <work> [<mod id>...] turns each content mod with a jar into a folder
content mod (API 0.2) in <work>/mods/<id>/carry/, for local use:

- assets/<id>/, data/<id>/ and data/minecraft/tags/ are copied; other
  namespaces and vanilla data go to outside/ for review; no class file is
  copied.
- Vanilla-type recipes get 26.3 ingredients ("id", "#tag", lists);
  custom types and NeoForge conditions are kept and listed.
- Item models get the items/ client definitions 1.21.4 introduced.
- Each block is declared from a vanilla template guessed from its
  blockstate properties; unknown property sets are listed. Items
  without a block are declared from minecraft:stick.

The test builds a small NeoForge jar, carries it over, and reads the
result with horizon-mods' own Content reader.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:25:28 +02:00
Ludovic MarieandClaude Opus 5.5 85858610bf horizon-port-pack: import a modpack and classify its mods
A CLI for the first steps of docs/modpacks.md's pipeline:

- import: a Modrinth .mrpack, a CurseForge zip or a Prism / MultiMC
  instance (or a folder of jars) to pack.json: Minecraft and loader
  versions, each file with its hash and sides, and the overrides.
- inventory: Modrinth metadata by SHA-1 (cached in modrinth.json, so
  reruns work offline), and the jars when the user's instance has them
  (neoforge.mods.toml / mods.toml / fabric.mod.json and a file census,
  without decompiling). Each mod gets a class and API tier from the
  reviewed ATM10 table, then rules on categories, sides and the census,
  and a dependency layer. Writes inventory.json and inventory.csv.

Only metadata is downloaded; jars are never fetched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:25:04 +02:00
Ludovic MarieandClaude Opus 5.5 ac7b7f98c0 Content mods in the game: loot, recipes, models, names and icons from mod namespaces
The game builds the content root from the enabled content mods at startup.
Loaders that only read the minecraft namespace now also read the content
mods' namespaces (from the root's marker file): block loot tables, recipes,
blockstates, language files, and the HUD atlas's item sprites. /give and the
command registries know the new items and blocks. A block without a
blockstate of its own takes its template's, so every state has a model.

Adds examples/ruby-content, a folder content mod: a ruby block and a ruby
item with recipes, loot, tags, models, textures and names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:23:35 +02:00
Ludovic MarieandClaude Opus 5.5 002cfcd859 Content mods in the resource root: template rows appended to every block table
horizon_client::mod_content builds the resource root for content mods as
an overlay over the base root (vanilla, or the resource-pack overlay):
- the base is linked;
- the block and item tables are rewritten, each mod block getting its
  template's rows appended. That covers 13 JSON tables (facts, gameplay,
  behaviours, models, render, landing, items, command registries, mob
  data, worldgen flags, structure, survival) and 6 NBT ones (occlusion,
  collision, support, movement, worldgen block properties). Names,
  translation keys and in-table state ids move over, and the declared
  overrides apply;
- each mod's own assets/ and data/ are copied in;
- tag additions are merged into the tag files and both block-tag bundles.

Vanilla state ids never move: mod states come after them, mods and blocks
in id order. Rewritten JSON keeps its key order. With no content mod the
root is the base itself. The game doesn't call this yet.

Tested over the real prepared resources: every loader reads the overlay
(Assets, BlockFacts, Items, collision, visibility, light, motion, worldgen
GeneratorData). The mod blocks carry their templates' facts with the
overrides applied, and vanilla's rows and ids are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:23:35 +02:00
Ludovic MarieandClaude Opus 5.5 746c888cd3 horizon-mods: content mods (API 0.2), blocks and items declared from vanilla templates
A .hmod may carry assets/ and data/ beside mod.wasm, or instead of it. The
manifest needs api = "0.2" and the new registries capability ("add blocks
and items to your worlds"). Content mods have no WIT interface: code still
links API 0.1.

horizon_mods::content::Content reads a mod's content and checks it:
- plain resource paths only;
- 65,536 files, 16 MiB each, 512 MiB in all;
- files in the mod's own namespace, plus additions to vanilla tags;
- blocks and items declared in data/<id>/horizon/{block,item}/*.json, each
  copying a vanilla template with typed overrides (hardness, resistance,
  light, tool, friction, map colour, sounds; stack size, rarity), unknown
  keys refused.

Mods::load refuses an enabled mod whose content fails these checks, with
the reason on its row. A content-only mod is on with no engine and no hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:23:35 +02:00
Cosmic c7073db8b5 Apply falling anvil impact damage and wear 2026-10-16 12:47:49 -04:00
Ludovic Marie 71635fbab7 Merge pull request #518 from ludovic111/docs/modpacks
Modpacks design: port any pack, with ATM10's real mod list classified
2026-10-16 18:31:56 +02:00
Ludovic Marie dd7282c9da Merge pull request #513 from Online5277/ci/skip-rust-for-docs-and-website
Skip Rust verify steps for website and documentation PRs
2026-10-16 18:31:28 +02:00
Cosmic 4c3b2b789f Draw translated hover tooltips for Classic options 2026-10-16 12:20:41 -04:00
Ludovic MarieandClaude Opus 5.5 0981e0abcf Data packs: walk only the folders functions, tags and predicates live in
Loading a world read every file name under a folder pack's data/ to find
functions, function tags, predicates and item modifiers. The built-in pack
holds about 9,900 other data files, so this walk took 190-340 ms on the
render thread at every world load. A folder pack now walks
data/<namespace>/<kind>/ for those four kinds only, in the same order as
before; zip packs keep filtering their index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 18:19:46 +02:00
Ludovic Marie 0cdcbd17ba Merge pull request #502 from ludovic111/fix/animated-texture-tick-spikes
Animated textures: cache every animation frame's mip chain
2026-10-16 18:16:10 +02:00
Cosmic c47dc83eab Honor villager texture hat metadata in type layers 2026-10-16 12:16:05 -04:00
Ludovic MarieandClaude Opus 5.5 a06367edf1 docs: modpacks design, with ATM10's real mod list classified
docs/modpacks.md: how any pack (ATM10 down to small Fabric packs) gets to
Horizon: import (.mrpack, CurseForge zip or instance, Prism, packwiz),
inventory and classification, dependency order, mechanical carry-over of
data and assets, one agent per mod for code with a per-mod harness against
a dev-only NeoForge/Fabric oracle, and a coverage report. Also the mod API
gaps against 0.1, the registries 0.2 design (template blocks through the
overlay root), licence handling, how "10x" is measured against NeoForge,
and the scope for 2026-10-23.

docs/modpacks/atm10-mods.csv: ATM10's 495 jars from the pack's own
crash_assistant modlist (2026-10-15), with Modrinth metadata looked up by
hash: class, API tier, licence, sides. Metadata only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 18:08:23 +02:00
Cosmic 5bc981e577 Match world-list search hints and detail placement 2026-10-16 12:07:39 -04:00
Ludovic Marie bd8dff2076 Merge pull request #515 from ludovic111/fix/release-smoke-world-frames
Release smoke run: count frames drawn in the world
2026-10-16 18:00:07 +02:00
Ludovic MarieandClaude Opus 5.5 69b3eb1864 streaming_bench: the chunk pipeline on its worker pool, without a window
Fills an area with N workers, optionally flies the centre along +x, and
reports wall time, chunks generated, sections meshed, how often a column
near the player is still unlit, and what Streaming::update costs the
calling thread. Allocations can be counted with
HORIZON_BENCH_COUNT_ALLOCATIONS=1 at build time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:53:49 +02:00
Ludovic MarieandClaude Opus 5.5 ece9ab2b79 Release smoke run: count frames drawn in the world
The macOS release build has failed since 05:54 UTC on 2026-10-16: on the
self-hosted Mac mini runner the game drew its 1500 frames in about 2.5 s,
before the world had started generating (chunks 0, player null), so no
release has been published since v2026.10.16.03.

--world-frames N exits after N frames drawn with the player in the world.
It is otherwise a frame-limited run (classic menus, --timeout-seconds), and
--frames keeps its meaning for the harnesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:53:47 +02:00
Ludovic MarieandClaude Opus 5.5 b9d6d1c7d9 Stronghold ring searches run on every worker that needs them
The 128 stronghold ring positions each need a biome search over a 57x57
quart area. The first worker to ask computed all of them inside a OnceLock
while every other worker blocked on it: about 2 s on an M4 Pro with 12 of
13 workers idle, after every world load and every return to the Overworld.

Vanilla's generateRingPositions runs each search as its own task on the
background executor. Now the ring chunks and search randoms are planned
serially (cheap), and every thread that needs the rings takes searches
until none are left, then waits for the rest. Positions are unchanged:
each search only uses its own forked random.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:52:23 +02:00
Cosmic b27e4b9323 Skip Rust verify steps for content-only pull requests 2026-10-16 11:46:49 -04:00
Cosmic 65f9819686 Limit Classic graphics submenus to additional settings 2026-10-16 11:43:31 -04:00
Cosmic e86b05333e Keep cloud alpha independent of face shading 2026-10-16 11:40:17 -04:00
Cosmic 18dc7e7d8a Handle fullscreen shortcut before screen input 2026-10-16 11:38:12 -04:00
Ludovic Marie 8c5efc54d8 Merge pull request #503 from ludovic111/perf/section-face-culling
Skip section face groups that face away from the eye
2026-10-16 17:31:08 +02:00
Ludovic MarieandClaude Opus 5.5 f7d042c6d0 Skip section face groups that face away from the eye
The mesher groups each section's solid quads by the axis side they face
(Sodium's ModelQuadFacing) and records each group's nearest plane. When
the eye is behind every plane of a group, back-face culling would discard
all of it after vertex shading, so the renderer leaves the group out of
the section's draws. Seen from the village path, 29-36% fewer solid quads
reach the GPU at render distances 8-32; the picture is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:56:43 +02:00
Ludovic MarieandClaude Opus 5.5 55945ee975 Collision scans: skip blocks with no shape, build the entity shape lazily
BlockCollisionScan asked every cursor cell for its collision shape,
moved it into place and intersected it with Shapes.create(box), even
for air. Mob movement and every other Entity.collide spent most of
their time there. Now:

- CollisionBlock carries empty_shape, set by the registry for states
  with no contextual rule and an empty cached shape (air, plants): the
  scan skips them without asking. Providers that build CollisionBlock
  themselves (the query-order fixture) leave it false, so their query
  logs are unchanged.
- A shape whose bounds miss the box is skipped before moving it:
  joinIsNotEmpty(AND) of the two is false anyway.
- Shapes.create(box) is built the first time a shape is not the unit
  cube (finite bounds always make one), not for every scan.
- SceneCollisions hands the scan the resident chunk itself, so a cell
  costs one section read instead of a chunk-map lookup.

Results are unchanged: running both paths side by side over 900,000
mob moves in a 600-mob village gave bit-identical movement.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:40:22 +02:00
Ludovic MarieandClaude Opus 5.5 bc4d0cc806 Villagers: know each block state's POI type from a table built once
PoiTypes.forState ran per palette entry of every section a villager
searched (and per block of the sections that hold one), with a string
match on the block name each time and a per-search hash map in front.
At world load, when every villager looks for a job site, bed and
meeting point at once, that was 16 % of the tick. The POI type of
every state is now worked out once, the first time a villager looks,
and read from a table after that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:40:22 +02:00
Ludovic MarieandClaude Opus 5.5 8a48f3b383 mob_ai probe: keep the snapshot grid as the game does
The probe builds MobWorld itself; it now keeps the same SeenGrid over
its snapshot for the mob ticks, so the mob_ai harness checks pushes
through the grid against vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:40:22 +02:00
Ludovic MarieandClaude Opus 5.5 54acb709e4 Mobs: shape only the cells that can support them
Entity.checkSupportingBlock runs findSupportingBlock after every move of
a mob on the ground. Horizon looked up and built the collision shape of
every cell of the cursor (27 to 48 per call, twice when it falls back
to where the mob came from). Like BlockCollisions' cursor, a block whose
shape stays inside its own cell (neither hasLargeCollisionShape nor a
moving piston) can only meet the area when its cell does, so only those
cells, and large shapes such as fences, are shaped now. The pick is a
total order, so visiting column by column finds the same block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:40:22 +02:00
Ludovic MarieandClaude Opus 5.5 4f1735170e Mobs: find the mobs to push through a grid, not a scan of every mob
LivingEntity.pushEntities compared each mob's box with every other mob
of the tick's snapshot: quadratic, about 1 ms per tick with 600 mobs.
The snapshot is now also kept in a grid of 4-block columns by each box's
minimum corner, updated as each mob finishes its tick, and the push
reads only the columns its box can reach. Candidates come back in
snapshot order, so the pushes, their order and their sums are the same:
over 800,000 pushes in a 600-mob village both ways gave the same
targets in the same order. A query wider than the grid (or not finite)
falls back to the full scan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:40:22 +02:00
Ludovic MarieandClaude Sonnet 5.5 dbe28edc9b Merge origin/main: use #501's staging belt, keep only the animation cache change
Drop this branch's own staging buffer (gfx/uploads.rs); the sprite uploads
go through the StagingBelt from #501. What stays is the animation mip-chain
cache holding every vanilla frame, with tests.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 16:40:11 +02:00
Ludovic MarieandClaude Opus 5.5 baaa5e7374 Per-frame uploads through the staging belt; skip unchanged lightmaps
Renderer::render's buffer writes (globals, section origins and draws,
entity, cloud, celestial, border, debug-line and screen-effect vertices)
and the HUD's vertices now go through the frame's staging belt instead of
one Queue::write_buffer staging buffer each. The lightmap is uploaded only
when its pixels change instead of every frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:39:01 +02:00
Ludovic Marie e4b336fe6d Merge pull request #501 from ludovic111/perf/animated-sprite-uploads
Upload animated sprites through a reused staging belt
2026-10-16 16:31:11 +02:00
Ludovic MarieandClaude Opus 5.5 1e1c3b79e5 Stop animated textures from slowing every tick's frame
Every frame that crossed a tick re-uploaded the animated block sprites, and
that took about 1.8 ms on an M4 Pro (more than the world tick itself):

- The mip-chain cache held 8 MiB, which fits 256 of the 594 vanilla
  animation frames (64-pixel layers, chains stored with spare capacity), so
  the other 338 frames had their resize and mip chain rebuilt on the render
  thread whenever their animation reached them. The budget is now 32 MiB and
  chains are allocated at their exact size: all 594 frames fit in 13 MB.
- Each changed sprite mip level went through its own Queue::write_texture,
  which allocates and frees a GPU staging buffer per call (a few hundred a
  tick). They are now packed into one reused buffer, written once, and
  copied with one submission per tick.

The texels are unchanged: a GPU test compares the batched copies with
write_texture over several flushes, and the existing cache tests compare
cached and generated chains.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:09:21 +02:00
Ludovic MarieandClaude Opus 5.5 9e73a8417f Upload animated sprites through a reused staging belt
Queue::write_texture creates a new staging buffer for every call, a kernel
call each on Metal. Animated sprites write every changed frame's mip
levels once per tick, so tick frames spent about 1.3 ms in buffer creation.
The copies now go through one wgpu StagingBelt recorded into the frame's
encoder; a GPU readback test checks they write the same texels as
write_texture on every mip level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 15:42:57 +02:00
Ludovic Marie 65e0ec9f97 Merge pull request #493 from ludovic111/docs/drop-sprint-known-issue
Release notes: drop the sprint-jumping known issue
2026-10-16 15:30:43 +02:00
Ludovic Marie 93ce080d71 Merge pull request #492 from rdbtCVS/patch-1
Add missing gitignore files
2026-10-16 14:31:22 +02:00
Ludovic MarieandClaude Opus 5.5 81243f7897 Release notes: drop the sprint-jumping known issue
Nobody could reproduce it, and no issue tracks it, yet every release since
2026-10-14 repeated it. Players noticed (rdbt on Discord).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 14:29:46 +02:00
rdbt 5c7fa1c575 Add missing gitignore files 2026-10-16 13:07:23 +01:00
Ludovic Marie 8953448987 Merge pull request #485 from LookImEcho-Rick/codex/issue-480-cloud-cpu
Reuse cloud geometry and GPU buffers between frames
2026-10-16 14:06:00 +02:00
LookImEcho-Rick 45397e4b30 Merge main and reconcile cloud caching with see-through text 2026-10-16 06:25:15 -05:00
Ludovic Marie b8f1011cb1 Merge pull request #474 from ludovic111/fix/lan-name-tags-through-walls
Name tags show through walls
2026-10-16 13:03:23 +02:00
Ludovic Marie 9c87672a0c Merge pull request #473 from ludovic111/fix/lan-trial-guests
LAN: trial spawners and vaults see the guests
2026-10-16 13:03:15 +02:00
Ludovic Marie b523567d1e Merge pull request #472 from ludovic111/fix/lan-guest-chat
LAN guest: command feedback names the guest and shows its numbers
2026-10-16 13:03:08 +02:00
Ludovic Marie 330c3ef07e Merge pull request #471 from ludovic111/fix/lan-guest-containers
LAN guest: their own ender chest, and double chests as 9x6
2026-10-16 13:03:01 +02:00
Ludovic Marie b1a3adff81 Merge pull request #470 from ludovic111/fix/lan-pvp
LAN: PvP between the host and guests
2026-10-16 13:02:54 +02:00
Ludovic Marie 018c5e43e3 Merge pull request #467 from ludovic111/fix/lan-guest-environment
LAN guest: hurt by lava, fire, drowning, suffocation and the void; hungry from moving
2026-10-16 13:02:47 +02:00
Ludovic Marie 7cfc19305a Merge pull request #466 from ludovic111/fix/lan-mobs-target-guests
LAN: mobs target and attack guests
2026-10-16 13:02:41 +02:00
Ludovic Marie 6ff898ec29 Merge pull request #481 from ludovic111/fix/block-entity-data-tooltips
Item tooltips for container contents, spawner data and op-only data
2026-10-16 12:45:14 +02:00
LookImEcho-Rick 56fec974ee Reuse cloud geometry and GPU buffers between frames 2026-10-16 05:45:09 -05:00
Ludovic Marie f5c91eded1 Merge pull request #479 from ludovic111/fix/pick-block-data
Ctrl + Pick Block copies the block entity's data in creative
2026-10-16 12:45:09 +02:00
Ludovic Marie 8da9c8c4c6 Merge pull request #483 from ludovic111/fix/world-options-game-rules
World Options: Edit Game Rules (InWorldGameRulesScreen)
2026-10-16 12:42:04 +02:00
Ludovic Marie 04bdf82ac9 Merge pull request #482 from ludovic111/fix/world-options-buttons
World Options: difficulty lock with its confirmation, and Restrictions
2026-10-16 12:42:00 +02:00
Ludovic Marie e8757cd75c Merge pull request #478 from ludovic111/fix/consent-buttons-reachable
Launcher consent: Send nothing / Save choices always on screen
2026-10-16 12:41:56 +02:00
Ludovic Marie 9bbe5c4fd3 Merge pull request #476 from ludovic111/fix/debug-crosshair-no-bob
F3 debug crosshair: no view bob, drawn after the hand like vanilla
2026-10-16 12:41:53 +02:00
Ludovic MarieandClaude Opus 5.5 a950a046ae World Options: Edit Game Rules (InWorldGameRulesScreen)
The rules by category under a search box, a switch or number box each,
with vanilla's tooltip, number validation (Brigadier readInt and the
rule's range, red text, Done held) and ConfirmScreen before discarding.
Done sets the changed rules and tells the player, as handleSetGameRule
does. The button follows the commands the player has, as updateButton.

The control socket's `type` now reaches World Options' boxes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:50:00 +02:00
Ludovic MarieandClaude Opus 5.5 e27e556ab2 World Options: the difficulty lock (with its question) and Restrictions
The Lock button toggles the wanted lock; Apply with a new lock asks first
(PopupScreen "Lock World Difficulty"), Yes locks the level's difficulty
(difficulty_settings.locked, now read and written), No returns to the
options. Restrictions opens RestrictionsScreen: the chat restriction the
Chat option makes, with its way to Chat Settings, and the four chat
permissions. A locked or hardcore level keeps both the difficulty and the
lock inactive, and LAN guests are told about the lock.

Part of #464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:15:43 +02:00
Ludovic MarieandClaude Opus 5.5 ac4ed86925 Item tooltips for container contents, spawner data and op-only data
ItemContainerContents.addToTooltip: an item with a container (a shulker box,
or a chest Ctrl + Pick Block copied) lists its first five stacks as
"count x name" and how many more there are. Spawner.appendHoverText: a
spawner or trial spawner item names the entity its block_entity_data spawns,
else how to set one. ItemStack.OP_NBT_WARNING: for a player with game master
permission, an item carrying data of an op-only block entity type (command
blocks, lecterns, signs, spawners) warns that it might run commands.

The item argument now refuses block_entity_data without an id or with an
unknown block entity type, with vanilla's TypedEntityData messages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:09:03 +02:00
Ludovic MarieandClaude Opus 5.5 91360e2f29 tools/lan_e2e.py: walls runs next to nametags, before the world fills up
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:08:31 +02:00
Ludovic MarieandClaude Opus 5.5 ee088c0022 tools/lan_e2e.py: walls sees a dead guest by its death screen too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:05:39 +02:00
Ludovic Marie 4224687e54 Merge pull request #477 from ludovic111/fix/ctl-wait-keeps-held-keys
Keep held keys held through wait on the control socket
2026-10-16 11:02:24 +02:00
Ludovic MarieandClaude Opus 5.5 a8b6eea474 tools/lan_e2e.py: walls puts the wall halfway between the players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:02:21 +02:00
Ludovic MarieandClaude Opus 5.5 bd42496af0 Ctrl + Pick Block copies the block entity's data in creative
ServerGamePacketListenerImpl.addBlockDataToItem: with Ctrl held in creative,
Pick Block gives the block's item the block entity's data. What each type
turns into item components (removeComponentsFromTag and
collectImplicitComponents: container, custom_name, lock, container_loot,
sign text, waxed, bees, banner patterns, pot decorations, skull profile)
leaves its NBT; the rest becomes minecraft:block_entity_data, tagged with
the type's id. Placing the item does the reverse
(BlockItem.updateCustomBlockEntityTag, op-only types only for a game
master, then applyComponentsFromItemStack), for every block entity type,
which also fills placed chests, barrels, hoppers and the like from their
container component, as vanilla does, where only shulker boxes were.

The client sends includeData on Ctrl in multiplayer, a picked banner keeps
its patterns without Ctrl (AbstractBannerBlock.getCloneItemStack), a picked
beehive carries its honey level, a placed command block with data keeps its
own auto and output tracking, and a placed waxed sign opens no editor.

tools/test_pick_block_data_parity.py compares both directions with vanilla's
own block entities: 148/148 cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:59:56 +02:00
Ludovic MarieandClaude Opus 5.5 4410064c58 tools/lan_e2e.py: walls brings a dead guest back first
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:27 +02:00
Ludovic MarieandClaude Opus 5.5 7e1e5eb7a0 tools/lan_e2e.py: trials puts the host back where it stood
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 3e6052ac92 control: keys reach a server's chat input
The control socket's key command went straight to key_pressed, so Escape
paused the game instead of closing the chat a server connection opens
(remote_chat_key handles the real keyboard).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 bc0cd9cc84 LAN guest: a use packet uses the item alone
ServerGamePacketListenerImpl.handleUseItem takes the client's rotation and
uses the held item, nothing else. The host ran the whole right click, with
the block the guest had looked at before: a guest that turned from an
ender chest to a double chest could get the ender chest opened again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 a8579b4df8 lan status shows whether each guest crouches; walls sets up its own scene
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 2a33f2c500 tools/lan_e2e.py: trials clears the air between its blocks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 984a06e582 tools/lan_e2e.py: feedback expects the rounded position and closes the chat
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 487d76c0ae gfx: the batch test covers see-through text
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 74fafdd787 LAN: trial spawners and vaults see the guests
PlayerDetector reads level.getPlayers(): the guests count for a trial
spawner's detection and a vault's connected players, and a key a guest
inserts rewards the guest's UUID.

Part of #462

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 11130e6ef7 LAN guest: command feedback names the guest and shows its numbers
Feedback about the player (target_name) names the guest a command runs
for. A client reading a server's chat now unwraps NbtOps' {"": value}
wrapper of any bare value, not only strings, so translation arguments that
are numbers show ("Replaced 1 slot(s)", the coordinates). horizon-ctl
messages reads a server's chat too.

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 3f7fba836e Name tags show through walls
SubmitNodeCollection.submitNameTag: a name that is not discrete is drawn
in full over the world and, faded on its background, again with
RenderPipelines.TEXT_SEE_THROUGH (no depth test) after the rest of the
world; a discrete one (a crouching player) only once, faded, on its
background. The background is ARGB.color(0.25, black): alpha 0x3F.

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 ce49480b3e Launcher consent: the answers stay at the card's foot, its body scrolls
With "View what would be sent" open in a short window, "Send nothing" and
"Save choices" sat below the overlay's scroll range. The card now fits the
window (max height 100%): its body scrolls, and the answers row stays pinned
at its foot.

Fixes #460

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:57:53 +02:00
Ludovic MarieandClaude Sonnet 5.5 fc59063ada Keep held keys held through wait on the control socket
`wait` ended with release_agent_keys like every other task, so
`key w down` + `wait 40` + `wait 20` walked for the first wait only.
A wait holds nothing: it leaves keys and buttons alone. They stay held
until `key ... up`, `release` or `stop`. Documented in horizon-ctl's
key/wait help (and so the MCP tools) and docs/agent-control.md.

Fixes #469

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 10:56:48 +02:00
Ludovic MarieandClaude Opus 5.5 23a54d4a22 F3 debug crosshair: drawn at the hand's projection, without the view bob
GameRenderer.render3dHud draws DebugCrosshairRenderer after the hand and
the screen effects, with the hudFov projection, no bob and FogMode.NONE.
Horizon drew it among the world's on-top gizmos, so the axes bobbed with
View Bobbing.

Fixes #475

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:38:20 +02:00
Ludovic MarieandClaude Opus 5.5 257b5e9f2d tools/lan_e2e.py: containers waits for the server's menu before reading or taking
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:36:31 +02:00
Ludovic MarieandClaude Opus 5.5 64f7ecd367 tools/lan_e2e.py: containers takes its chests away
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:27:11 +02:00
Ludovic MarieandClaude Opus 5.5 36d7f02802 LAN: PvP between the host and guests
Player.attack on a player: the host hits the guest on its crosshair, a guest
hits the host or another guest. The hit is worked out as the attacker
(Game::melee_damage, shared with attacking mobs) and lands on the victim as a
player_attack (blocking, armour, knockback to the guest's client), when the
pvp game rule allows it (ServerPlayer.canHarmPlayer).

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:19:00 +02:00
Ludovic MarieandClaude Opus 5.5 2e1212346f tools/lan_e2e.py: hazards counts the food spent from full health
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:19:00 +02:00
Ludovic MarieandClaude Opus 5.5 e289980c1e LAN guest: their own ender chest, and double chests as 9x6
The hub's container menus now say where their slots live (lan::menu::Slots):
one block entity, both halves of a double chest (CompoundContainer, the right
half first) or the guest's own ender inventory (PlayerEnderChestContainer,
saved as EnderItems). The host's ender inventory stays the host's while a
guest acts. A Horizon client shows a server's chest-like menu with the rows
and title the server sent, and keeps its slots off a block that holds no such
container.

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:19:00 +02:00
Ludovic Marie 90644915c5 Merge pull request #455 from ludovic111/fix/command-blocks
Command blocks: placing, the edit screen, and running commands
2026-10-16 10:12:07 +02:00
Ludovic Marie 7b53b06aca Merge pull request #448 from ludovic111/fix/drop-swing
Dropping from a menu swings the hand
2026-10-16 10:12:02 +02:00
Ludovic Marie 53fcf14850 Merge pull request #447 from ludovic111/fix/drop-key-repeat
Holding the drop key keeps dropping, in game and in menus
2026-10-16 10:11:58 +02:00
Ludovic Marie 84999e0762 Merge pull request #446 from ludovic111/fix/wayland-uncapped-windowed
Wayland: VSync off draws uncapped in a window
2026-10-16 10:11:54 +02:00
Ludovic Marie 3c81a89d1e Merge pull request #463 from ludovic111/fix/mod-commands-in-chat
Mod commands: coloured and Tab-completed in the chat like vanilla's (#456)
2026-10-16 09:50:12 +02:00
Ludovic Marie d993c36e02 Merge pull request #465 from LookImEcho-Rick/codex/fix-happy-ghast-riding
Fix Happy Ghast riding controls and standing after dismount
2026-10-16 09:19:43 +02:00
Ludovic Marie 9311cd5cd3 Merge pull request #468 from ludovic111/fix/parity-free-ports
[tools] parity harnesses: free ports instead of fixed ones
2026-10-16 09:16:45 +02:00
Ludovic MarieandClaude Opus 5.5 600bae5703 LAN guest: hurt by lava, fire, drowning, suffocation and the void; hungry from moving
The host now runs the rest of the server's body tick for each guest:
LivingEntity.baseTick's drowning and lava, fire blocks, suffocation
(isInWall) and the void (checkBelowWorld, 4 a tick, creative too), and per
move handleMovePlayer's jump (ServerPlayer.jumpFromGround's exhaustion) and
checkMovementStatistics (sprinting, swimming). The first move of a tick now
starts from the ground state before it, so take-offs are seen. Hurt sounds
are heard at the guest; lan status lists each guest's health and food.

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 09:13:43 +02:00
Ludovic MarieandClaude Opus 5.5 327aa5af6e tools/lan_e2e.py: a world with commands, so the guest's commands run again
Since #372 a guest's commands need the world's Allow Commands as well as
the LAN's; the test's survival world had none, so every guest command (tp,
gamemode, setblock) was refused and the checks built on them failed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 08:59:53 +02:00
Ludovic MarieandClaude Opus 5.5 dac18e8350 LAN: mobs target and attack guests
Each mob sees the nearest player (host or guest), as Level.getNearestPlayer
gives it, and keeps a guest it targets or that hurt it in the last 100 ticks.
Its hits, pushes, fire and launches on that player run with the guest swapped
in; mobs' projectiles hit guests too, and knockback reaches the guest's client
(SetEntityMotion on its own entity).

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 08:44:07 +02:00
Ludovic MarieandClaude Sonnet 5.5 e27efc16ba [tools] parity harnesses: free ports instead of fixed ones
vanilla_world_check.py (used by the light harness) defaulted to port 25671, so two parity runs on one machine failed with 'FAILED TO BIND TO PORT'. It now picks a free port unless --port is given. The save round trip, datafix and upgrade_world tools derived their port from the pid or a random number; they ask the OS for a free one like test_dedicated_server_parity does.

Fixes #461

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 08:38:03 +02:00
LookImEcho-Rick 924698e506 Support standing and walking on happy ghasts 2026-10-16 01:10:46 -05:00
LookImEcho-Rick 705fab9bed Fix happy ghast rider collisions and turning 2026-10-16 01:10:19 -05:00
Ludovic Marie 7e8dd577ac Merge pull request #454 from ludovic111/fix/title-toast-slide
World backup toast slides in and out with its sounds (#384)
2026-10-16 07:37:05 +02:00
Ludovic Marie a75c3a315f Merge pull request #453 from ludovic111/fix/slider-smooth-drag
Classic sliders follow the pointer and snap on release (#385)
2026-10-16 07:37:01 +02:00
Ludovic Marie 932c7bfc17 Merge pull request #452 from ludovic111/fix/classic-text-fields
World screens' text fields: move the cursor, select, click and drag
2026-10-16 07:36:56 +02:00
Ludovic Marie d0fa05b3e7 Merge pull request #451 from ludovic111/fix/level-loading-screen
Level loading screen like vanilla: panorama, chunk map and progress bar
2026-10-16 07:36:52 +02:00
Ludovic Marie 592ac6966b Merge pull request #450 from ludovic111/fix/world-options-layout
World Options: vanilla layout, headings, port box, lock and footer
2026-10-16 07:36:47 +02:00
Ludovic Marie 3e19e0d4dd Merge pull request #449 from ludovic111/fix/pause-icon-buttons
Pause menu: icons centred and in full colour like vanilla
2026-10-16 07:36:43 +02:00
Ludovic Marie 6a0d4284d0 Merge pull request #445 from ludovic111/fix/world-list-join-icon
World list: hovered rows show the join arrow, clicking the icon joins
2026-10-16 07:36:39 +02:00
Ludovic MarieandClaude Opus 5.5 d9deabd54e Command blocks: placing, the edit screen, and running commands
A creative player with game master commands (Player.canUseGameMasterBlocks)
places command, chain and repeating command blocks and opens
CommandBlockEditScreen on them: the command, its previous output with the
O/X tracking toggle, Impulse/Chain/Repeat, Conditional and Needs
Redstone/Always Active, applied as handleSetCommandBlock does.

The block entity (CommandBlockEntity with BaseCommandBlock) saves and
loads its vanilla fields. CommandBlock.tick runs impulse blocks on a
rising signal, repeating blocks every tick while powered or always
active, and the chain in front (executeChain), honouring conditional
blocks, command_blocks_work and max_command_sequence_length. Commands
run through the command system as the block (position, facing, game
master level); their feedback becomes the block's last output and the
[@: ...] broadcast under command_block_output. Comparators read the
success count.

Fixes #444

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 07:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 3cfb7b8053 Mods' commands in the chat's command tree (#456)
A mod's commands ran but the chat box parsed against vanilla's tree only,
so `/coords` showed red as unknown and never completed. The game now keeps
the chat's tree as vanilla's plus a literal per running mod's command (every
permission level, optional greedy `args`), rebuilt when mods start, reload
or stop.

Fixes #456

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:48:56 +02:00
Ludovic Marie f188dfeb45 Merge pull request #439 from ludovic111/feat/ominous-trials
Trial spawners: become ominous, Trial Omen and ominous item spawners
2026-10-16 06:42:37 +02:00
Ludovic Marie 45404c4507 Merge pull request #433 from ludovic111/feat/trial-pots
Give trial chamber pots their loot, sherds and insertion
2026-10-16 06:42:32 +02:00
Ludovic Marie d7c7c56e05 Merge pull request #422 from ludovic111/feat/trial-visuals
Show trial spawner and vault visuals
2026-10-16 06:42:27 +02:00
Ludovic Marie d1c753e085 Merge pull request #417 from ludovic111/feat/vault-game
Vaults in the game: ticking, keys, rewards and the display item
2026-10-16 06:42:22 +02:00
Ludovic Marie 9126df5ac5 Merge pull request #410 from ludovic111/feat/vault-logic
Vaults: server logic and a parity harness
2026-10-16 06:42:17 +02:00
Ludovic Marie d3b9441833 Merge pull request #457 from LookImEcho-Rick/fix/launcher-data-preview-scroll
Fix launcher data preview scroll isolation
2026-10-16 06:40:49 +02:00
Ludovic Marie e6a2d72467 Merge pull request #459 from LookImEcho-Rick/codex/chat-arrow-history
Keep chat arrow keys from turning the camera
2026-10-16 06:40:45 +02:00
Ludovic MarieandClaude Opus 5.5 3c57aba6c9 Classic sliders follow the pointer and snap on release
Vanilla's AbstractSliderButton keeps its own position while held and
only snaps to the option's stepped value in resetValue (on release, or
600 ms after the last change for sliders that apply late).

Fixes #385

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:18:28 +02:00
Ludovic Marie 9b9b23bf3a Merge pull request #436 from ludovic111/feat/shaderpack-client
horizon-client: run a Java pack's full-screen passes over the world
2026-10-16 05:31:32 +02:00
Ludovic Marie 953cdc947f Merge pull request #418 from ludovic111/feat/shaderpack-passes
horizon-shaderpack: plan a Java pack's full-screen passes like Iris
2026-10-16 05:31:28 +02:00
Ludovic Marie 4de37ac0e4 Merge pull request #415 from ludovic111/feat/shaderpack-properties
horizon-shaderpack: read shaders.properties like Iris
2026-10-16 05:31:23 +02:00
Ludovic Marie 1bf51d786f Merge pull request #413 from ludovic111/feat/shaderpack-translate
Java shader packs (2): translate programs to WGSL (syntax-tree legaliser + naga)
2026-10-16 05:31:19 +02:00
Ludovic Marie 64a82acdab Merge pull request #405 from ludovic111/feat/shaderpack-preprocess
Java shader packs (1): horizon-shaderpack crate, Iris's preprocessor and options
2026-10-16 05:31:14 +02:00
LookImEcho-Rick f2bbd6e3dc Keep arrow-key camera controls out of chat and menus 2026-10-15 22:23:25 -05:00
Ludovic MarieandClaude Opus 5.5 63453086b6 Trial spawners: become ominous, Trial Omen and ominous item spawners
A player seen with Bad Omen or Trial Omen turns a trial spawner ominous
(tryDetectPlayers' ominous branch): Bad Omen becomes Trial Omen, level
events 3020, the spawner's mobs vanish (3012) and the ominous config
starts over. While active, an ominous spawner drops an ominous item
spawner over a player or one of its mobs every 160 ticks
(spawnOminousOminousItemSpawner, getDispensingItems seeded per 30x20x30
cell); it floats, grows and turns for 60 to 120 ticks, then the item
falls or is fired down (OminousItemSpawner).

Zombies, piglins and zoglins read IsBaby from their tag, so the baby
zombie trial spawners spawn babies, and zombies save it.

The trial spawner harness covers the ominous branch, Trial Omen, the
discarded mobs and the item spawners' items, positions and delays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:04:30 +02:00
Ludovic MarieandClaude Opus 5.5 c4f915c9d1 Give trial chamber pots their loot, sherds and insertion
Generated chunks now keep the block entities Horizon does not model
(decorated pots, brushable blocks) as placed, like a loaded chunk's, so a
trial chamber pot keeps its loot table and sherds. The loot table is
rolled into the pot's item the first time it is read
(RandomizableContainer.unpackLootTable: the seed, else the table's random
sequence, else the level random), so breaking the pot drops it. Pots
render their sherds from the saved block entity, and DecoratedPotBlock's
useItemOn / useWithoutItem insert items (wobble, sounds, dust plume,
game event) or wobble and refuse.

Part of #223.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:04:06 +02:00
Ludovic MarieandClaude Opus 5.5 ed5427197f Show trial spawner and vault visuals
LevelEventHandler 3011-3021 (spawn, detect, eject, ominous and item spawner
particles and sounds, vault activation and deactivation), the trial
spawner's client tick (state particles, the spinning display mob, the
ambient sound) and the vault's client tick (idle smoke and flames, the
connection particles towards the player, the ambient sound).

Part of #223.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:03:47 +02:00
Ludovic MarieandClaude Opus 5.5 3ac3efe749 Save round-trip: a vault's next state update moves with the clock
Vaults tick in the game now, and an idle one pushes
state_updating_resumes_at to gameTime + 20 on every update, so Horizon,
which saves later than vanilla, stores a later value. Stage 3 already
treats it as volatile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:03:38 +02:00
Ludovic MarieandClaude Opus 5.5 2c07b5e621 Wire vaults into the game
Vaults are block entities now (loaded from saves and structure templates,
so trial chambers keep their ominous configs) and tick every game tick:
their state changes, sounds, level events and ejected rewards reach the
world, keys go in through VaultBlock.useItemOn before the item's own use,
and VaultRenderer draws the display item spinning inside the cage.

Part of #223.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:03:38 +02:00
Ludovic MarieandClaude Opus 5.5 2567a4a41a Add the vault's server logic with a parity harness
vault::Vault is VaultBlockEntity with its config, server and shared data:
load and save, Server.tick (display item cycling, VaultState.tickAndGetNext,
ejection, transitions with their sounds and level events) and
VaultBlock.useItemOn / tryInsertKey (keys, rewarded players, fail sounds,
the reward roll with its random sequence).

tools/test_vault_parity.py runs 3,000 cases against vanilla through a new
mode=vault of the natural spawn oracle and fixture.

Part of #223.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:03:28 +02:00
LookImEcho-Rick d5fbe2b7d6 Fix launcher data preview scroll isolation 2026-10-15 22:02:18 -05:00
Ludovic Marie 9044ae30c9 Merge pull request #423 from ludovic111/fix/server-list-dialogs
Server list: confirm before deleting, vanilla Direct Connection layout
2026-10-16 03:51:54 +02:00
Ludovic Marie c72a5643a4 Merge remote-tracking branch 'origin/main' into fix/server-list-dialogs
# Conflicts:
#	crates/horizon-client/src/multiplayer.rs
2026-10-16 03:46:14 +02:00
Ludovic Marie 3315138536 Merge pull request #421 from ludovic111/fix/server-list-entries
Server list rows: favicons, styled MOTDs, player count colours, icon arrows
2026-10-16 03:46:02 +02:00
Ludovic Marie f8c22f4e72 Merge pull request #416 from ludovic111/fix/f3-crosshair
F3 shows vanilla's debug crosshair (world axes)
2026-10-16 03:45:58 +02:00
Ludovic Marie 128111ff27 Merge pull request #414 from ludovic111/fix/f3-on-top
F3 overlay like vanilla: on top of menus, debug GUI scale, default entries
2026-10-16 03:45:54 +02:00
Ludovic Marie 09e01f066f Merge pull request #409 from ludovic111/fix/graphics-preset-distances
Graphics presets set render and simulation distance like vanilla
2026-10-16 03:45:51 +02:00
Ludovic Marie fb5a307903 Merge pull request #407 from ludovic111/fix/allow-commands
Commands follow the world's Allow Commands
2026-10-16 03:45:47 +02:00
Ludovic Marie 7cad2d7284 Merge pull request #406 from ludovic111/fix/world-reopen-entities
Reopening a world no longer duplicates its entities
2026-10-16 03:45:43 +02:00
Ludovic MarieandClaude Opus 5.5 c163fbff83 The world backup toast slides in and out with its sounds
EditWorldScreen's SystemToast goes through vanilla's ToastManager: it
slides in from the right over 600 ms (squared), shows for 5 s times the
notification display time, slides out, and plays ui.toast.in / out. The
in-game toasts' slide moves to a shared toasts module for it.

Fixes #384

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:32:21 +02:00
Ludovic MarieandClaude Opus 5.5 608507699c Classic world fields edit at a cursor: arrows, Home/End, Delete, clicks and drags
The world list's search, Edit World's name and Create New World's name and
seed kept only their String and appended to it. They now edit through an
EditBox (ui::Caret keeps the focused field's cursor, selection and scroll
between frames): EditBox.keyPressed's moves and deletions (Ctrl by word,
Shift to select), Ctrl+A/C/V/X on the selection, onClick placing the
cursor under the pointer (Shift extends, a double click within 250 ms
selects the word) and onDrag selecting.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:05:46 +02:00
Ludovic MarieandClaude Opus 5.5 03fe857ec2 Dropping from a menu swings the hand
LivingEntity.drop swings the main hand for every drop, so throwing a
slot's item with Q over the inventory, a container or a creative tab
plays the swing (the inventory's player model shows it). Horizon only
swung for the drop key in game; Player.drop's port now swings itself.

Fixes #442

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:05:26 +02:00
Ludovic MarieandClaude Opus 5.5 5373998ee0 Level loading screen like vanilla: panorama, chunk map and progress bar
LevelLoadingScreen while the player waits for its chunks: the title's
panorama, blur and menu background when a world opens (Reason.OTHER),
the nether portal sprite after a nether portal, Loading terrain... above
the 17x17 chunk status map (ChunkLoadStatusView, radius 8, COLORS) and
the 200x2 progress bar easing towards LevelLoadProgressTracker's value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:05:21 +02:00
Ludovic MarieandClaude Opus 5.5 93ea338bfd Holding the drop key keeps dropping, in game and in menus
KeyboardHandler.keyPress sends a key repeat (InputConstants.REPEAT) to
Screen.keyPressed and KeyMapping.click like a press, so holding key.drop
keeps throwing the held item, the hovered slot's item in a menu, and
the hovered item of a creative tab. Horizon dropped every repeat; the
drop key's now reach key_pressed. horizon-ctl `key KEY repeat` sends
one auto-repeat, for scripted checks.

Fixes #443

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:57:59 +02:00
Ludovic MarieandClaude Opus 5.5 98321f5d8d World Options: tests of the layout and active states
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:52:47 +02:00
Ludovic MarieandClaude Opus 5.5 9f7c1c3ce5 World Options: vanilla layout, headings, port box, lock, game rules and restrictions
WorldOptionsScreen in a HeaderAndFooterLayout: the title in the 33 pixel
header, the content in a ScrollableLayout over the in-world list
background between the separators, the footer buttons at h - 27.
Bold underlined category headings (FocusableTextWidget), the difficulty
button 130 wide beside its lock, Edit Game Rules... and Restrictions...,
the port EditBox always shown with its hint and error colour, and the
buttons' active states from updateButton and the update*Button methods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:46:19 +02:00
Ludovic MarieandClaude Opus 5.5 a8e0dead35 Wayland: VSync off draws uncapped in a window
pre_present_notify ties winit's RedrawRequested to the compositor's
frame callbacks on Wayland, one per refresh, so a window drew at the
monitor's rate whatever the present mode. Only Fifo (VSync) asks for
that pacing now; Mailbox and Immediate draw as fast as the frame limit
allows, like vanilla's GLFW window.

Fixes #441

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:42:22 +02:00
Ludovic MarieandClaude Opus 5.5 8ca9cda5f6 Pause menu: icon sprites centred and untinted like SpriteIconButton
CenteredIcon draws its 15x15 sprite at x + 20/2 - 15/2 = x + 3 (integer
division), y + 3, with the widget's alpha only: Horizon drew it at +2
and tinted it grey.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:26:36 +02:00
Ludovic MarieandClaude Opus 5.5 9cec0dfb45 World list: hovered rows show vanilla's join arrow, a click on the icon joins
WorldListEntry.extractContent dims a hovered row's icon (0xA0909090) and
draws world_list/join over it, join_highlighted under the pointer;
mouseClicked joins the world when the click lands on the icon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:25:54 +02:00
Ludovic Marie d333780478 Merge pull request #420 from ludovic111/contrib/cull-leaves-387
Optional leaf and entity culling (Online5277's #387, rebased)
2026-10-16 02:13:02 +02:00
Ludovic MarieandClaude Opus 5.5 964280aa00 Guests on a dedicated server keep their ops-based commands
Allow Commands is the integrated server's setting; a dedicated server's
guests run what their permission level allows (dedicated_server parity).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 01:18:08 +02:00
Ludovic Marie 457a5d7ce2 Merge pull request #440 from ludovic111/feat/launcher-owner-download
Launcher: download Minecraft from Mojang for a signed-in owner
2026-10-16 00:53:49 +02:00
Ludovic Marie 310a3fb73b Merge pull request #438 from ludovic111/feat/launcher-other-launchers
Launcher: prepare from Prism, MultiMC, Modrinth App, ATLauncher or GDLauncher
2026-10-16 00:53:37 +02:00
Ludovic Marie d56c6dfd23 Merge pull request #437 from ludovic111/fix/launcher-missing-files-error
Launcher: when Minecraft isn't found, say where it looked and what to do
2026-10-16 00:53:23 +02:00
Ludovic MarieandClaude Opus 5.5 d30df8ce33 launcher: download Minecraft from Mojang for a signed-in owner
When no folder has the pinned version and the selected Microsoft account
owns Minecraft: Java Edition (the mcstore entitlement, asked again with the
saved token), Prepare downloads it the way other launchers do: the version
JSON from piston-meta by its pinned sha1, the client jar, this system's
libraries (the JSON's os.name rules) and the asset index with its objects
(the sounds), each checked against its sha1, written whole, 8 at a time,
into the data folder's minecraft/ in the official layout. The next
preparations find it there first. An asset that fails is a line in the log;
anything else is an error.

Not signed in, or signed in without the game, the not-found error says so as
its last line. Ownership stays required: there is no other path to the
download, and nothing of Mojang's is part of Horizon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 00:46:57 +02:00
Ludovic MarieandClaude Opus 5.5 e0b5e25c1a Commands: prune suggestions in the chat only, like the client's tree
Brigadier's getCompletionSuggestions lists every child whatever its
requirement (command_parse compares it at permission 4, 2 and 0); only the
tree the server sends the client is pruned (Commands.sendCommands). The
player's chat now asks client_suggestions, which skips what the player
may not use; guests' ask_server requests keep brigadier's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 00:42:38 +02:00
Ludovic MarieandClaude Opus 5.5 b2e9fafbe0 launcher: prepare from Prism, MultiMC, Modrinth App, ATLauncher or GDLauncher
The official launcher crashes on some Linux systems (rdbt's Arch), and
Horizon only read its layout. Preparing now finds the pinned version in the
chosen folder (or a folder above it, so an instance folder works), then in
every launcher's default folder on Linux (Flatpak included), macOS and
Windows:

- the official launcher: versions/<v>/<v>.jar
- Prism Launcher, MultiMC: the jar their meta/net.minecraft/<v>.json names
  (a Maven name in libraries/), libraries/com/mojang/minecraft/<v>/...
- the Modrinth App: meta/versions/<v>/<v>.jar, meta/libraries
- ATLauncher, GDLauncher (data/, Next's datastore/):
  libraries/net/minecraft/client/<v>/client-<v>.jar

The client jar is checked against the lock's sha1. horizon-prepare reads the
official layout and pins the version JSON's own sha1, so files found
elsewhere are linked into that layout in the data folder
(linked-minecraft/), with the pinned version JSON from piston-meta (by its
sha1, checked) when the launcher keeps only its own format or a rewrite.
Nothing else is copied or downloaded. Every preparer, old Python ones
included, reads the linked folder as an official one.

When nothing is found, the error lists the chosen folder and every installed
launcher's, with what each lacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 00:40:31 +02:00
Ludovic Marie 71fc3482ba Merge pull request #404 from Online5277/fix/rail-mipmap-cutout
Preserve native sprite mipmaps in mixed-size texture arrays
2026-10-16 00:37:42 +02:00
Ludovic MarieandClaude Opus 5.5 561efb340e launcher: say which folders were checked when Minecraft is not found
Prepare used to stop with "Minecraft 26.3 is not in <dir>. Play 26.3 once from
the official launcher, or choose another folder." followed by an unrelated log
line ("Checking Java and your Minecraft install"). It now lists every folder it
looked in with what each one lacks (no such folder, no versions/26.3/26.3.json,
no 26.3.jar) and the two ways out, one per line. A missing client jar is caught
here too, instead of later inside horizon-prepare.

Only a preparer process's "Exited with ..." gets the last log line appended;
the launcher's own errors are full sentences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 00:24:43 +02:00
Ludovic MarieandClaude Opus 5.5 2862f17e86 horizon-client: run a Java pack's full-screen passes over the world
A folder holding shaders/, or a .zip, in shaderpacks/ now loads from the
Shader Packs screen: every planned begin, prepare, deferred, composite
and final pass is translated and drawn each frame over Horizon's world,
with Iris's formats, clears and buffer flips.

The targets keep OpenGL's layout (the translator flips gl_Position and
remaps its depth), the world enters as OpenGL depth, and the pack gets
OpenGL matrices. Textures share four samplers by kind (Metal allows 16
per stage), and isnan/isinf test bits (Metal's fast math folds x != x).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 00:22:39 +02:00
Cosmic 23cadd8b65 Keep entity culling description within the UI line limit 2026-10-16 00:12:58 +02:00
Cosmic 6aea14c8a0 Add conservative opt-in entity culling 2026-10-16 00:12:58 +02:00
Cosmic 504a70c062 Add an opt-in Cull Leaves graphics setting 2026-10-16 00:12:45 +02:00
Ludovic Marie 5350f3465b Merge pull request #424 from ludovic111/test/lan-e2e
tools/lan_e2e.py: a LAN host and guest, checked from both sides
2026-10-16 00:10:22 +02:00
Ludovic Marie 99cd0ec0ad Merge pull request #430 from ludovic111/fix/lan-guest-damage
LAN guest: fall damage, regeneration and hunger on the host
2026-10-16 00:10:16 +02:00
Ludovic Marie a42db752d7 Merge pull request #429 from ludovic111/fix/lan-guest-crafting
LAN guest: crafting in the 2x2 grid and at a crafting table
2026-10-16 00:10:10 +02:00
Ludovic Marie de53b7c14e Merge pull request #428 from ludovic111/fix/lan-player-nametags
LAN: name tags over other players, and the host under its own name
2026-10-16 00:09:58 +02:00
Ludovic Marie e2fa721c7a Merge pull request #427 from ludovic111/fix/lan-chest-lids
LAN: chest and shulker box lids open and close for every player
2026-10-16 00:09:53 +02:00
Ludovic Marie 1c91fad962 Merge pull request #426 from ludovic111/fix/lan-player-armour
LAN: every player wears their own armour
2026-10-16 00:09:45 +02:00
Ludovic Marie 824fad45cf Merge pull request #425 from ludovic111/fix/lan-guest-self-model
LAN guest: the guest sees himself (own player model on a server)
2026-10-16 00:09:40 +02:00
Ludovic Marie 5f45cf3830 Merge pull request #431 from ludovic111/fix/release-tag-order
Release train: two-digit same-day counter so GitHub lists releases in order
2026-10-15 23:22:49 +02:00
Ludovic MarieandClaude Opus 5.5 08c4805e10 Release train: pad the same-day counter to two digits
GitHub's releases page sorts a day's tags as text, so v2026.10.15.9 showed
above v2026.10.15.18 (reported by rdbt on Discord). Tags now count .01, .02...
Every reader (release_train.tag_key, the launcher's compare_versions) compares
the parts as integers, so older unpadded tags still order correctly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 23:17:09 +02:00
Ludovic MarieandClaude Opus 5.5 fc97b78931 Server list: confirm before deleting, vanilla Direct Connection layout
Delete opens JoinMultiplayerScreen's ConfirmScreen (title, wrapped
warning, Delete and Cancel; Escape answers no) and only removes the
server on yes. Direct Connection puts its label at y 100 and the box at
116 like DirectJoinServerScreen, and Add/Edit Server its boxes at 66 and
106 like ManageServerScreen, labels one pixel in.

Fixes #380, #381

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 23:05:32 +02:00
Ludovic MarieandClaude Opus 5.5 37f6481e14 Server list rows: favicons, styled MOTDs, player count colours, icon arrows
OnlineServerEntry draws the server's 64x64 favicon (from servers.dat, then
from each ping, saved back like updateServerList), the description as a
styled component with legacy codes, formatPlayerCount's dark grey with
grey numbers, and on hover the join and move arrows over the icon. The
icon's right half joins, its left quarters move the server up or down.

Fixes #365, #378, #370

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:53:15 +02:00
Ludovic MarieandClaude Opus 5.5 e1d9ae1dd7 horizon-shaderpack: plan a Java pack's full-screen passes like Iris
The begin, prepare, deferred, composite and final passes of a program
folder: what each writes (the later of DRAWBUFFERS and RENDERTARGETS),
which side of every ping-ponged colour target it reads (Iris's
BufferFlipper, with flip.* overrides), mipmapped targets, scale.*, and
the targets' formats, clearing and sizes from the consts of every pass
in Iris's order. The preprocessor now keeps the directive comments of
active code, as Iris's does, so a DRAWBUFFERS in an #if'd out branch
doesn't count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:36:18 +02:00
Ludovic MarieandClaude Opus 5.5 38a9f6543f horizon-shaderpack: read shaders.properties like Iris
Java .properties parsing, the two reads Iris makes (the raw file for
screens, sliders and profiles; the preprocessed one, with enabled options
and the environment's macros defined, for everything else), option
screens, profiles and the current one, program.*.enabled through a port
of Iris's BooleanParser, image formats for shadowcomp's writeonly
images, and blend overrides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:36:18 +02:00
Ludovic MarieandClaude Opus 5.5 98ebdcad7b horizon-shaderpack: translate Java shader pack programs to WGSL
The legaliser of docs/shader-packs.md step 1, on a syntax tree (glsl-lang)
instead of the spike's tokens: attribute/varying to located in/out with
matrix and array varyings split, loose uniforms in one std140 block that
extends across a pass, combined samplers split into texture + sampler
bindings (through function parameters, respecting locals that hide them),
compatibility built-ins and Iris's core-profile names declared, legacy
texture and shadow calls, hoisted global initialisers, sized unsized
arrays, isnan/isinf/modf helpers, and dead code after a jump dropped (the
spike's 'naga constructor bug' in BSL was code after discard). naga then
parses, validates and writes WGSL. The programs of one pass share a Link
so locations, bindings and the uniform layout agree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:36:18 +02:00
Ludovic MarieandClaude Opus 5.5 b22583d057 horizon-shaderpack: open Java shader packs and preprocess them like Iris
A new crate for the OptiFine/Iris translation (docs/shader-packs.md, order
step 1, first half): open a pack folder or zip (shaders/ found as Iris finds
it), list its programs per dimension folder, build the #include graph,
discover options per include component (OptionAnnotatedSource rules: boolean
defines referenced by #ifdef, value defines and configurable consts with a
[...] list, conflicting defaults dropped), write the player's values into
the lines, and expand Iris's standard macros with pp-rs, keeping line
numbers so errors name file:line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:36:18 +02:00
Ludovic Marie d28521f765 Merge pull request #362 from ludovic111/feat/dualsense-lights
DualSense lightbar and player LEDs
2026-10-15 22:31:10 +02:00
Ludovic Marie 7d6936e000 Merge pull request #361 from ludovic111/feat/dualsense-vibration
DualSense vibration: shaped pulses, HD haptics on USB (opt-in)
2026-10-15 22:30:54 +02:00
Ludovic Marie 5419ef0ed4 Merge pull request #360 from ludovic111/feat/dualsense-triggers
DualSense adaptive triggers
2026-10-15 22:30:46 +02:00
Ludovic Marie 457fb77ab0 Merge pull request #403 from Online5277/fix/server-ping-tooltip
Show ping tooltips over multiplayer network bars
2026-10-15 22:29:59 +02:00
Ludovic MarieandClaude Opus 5.5 850788d46c F3 shows the debug crosshair's world axes (#375)
The default debug profile enables 3d_crosshair while the overlay is open:
GameRenderer draws DebugCrosshairRenderer's axes one block ahead of the
camera (0.01 x GUI scale long; X red, Y green, Z light blue, 2 px over a
4 px black line) and Hud.extractCrosshair skips the 2D crosshair and its
attack indicator. Hidden with reduced debug info, in third person and
with F1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 77127ccfed F3 overlay like vanilla's DebugScreenOverlay (#363, #376, #377)
The overlay is drawn after screens and toasts, so it covers menus, and on
the title and options screens too (Gui.extractRenderState); F1 hides it
only in game. It lays out at the debug GUI scale (debugGuiScale, default
half the largest automatic scale) and shows the default profile's entries
sorted by id, split into vanilla's two columns: fps, version, position,
section position, performance impactors, system specs (CPU, display,
window, GPU, backend) and the integrated server's tick time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:12:56 +02:00
Ludovic Marie 13c4230431 Merge pull request #347 from ludovic111/feat/shader-pack-screen
Shader packs (3/3): pick a pack in Video Settings
2026-10-15 22:07:21 +02:00
Ludovic Marie 99dfeabfa5 Merge pull request #346 from ludovic111/feat/shader-pack-render
Shader packs (2/3): run a pack's composite stages over the world, with hot reload
2026-10-15 22:07:01 +02:00
Ludovic Marie 4a0c639f63 Merge pull request #345 from ludovic111/feat/game-mods
Mods in the game: Options > Mods, hooks and HUD panels, off by default
2026-10-15 22:06:46 +02:00
Ludovic Marie 18b5310b0f Merge pull request #343 from ludovic111/fix/sprite-interpolation
Interpolate animated textures like vanilla (prismarine, magma, stems, lit furnaces)
2026-10-15 22:06:39 +02:00
Ludovic MarieandClaude Opus 5.5 5a0ad89207 Graphics presets set the distances like vanilla (#382)
GraphicsPreset.apply also sets render distance (8/16/32), simulation
distance (6/12/12), chunk update priority, smooth lighting and menu blur,
and its cloud range (32/64/128) and Fabulous entity distance (1.25)
differ from what Horizon set. Dragging the classic preset slider only
moved the label: the preset now applies whenever its value changes
(Options.applyGraphicsPreset), in both menu styles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:00:55 +02:00
Ludovic MarieandClaude Opus 5.5 d1495076e6 Commands follow the world's Allow Commands (#372)
The single-player owner was always at permission level 4. Like
IntegratedServer.getProfilePermissions, the owner now gets OWNER (4)
only when the world allows commands, else ALL (0): /gamemode, /give and
friends answer "Unknown or incomplete command" and are not suggested
(the client's tree only holds usable nodes, Commands.sendCommands).
allowCommands is read from level.dat, set by World Options, and worlds
made without the Create New World screen take its default (Creative only).
The server console, the command harness and horizon-ctl `command` keep
the owner's level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:53:24 +02:00
Ludovic Marie b66eea6506 Merge pull request #348 from ludovic111/feat/dualsense-hid
DualSense output over HID: the report, a writer thread, and rumble through it
2026-10-15 21:52:08 +02:00
Ludovic MarieandClaude Opus 5.5 b5a7138489 Reopening a world no longer duplicates its entities (#374)
Save and Quit to Title kept boats, minecarts, armor stands, item frames,
paintings and other non-mob entities in memory. Opening the world again
loaded them from disk a second time and the next save wrote both copies,
so each reopen doubled them until the game crawled. Closing and opening a
world now let go of every entity of the level (MinecraftServer.stopServer
unloads them with the level). tools/test_world_reopen_parity.py reopens a
world three times and checks its minecarts stay the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:43:25 +02:00
Cosmic 725146c0ca Generate sprite mips before array-only enlargement 2026-10-15 15:41:53 -04:00
Cosmic d675918db9 Show server latency when hovering network bars 2026-10-15 15:30:10 -04:00
Ludovic Marie 0c103a4a97 Merge pull request #402 from Online5277/fix/menu-hotbar-keys
Prevent hotbar selection from changing behind menus
2026-10-15 21:30:02 +02:00
Cosmic 9b4d5d5613 Keep gameplay hotbar selection out of open menus 2026-10-15 15:20:09 -04:00
Ludovic Marie 292873e11a Merge pull request #397 from ludovic111/feat/inventory-sort
Sort buttons for the inventory and chests (extra, off by default)
2026-10-15 21:08:49 +02:00
Ludovic Marie c6aefdb82b Merge pull request #396 from ludovic111/feat/mouse-tweaks
Mouse Tweaks-style inventory controls (extra, off by default)
2026-10-15 21:08:44 +02:00
Ludovic Marie 66dc8ce3a4 Merge pull request #395 from ludovic111/fix/creative-search-select
Clicking a creative search result selects the query
2026-10-15 21:08:36 +02:00
Ludovic Marie 6bf89f912c Merge pull request #393 from ludovic111/fix/quick-craft-highlight
Drag highlight and counts as AbstractContainerScreen draws them
2026-10-15 21:08:28 +02:00
Ludovic Marie 17feed1620 Merge pull request #392 from ludovic111/fix/shift-double-click
Shift + double-click moves every matching stack
2026-10-15 21:08:19 +02:00
Ludovic Marie 32f7356eda Merge pull request #391 from ludovic111/fix/creative-item-tab-clicks
Creative item tabs click as CreativeModeInventoryScreen does
2026-10-15 21:08:12 +02:00
Ludovic Marie 2f23a776f8 Merge pull request #398 from ludovic111/fix/pick-block
Pick Block picks from the inventory in survival
2026-10-15 21:08:03 +02:00
Ludovic Marie bf4fcc3aa9 Merge pull request #394 from ludovic111/fix/text-highlight
Vanilla text selection colours and blinking cursors in the creative search and anvil
2026-10-15 21:07:57 +02:00
Ludovic MarieandClaude Opus 5.5 8e6a129f6b LAN: every player model wears its own armour
The host drew each guest with the host's own armour (player_armor_layers),
and a guest ignored ClientboundSetEquipmentPacket, so it drew the host and
other guests bare-handed and without armour. Player models now carry what
they wear in extra.head..feet: the host fills it from the guest's armour,
the guest from SetEquipment (main and off hand too, for mobs as well), and
both draw HumanoidArmorLayer from it. The host also draws what a guest holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:57:07 +02:00
Ludovic MarieandClaude Opus 5.5 dd856ec5f6 LAN guest: tick the player's own model on a server, so the guest sees himself
The player model (third person, the inventory's preview) was only ticked by
the single-player tick; on a server it was never created.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:57:07 +02:00
Ludovic MarieandClaude Opus 5.5 fd1acd5ed7 tools/lan_e2e.py: a host and a guest on LAN, checked from both sides
Two Horizon games under horizon-ctl, each with its own scratch home: the
host opens a survival world to LAN with commands, the guest joins by
address, and each check (tab, self, armour, nametags, chest, crafting,
damage) is reported on its own with screenshots of both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:56:45 +02:00
Ludovic MarieandClaude Opus 5.5 227edf7517 LAN guest: crafting in the inventory grid and at a crafting table
A guest could not craft: the host's inventory menu for guests had no
crafting slots (0-4 were always empty), and a crafting table the guest
used switched the host's own grid to 3x3 and opened nothing for the guest.

The guest's inventory menu (window 0) and a crafting table window
(minecraft:crafting) now run the host's vanilla-checked menu engine
(horizon_client::menu, Kind::Inventory / Kind::Crafting) over the guest's
inventory, kept between clicks so drags work, with the usual click
corrections. The 2x2 grid and its result are real slots of the guest's
inventory menu, so the slot sync carries them. Closing a menu puts its grid
back into the inventory (dropped if full); a guest leaving keeps it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:37:56 +02:00
Ludovic MarieandClaude Opus 5.5 9948ff42aa LAN: other players' name tags, and the host under its own name
No player had a name tag: only mobs with a custom name drew one. The other
players now get theirs on both sides (the guests on the host, the players a
server sends on a client), shown as LivingEntityRenderer.shouldShowName
allows: no custom name or crosshair needed, a crouching player's only within
32 blocks, team visibility and colours by player name, the below-name score.

The host also joined its own LAN world as "Player": the guests' player
list, name tags and chat, the MOTD and the offline UUID now use the host's
profile name, as IntegratedServer does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:37:56 +02:00
Ludovic MarieandClaude Opus 5.5 8d6b8b3f03 LAN: a trading screen a guest opens releases the chest it replaces
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:37:49 +02:00
Ludovic MarieandClaude Opus 5.5 f601630f05 LAN guest: fall damage, regeneration and hunger on the host
Nothing hurt a guest: the host only set a guest's position from its move
packets, so a fall did nothing, and their body never ticked on the server
(no damage clock, no regeneration, no food tick).

Each tick the host now runs a guest's server-side body like ServerPlayer:
the damage clock and Entity.baseTick's fluid phase, then
ServerPlayer.doCheckFallDamage for every move packet accepted that tick
(the supporting block, the fall distance and the landing, through the same
landing code as the host's own falls, so water resets a fall and lava
halves it), then peaceful regeneration and the food tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:28:36 +02:00
Ludovic MarieandClaude Opus 5.5 4718fef2ab LAN: chest lids open and close for every player
A chest a guest opened opened on the host only, and never closed: the
host ran the guest's open as its own player's (one opener, a local block
event), nothing was sent to the guests, and the guest's close was not
counted. Chests now count their openers like ContainerOpenersCounter (the
host and each guest; a guest's close, a menu it replaces and its leaving
take it off) and the block event goes to the guests too (shulker boxes as
well). A guest's client handles ClientboundBlockEventPacket and ticks its
block entities (ClientLevel.tickBlockEntities, without the server copy's
bell sound and gateway ticks), so lids move on its screen.

The control socket's block reply gives a chest's or shulker box's lid
openness (lid, 0..1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:53:23 +02:00
Ludovic MarieandClaude Opus 5.5 9e2d46d54e Pick Block picks from the inventory in survival, as vanilla does
In singleplayer the middle button did nothing outside creative, and in
creative it overwrote the selected hotbar slot. It now runs the server's
handlePickItemFromBlock (tryPickItem): a stack of the block's item
already in the hotbar is selected; one in the main inventory is swapped
into getSuitableHotbarSlot (the first empty hotbar slot from the selected
one, else the first unenchanted); only creative adds the item when none
is there, moving what the slot held to a free slot. Servers already got
the packet and do this themselves.

Fixes #367.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:19 +02:00
Ludovic MarieandClaude Opus 5.5 adef2bdc24 Draw text selections with vanilla's colours and blink the field cursors
GuiGraphicsExtractor.textHighlight paints an inverting white fill, then
adds pure blue (GUI_TEXT_HIGHLIGHT, additive). Horizon only inverted, so
selections lacked the blue: black text on a light box, not blue text.
The anvil's name box skips the inversion (setInvertHighlightedTextColor
false), as in AnvilScreen.

The creative search and anvil name box now draw like EditBox: the cursor
blinks from when the field took the focus (300 ms on, 300 ms off), turns
into a bar once the field is full, and Select All highlights the value.
Sign lines draw their Select All highlight too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:19 +02:00
Ludovic MarieandClaude Opus 5.5 3761f2a5fb Sort buttons for the inventory and chests, an extra off by default
The Extras page's Inventory section gains "Sort buttons" (sort, off) and
"Sort key" (sortKey, unbound). A small button right of a chest, barrel,
ender chest or shulker box title sorts the container; one right of the
"Inventory" line sorts the main inventory (the hotbar stays). The key
sorts the part under the cursor.

inv_sort::plan merges partial stacks, then orders the stacks by creative
tab order (the search tab's), then name, full stacks first, using only
left PICKUP clicks with an empty hand: the game sends them as ordinary
clicks, so vanilla servers accept them. A unit test replays the plans
through the menu engine on 40 random chests.

The Extras page binds any extra key the way it bound the zoom key
(Item::key_name, Extras::set_key).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 05a1e10bbe Mouse Tweaks-style inventory controls, an extra off by default
The Extras page gains an Inventory section with "Mouse Tweaks controls"
(mouseTweaks, off) and "Scroll wheel moves items" (mouseTweaksWheel). In
the player inventory and chest-like containers, locally and on servers:

- right-drag holding a stack puts one item in each slot entered, at once;
- left-drag holding a stack gathers the slots of the same item into the
  hand, while vanilla's drag covers one slot (a drag over empty slots
  still splits as vanilla does); with Shift they are quick-moved;
- Shift + left-drag with an empty hand quick-moves every slot entered;
- the wheel over a slot moves one item to the other side (down) or
  brings one back (up).

Every action is ordinary slot clicks: the menu engine's, or ContainerClick
packets for any slot (remote_slot_click, factored out of remote_click).
With the extra off each hook returns at once.

horizon-ctl gains drag-at and scroll-at, which send the window events of
a mouse drag and of wheel steps, to test these and vanilla's drags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 3e0793af21 Select the creative search query when a result is clicked
CreativeModeInventoryScreen.slotClicked moves the search box cursor to
the end and sets the highlight to 0 on any click of a display slot, so
the next typed character replaces the whole query. Horizon kept the
query and appended to it.

horizon-ctl's type now edits the creative search as typing does (the
selection and the 50 character limit apply) instead of appending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 5efb5ea335 Draw the quick craft drag as AbstractContainerScreen does
While a held stack is dragged over slots, each dragged slot shows the
stack it would get over a white highlight, its count yellow when the slot
caps it, and the cursor shows what stays in hand (extractSlot,
recalculateQuickCraftRemaining, extractCarriedItem); a drag over a single
slot hides that slot's item. Slots join the drag by
shouldAddSlotToQuickCraft (same item or empty, placeable, canDragTo, and
not more slots than carried items for a right drag), so the preview counts
the slots the menu will fill.

The menu engine computes both (Menu::quick_craft_accepts and
quick_craft_preview) for the inventory, crafting table, chests, hopper,
dispenser, shulker box, furnaces, crafter, loom, trading and creative
screens; the slot and carried-stack drawing of those screens goes through
draw_slot_item and draw_carried.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 a061c40fde Move every matching stack on a Shift double click
AbstractContainerScreen.mouseReleased: a Shift double click QUICK_MOVEs
every slot of the clicked slot's container that holds the item the last
Shift click moved (lastQuickMoved), when canTakeItemForPickAll allows the
clicked slot. Menu::quick_move_all runs it on the menu engine;
test_menu_click_parity generates it as a QUICK_MOVE_ALL op that the oracle
runs with the real Slot.mayPickup, Slot.container and
AbstractContainerMenu.canItemQuickReplace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 b9e926588b Run the creative item tabs' clicks as CreativeModeInventoryScreen does
The item tabs handled clicks on press with their own rules: Shift sent the
item to the inventory, the hotbar row swapped with the carried stack, a
carried stack clicked on the tab was always deleted, and there were no
drags. They now go through the same mouseClicked/mouseReleased as the other
container screens, over ItemPickerMenu:

- display slots: slotClicked's CONTAINER branch (creative::click_display):
  an empty hand takes the shown stack (a full one with Shift), the same item
  grows by one, fills with Shift or shrinks with a right click, another item
  is deleted or loses one; number keys fill a hotbar slot, Q drops copies;
- the hotbar row: the menu engine's new ItemPicker kind, whose
  quickMoveStack deletes the stack (Shift-click on the hotbar from an item
  tab), and whose display slots take no drags or PICKUP_ALL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic Marie aca5f3a8ba Merge pull request #358 from ludovic111/fix/inventory-shift
Shift-click on every container screen, even when Shift is pressed after it opens
2026-10-15 19:38:36 +02:00
Ludovic MarieandClaude Opus 5.5 f8bcfcfec6 DualSense lights: pin the host defaults, document quit and the mute LED
Tests pin player 1 (0x04), the default blue (0, 0, 128) and the instant
bit (0x20) to hid-playstation.c and SDL; the docs say what Horizon resets
on quit (more than SDL or the kernel do) and that the mute LED is never
touched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 be57f82a07 DualSense HD haptics: Windows' device name, the arming rule, Bluetooth notes
- The pad's audio device is also found by Windows' "Wireless Controller"
  endpoint name (the four channels rule out a DualShock 4); a name test.
- docs/controllers.md: the haptics need reports without the rumble flags
  (SDL, nondebug/dualsense, dse-haptics), which Horizon now always sends while
  they are open; Windows needs the Quadraphonic speaker setup; on Linux ALSA
  can't open the card while PipeWire holds it; Bluetooth haptics (report
  0x32, SAxense) are documented and not done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 6663af199e DualSense triggers: catches at the press point, shields, official modes only
- A loaded crossbow and the boosts (rocket, pig, strider) get a weapon
  catch that breaks in the zone where the trigger threshold presses, so the
  click is the shot (the boost was a feedback wall that never gave way).
- Draws start at zone 2 and stop at 6 of 8 (held for as long as you like).
- A shield in the use hand weighs (feedback 2, 3); disabled by an axe, the
  trigger locks (feedback 1, 8), as Deathloop does for a jammed gun.
- Mining buzzes slower on harder blocks (40/30/20/10 Hz).
- A 0 Hz vibration frees the trigger (Nielk1's factory); tests pin the
  bytes to DSX++'s and dualsensectl's examples.
- docs/controllers.md: the table with the effect of each action and why.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 6086758f94 DualSense output: rumble, LEDs and Bluetooth checked against SDL and the kernel
- Every report carries the motors; still motors send no rumble flags, as SDL
  stops rumble (a report without them ends the emulation and re-arms the
  audio haptics), so a trigger change no longer cuts a vibration.
- Legacy vibration at half strength (SDL); the Edge always takes V2; an
  unreadable firmware version falls back to legacy.
- The first report after opening clears the motor/trigger attenuation
  (flags 1 bit 6, byte 36; dualsensectl, pydualsense).
- The LED reset goes in a report of its own before any colour
  (hid-playstation's dualsense_reset_leds); over Bluetooth the colour waits
  for the connection animation (SDL's sensor clock check).
- Bluetooth output only where gilrs keeps reading the pad (Linux), else with
  HORIZON_DUALSENSE_BT=1: any output switches the pad to its full report.
- USB is preferred when the pad is on both.
- Tests pin the reset report and its CRC (zlib), the flags and the clock offset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 df3244d335 DualSense lightbar and player LEDs
The lightbar shows the player's health (green to amber to red) or the
dimension, at half brightness, chosen in the controller options (Lightbar:
Health / Dimension / Off; health by default). The player LEDs show player 1.
When Horizon lets go of the pad, the lights it changed go back to a host's
defaults.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 0681064767 DualSense vibration: shaped pulses, HD haptics on USB (opt-in)
Each rumble event plays a short shape instead of one flat block: a hit
starts at full strength and dies away, an explosion rolls on, a totem
swells, a broken block is a tick. They drive the motors' emulation in the
HID report.

On USB the pad is also a four-channel audio device whose channels 3 and 4
drive its actuators: with HORIZON_DUALSENSE_HAPTICS=1 the shapes play there
as waveforms (80 Hz and 200 Hz tones for the heavy and light parts),
channels 1 and 2 silent, until that path is confirmed on a real pad.
docs/controllers.md says what each OS and transport allows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 a316768bfc DualSense adaptive triggers
Each trigger feels the action bound to it: resistance growing while a bow
draws, a trident charges or a crossbow loads, a gun trigger's click on a
loaded crossbow, a wall where pulling boosts (a rocket while gliding, the
stick of a ridden pig or strider), and a light buzz while mining, scaled by
the block's hardness. Free in menus, paused, unfocused, on another pad,
with controllers off, and on quit. On by default, "Adaptive Triggers" in
the controller options turns them off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic Marie 36f61d68f1 Merge pull request #250 from ludovic111/feat/telemetry-launcher
Opt-in data collection (2/5): the launcher asks, Settings changes it, CLI and MCP
2026-10-15 19:06:43 +02:00
Ludovic MarieandClaude Opus 5.5 d53a366718 Opt-in data collection (2/5): the launcher asks, and Settings changes it
- First run (and when CONSENT_VERSION changes): a question over the launcher
  with four switches, all off, what each sends in plain words, what is never
  sent, "View what would be sent" (the exact JSON), and "Send nothing" /
  "Save choices".
- Settings → Data collection: the same switches any time, the install ID and
  what waits, "View what would be sent" and "Delete my data" (server deletion,
  outbox cleared, new install ID).
- The outbox is sent at start and on close (at most 3 s), only when there is a
  server (none yet) and only what the player allows.
- CLI `horizon-launcher data status|allow|deny|show|delete|send|bug|benchmark`
  and MCP tools data_collection, set_data_collection, data_preview,
  delete_my_data, report_bug. Bug reports and benchmark uploads are printed for
  the player to confirm; logs are scrubbed and lose chat lines.
- horizon-telemetry joins the launcher's paths (launcher.yml, release_train,
  pr_ready).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:52:45 +02:00
Ludovic Marie 713ff1d4a1 Merge pull request #355 from ludovic111/perf/spawn-heightmap
Natural spawning skips air sections when finding the surface
2026-10-15 18:42:48 +02:00
Ludovic Marie 4566d3175b Merge pull request #354 from ludovic111/fix/cpu-defaults
Cap a fresh install at vanilla's 120 FPS
2026-10-15 18:42:36 +02:00
Ludovic Marie 1264cb6556 Merge pull request #353 from ludovic111/fix/proto-save-off-main
Save proto chunks on a worker, not the main thread
2026-10-15 18:42:24 +02:00
Ludovic Marie 1ce266f66c Merge pull request #352 from ludovic111/fix/remesh-light-changes
Relit chunks remesh only where their light changed
2026-10-15 18:42:06 +02:00
Ludovic MarieandClaude Opus 5.5 ac955934b5 DualSense output over HID: the report, a writer thread, and rumble through it
The horizon-dualsense crate builds the DualSense's output report (USB 0x02,
Bluetooth 0x31 with its CRC-32 seeded with 0xA2) and writes it with hidapi
from a thread. The game sends its rumble pulses through it when the active
pad is a DualSense, so vibration now works on macOS too, and falls back to
gilrs force feedback when the pad can't be opened. Quitting frees the
triggers and stops the motors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:38:10 +02:00
Ludovic MarieandClaude Opus 5.5 466e260514 Mods in the game: load the ones turned on, hooks, HUD panels, Options > Mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:33:40 +02:00
Ludovic Marie 9cb9f8beeb Merge pull request #329 from ludovic111/feat/cargo-horizon-mod
cargo horizon-mod: new, build and run Horizon mods
2026-10-15 18:30:21 +02:00
Ludovic Marie 1a04810baf Merge pull request #328 from ludovic111/feat/mod-runtime
Horizon mods runtime: sandboxed WebAssembly components with wasmtime
2026-10-15 18:04:48 +02:00
Ludovic Marie c3b32fdab5 Merge pull request #327 from ludovic111/feat/mod-sdk
Horizon mod SDK: the WIT contract, a Rust SDK and an example mod
2026-10-15 18:02:34 +02:00
Ludovic Marie b8ed3a113a Merge pull request #326 from ludovic111/docs/mods-design
Mods design: Horizon's Rust mod system and what carries over from Java mods
2026-10-15 18:02:26 +02:00
Ludovic Marie 68cbad54a3 Merge pull request #351 from ludovic111/feat/agent-waits
Agent control: wait for a state, and the log of what happened
2026-10-15 17:43:54 +02:00
Ludovic Marie 24a702181a Merge pull request #350 from ludovic111/feat/agent-options
Agent control: read and set every option by key
2026-10-15 17:35:49 +02:00
Ludovic Marie f6ceb1da22 Merge pull request #342 from ludovic111/feat/launcher-mcp-game
Launcher MCP: play the running game through game_* tools
2026-10-15 17:34:47 +02:00
Ludovic Marie e6588a13f3 Merge pull request #349 from ludovic111/feat/agent-ui-tree
Agent control: the widget tree of every screen, click and set by id
2026-10-15 17:33:58 +02:00
Ludovic MarieandClaude Opus 5.5 c8d5d13813 Interpolate animated textures like vanilla (prismarine, magma, stems, lit furnaces)
SpriteContents.AnimationState.drawToAtlas mixes an animation with
"interpolate": true towards its next frame every tick
(animate_sprite_interpolate.fsh: mix(current, next, subFrame / time),
per mip level, into the 8-bit atlas). Horizon stepped the frames: the
sprite upload now mixes the two frames' mip chains the same way and
re-uploads every tick while the mix changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:32:39 +02:00
Ludovic Marie 49fc287254 Merge pull request #321 from ludovic111/docs/agent-control
Agent control: audit and design (docs/agent-control.md)
2026-10-15 17:25:24 +02:00
Ludovic Marie 92d1338e24 Merge pull request #341 from rdbtCVS/perf/20261015-animated-sprite-mips
Cache animated sprite mip chains within an 8 MiB budget
2026-10-15 17:25:19 +02:00
Ludovic Marie 43134d5ebf Merge pull request #330 from rdbtCVS/perf/20261015-shared-model-library
perf(client): share parsed block and item models
2026-10-15 17:24:47 +02:00
Ludovic MarieandClaude Opus 5.5 6b26f926df docs/agent-control.md: match what was built; before and after; status
The design sections now describe the implementation: ids for rows,
switches and segments, the redraw wait, wait-for --since and event KIND,
the events log (subscribe comes next), the launcher proxying through
horizon-ctl mcp. A before/after table and the PRs of each step replace
the plan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:01:03 +02:00
Ludovic MarieandClaude Opus 5.5 293c589e35 Read the physical Shift and Ctrl keys on every container screen
The workstation screens (anvil, grindstone, stonecutter, smithing,
enchanting, cartography, loom, beacon, villager trading, brewing stand),
the horse/llama/camel inventory and the creative item grid read Shift from
the sneak mapping (`keys`). Behind a screen a Shift press never reaches the
mapping (KeyboardHandler.keyPress: only releases do) and Right Shift isn't
mapped at all, so shift-click there worked only when Shift was already held
before the screen opened. They now read `has_shift_down` like
AbstractContainerScreen (`InputWithModifiers.hasShiftDown`), and the recipe
book reads Ctrl the same way (`Minecraft.hasControlDown`).

`status` reports `physical_keys` so scripts can check what the OS delivered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:50:44 +02:00
Ludovic Marie 58af88f809 Merge pull request #344 from ludovic111/feat/shader-pack-loader
Shader packs (1/3): load and validate Horizon WGSL packs
2026-10-15 16:14:09 +02:00
Ludovic Marie 7e9bca982b Merge pull request #340 from ludovic111/docs/resource-pack-audit
Resource packs: what works, audited against 26.3
2026-10-15 16:14:03 +02:00
Ludovic Marie b4cb428ca9 Merge pull request #333 from ludovic111/docs/shader-packs
Shader packs design: Horizon WGSL packs and OptiFine/Iris translation
2026-10-15 16:13:56 +02:00
Ludovic MarieandClaude Opus 5.5 a6047e59c0 Shader packs: pick a pack in Video Settings (Advanced)
A Shader Pack row cycles Off and every folder in shaderpacks/; the game
loads the pick on the next frame. Not part of the presets, off by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:11:21 +02:00
Ludovic MarieandClaude Opus 5.5 1b468100aa Save proto chunks on a worker, not the main thread (#224)
Converting a proto chunk for saving (VanillaTerrain::saved_proto copies it
block by block into a column, then encodes it) ran on the main thread: in
Streaming::save during the autosave, and in Streaming::update for every far
proto unloaded while exploring. The protos are now checked out (like a
decoration job does) and saved by a worker, ahead of every other job, then
handed back. The explicit save and quitting still save them in place, after
waiting for the saves in flight; dropping the streamer waits for them too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:00:00 +02:00
Ludovic MarieandClaude Opus 5.5 e0ecbf1db1 Test that relit columns keep every mesh current
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:59:58 +02:00
Ludovic MarieandClaude Opus 5.5 3137c7b977 Relit chunks remesh only where their light changed (#270)
A light job for a chunk that was already lit (a neighbour loaded, or a block
changed in an unlit neighbour) dirtied every section of the 3x3 columns
around it: 216 remeshes, whether or not any light changed. It now writes only
the sections whose light changed and dirties the sections around the changed
positions, as the incremental relight already does. A chunk lit for the
first time still dirties its 3x3 neighbourhood.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:59:58 +02:00
Ludovic MarieandClaude Opus 5.5 7f631bb36f Natural spawning skips sections of air when it finds the surface
GameLevel::heightmap found getHeight by testing every block from the top
of the world down (with a block tag lookup each), for every spawnable chunk
every tick: a quarter of the main thread in a village. Sections holding
only air are now passed over whole; air counts for no heightmap, so the
height is the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:43:40 +02:00
Ludovic MarieandClaude Opus 5.5 047bf45af1 Shader packs doc: match the first slice (sun angle, folders)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:25:31 +02:00
Ludovic MarieandClaude Opus 5.5 35f78ae27c Shader packs: run a pack's composite stages over the world, with hot reload
With shaderPack set in horizon-graphics.txt, the world renders into
world_post's target with its depth kept, each composite stage of the
pack (gfx/pack_stages.rs) draws a full-screen pass reading the scene
colour and depth, the previous stage and the Frame uniform (camera,
matrices, time, sun and moon, sky and fog colours and distances,
weather, camera state, dimension), and the last output goes to the
screen through the usual copy/FXAA, under the hand, screen effects and
HUD. The game loads the pack from shaderpacks/, checks its files twice
a second and on F3 + T, and keeps the last good pack on errors, which
go to chat. Without a pack nothing changes: no target, no extra pass,
depth discarded as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:25:23 +02:00
Ludovic MarieandClaude Opus 5.5 9739740379 Shader packs: load and validate Horizon WGSL packs (pack.toml, prelude, options)
shader_pack.rs reads shaderpacks/<pack>/pack.toml, the player's option
values (shaderpacks/<pack>.txt) and each composite stage's WGSL, puts
the api 1 prelude (bindings, the Frame uniform, depth helpers) before
it and has naga parse and validate it, with errors pointing into the
pack's own file. Options are WGSL override constants. A file stamp
tells when the pack was edited, for hot reload. Nothing draws with it
yet. examples/shaderpacks/horizon-sample is the MIT sample pack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:18:32 +02:00
Ludovic MarieandClaude Opus 5.5 eb466fe2d2 Shader packs: Iris translation spike results and implementation order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:15:06 +02:00
rdbtCVS 6d4aa954bb docs: record model sharing results after upstream integration 2026-10-15 14:00:10 +01:00
rdbtCVS b1c365fca1 Cache animated sprite mip chains within an 8 MiB budget 2026-10-15 13:59:45 +01:00
Ludovic MarieandClaude Opus 5.5 0892f944c0 Agent control: wait for a state, and the log of what happened
wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND replies once
the game gets there (--timeout, default 30 s, ends in an error of kind
timeout; --since SEQ counts chat and events from an earlier point), so
scripts and agents stop polling. events [--since SEQ] lists what
happened, oldest first, numbered: screens, chat lines, deaths, respawns,
items gained or lost, dimensions, worlds opened and closed. The state is
compared once per event loop turn and only under --control, so watching
never changes the game. respawn now says when the death screen's button
is not active yet instead of clicking it to no effect.

horizon-ctl and its MCP gain both; tools/agent_control_e2e.py gains an
events scenario.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:51:30 +02:00
Ludovic MarieandClaude Opus 5.5 41f82e8db0 Resource packs: audit of every 26.3 pack feature with its proof
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:42:42 +02:00
Ludovic MarieandClaude Opus 5.5 1a067b3d9e Agent control: read and set every option by key
options-get [KEY...] gives every options.txt option by its vanilla key
with its value and what it accepts (type, range, step or choices; enums
by name, key bindings as key.keyboard.w). options-set KEY VALUE sets one,
applies it at once and saves options.txt as the options screen does
(language and resource packs reload too); a value the option refuses is
an error naming what it accepts, and the option keeps its value. After a
set, ui reads the redrawn screen.

horizon-ctl and its MCP gain both; tools/agent_control_e2e.py gains an
options scenario.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:38:20 +02:00
Ludovic MarieandClaude Opus 5.5 304ea50a5f Shader packs design: the renderer's passes, Horizon WGSL packs and Iris translation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:35:11 +02:00
Ludovic MarieandClaude Opus 5.5 1573e4e553 Cap a fresh install at vanilla's 120 frames per second (#271)
Without an options.txt the game ran unlimited (260), while an options.txt
without maxFps, like vanilla, gives 120. With VSync off (or rendering
offscreen) that kept a core busy drawing frames nobody sees.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:34:36 +02:00
Ludovic Marie 337ee470b5 Merge pull request #318 from rdbtCVS/perf/20261015-random-tick-eligibility
Cache random-tick section eligibility across unchanged chunks
2026-10-15 14:29:44 +02:00
Ludovic Marie 1286523047 Merge pull request #309 from rdbtCVS/perf/20261015-typed-block-facts
Load gameplay facts with typed decoding and interned shapes
2026-10-15 14:29:37 +02:00
Ludovic MarieandClaude Opus 5.5 b0abb62883 Launcher MCP: play the running game through game_* tools
An agent now needs one MCP server. horizon-launcher mcp runs the selected
version's horizon-ctl mcp as a child and offers its tools as game_<name>
(all but launch, which launch_client covers), passing calls and image
results through. launch_client starts the game with a control socket in
the launcher's data folder, takes world, creative and offscreen, and
returns once the socket answers. A game tool called while no game listens
says to call launch_client.

tools/launcher_mcp_e2e.py checks it end to end against this checkout's
builds in a scratch launcher home.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:23:57 +02:00
Ludovic Marie a2b62913ba Merge pull request #331 from ludovic111/chore/discord-assets-to-kira
Move the Discord brand assets to the kira repo
2026-10-15 14:23:38 +02:00
Ludovic MarieandClaude Opus 5.5 8dc2a0e90a Move the Discord brand assets to the kira repo; this repo holds the game, launcher and website
The Discord server banner (page + PNG), the server icon render and Kira's avatar (SVG master +
render) now live in the private kira repo (ludovic111/kira#1). xtask icons no longer renders them;
the brand README points there, and CONTRIBUTING states what belongs in this repository.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:23:14 +02:00
rdbtCVS 969ff7e4d9 Merge main and preserve shared model-state preparation 2026-10-15 13:22:02 +01:00
rdbtCVS 7b8189d19c perf(client): share parsed block and item models 2026-10-15 13:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 8c5d1ed86b Agent control: the widget tree of every screen, click and set by id
`ui` lists every widget the last frame drew (title menus, pause, options
in both menu styles, inventories and menus) with an id, its kind, label,
bounds and value; `click ID` clicks one through the same window events
as the mouse, `set ID VALUE` moves a slider along its track. Ids come
from labels (FOV: 70 is fov), world/server/language rows carry their
names, Horizon switches, sliders and segments their row's caption, and
inventory slots are slot-ID with their stacks. After a click, the next
widget command waits for a redrawn frame.

horizon-ctl and its MCP gain ui, click and set, and launch --offscreen.
tools/agent_control_e2e.py gains a ui scenario and runs offscreen on
macOS; both scenarios now handle Singleplayer opening Create New World
when there are no worlds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:08:15 +02:00
Ludovic MarieandClaude Opus 5.5 90edb028bf Mods design: Horizon's Rust mod system and what carries over from Java mods
docs/mods.md: the sandboxed WebAssembly runtime, capabilities, hooks, budgets,
authoring, versions, multiplayer and distribution, with measured hook costs.
docs/research/java-mod-compat.md: why Fabric/NeoForge jars cannot run, data
import, which mods to port natively first, a porting guide outline.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:04:56 +02:00
Ludovic MarieandClaude Opus 5.5 0248df4d90 cargo horizon-mod: new, build and run Horizon mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:01:57 +02:00
Ludovic MarieandClaude Opus 5.5 b3906d803d Horizon mods runtime: load sandboxed WebAssembly components with wasmtime
Manifest and .hmod reading, capability checks against the component's imports,
fuel and memory limits per call, a trapping mod stopped alone, hot reload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:01:57 +02:00
Ludovic MarieandClaude Opus 5.5 670e055f4a Horizon mod SDK: the horizon:mods WIT contract, a Rust SDK and an example mod
horizon-mod-sdk wraps wit-bindgen: a Mod trait with default hooks and export_mod!.
coords-hud shows coordinates, facing and the time of day, with /where and /coords.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:01:57 +02:00
Ludovic Marie c6eb50f10b Merge pull request #325 from ludovic111/feat/version-port-report
Port report: ranked work items from a Minecraft version diff
2026-10-15 14:00:06 +02:00
Ludovic Marie 62b704ceaf Merge pull request #324 from ludovic111/feat/version-port-classes
Diff vanilla classes method by method and map them to Horizon
2026-10-15 13:59:57 +02:00
Ludovic Marie 59d0148057 Merge pull request #323 from ludovic111/feat/version-port-diff
version_port.py: detect, prepare and diff Minecraft releases
2026-10-15 13:59:50 +02:00
Ludovic Marie 64f480ce65 Merge pull request #322 from ludovic111/docs/version-ports
Design: port each new Minecraft release with as little human work as possible
2026-10-15 13:59:44 +02:00
Ludovic MarieandClaude Opus 5.5 7c0d3ceea6 Port report: ranked work items from a version diff
tools/port_report.py turns a diff into work items: rank 1 blocks the switch (facts,
packets, DataFixers, data component types, report formats, tables to regenerate),
rank 2 and 3 one item per Horizon file that ports changed vanilla code (with or
without a harness), rank 4 new content, rank 5 summaries. version_port.py report
writes report.md and report.json; port prepares, diffs and reports in one go.
The class map now resolves names of both versions. The doc records the 26.2 -> 26.3
replay against Horizon's own code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:51:39 +02:00
Ludovic MarieandClaude Opus 5.5 93bb113e0d Diff vanilla classes method by method and map them to Horizon
tools/class_index.py hashes every method's bytecode with constant-pool references
resolved and branch targets as instruction indices, lambdas folded into their
declaring method; class diffs list moved classes. tools/vanilla_map.py maps vanilla
classes to the Rust files whose comments name them and the harnesses whose oracles
or probes reach them. version_port.py prepare indexes, diff reports both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:44:26 +02:00
Ludovic MarieandClaude Opus 5.5 58ad81674b version_port.py: detect, prepare and diff Minecraft releases
detect reads Mojang's manifest; prepare downloads and verifies a version into the
version store (outside the repository), runs its data generator and reads the
jar's own version facts; diff compares facts, packets, registries, blocks, item
components, commands, data packs, assets and lang.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:38:37 +02:00
Ludovic MarieandClaude Opus 5.5 720e91fb8c Design the version-port pipeline: detect, prepare, diff, regenerate, report
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:33:51 +02:00
Ludovic MarieandClaude Opus 5.5 81c3e58a1c docs/agent-control.md: inventory of agent control and the design ahead
What the launcher, the game socket, horizon-ctl and the two MCP servers
can do today, and one model for what comes next: shared names and JSON
shapes, a widget tree with actions by id, options get/set, waits, an
event stream, the launcher's MCP proxying the game, errors with a kind,
and the safety rules.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:32:32 +02:00
Ludovic Marie a3cf73d9be Merge pull request #316 from ludovic111/feat/fishing-rod-visuals
Fishing: the rod casts, bites splash and bubble, and the line is vanilla's
2026-10-15 12:40:48 +02:00
Ludovic MarieandClaude Opus 5.5 44e21e6601 Merge main into feat/fishing-rod-visuals: keep the off-hand renderer, add the cast-rod flag
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:35:49 +02:00
Ludovic Marie ce6bc9c5c7 Merge pull request #320 from ludovic111/feat/trial-spawners-game
Trial spawners run in game
2026-10-15 12:32:58 +02:00
Ludovic Marie 96268647d3 Merge pull request #319 from ludovic111/feat/trial-spawners
Trial spawner state machine, checked against vanilla
2026-10-15 12:32:52 +02:00
Ludovic Marie d4c16084d7 Merge pull request #317 from ludovic111/fix/axe-copper-animation
Axes on copper: a shield in the off hand is raised first, and sculk sensors hear it
2026-10-15 12:32:40 +02:00
Ludovic Marie fd3d456de8 Merge pull request #315 from ludovic111/feat/minecart-sounds
Minecarts roll and rattle when ridden, and hits on vehicles sound
2026-10-15 12:32:32 +02:00
Ludovic Marie 23ac335b53 Merge pull request #314 from ludovic111/fix/offhand-render
Show the off-hand item in first person
2026-10-15 12:32:26 +02:00
Ludovic Marie 204e4fdccb Merge pull request #313 from ludovic111/fix/offhand-use
Use the off hand when the main hand passes: place blocks, eat, charge anchors
2026-10-15 12:32:20 +02:00
Ludovic Marie e00ce7c89e Merge pull request #312 from ludovic111/fix/offhand-crossbow-fireworks
Crossbows load firework rockets from either hand; bows and crossbows take held arrows first
2026-10-15 12:32:14 +02:00
Ludovic Marie 40d5b43e43 Merge pull request #311 from ludovic111/fix/shield-deflects-arrows
Shields turn arrows back, as vanilla does
2026-10-15 12:32:06 +02:00
Ludovic Marie d5e7dc76e6 Merge pull request #310 from ludovic111/fix/shield-block-no-hurt-sound
Shield blocks: the player hears the block sound, not the hurt sound
2026-10-15 12:32:00 +02:00
rdbtCVS ed0febf163 Merge main and preserve tick chunk membership invalidation 2026-10-15 11:29:05 +01:00
Ludovic MarieandClaude Opus 5.5 ed3d1e1168 Trial spawners run in game
Trial spawner block entities are kept (generated chunks and saves), tick on
the server with the level random and random sequences, spawn persistent
mobs, eject their rewards as items and set their block state, with their
level events and shutter sounds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:20:30 +02:00
Ludovic MarieandClaude Opus 5.5 ca76075cc9 Trial spawner state machine, checked against vanilla
mobs::trial_spawner: TrialSpawner and its state data, the registry
configs (data/*/trial_spawner), the six states, player detection with the
visual line of sight, mob spawns and reward ejection, read and saved as
vanilla does (a field that fails to decode takes its default alone).

tools/test_trial_spawner_parity.py ticks 2,500 random trial spawners in
VanillaNaturalSpawnOracle and natural_spawn_fixture and compares states,
spawns, events and ejected items line by line.

Also: copper grates have no visual shape; a slime's or magma cube's
obstruction box scales with the Size its spawn data sets; LootData::roll_split
for an empty-context getRandomItems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:20:29 +02:00
rdbtCVS ef92004ede test(client): cache legacy shape identity keys 2026-10-15 10:57:28 +01:00
rdbtCVS bea9d4b765 perf(client): decode gameplay facts without a JSON document tree 2026-10-15 10:57:28 +01:00
Ludovic MarieandClaude Opus 5.5 ce4d520f55 Fishing line: the hand side is a float, as in vanilla
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:55:30 +02:00
Ludovic MarieandClaude Opus 5.5 e162f9cdb7 Axes on copper: a shield in the off hand comes first, and sculk hears it
Scraping and waxing off copper already swing the arm, show the scrape and
wax-off particles and play the axe sound on main (#59, #104). Two gaps were
left in BlockTransformer.transformBlock:

- playerHasBlockingItemUseIntent: with a shield in the off hand, right-clicking
  with an axe raises the shield instead of scraping, unless sneaking.
- The block_change game event, which sculk sensors pick up.

Fixes #222

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:47:02 +02:00
Ludovic MarieandClaude Opus 5.5 64739d2b8b Show the off-hand item in first person
FirstPersonHandsAndItemsRenderer draws both hands: the off-hand item sits
on the other arm with its own equip height and swing, uses its own pose
when it is the hand in use (eating, blocking, drawing a bow), and
evaluateWhichHandsToRender hides a hand while a bow or crossbow is drawn
or a charged crossbow is held in the main hand. Only the main hand's empty
arm shows, as in vanilla.

Part of #276

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 5e2935747b Use the off hand when the main hand passes: place blocks, eat, charge anchors
Minecraft.startUseItem tries each InteractionHand in turn: useItemOn, then
useItem, stopping at the first that succeeds or fails. Item uses now act on
the hand that started them (Game::hand), so blocks, food, potions, buckets,
spawn eggs and the rest work from the off hand, the swing and the
re-equip animation follow that hand, and the empty-hand block use
(useWithoutItem) stays main-hand only. The respawn anchor passes on the
main hand when the off hand holds glowstone, as vanilla does, replacing the
special case for it.

The use_item_on harness now puts items in the off hand too.

Part of #276

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 ffd0297dc4 Crossbows load firework rockets from either hand; bows and crossbows take held arrows first
Player.getProjectile: what the weapon supports in the off hand, then in
the main hand (getHeldProjectile; crossbows also take firework rockets
there), else the first arrows in the inventory. Crossbows only looked
for arrows in the inventory, so a rocket in the off hand was never
loaded. A charged rocket is shot at an angle from 0.15 under the eyes
(CrossbowItem.createProjectile), every shot of a load holding one at
power 1.6 (getShootingPower), for three durability (getDurabilityUse).

Part of #276

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 e2473aafd8 Shields turn arrows back, as vanilla does
AbstractArrow.onHitEntity: an entity that takes no damage from the arrow
(a raised shield, invulnerability) turns it back with
ProjectileDeflection.REVERSE at power 0.2: a tenth of the speed, a half
turn give or take ten degrees from the arrow's own random; one left
almost still drops its pickup and is discarded. Arrows used to vanish.

The arrow tick now stops at the entity it hits: the game hurts the
entity, then arrows::finish_entity_hit spends the arrow (with its hit
sound, from the arrow's random) or turns it back, and ends the tick
(drag, gravity, fluids). The damage is taken from the speed at the hit,
the crit bonus from the arrow's random, the entity search follows
ProjectileUtil.getManyEntityHitResult (the box first, then
computeMargin's margin, which needs tickCount), Punch pushes only what
was hurt, and a refused burning arrow leaves the player's fire as it
was. Mobs' refusals keep the old ending until their hurt results follow
hurtServer.

The arrow harness gains an entity in the way that takes or refuses the
damage: squarely, grazing, slow enough to drop.

Part of #277

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 72ff742273 Shield blocks: the player hears the block sound, not the hurt sound
LivingEntity.hurtServer's playHurtSound goes through Player.playSound,
which leaves the player itself out; the player hears its hurt sound from
the client's handleDamageEvent, and a blocked hit sends BlocksAttacks'
block sound (onBlocked) instead of the damage event. Queue the local hurt
sound on the damage broadcast rather than on the server's hurt sound.

Part of #277

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:41:06 +02:00
rdbtCVS fac4944cfa Document transient chunk wrapper retention 2026-10-15 10:40:54 +01:00
Ludovic Marie 050b203846 Merge pull request #306 from ludovic111/feat/debug-lightmap
F3+4 shows the lightmap texture in the bottom right corner
2026-10-15 11:39:03 +02:00
Ludovic Marie b6ced87869 Merge pull request #305 from ludovic111/feat/debug-charts
Debug charts: F3+2 frame and tick times, F3+3 ping and bandwidth
2026-10-15 11:38:57 +02:00
Ludovic Marie 89465a8c6d Merge pull request #304 from ludovic111/feat/create-world-tabs
Create New World: vanilla's Game, World and More tabs
2026-10-15 11:37:55 +02:00
Ludovic Marie fd8223061c Merge pull request #303 from ludovic111/feat/list-scrollbars
World list and pack lists: vanilla scroller, dragged with the pointer
2026-10-15 11:37:49 +02:00
rdbtCVS 0c161a0336 Cache random-tick section eligibility for unchanged chunks 2026-10-15 10:37:46 +01:00
Ludovic Marie 889fff562d Merge pull request #301 from ludovic111/feat/multiplayer-lan-scanning
Multiplayer: vanilla's server list, with the LAN scanning text and its animated dots
2026-10-15 11:37:43 +02:00
Ludovic Marie fbb458252f Merge pull request #299 from ludovic111/feat/menu-blur
Blur the panorama behind world selection, multiplayer and world creation, as vanilla does
2026-10-15 11:37:36 +02:00
Ludovic Marie b92cc7c9e8 Merge pull request #295 from ludovic111/feat/recipe-toast-icons
Recipe toasts show the workstation icon and cycle through the unlocked recipes
2026-10-15 11:37:23 +02:00
Ludovic MarieandClaude Opus 5.5 61a127a2a2 Fishing: the rod casts, bites splash and bubble, and the line is vanilla's
- The fishing rod switches to its cast model while its hook is out
  (FishingRodCast), in first person, third person and the hotbar.
- FishingHook.catchingFish's particles: splashes where fish tease the bait,
  the fishing-particle trail and bubbles of a fish swimming up, and the
  bubble and fishing burst when it bites.
- FishingHookRenderer: the line is 16 black `lines` segments of the window's
  line width (2.5 px at 1080p) from a quarter block above the hook to the
  rod hand (on the near plane in first person, beside the body in third),
  and the bobber is centred on the hook.

Fixes #265

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:35:31 +02:00
Ludovic MarieandClaude Opus 5.5 adb19abd4c Minecarts roll, rattle when ridden, and take hits with the attack sounds
MinecartSoundInstance follows every minecart in the level (entity.minecart.riding,
louder with speed, silent when the tick rate is frozen), and getting in one starts
RidingMinecartSoundInstance's two inside sounds (normal and underwater). Hitting a
minecart or a boat plays Player.attack's strong/weak (and knockback) sounds.

The sound_engine harness now replays both instances against the vanilla client.

Fixes #227

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:29:15 +02:00
Ludovic Marie 4abf648a85 Merge pull request #280 from ludovic111/fix/streaming-reload-loop
Stop reloading saved columns every frame after the player moves
2026-10-15 11:27:01 +02:00
Ludovic Marie 5d17c56d77 Merge pull request #269 from ludovic111/perf/worker-face-visibility
Load worlds ~4.7x faster: stop copying the face visibility table per mesh job
2026-10-15 11:26:54 +02:00
Ludovic Marie 9044c69429 Merge pull request #307 from ludovic111/feat/bee-hives
Bees live in hives and nests: they go home, come out in the morning and make honey (#208)
2026-10-15 11:18:21 +02:00
Ludovic Marie 68ec2236e2 Merge pull request #293 from ludovic111/fix/drowned-parity
mob_ai oracle: wait for the light engine before ticking (drowned flake, #290)
2026-10-15 11:13:48 +02:00
Ludovic MarieandClaude Opus 5.5 5876014b57 Droppers and hoppers don't take a beehive for a container
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:36:37 +02:00
Ludovic MarieandClaude Opus 5.5 1f4154c0df F3+4 shows the lightmap texture in the bottom right corner
DebugScreenOverlay.toggleLightmapTexture and showLightmapTexture: the 16x16
lightmap at 64x64 on a black border, 2 pixels from the corner, sky light up
and block light right, as vanilla's flipped blit. It shares the bottom of the
screen with the F3+2 and F3+3 charts, so each switch turns the others off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:31:14 +02:00
Ludovic MarieandClaude Opus 5.5 dc863a66a4 Create New World: vanilla's Game, World and More tabs
CreateWorldScreen as in 26.3: a MenuTabBar (tab sprites, header
separators, underline) over the transparent tab header, each tab's
GridLayout aligned at (0.5, 1/6) between it and a 33-pixel footer with
Create New World and Cancel.

Game: the world name box with its save-folder tooltip, Game Mode
(Survival, Hardcore, Creative), Difficulty and Allow Commands, locked
in Hardcore. World: World Type (Default, Superflat), Customize, the seed
box with its hint, and the Generate Structures / Bonus Chest switches.
More: Game Rules, Experiments and Data Packs. Tooltips follow vanilla's.

The choices reach the new world: hardcore, difficulty and allowCommands
in level.dat, and world_gen_settings with the flat preset, structures and
bonus chest written before streaming starts. Re-Create reads them back.

Fixes #281

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:24:52 +02:00
Ludovic MarieandClaude Opus 5.5 6ae018055d Debug charts: F3+2 frame and tick times, F3+3 ping and bandwidth
DebugScreenOverlay's FpsDebugChart, TpsDebugChart, PingDebugChart and
BandwidthDebugChart with vanilla's scales, colours and places: 240 samples
(LocalSampleLogger), F3+2 and F3+3 toggles that show the overlay and exclude
each other, the overlay's "Debug charts" lines, and the play-state ping
request and pong response packets the ping chart uses on servers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:16:02 +02:00
Ludovic Marie df0acb2810 Merge pull request #302 from ludovic111/ci/parity-timeout
CI parity: allow 120 minutes so core changes finish instead of timing out
2026-10-15 10:05:43 +02:00
Ludovic MarieandClaude Opus 5.5 a8bd497132 World list and pack lists: vanilla scroller, dragged with the pointer
SelectWorldScreen's list now sits at vanilla's 49..h-60 with the list
background, header and footer separators, white selection outline and
the widget/scroller sprites at the row's right + 8. The scroller follows
the pointer while held (AbstractScrollArea.mouseDragged), the wheel moves
half a row, and the pack screen's lists can be dragged the same way.

Part of #282

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:04:37 +02:00
Ludovic MarieandClaude Opus 5.5 f2e87721f4 CI parity: allow 120 minutes, so changes to core crates finish instead of timing out
Changes to horizon-core or horizon-world make ~280-300 harnesses rerun, which takes
60-90 min on the Mac mini runners; at 45 min those runs were cancelled and proved nothing
(#269, #280, #292 today).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:59:10 +02:00
Ludovic Marie 3ed8abd25e Merge pull request #300 from ludovic111/fix/autosave-spike
Autosave stops waiting for entity writes: no more 2 s freeze (#224)
2026-10-15 09:58:50 +02:00
Ludovic Marie c0e1095ea9 Merge pull request #294 from ludovic111/fix/arrow-interpolation
Arrows stop jumping along their path after they land (#278)
2026-10-15 09:58:46 +02:00
Ludovic Marie 9593d9e329 Merge pull request #292 from ludovic111/fix/golem-persistence
Copper golems keep the persistence vanilla saved (#289)
2026-10-15 09:58:42 +02:00
Ludovic MarieandClaude Opus 5.5 587695e108 Multiplayer: vanilla's server list, with the LAN scanning header and its dots (#282)
JoinMultiplayerScreen's layout: the title centred in the 33-pixel header, the
ServerSelectionList between the separators (list background, 305-wide rows,
white selection outline, scissored rows, the scroller sprites), the footer
buttons centred in 60 pixels. The list always ends with the LAN header,
"Scanning for games on your local network" over LoadingDotsText's dots,
then the LAN games. Saved servers show two MOTD lines, the player count and
the ping, pinging or unreachable icon; Edit and Delete need a saved server.
The list scrolls with the wheel and a draggable scrollbar (ui::Scroll, after
AbstractScrollArea).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:48:14 +02:00
Ludovic MarieandClaude Opus 5.5 b18261cc79 Autosave doesn't wait for the entity writes
The autosave queued its chunks without waiting (#284), then saved the
entities with the waiting call. The region writer is one FIFO thread,
so the main thread still waited for every chunk queued before the
entities: a 1.9 s frame at the 5-minute autosave on a world with ~900
columns. Like PersistentEntitySectionManager.autoSave (saveAll's flush
only on save-all and quit), the autosave now queues the entities too.

Part of #224

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:39:04 +02:00
Ludovic MarieandClaude Opus 5.5 6953710c74 Blur the panorama behind the title menus' screens, as vanilla does (#282)
Screen.extractBlurredBackground: every classic screen reached from the title
(world selection and creation, multiplayer, options) blurs the panorama with
vanilla's minecraft:blur post chain, three horizontal and vertical box blur
passes of menuBackgroundBlurriness pixels, then draws menu_background over it
instead of a flat dark fill. The title screen itself stays sharp.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:32:16 +02:00
Ludovic Marie 02cfbf6f28 Merge pull request #298 from ludovic111/fix/firework-entity-light
Draw flying firework rockets as their lit item model like vanilla
2026-10-15 09:31:33 +02:00
Ludovic Marie eb1467f45f Merge pull request #297 from ludovic111/fix/leaves-option-remesh
Rebuild every section when See-Through Leaves, Smooth Lighting or Biome Blend changes
2026-10-15 09:30:39 +02:00
Ludovic Marie bab7cf92a7 Merge pull request #296 from ludovic111/fix/reversed-z-depth
Render the world with reversed depth like vanilla (far fire no longer flickers)
2026-10-15 09:30:02 +02:00
Ludovic MarieandClaude Opus 5.5 cf67db1873 Render firework rockets as their lit item model like vanilla
FireworkEntityRenderer submits the rocket's item in the GROUND context,
turned to the camera (and over on its side when shot at an angle), at the
packed light of its eye. Horizon drew a flat 0.5-block sprite at full
brightness, so rockets glowed at night and had no item shading.

Fixes #279

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:24:19 +02:00
Ludovic MarieandClaude Opus 5.5 81b07d1dd9 Rebuild every section when a meshing option changes like vanilla
Options.cutoutLeaves, ambientOcclusion and biomeBlendRadius call
LevelExtractor.allChanged when they change. Horizon only handed the new
value to the mesher, so after turning See-Through Leaves off the loaded
sections kept their see-through leaves, pale against the sky, beside the
opaque ones of sections rebuilt later.

Fixes #273

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:24:19 +02:00
Ludovic MarieandClaude Opus 5.5 1bfef88477 Render the world with reversed depth like vanilla
Vanilla 26.3's Projection passes its planes reversed and every pipeline
tests GREATER_THAN_OR_EQUAL. Horizon kept forward depth with a 0.05 near
plane, so far geometry lost precision and fire_side's quad, 0.01 pixel in
front of the block it burns on, z-fought with it.

Fixes #272

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:24:19 +02:00
Ludovic MarieandClaude Opus 5.5 111b237b6d Bees live in hives and nests: they go home, come out in the morning and make honey (#208)
BeehiveBlockEntity (crate::beehive): up to three bees stored as entity
tags, out when their time is up (not at night, in the rain or through a
blocked front unless in an emergency), honey for nectar, the flower they
knew; vanilla's bees / flower_pos save format. Bees find a hive with room
(BeeLocateHiveGoal), fly home and go in (BeeEnterHiveGoal through
MobEvent::EnterHive). In the game hives tick, breaking one lets the bees
out angry unless smoked, fire empties it, dispensers release them.

mob_ai: bee_hive_night, _rain, _morning and _honey match vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:14:21 +02:00
Ludovic MarieandClaude Opus 5.5 5530d682ab Arrows interpolate from their last tick, not from where they were shot
arrows::tick never set the projectile's previous position (Entity.setOldPosAndRot
at the start of every tick); Projectile::tick does, but arrows don't go
through it. The renderer lerped from the spawn point to the current
position every frame, so a flying or stuck arrow jumped along its whole
path. The arrow harness now prints the old position too.

Fixes #278

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:03:39 +02:00
Ludovic MarieandClaude Opus 5.5 f88bfc66f8 Recipe toasts show the crafting station and cycle through every unlocked recipe (#283)
RecipeToast keeps the station and result of each display unlocked while it
is up, draws the station at 0.6 scale in the plaque's corner over the result,
takes turns through them over the display time and stays up five more seconds
after each unlock. Toasts slide in and out on ToastInstance's squared curve,
honour notificationDisplayTime and play ui.toast.in / ui.toast.out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:55:36 +02:00
Ludovic MarieandClaude Opus 5.5 fa188fca42 mob_ai oracle: wait for the scene's light updates before ticking (#290)
The oracle started a scenario once hasLightWork() had been false for six
ticks, but light updates reach the light thread through the chunk task
dispatcher, where hasLightWork() can't see them. A drowned's first
sun-burn roll (Mob.isSunBurnTick draws only when the brightness at its
eyes is above 0.5) then read the dirt's light under a freshly filled
pool or the water's, depending on thread timing: one random draw more or
less, and drowned_pool / drowned_pool_swimmer failed at random.

Queue ThreadedLevelLightEngine.waitForPendingTasks on every forced chunk
after building the scene and tick only once they have all completed.

Fixes #290

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:34:05 +02:00
Ludovic Marie b5a2afb71e Merge pull request #291 from ludovic111/feat/bee-flight
Bees fly, pollinate flowers, get angry and sting
2026-10-15 08:19:07 +02:00
Ludovic Marie 77cad63374 Merge pull request #287 from ludovic111/fix/bat-flight
Bats hang upside down and fly, with their wing animations
2026-10-15 08:12:08 +02:00
Ludovic Marie 271a26515a Merge pull request #285 from ludovic111/fix/strider-lava
Striders stand on lava instead of bobbing in it
2026-10-15 08:08:58 +02:00
Ludovic MarieandClaude Opus 5.5 7c2a31206c Bees fly, pollinate flowers, get angry and sting (#208)
A port of vanilla 26.3 Bee: FlyingMoveControl and flying navigation,
hover wandering, flower search and pollination (nectar), growing crops,
tempting and breeding, persistent anger with the hurt-by alert, the sting
(poison, stinger lost, later death), drowning, staying home at night and
in rain, roll, textures and NBT. Hives as homes are not simulated yet.

FlyingMoveControl clamps the pitch like Entity.setXRot, so a rotlerp
that wraps to 359 gives 90 as in vanilla.

Mob AI parity: 9 bee scenarios, 3609/3609 ticks; full harness 120/120.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:57:51 +02:00
Ludovic MarieandClaude Opus 5.5 2bcb30b375 Bats hang upside down and fly, with their wing animations (#215)
Bats fell into the generic fallback AI and walked like land animals; their
animation states were never started. Port vanilla Bat: hanging from a solid
block until a player comes within 4 blocks or the block goes, the take-off
level event, customServerAiStep's flight towards random nearby air, Bat.tick
(held to the ceiling while resting, vertical speed scaled by 0.6 in flight),
setupAnimationStates (flying and resting keyframes), waking when hurt,
BatFlags in the save, no fall damage, movement events without sounds, the
lower voice pitch, and no omnidirectional air drag (vanilla gives that only
to bees, parrots and sulfur cubes).

Mob AI parity harness: six bat scenarios, 6/6 match (1906/1906 mob-ticks).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:57:51 +02:00
Ludovic MarieandClaude Opus 5.5 8cd97334bf Striders stand on lava instead of bobbing in it (#256)
Striders fell into the generic fallback AI, whose FloatGoal jumped them
up and down in lava. Port vanilla Strider: its goals (no FloatGoal,
StriderGoToLavaGoal, RandomStrollGoal every 60 ticks, LookAtPlayerGoal for
striders), floatStrider, standing on lava sources through the living
collision context and the half-block liquid shape, travelling in air where
lava holds it up, StriderPathNavigation's lava destinations, the walk target
value, going cold (suffocating speed modifier, cold texture) away from warm
blocks, the happy/retreat sounds and a lava-proof checkFallDamage.

Mobs now keep Entity.mainSupportingBlockPos from their last move, as
getBlockStateOnLegacy reads it.

Mob AI parity harness: five strider scenarios, 5/5 match (1105/1105
mob-ticks; 0/5 and 5/1105 before).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:57:51 +02:00
Ludovic MarieandClaude Opus 5.5 fb15822b3b Load PersistenceRequired as vanilla does: copper golems save 0 again
Mob.readAdditionalSaveData assigns persistenceRequired from the tag,
overriding the constructor's setPersistenceRequired (copper golems,
elder guardians). Horizon ORed it in, so a copper golem vanilla saved
with 0 came back with 1. The monument's elder guardian, which Horizon
builds from NBT where vanilla calls the constructor, now carries the
flag in its spawn NBT.

Fixes #289

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:41:31 +02:00
Ludovic Marie e626a727a1 Merge pull request #267 from ludovic111/feat/perf-mesh-once
Mesh each column once its neighbours' light has settled
2026-10-15 07:35:44 +02:00
Ludovic Marie 6dde5ec2d0 Merge pull request #288 from ludovic111/fix/save-roundtrip-macos
Run the save round-trip harness on macOS without vscreen
2026-10-15 07:26:54 +02:00
Ludovic Marie 282acfe086 Merge pull request #254 from ludovic111/feat/perf-battery-mode
Battery mode: an opt-in 60 FPS cap (30 in menus or unfocused) and half the worker threads
2026-10-15 07:23:31 +02:00
Ludovic Marie 0f569a4918 Merge pull request #261 from ludovic111/feat/launcher-perf
Launcher measurements: a session performance log and horizon-launcher benchmark
2026-10-15 07:20:59 +02:00
Ludovic Marie a6860bbec6 Merge pull request #263 from ludovic111/feat/benchmark-mode
horizon-client --benchmark SCENARIO: benchmark your own machine, report in the shared format
2026-10-15 07:20:54 +02:00
Ludovic Marie 18234d3a01 Merge pull request #264 from ludovic111/feat/perf-bench-report-v1
perf_bench.py: repeats with 95% CIs, the shared report format, loads end when meshing is done
2026-10-15 07:20:42 +02:00
Ludovic Marie 7b6ef36d90 Merge pull request #247 from ludovic111/feat/bench-report
Benchmark report format v1 (docs/benchmark-report.md, crates/horizon-bench)
2026-10-15 07:20:04 +02:00
Ludovic MarieandClaude Opus 5.5 0c685d9db4 Stop reloading saved columns every frame after the player moves
Generation asked for columns within the view distance + 3 or two of a
finished chunk, but a column that came back finished (loaded from the
save) was kept only within + 2 or one of a finished chunk. After a
teleport, saved columns in between were loaded, dropped, asked for and
loaded again on every frame, forever: 8 of them at render distance 16
held 8 of the 39 in-flight generation slots and kept the disk and a
worker busy. The requested, kept and unloaded ranges now live in Ranges
and nest; requested_columns_stay_loaded checks it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:14:31 +02:00
Ludovic Marie 375a685b84 Merge pull request #230 from rdbtCVS/perf/generation-scan-invalidation-20261014
perf(streaming): skip unchanged generation scans
2026-10-15 07:11:36 +02:00
Ludovic MarieandClaude Opus 5.5 e64c80243d Run the save round-trip harness on macOS without vscreen
On macOS the Horizon client renders offscreen (--offscreen) with audio off,
from the logged-in session; Linux keeps vscreen and the null ALSA device.
The vanilla side was already a headless dedicated server. --bin-dir is
resolved, since the client runs from the run folder. parity.py no longer
skips save_roundtrip off Linux; it stays local-only (no rendering over SSH).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:04:56 +02:00
Ludovic MarieandClaude Opus 5.5 78f206bc22 Mesh only when no neighbour is being lit; skip columns that can't be lit yet
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:04:25 +02:00
Ludovic MarieandClaude Opus 5.5 4f3326e983 Mesh a column once its neighbours' light has settled
A new chunk relights its eight neighbours, and every finished light job
dirties all sections of the 3x3 columns around it. Meshing waited only for
the column itself to be lit, so sections near the loading frontier were
meshed with half-settled light and again after each neighbour's relight:
about 2.25 meshes per section while a world loads. Wait until no column of the
3x3 is being lit or queued for light. The final meshes are the same; they are
built once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:04:25 +02:00
rdbtCVS 69925a57c5 perf(streaming): skip unchanged exhausted generation scans 2026-10-15 07:04:23 +02:00
Ludovic Marie cfa6033535 Merge pull request #220 from rdbtCVS/perf/reuse-pack-overlays-20261014
perf: reuse unchanged resource-pack overlays after validation
2026-10-15 07:03:05 +02:00
Ludovic Marie ff29d50f73 Merge pull request #219 from rdbtCVS/fix/classic-title-menu-parity
Fix classic title menu layout and accessibility shortcut
2026-10-15 07:02:08 +02:00
Ludovic MarieandClaude Opus 5.5 146c0fd3ce benchmark: a load ends when every streaming stage is idle
Same rule as perf_bench: with status.streaming, the last sections are drawn
once no stage has held work for half a second, so the remeshing that
follows a load (fluids settling, random ticks) isn't counted as loading.
Without it, meshing still has to go quiet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:18 +02:00
Ludovic MarieandClaude Opus 5.5 014b1f5267 Benchmark reports go to the telemetry outbox, with the benchmark-upload consent only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:18 +02:00
Ludovic MarieandClaude Opus 5.5 0f61ed6b63 benchmark: Apple GPUs by name (Metal has no vendor ID)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:18 +02:00
Ludovic MarieandClaude Opus 5.5 32695d4680 benchmark: a load ends when meshing goes quiet too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:18 +02:00
Ludovic MarieandClaude Opus 5.5 309dea385b horizon-client --benchmark SCENARIO: the perf_bench run from inside the game, written as a report
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:18 +02:00
Ludovic MarieandClaude Opus 5.5 49b6367d2e perf_bench: a load ends when every streaming stage is idle
Builds that report status.streaming are loaded once chunk counts are
stable and no stage (generation, decoration, light, meshing) has held work
for half a second. Meshing going quiet also waited through the remeshing
that follows a load (fluids settling, random ticks: 1500 falling to 60
meshes/s for ~150 s at RD16), which is simulation, not loading. Older
builds keep the meshing-rate rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:03 +02:00
Ludovic MarieandClaude Opus 5.5 7d36d09edd perf_bench.py: a load ends when meshing goes quiet too
Sections wait for their neighbours' light before they are meshed, so the
chunk counts settled while edge sections were still undrawn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:03 +02:00
Ludovic MarieandClaude Opus 5.5 2a91612928 perf_bench.py: repeats, and the benchmark report format v1
Each game runs --repeats times (3) per render distance and writes one v1
report per distance (benchmarks/perf/<label>-rd<N>.json): figures as means
with standard deviation and 95% CI, per-flight frame entries with the fixed
histogram, the hardware without the host name. frames() and summary() match
crates/horizon-bench value for value (tested on the same inputs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:03 +02:00
Ludovic MarieandClaude Opus 5.5 bb1c292a40 Launcher benchmark reports go to the telemetry outbox with the benchmark-upload consent; tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:02 +02:00
Ludovic MarieandClaude Opus 5.5 c78f2d3e27 Launcher measurements: a session performance log and horizon-launcher benchmark
Each session rewrites benchmarks/launcher-<time>-session.json in the data
folder (report format v1): start to first frame, release list fetch,
install/update and prepare durations, Play to the game's first frame (the
game now logs its first frame), RSS, UI frame times, binary size.
horizon-launcher benchmark starts the launcher N times (cold, then warm),
switches pages, measures idle CPU and memory and the release list fetch,
and writes one report with means and 95% CIs. Nothing is uploaded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:02 +02:00
Ludovic MarieandClaude Opus 5.5 650ad39d85 Benchmark report uploads are dated to the day, like every telemetry item
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:00:54 +02:00
Ludovic MarieandClaude Opus 5.5 a500e25705 horizon-bench: load average, power source, RSS and CPU time probes; launcher metric names
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:00:54 +02:00
Ludovic MarieandClaude Opus 5.5 e0ab3f8fcc Benchmark report format v1: docs/benchmark-report.md and crates/horizon-bench
One versioned JSON report for every benchmark Horizon writes (the game's
--benchmark, the launcher's measurements, tools/perf_bench.py): the local log
a player can attach and, with consent, what the telemetry upload sends.
Summaries over repeats (mean, stdev, 95% CI) and fixed-edge frame-time
histograms, never raw frame streams; no host, user or path in uploads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:00:54 +02:00
Ludovic Marie c44d311d46 Merge pull request #239 from rdbtCVS/perf/chunk-membership-sync-20261014
perf(client): skip tick chunk synchronization when membership is unchanged
2026-10-15 06:58:40 +02:00
Ludovic Marie c8ce1e081d Merge pull request #184 from rdbtCVS/t3code/fix-effect-icon-rendering
Fix status effect icons and display effects in inventories
2026-10-15 06:58:33 +02:00
Ludovic Marie 9b0d8fd7d7 Merge pull request #204 from rdbtCVS/perf/entity-model-document-20261014
perf(client): share parsed entity models across startup batches
2026-10-15 06:58:11 +02:00
Ludovic Marie 639d315859 Merge pull request #210 from rdbtCVS/perf/single-state-model-parse-20261014
perf(client): reuse parsed model states during preparation
2026-10-15 06:56:45 +02:00
Ludovic Marie 407e863d9f Merge pull request #233 from rdbtCVS/perf/lighting-worker-results-20261014
perf: return only lighting from chunk light workers
2026-10-15 06:56:10 +02:00
Ludovic Marie 5071cd785a Merge pull request #237 from rdbtCVS/perf/entity-material-buckets-20261014
perf: reuse renderer entity material batching buffers
2026-10-15 06:55:45 +02:00
Ludovic Marie 3df34df87b Merge pull request #284 from ludovic111/fix/bastion-chunks
Save every generated chunk and decorated proto chunks, as vanilla does (#249)
2026-10-15 06:48:44 +02:00
Ludovic Marie 8965ca5e2b Merge pull request #274 from ludovic111/fix/mesh-out-of-range
Mesh columns loaded past the render distance once they come in range (#228)
2026-10-15 06:48:24 +02:00
Ludovic Marie 30144e478d Merge pull request #206 from ludovic111/feat/perf-bench
Benchmark Horizon against vanilla 26.3 and vanilla + performance mods (tools/perf_bench.py, docs/benchmark.md)
2026-10-15 06:38:45 +02:00
Ludovic MarieandClaude Opus 5.5 1c3fd6e23c Save every generated chunk and the proto chunks decoration wrote to (#249)
Horizon saved a chunk only once a player changed it and never saved proto
chunks, so leaving an area dropped decorated chunks while the final chunks
next to them stayed loaded (or were regenerated later). Coming back, those
chunks were generated again and their decoration was skipped beside a final
neighbour: whole chunks of a bastion lost their pieces, other chunks their
features. Like vanilla, generated chunks are now saved, proto chunks that
decoration wrote to are saved with their status (terrain or features) and
restored onto the chunk generated again, columns that finish after the player
left are saved, and saving waits for the decorations in flight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:37:05 +02:00
Ludovic MarieandClaude Opus 5.5 4901bdf231 Mesh sections of columns that load past the render distance once in range (#228)
The mesh loop dropped every dirty section of a loaded column outside the
render distance. Columns load up to three chunks past it, and a column
loaded from a save with its light runs no light job, so nothing asked for
its sections again: once the player came closer it stayed invisible until
a block in it changed. Those sections now wait and are put back when the
centre, radius or forced chunks change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:12:18 +02:00
Ludovic Marie 087cbacdc4 Merge pull request #268 from Online5277/fix/entity-use-occlusion
Prevent block interactions through targeted entities
2026-10-15 05:38:42 +02:00
Ludovic Marie 416012ad4c Merge pull request #241 from ludovic111/feat/controllers2-focus
Controllers: focus navigation in menus, on-screen keyboard, menu navigation option
2026-10-15 05:25:30 +02:00
Ludovic Marie 5c2201a781 Merge pull request #200 from ludovic111/feat/friends-join
Play with friends (3/3): Join a friend with the code alone, in game and in the launcher; end-to-end harness
2026-10-15 05:07:05 +02:00
Ludovic Marie 44a78fe65e Merge pull request #199 from ludovic111/feat/friends-host
Play with friends (2/3): Invite friends in the pause menu (no server needed)
2026-10-15 05:06:36 +02:00
Ludovic MarieandClaude Opus 5.5 213af61b92 status: report each streaming stage's work
`status` gains `streaming` (queued jobs, chunks generating, protos waiting for
decoration, decorating, finishing, lighting, meshing) and `dirty_light`, so a
slow load shows which stage it waits on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 04:55:14 +02:00
Ludovic Marie 12b5e39ac8 Merge pull request #192 from ludovic111/feat/friends-relay
Play with friends (1/3): invite codes that need no server, port mapping, STUN, a relay anyone can run
2026-10-15 04:49:42 +02:00
Ludovic Marie 7a184a41f0 Merge pull request #259 from Online5277/fix/options-list-clipping
Clip options rows smoothly at list boundaries
2026-10-15 04:39:25 +02:00
Ludovic Marie ef569d3201 Merge pull request #262 from Online5277/fix/spectator-movement
Prevent climbable blocks from slowing spectator flight
2026-10-15 04:39:22 +02:00
Ludovic Marie 20ecaf0254 Merge pull request #266 from Online5277/fix/gamemode-switcher
Add the F3+F4 game mode switcher
2026-10-15 04:39:17 +02:00
Ludovic Marie 503b91df33 Merge pull request #260 from Online5277/fix/cloud-settings
Honor Off, Fast and Fancy cloud settings
2026-10-15 04:38:40 +02:00
Ludovic MarieandClaude Opus 5.5 75a30185a5 Keep blank splash lines like SplashManager.prepare
Vanilla trims each line and drops only the hidden splash (hash 125780783);
a blank line stays a (blank) splash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 04:37:23 +02:00
rdbtCVS bd4101e9d1 Update merged world-menu test for title resource loading 2026-10-15 04:37:23 +02:00
rdbtCVS a0f3315a79 Address title menu review: language, icon opacity, and splash behavior 2026-10-15 04:37:23 +02:00
rdbtCVS cccb5bff96 Match Minecraft integer centering for title menu icons 2026-10-15 04:37:08 +02:00
rdbtCVS 6cdcf6c85a Fix classic title menu layout and accessibility shortcut 2026-10-15 04:37:08 +02:00
Cosmic 22e548471c Keep entity item use from falling through to blocks 2026-10-14 22:22:01 -04:00
Cosmic 25858f63ae Add the F3+F4 game mode switcher 2026-10-14 22:09:12 -04:00
Ludovic MarieandClaude Opus 5.5 50a1649dea Pack small palettes without hashing
PalettedContainer::pack hashed all 4096 values with SipHash to build the
palette. It now shares from_values' scan, which only switches to a map
past 16 distinct values. Same palette order, same output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 04:06:42 +02:00
Ludovic MarieandClaude Opus 5.5 ed8f3d70b8 Keep each worker's face visibility table across mesh jobs
Every mesh job cloned the whole FaceVisibility (every state, every face
VoxelShape and the occlusion cache) and dropped it afterwards: about half
of the workers' busy time while a world loads. Each worker now keeps its
copy, like vanilla's per-thread occlusion cache, and clones a new one only
when the mesh tables change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 04:06:42 +02:00
Cosmic 4c7d77352e Skip climbable movement limits for spectators 2026-10-14 21:55:34 -04:00
Cosmic 21411176a7 Honor Off and Fast cloud settings 2026-10-14 21:47:18 -04:00
Cosmic ede136bdd2 Clip partially visible options list rows 2026-10-14 21:42:07 -04:00
Ludovic MarieandClaude Opus 5.5 e6be303517 docs/benchmark.md: no unmeasured startup figure
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:40:12 +02:00
Ludovic MarieandClaude Opus 5.5 b691b5ebaa Benchmark Horizon against vanilla 26.3 and vanilla with performance mods
tools/perf_bench.py runs Horizon, the vanilla 26.3 client and vanilla with
Sodium, Lithium, FerriteCore and ImmediatelyFast through the same steps:
fresh world (seed 1234, creative) per render distance, start to title, start
to in-world, then per scene teleport, chunk load, settle and a fixed 192-block
flight. Every frame time is kept and one function computes average FPS, 1% and
0.1% lows, p50/p95/p99/p99.9 and a frame-time histogram for every game; RSS is
sampled from outside the process for every game. One options.txt (vanilla's
keys, Fancy preset values with a Custom preset so the render distance holds)
is written for all of them.

Vanilla is driven by a dev-only Fabric mod (mods/horizon/src/bench) compiled
against the user's own client jar: it creates the world with
WorldOpenFlows.createFreshLevel, teleports with /tp and places the player on
the path every frame as horizon-ctl fly does. Nothing of the game is
redistributed and the performance mods are fetched from Modrinth with the
hashes pinned in packs/mods.lock.json.

Horizon side: `fly ... PITCH raw` also replies with every frame time, and
--offscreen drives frames from the event loop, so a window nobody sees (a
locked macOS session) still renders.

docs/benchmark.md describes what is measured, how fairness is kept, the
caveats and how to reproduce it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:40:12 +02:00
Ludovic Marie ff5df6aa5e Merge pull request #257 from Online5277/fix/keybind-scrollbar
Make options list scrollbars draggable
2026-10-15 03:38:29 +02:00
Cosmic 7cfede86f4 Handle dragging in options list scrollbars 2026-10-14 21:34:04 -04:00
Ludovic MarieandClaude Opus 5.5 bf74ac6f30 Controllers: the Steam keyboard choice falls back to Horizon's without xdg-open
Picked by hand on macOS or Windows, A on a text field opened nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:10:27 +02:00
Ludovic MarieandClaude Opus 5.5 635622652e Controllers: options sub-pages keep the classic focus highlight under the Horizon style
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:10:15 +02:00
Ludovic MarieandClaude Opus 5.5 210a6f5ddd Controllers: focus follows scrolled lists, clipped targets, pad status shows focus
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:10:15 +02:00
Ludovic MarieandClaude Opus 5.5 9927d06049 Controllers: focus navigation in menus, on-screen keyboard, menu navigation option
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:10:15 +02:00
Ludovic MarieandClaude Opus 5.5 c9b3a06cf7 Launcher lockfile: horizon-friends' if-addrs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 7a475a948c Invite friends works with no relay: the code, and what friends can expect
Sharing no longer needs a relay. The panel shows the full invite code in
groups (monospace, wrapped) with Copy and Stop sharing, then who can join:
friends anywhere when the router opened the port or this computer has a
public address; otherwise what to do (UPnP, the port to forward and the
LAN address, a relay, a VPN; CGNAT called out) with a link to the
troubleshooting docs. A relay set in horizon.txt (friendsRelay) or
HORIZON_RELAY_URL adds its short code. horizon.txt keeps other lines when
the menu style is saved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 fe7df463fa Play with friends: bound handshakes and the relay's first line in time, cap relay connections
A read timeout restarts with every byte, so a peer trickling one byte at
a time could hold the host's 8 handshake slots (or relay threads) for
hours. The host's openings and the relay's first line now end after 10 s
in all; the relay closes connections past RELAY_MAX_CONNECTIONS (4096).
The docs say what happens to a router mapping when the game crashes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 9e6d248fbe test_friends: --xvfb and --offscreen for headless Linux machines
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 872e77e19b Invite friends: who is here and notices read in the muted colour, the hint stays faint
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 a3913be641 Join a friend with the full code (no server) or a relay's short code
The game joins `friend:CODE` with either code: a full invite is dialled
directly, then through the relay it names; a short code goes through the
relay the player set. Join a friend gains a Relay (optional) field, saved
to horizon.txt (friendsRelay) and used for hosting too; the code field
shows the end of long codes and says what is wrong with one (typo, other
version, short code without a relay). The launcher validates both kinds.

tools/test_friends.py now runs (a) the code alone with no relay anywhere
and (b) a relay run locally with the short code; routers and STUN stay
out of tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 241dee2649 Invite friends: count direct and relayed sessions in friends status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 341aa98a96 test_friends: --hold for the guest role, report chunks once they arrive
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 21a70c8e97 LAN: a guest's entity id no longer takes the host's
Hub::admit read the entity counter before incrementing it, while the game
increments first (the current value is the last id in use). The first
guest after Open to LAN got the host's own entity id, so its client took
the host's AddEntity for itself and never showed the host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 c7f30d2105 Relay: RELAY_BIND listens on one address (tailnet-only test relays)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 7784350bad Play with friends: Invite friends in the pause menu
The Horizon pause menu gains "Invite friends" beside Open to LAN. It opens
the world to LAN if it isn't (Open to LAN's defaults) and shares that port
behind an invite code through the relay; the menu shows the code with Copy
and Stop sharing, who is here and why the code changed. Closing the LAN
server ends the code; stopping sharing closes LAN again if sharing opened
it. `horizon-ctl friends host|stop|status` drives it in harnesses.

Without HORIZON_RELAY_URL (the default until a relay is deployed) the button
is disabled and says the feature isn't available yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 5d29f31663 Play with friends: Join a friend in game and in the launcher
The game joins `friend:CODE` addresses: the network thread resolves the code
through the relay, agrees on the key, settles on a direct or relayed stream
and connects the client to the tunnel's loopback port; everything after is
an ordinary join. Errors (unknown or ended code, wrong secret, host not
answering, version mismatch, rate limits) end on the Disconnected screen in
plain words. Shown and logged addresses hide the code's secret half.

Horizon's multiplayer page gains "Join a friend" (a code field); the classic
screens keep vanilla's buttons. The launcher's Play page gains a "Join a
friend" row (paste works) that starts the game straight into the join;
`horizon-launcher play --join CODE` does the same.

tools/test_friends.py runs a relay, a host Horizon sharing a world and a
guest Horizon, over plain LAN (--baseline), the direct path and the forced
relay path: chat, movement and blocks both ways, then the ended invite.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic Marie 6ddabae4dd Merge pull request #238 from ludovic111/fix/player-water-physics
Player water on #59: bubble columns, swim sounds, drown bubbles, vanilla fixes
2026-10-15 03:07:20 +02:00
Ludovic MarieandClaude Opus 5.5 dbc85822c1 Battery mode: a one-line description
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:07:02 +02:00
Ludovic Marie 6da701a669 Merge pull request #253 from ludovic111/feat/telemetry-privacy
website: privacy page for opt-in data collection
2026-10-15 03:06:13 +02:00
Ludovic MarieandClaude Opus 5.5 61b0aafbf8 Relay: no hosting platform in its comments
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:05:54 +02:00
Ludovic MarieandClaude Opus 5.5 5189a735c7 Invite codes: one address per LAN, no unique-local IPv6, the macOS firewall prompt in the docs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:05:54 +02:00
Ludovic MarieandClaude Opus 5.5 de464a8fc0 Play with friends without a server: endpoints and key in the code
Ludovic can't pay for a hosted relay, so nothing depends on one now. The
host's game finds how it can be reached on its own: its interface
addresses (LAN, VPNs such as Tailscale or ZeroTier, IPv6), a port opened
on the router for an hour at a time (PCP, then NAT-PMP, then UPnP IGD;
renewed while sharing, deleted on stop) and its public address from STUN
(Cloudflare's and Google's public servers by default, configurable). The
invite code carries those endpoints and an 80-bit secret in base32 groups
with a checksum; guests dial every endpoint at once and run the same
SPAKE2 + ChaCha20-Poly1305 handshake, no rendezvous involved. When nothing
can reach the host it says why and what to do (UPnP, the port to forward,
a relay, a VPN; CGNAT and double NAT detected).

The relay stays as something anyone can run (binary, Dockerfile, systemd
unit; Railway config removed): with one configured the host also gets a
short code, and the full code names the relay so guests fall back to it.
No relay by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:05:54 +02:00
Ludovic MarieandClaude Opus 5.5 74c1a90980 Relay: hand out IPv4 clients' addresses as IPv4
A dual-stack listener reports them as ::ffff:a.b.c.d, which the other end
can't dial from an IPv6-only socket.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:05:54 +02:00
Ludovic MarieandClaude Opus 5.5 60ddf3daf8 Play with friends: invite codes, the relay and the encrypted tunnel
horizon-friends: codes (Crockford base32, nameplate + secret), the relay's
JSON-line protocol, SPAKE2 then ChaCha20-Poly1305 frames keyed off the
code's secret, direct connections (candidates, TCP simultaneous open from
a reuse-port socket) with the relay as fallback, and the host and guest
ends that carry the game's TCP stream between loopback ports.

horizon-relay: the service (codes, rendezvous, byte relay, rate limits,
bandwidth caps, idle timeouts, counts only), its Dockerfile and Railway
config. Nothing is deployed and nothing in the game uses it yet.

Design: docs/play-with-friends.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:05:54 +02:00
Ludovic Marie 186cf8c320 Merge pull request #251 from ludovic111/feat/telemetry-server
Opt-in data collection (4/5): the ingest server (SQLite, rollups, retention, aggregate endpoint)
2026-10-15 03:04:31 +02:00
Ludovic Marie 56861be08c Merge pull request #252 from ludovic111/feat/telemetry-game
Opt-in data collection (3/5): the game keeps crash reports and session summaries only with consent
2026-10-15 03:01:37 +02:00
Ludovic Marie f12578238e Merge pull request #248 from ludovic111/feat/telemetry-consent
Opt-in data collection (1/5): consent, payloads, outbox and a gated uploader
2026-10-15 03:01:26 +02:00
Ludovic MarieandClaude Opus 5.5 b3755bd6b1 website: privacy page for opt-in data collection
/privacy says what the launcher may send when a player allows it (performance
summaries, crash and bug reports, benchmark uploads), why, on what basis,
where it is kept and for how long, and how to delete it. The footer links it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:59:29 +02:00
Ludovic MarieandClaude Opus 5.5 12c1af0261 Opt-in data collection (4/5): the ingest server
crates/horizon-telemetry-server, its own workspace like the launcher's (the
game's workspace never builds a web server):
- POST /v1/batch (zstd, decoded and checked by horizon-telemetry), DELETE
  /v1/installs/<id>, GET /v1/aggregate (cached 10 min, CORS for the website),
  GET /healthz.
- SQLite: machine descriptions stored once, one row of columns per session
  with 80-byte summary blobs, zstd bodies for crash/bug/benchmark, daily totals
  rebuilt when a day changes and kept after raw rows expire (180 days). About
  190 bytes per session on disk.
- Per-address token bucket (addresses in memory only), per-install daily cap,
  groups under 5 install-days hidden from the totals.
- Tests: refusals, rate limit, daily cap, deletion (rows and totals), retention,
  storage size, and end to end with the launcher's uploader.
- Dockerfile and a path-filtered CI workflow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:53:59 +02:00
Ludovic MarieandClaude Opus 5.5 d7b975185c Opt-in data collection (3/5): the game obeys the player's consent
The game never sends anything itself; it keeps items in the outbox for the
launcher, and only for kinds the player allows:
- Crash reports: crash::deliver keeps a scrubbed copy (error, stack, thread,
  Horizon version, graphics adapter and render settings; never the log, world,
  server or resource packs; the world's name and folder and the server address
  are scrubbed wherever they appear). Without consent crash-reports/ stays
  local, as before.
- Performance: a Session records frame times while playing and the integrated
  server's tick times into fixed-size Timings, with the time to the title
  screen and to the world; on leaving a world or quitting, a session of a
  minute or more becomes a summary (under 1 KiB) when the player allows it.
- The GPU, its driver and backend come from the wgpu adapter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:51:02 +02:00
Ludovic MarieandClaude Opus 5.5 a2b2b3f776 Player water on #59: bubble columns, swim sounds, drown bubbles, vanilla fixes
Builds on Cosmic's #59 (player fluid travel, swimming, air supply) and
changes only what differs from vanilla 26.3 or what it lacks:

- BubbleColumnBlock.entityInside for the player (isPrecise, onAbove /
  onInsideBubbleColumn, not while flying) with its splash/bubble particles.
- LocalPlayer.goDownInWater before LivingEntity.aiStep's velocity cleanup.
- The aiStep jump phase as vanilla: nothing while flying, the fluid jump
  threshold from the eye height (also in travelInLava).
- Player.travel: swim steering samples y + 1.0 - 0.1, and LivingEntity.travel
  checks fluids before gliding; water and lava no longer end a glide.
- LocalPlayer.isUnderWater is wasUnderwater; sprint conditions get shallow
  water, flight and gliding; no flight toggle while swimming; the base tick
  runs before local crouching; the desired pose puts swimming before gliding.
- decreaseAirSupply creates the oxygen_bonus instance (getAttribute).
- Swim sounds and muffled steps in water; entity event 67 drown bubbles.
- Scene motion harness: swim_corpus (player travel in water and lava, bubble
  columns, air supply) and sprint-swim cases for --sprint-input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:45:13 +02:00
Ludovic Marie 89b58991bb Merge pull request #246 from rdbtCVS/perf/save-batch-entity-grouping-20261014
perf: group block entities once per save batch
2026-10-15 02:38:17 +02:00
Ludovic MarieandClaude Opus 5.5 cc8744a06b Trim synthetic overlay benchmarks; record Mac real-pack numbers
The real-pack benchmark on the production resource_root path stays as the
reproducible measurement. On the Mac mini it saves 1.18 s (Faithful 32x) and
5.29 s (FPBR) per startup with the pack enabled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:26:46 +02:00
Ludovic MarieandClaude Opus 5.5 0229615560 Battery mode: an opt-in frame cap and fewer worker threads
A Horizon graphics setting (Performance, off by default, batteryMode in
horizon-graphics.txt): at most 60 frames per second in a world, 30 in menus
without a world or behind other windows, and half the automatic worker
threads. It only changes when frames are drawn; ticks and what frames show
are untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:24:57 +02:00
Ludovic MarieandClaude Opus 5.5 43138159b2 Opt-in data collection (1/5): consent, payloads, outbox and a gated uploader
horizon-telemetry, shared by the launcher, the game and the ingest server:
- consent.json in the game folder: four switches, all off until the player
  answers, a consent version that re-asks when what is collected changes, the
  answer's time, and a random install ID made at the first yes.
- Payloads: perf summaries (percentiles + 14 frame-time buckets from a
  fixed-size Timings recorder), crash and bug reports, and benchmark reports
  carried as docs/benchmark-report.md defines them; size caps per kind.
- A scrubber for user folders, user names, e-mail and IP addresses, UUIDs and
  caller-known private terms.
- The outbox: only allowed kinds get in, daily caps, 200 items / 2 MiB.
- zstd batches with bounded decoding; the uploader re-reads the consent and
  drops withdrawn items before connecting, backs off and caps batches a day.
- tests/consent_gating.rs: a local mock server sees zero connections with the
  consent missing, outdated or withdrawn.

No server is set (DEFAULT_ENDPOINT is None): nothing is sent yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:21:13 +02:00
rdbtCVS 1295fccd17 perf(client): validate overlay reuse with source stamps and benchmark real packs 2026-10-15 02:19:11 +02:00
rdbtCVS 8177726f70 perf: reuse validated resource-pack overlays 2026-10-15 02:19:11 +02:00
rdbtCVS dd5a361a58 bench(client): measure state reuse after slice reader changes 2026-10-15 02:18:30 +02:00
rdbtCVS cb28f691ff perf: return only lighting from chunk light workers 2026-10-15 02:18:30 +02:00
rdbtCVS ded40d60c6 perf(client): reuse parsed model states during preparation 2026-10-15 02:18:30 +02:00
rdbtCVS 2746802152 perf(client): synchronize tick chunk membership only on changes 2026-10-15 02:14:16 +02:00
rdbtCVS 4cac0a13cd perf(client): track structural chunk membership revisions 2026-10-15 02:14:16 +02:00
rdbtCVS 5c076ca38f perf(client): parse entity model document once for startup batches 2026-10-15 02:14:04 +02:00
Ludovic Marie 44144373ea Merge pull request #240 from rdbtCVS/perf/reuse-entity-geometry-20261014
perf: reuse entity geometry vertex storage between frames
2026-10-15 02:13:54 +02:00
Ludovic Marie da0c985d1d Merge pull request #244 from rdbtCVS/fix/launcher-desktop-wrapper
Preserve package wrappers in launcher desktop entries
2026-10-15 02:09:35 +02:00
rdbtCVS a794c05417 bench: measure dense entity geometry buffer reuse 2026-10-15 02:08:23 +02:00
rdbtCVS 90bdf5bb1a perf: retain entity geometry vertex storage between frames 2026-10-15 02:08:23 +02:00
rdbtCVS 34628c1471 perf: group block entities once per save batch 2026-10-15 01:07:56 +01:00
Ludovic Marie 8df59b96a9 Merge pull request #243 from rdbtCVS/perf/visibility-shortcuts-20261014
perf(mesher): short-circuit section visibility flood fills
2026-10-15 02:06:31 +02:00
Ludovic Marie de75330b21 Merge pull request #245 from rdbtCVS/fix/launcher-warning-wrap
fix(launcher): wrap long warnings within the window
2026-10-15 02:06:19 +02:00
rdbtCVS 2491d49bf5 perf: reuse entity material batching scratch buffers 2026-10-15 02:05:41 +02:00
Ludovic Marie 142b8da33e Merge pull request #236 from ludovic111/feat/pr-ready
pr_ready: one self-check for agents and contributors, CONTRIBUTING.md and a PR template
2026-10-15 02:04:01 +02:00
rdbtCVS 954b67ffa2 Merge main and preserve both HUD regression tests 2026-10-15 01:00:00 +01:00
rdbtCVS f9fdcd2912 fix(launcher): let warning text wrap within the page 2026-10-15 00:58:00 +01:00
rdbtCVS 930f6fa19e Honor package wrappers in launcher desktop entries 2026-10-15 00:49:41 +01:00
rdbtCVS 90f84ac10d perf(mesher): short-circuit section visibility flood fills 2026-10-15 00:45:44 +01:00
Ludovic Marie e96e1571e9 Merge pull request #59 from Online5277/fix/vanilla-item-use-animations
Fix animations, axolotls, fluids, fire and world progress isolation
2026-10-15 01:36:40 +02:00
Ludovic Marie af0e0db4ec Merge pull request #205 from rdbtCVS/perf/buffer-json-readers-20261014
perf(client): buffer model and render JSON reads
2026-10-15 01:21:21 +02:00
Ludovic Marie adbbff5904 Merge pull request #234 from ludovic111/fix/horizon-menus-delete-world
Horizon menus: delete singleplayer worlds
2026-10-15 01:18:39 +02:00
Ludovic Marie 1ac9e4860a Merge pull request #207 from rdbtCVS/perf/slice-large-json-20261014
perf(client): parse large fact JSON from in-memory bytes
2026-10-15 01:17:38 +02:00
Ludovic Marie 51a939bd00 Merge pull request #225 from rdbtCVS/perf/single-release-fetch-20261014
perf(launcher): fetch startup releases once for list and updates
2026-10-15 01:17:31 +02:00
Cosmic ccbf6f09c6 Merge latest main without duplicate hurt sounds 2026-10-14 19:17:30 -04:00
Ludovic Marie 61cb0c92bb Merge pull request #190 from Online5277/fix/crosshair-attack-indicator
Match crosshair alpha cutoff and ready-target checks
2026-10-15 01:16:41 +02:00
Ludovic Marie fa8056e172 Merge pull request #226 from ludovic111/feat/rust-edition-title
Title screen: "RUST EDITION" under the logo
2026-10-15 01:16:08 +02:00
Cosmic a18410f92f Merge main while preserving fluid phases and baked arrow rendering 2026-10-14 19:14:59 -04:00
Ludovic MarieandClaude Opus 5.5 48c85fa19f Add tools/pr_ready.py, CONTRIBUTING.md and a pull request template
pr_ready is the one self-check to run before asking for review: from the
files a branch changes it runs rustfmt, clippy with warnings denied, the
tests of the touched crates (mapped through cargo metadata), the launcher's
own workspace when it is affected, the tokens.css check, the Cargo.lock
rule, the Python tests and the public-tree check, and the vanilla harnesses
the change can affect, picked by tools/parity.py exactly as CI's parity job
picks them. It runs locally or on a fleet machine through ~/fleet/rbuild;
on the Mac mini the harnesses use the parity runner's own workspace and
scripts. It prints one Markdown block for the pull request body and exits
non-zero on any failure.

tools/parity.py gains --changed-files for copies without Git history, and
tools/ci/vanilla-data.sh accepts the preparation key from outside.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:13:39 +02:00
Ludovic Marie f02db7aede Merge pull request #232 from ludovic111/fix/explorer-maps-147
Explorer, treasure and abandoned camp maps work (#147)
2026-10-15 01:13:24 +02:00
Ludovic MarieandClaude Opus 5.5 4d7ca0fc93 Horizon menus: delete singleplayer worlds
The Horizon-style Singleplayer page had no way to delete a save. A Delete
(danger) button now shows beside Play when a world is selected and opens a
confirmation page in the design language: the world's name and last-played
date in a notice led by the danger dot, "It will be lost forever. This
can't be undone.", then Delete world / Cancel. After deleting, the list
refreshes and the selection moves to the world that took the deleted row
(or the new last one). A failure shows a Horizon error page whose Back
returns to the question.

Both menu styles share world_management::delete, now a port of
LevelStorageAccess.deleteLevel: it takes the world's session lock first, so
a world open in this or another instance is refused, deletes everything but
the lock (retrying five times, 500 ms apart), then releases and removes the
lock and the folder. Only a single plain folder name directly in saves is
accepted, and links inside the world are removed, never followed.

Leaving a world now flushes the region writer and releases the session
lock (vanilla closes the storage access), so the world just played can be
deleted from the title.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:12:18 +02:00
Ludovic Marie b980ae9150 Merge pull request #188 from rdbtCVS/fix/title-panorama-quality
Fix pixelated Classic menu panorama
2026-10-15 01:10:47 +02:00
Ludovic Marie b6f1ca63ba Merge pull request #231 from ludovic111/ci/runner-disk
CI disk: short artifact retention, runner cleanup, sccache on the Mac runners
2026-10-15 01:10:25 +02:00
Ludovic MarieandClaude Opus 5.5 2ce2ddcd4d Explorer, treasure and abandoned camp maps work (#147)
Vanilla 26.3 has a MapItem per explorer map (buried_treasure_map,
abandoned_camp_map, woodland_mansion_map...) and treats any stack with a
map_id as a map. Its ExplorationMapFunction finds the nearest structure of
the tag, makes an unlimited tracking map centred on it, draws the biome
preview (MapItem.renderBiomePreviewMap) and adds the target decoration
(MAP_DECORATIONS, drawn by tickCarriedBy). Horizon only recognised
filled_map, turned trade maps into filled_map, cleared every decoration but
the player's each tick, and chest loot only flagged the map as found: the
treasure maps of shipwrecks and underwater ruins and the maps of 26.3's
abandoned camps had no map id, no target and no picture.

- map_id: any stack with the component.
- tick_maps: the item's map_decorations (red X, mansion, camp...) are drawn
  as addDecoration places them (off the map only when unlimited).
- render_biome_preview: vanilla's water outline picture from the noise
  biomes at sea level (new generator query noise_biomes, fuzzed through
  BiomeManager's zoom) and #water_on_map_outlines.
- Chest loot (rolled with the chunk here) keeps the function's search in
  minecraft:custom_data; opening the chest runs it from the chest (vanilla
  runs it when the chest is unpacked on first open), else reaching the
  inventory runs it from the player. Nothing found drops the item, as the
  tables' filtered map_id check does.
- Trades keep their map item.

Fixes #147

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:07:48 +02:00
Ludovic MarieandClaude Opus 5.5 7a07251acf CI disk: short artifact retention, runner cleanup, sccache on the Mac runners
Releases now ship as often as changes land, so nothing they leave behind may
grow without bound. Workflow uploads keep 3 days (nightly parity logs 7); the
releases carry their own assets.

tools/ci/runner-gc.sh, run every 6 hours by launchd on the Mac mini from a copy
of main, skips a runner in a job, removes a checkout's cargo build folders past
12 GB and harness work folders under .local/ untouched for 3 days, and keeps the
2 newest shared preparations while no runner works. The runners build through
the machine's sccache, so what it removes comes back from the cache.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:05:59 +02:00
Ludovic Marie cc0d6736a1 Merge pull request #229 from ludovic111/ci/release-debounce
Release train: ship every settled player-facing change, no 3-hour gap
2026-10-15 01:01:37 +02:00
Ludovic MarieandClaude Opus 5.5 86e0e122f4 Release train: ship every settled player-facing change, no 3-hour gap
Ludovic wants releases as often as changes land. A kind now releases when
player-facing pull requests merged since its last release and the newest of
them merged 15 minutes ago (a burst of merges becomes one release), or the
oldest an hour ago when merges never pause; release-now still ships at once.
There is no minimum time between releases. The scheduled checks run every 10
minutes (game at :02, launcher at :07) so a settled release goes out without
another merge.

Release titles keep proper nouns (Horizon's mark, Microsoft, LAN, ...) when
they lowercase the clauses after the first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:01:26 +02:00
Ludovic Marie 4552494550 Merge pull request #216 from ludovic111/fix/berry-prick-sound-144
Play the player's hurt sound for every damage, by its damage effects (#144)
2026-10-15 00:56:39 +02:00
Ludovic Marie 339bed0b7d Merge pull request #218 from ludovic111/fix/pickup-anim-142
Items, orbs and arrows fly to whoever picks them up (#142)
2026-10-15 00:56:30 +02:00
Ludovic Marie 440d9257ed Merge pull request #214 from ludovic111/fix/lan-name-195
LAN: offline players get a kept generated name instead of Player (#195)
2026-10-15 00:55:56 +02:00
Ludovic MarieandClaude Opus 5.5 8f3f72c7ef Title screen: "RUST EDITION" under the logo
The classic title screen's banner now reads "RUST EDITION". It is rebuilt
at load time from the player's own textures/gui/title/edition.png:
"EDITION" is kept column for column, the "T" is lifted out of it, and R, U
and S are Horizon's own glyphs (rectangles in title_edition.rs) drawn the
way the texture's letters are built, in shades sampled from it. "RUST"
spans "JAVA"'s columns, so the banner stays centred. Only vanilla 26.3's
exact texture (pixel fingerprint) is rebuilt: a resource pack's banner is
left as drawn. No Mojang pixels are added to the repository.

The logo and banner are now blitted as LogoRenderer does: the logo's top
44 of 64 rows and the banner's top 14 of 16 rows at y = 30 + 44 - 7
(the banner was 20 px too low and squeezed 16 rows into 14).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:54:50 +02:00
rdbtCVS aec1519f91 perf(launcher): share startup release response with update check 2026-10-14 23:52:59 +01:00
Ludovic Marie d85aa27aa8 Merge pull request #212 from ludovic111/fix/website-favicon-cache
Website: the new favicon reaches browsers at once, and /favicon.ico exists
2026-10-15 00:51:51 +02:00
rdbtCVS 8b6a2861b5 Merge main and retain both HUD regression tests 2026-10-14 23:33:46 +01:00
Ludovic MarieandClaude Opus 5.5 a3fcacb212 Website: the new favicon reaches browsers at once, and /favicon.ico exists
The icon files kept their names while the server lets browsers cache /favicon*
for a week, so visitors still saw the old mark. The pages now link each icon
with its content hash (?v=…), and the build writes /favicon.ico (the 32-pixel
PNG in an ICO container) for browsers that ask for it on their own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:32:42 +02:00
Ludovic MarieandClaude Opus 5.5 aec497c94b Items, orbs and arrows fly to whoever picks them up (#142)
Vanilla's ClientPacketListener.handleTakeItemEntity adds an
ItemPickupParticle for every take: the taken entity's render state
(extracted at partial tick 1) moves to the collector's x, (y + eyeY) / 2, z
along ((life + partial) / 3)^2 over three ticks, the target lerped between
ticks (ItemPickupParticleGroup). Horizon removed taken entities at once.

game/pickups.rs keeps those particles (joining the group at the end of the
particle tick, as ParticleEngine.tick does) and the item, orb and arrow
renderers draw them frozen at the moving position. Takes by the player
(items, orbs, arrows), by mobs (allays, villagers, piglins, sulfur cubes,
raid banners), by LAN guests on the host, and ClientboundTakeItemEntity on
a LAN guest or server all add one.

Fixes #142

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:31:19 +02:00
Cosmic e08687f362 Merge main and retain remaining crosshair parity fixes 2026-10-14 18:30:06 -04:00
rdbtCVS 576e88bd8c Zero-pad inventory effect duration fields 2026-10-14 23:29:29 +01:00
Ludovic MarieandClaude Opus 5.5 bf7b3d140f Play the player's hurt sound for every damage, by its damage effects (#144)
Vanilla plays the hurt sound on the client from broadcastDamageEvent
(LivingEntity.handleDamageEvent -> Player.getHurtSound, the damage type's
DamageEffects sound, pitch (r - r) * 0.2 + 1). Horizon only played
entity.player.hurt by hand for falls and mob hits, so sweet berry bushes
(poking -> hurt_sweet_berry_bush), fire and lava (burning -> hurt_on_fire),
drowning, freezing, cactus and the rest were silent.

The combat port's broadcast_damage now carries its damage source
(HealthEvent::DamageBroadcast, same trace line as before) and the game plays
hurt_sound(effects) for it; the two hand-written plays are gone.

Fixes #144

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:24:16 +02:00
Ludovic MarieandClaude Opus 5.5 dacdf69293 LAN: offline players get a kept generated name instead of Player (#195)
Every offline Horizon player joined as Player, the LAN host's own offline
profile, so the host refused every guest with "You logged in from another
location". Vanilla's client defaults --username to Player + millis % 1000
and the launcher passes the account's name. Horizon now generates
Player + five digits once and keeps it in horizon-player.txt next to
options.txt (same name, same offline UUID, same inventory on a friend's
world). --name, HORIZON_PLAYER_NAME and a signed-in account still win.

tools/test_lan_lifecycle.py: two default guests join one host together,
keep their names across rejoins, and a guest named Player is refused.

Fixes #195

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:24:16 +02:00
Ludovic Marie 830e8adbe5 Merge pull request #203 from ludovic111/fix/dedicated-server-spawners
Loaded spawners keep their mob; chunk block entities in vanilla's order
2026-10-15 00:23:26 +02:00
rdbtCVS 2217ebcb33 perf(client): parse large fact JSON from in-memory bytes 2026-10-14 23:21:03 +01:00
Ludovic Marie 02df01adbb Merge pull request #174 from ludovic111/feat/design-website
Website in the new design language
2026-10-15 00:18:33 +02:00
Ludovic Marie fe1b08f83a Merge pull request #173 from ludovic111/feat/design-menus
Game menus (Horizon style) in the new design language
2026-10-15 00:18:17 +02:00
Ludovic Marie b009c7652f Merge pull request #168 from ludovic111/feat/design-launcher
Launcher in the new design language
2026-10-15 00:18:02 +02:00
Ludovic Marie 0a0504dd63 Merge pull request #167 from ludovic111/feat/design-icon
Horizon's mark and its icon everywhere
2026-10-15 00:17:45 +02:00
Ludovic Marie 792aa79b0a Merge pull request #166 from ludovic111/feat/design-tokens
Design system: one set of tokens for the launcher, menus and website
2026-10-15 00:17:29 +02:00
rdbtCVS 99514c4b79 perf(client): buffer model and render JSON reads 2026-10-14 23:16:21 +01:00
Ludovic MarieandClaude Opus 5.5 8a95aa79a7 Website and README: the round-2 menus and launcher screenshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 5763ec7e58 Game menus: AA veils over bright backdrops, a whole wordmark on the home page
Every page and the in-world screens veil the backdrop at 0.90; the home page at 0.80, with its
secondary text in ink_soft (Ui::muted). Muted text over the backdrop is BACKDROP_MUTED. The home
page's faint wordmark is now whole: as wide as the content column, above the footnote, instead
of cut off at the bottom right.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 3e39dfae33 Launcher: a development-build override of the Microsoft endpoint, for the sign-in panel
HORIZON_MSA_MICROSOFT_ENDPOINT (debug builds only) points the device-code and token requests at
a local mock, so the restyled code panel can be seen and screenshotted without Microsoft.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 a248e9242a Ship the launcher as Horizon.app in a .dmg, and in Linux's menus
macOS: packaging/macos/package.sh (run by launcher.yml) builds Horizon.app (Info.plist, bundle
id dev.horizon.launcher, macOS 11+, the .icns), signs it ad hoc, and makes the two release
assets: horizon-launcher-macos-arm64.dmg, the installer (the app and an Applications link over
a background, laid out by dmgbuild without a GUI), and the .zip the launcher updates from. A
launcher inside the app replaces the whole bundle: copied beside it with ditto, swapped in by
renames, restarted; a bare binary, as older launchers are, still finds its binary in the zip.
A macOS test zips, unpacks, swaps and re-verifies a signed bundle. Signing for real is a
MACOS_SIGN_IDENTITY away (packaging/macos/README.md, with what Gatekeeper asks meanwhile).

Linux: release builds keep horizon-launcher.desktop (with the binary's current path),
horizon.desktop (hidden, for the game window's icon) and the hicolor icons under
$XDG_DATA_HOME, without root; `horizon-launcher desktop-entry --remove` takes them out and
stops it, `desktop-entry` puts them back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 cd39680534 Show the new launcher and menus on the website and in the README
The README banner is redrawn in the new design (docs/media/banner.html), and the launcher and
menu screenshots (README and website) are the restyled ones. The website's launcher section
gains a Launcher tab: its screenshots no longer show the old background, whose licence was
never confirmed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 2094fb58ad Describe the game menus' sidebar and plates as built
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 275f60efec Launcher settings: a switch for the entry in Linux's applications menu
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 fcbbff26a7 Give Horizon a mark, and its icon everywhere
The mark is an H whose crossbar is the horizon: a violet line edge to edge, the stems above it
and their reflection below at a third, on whole units of a 16-unit grid so it stays sharp at
16 px. docs/media/brand holds the SVG masters (square icon, macOS tile, avatars, the mark for
light and dark backgrounds), the two alternatives that were shown with it, the GitHub social
preview and the Discord banner, and a README of what goes where and how to upload what only
Ludovic's accounts can.

`cargo run -p xtask -- icons` renders the masters (rsvg-convert) and packs, in plain Rust:
packaging/icons/horizon.ico and horizon.icns, Linux's hicolor icons 16–512 and the scalable
SVG, the game's run-time window icon, the avatars and the website's favicons.
packaging/linux has .desktop entries for the launcher and the game.

On Windows, build.rs embeds the .ico as icon resource 1 in horizon-client (and its tools) and
horizon-launcher (winresource, a Windows-only build dependency); gpui loads that resource as
the launcher's window icon. The game window sets the icon itself (X11, Windows) and names
itself `horizon` on Wayland so the desktop entry matches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 c49a91a22c Restyle the website in the new design language
The page now reads the design tokens generated from crates/horizon-theme (src/tokens.css, put
before style.css by build.mjs), and follows the system's light or dark appearance, with a
toggle that remembers the choice. The full-screen sunset hero is replaced by type: a headline,
one paragraph, the download, and an index list of what the page holds. Every section is a mono
label, a display heading and its content as numbered rows with one-pixel rules: the facts, the
fourteen subsystems, the launcher's features, the community links. Rows fade a soft plate in
under the pointer; content arrives with a short fade, rise and unblur. The footer ends on the
giant faint wordmark. The 404 page and the social preview (og.html) follow.

Instrument Serif, the hero images, the parallax, the statement fill and the magnetic buttons
are gone; the icons come from Horizon's new mark (xtask icons). Touch targets are 44 px high.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 7df75b9d3b Restyle the game's Horizon menus in the new design language
Only Horizon's own style changes (what HORIZON_MENU_STYLE=horizon and "Menu style: Horizon"
show); vanilla's classic screens and the HUD are untouched.

The shared components in ui_theme follow the launcher: pill buttons, the sidebar as text items
with a soft sliding pill under the mark and a name instead of a tinted panel, the segmented
control's ink pill, a 36x20 switch, a thin ink slider, fields without borders, display-size page
titles, mono capitals for status lines and section labels. Settings rows, worlds and servers are
numbered index lists (01, 02…) with one-pixel rules; list rows fade a soft plate in under the
pointer and keep it under the selection. Plates are translucent ink so they read the same over
any backdrop. The veil over the live backdrop is flat (no gradients), lighter on the home page,
which ends on the faint wordmark. The opening's wordmark is "horizon" in Geist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 86149f7da6 Restyle the launcher in the new design language
A top bar (the mark, Horizon, the pages as text) replaces the sidebar; a mono footer carries
the launcher's version, its update and the community links. Every page is a display title, a
short muted line, then an index list: Play's resources and folders, the versions with their
names large and their size and state in mono, the patch notes, the settings. Buttons are pills,
the main action is the one violet thing on the screen, selection is an ink pill, the console,
notices and the crash report sit on soft plates without borders. The Play page ends with the
faint wordmark, half set below the window's edge.

The sunset photograph and its veils are gone (and the `image` dependency with them): the
window is the page colour, light or dark with the system. The opening draws the wordmark
letter by letter, then the launcher rises into place. HORIZON_LAUNCHER_APPEARANCE=light|dark
overrides the system's appearance, for screenshots and tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 7d26cb0a5b Hold the game menus' text to AA over the brightest backdrops
The veil goes to 0.90 (0.80 on the title's home page, which keeps to ink and ink_soft), and
muted text over the backdrop is lifted to #9fa3ae; a test checks every pair over a backdrop of
luminance 0.77 (the snowiest title panorama measured reaches 0.49), bare and on a row's plate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:22 +02:00
Ludovic MarieandClaude Opus 5.5 1a730659f0 Add a design system: one set of tokens for the launcher, menus and website
docs/design-system.md describes the new design language (after mkdir.directory: type as the
interface, numbered index lists with hairline rules, one violet accent, space instead of
borders, quiet motion, a faint wordmark) and its tokens, components, surfaces and mark.

crates/horizon-theme now holds the whole system: the two palettes (cool neutrals, light and
dark, each text colour checked against WCAG AA on every background by a test), the type scale
with web sizes, spacing, radii, rules, control heights and motion with CSS easing curves.
Instrument Serif is gone; display type is Geist at medium weight.

`cargo run -p xtask -- tokens` writes website/src/tokens.css from the crate, and a test fails
when the file is out of date. The launcher and the game's menus read the new names through
small adapters; the restyles follow in their own changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:22 +02:00
Ludovic Marie 4b4b36b716 Merge pull request #191 from rdbtCVS/t3code/classic-video-gui-button
Fix GUI Scale button in classic Video Settings
2026-10-15 00:08:20 +02:00
Ludovic Marie a5bd5ebf7a Merge pull request #201 from ludovic111/fix/cactus-mips-110
Sprite mips from vanilla's MipmapGenerator: no cactus corner gaps
2026-10-15 00:04:25 +02:00
Ludovic Marie c8dbb00031 Merge pull request #177 from rdbtCVS/t3code/classic-singleplayer-ui-parity
Fix Classic singleplayer world management screens
2026-10-15 00:04:20 +02:00
Ludovic Marie 288ea9dfa1 Merge pull request #198 from ludovic111/fix/end-fog-103
The dragon fight's world fog and the End's void darkness floor
2026-10-15 00:02:15 +02:00
Ludovic Marie e17a40fc6b Merge pull request #197 from ludovic111/fix/end-crystal-102
End crystals from EndCrystalModel: lit, with their base and beams
2026-10-15 00:02:09 +02:00
Ludovic Marie b91288a937 Merge pull request #196 from ludovic111/fix/crosshair-97
Inverting crosshair and a working attack indicator
2026-10-15 00:02:02 +02:00
Ludovic Marie 1643b78d7b Merge pull request #194 from ludovic111/fix/creeper-swell-157
Creepers swell and flash for LAN guests; load ignited from NBT
2026-10-15 00:01:56 +02:00
Ludovic MarieandClaude Opus 5.5 d93b347b41 Loaded spawners keep their mob; chunk block entities in vanilla's order
A saved spawner's block entity id is minecraft:mob_spawner, not its block's
name, so decode_block_entity found no block entity for it and dropped it:
every spawner loaded from a save (a vanilla world's dungeons and mineshafts,
or Horizon's own after a reload) lost its spawn data, and the server sent
its chunk with no update tag. The id now maps to the spawner block.

The chunk packet lists block entities in LevelChunk.blockEntities' order,
which Horizon reproduces from the position hashes; positions sharing a
bucket keep their insertion order, which for a loaded chunk is the save's
block_entities list (SerializableChunkData -> pendingBlockEntities). That
list order is now kept with the chunk and used as the insertion order.

dedicated_server: 542 transcript lines identical (was 10 differing).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:00:37 +02:00
Ludovic MarieandClaude Opus 5.5 95db0fce9d Sprite mips from MipmapGenerator: cutout edges keep their coverage
Horizon averaged each mip level's alpha plainly, so a cutout texture's
transparent edges grew past the alpha test with every level: the cactus
side's transparent border columns opened see-through slits along its
corners from about 40 blocks, more than vanilla shows. The sprite array's
mips now come from a port of MipmapGenerator.generateMipLevels: the
texture's .mcmeta mipmap_strategy (auto: cutout when a pixel is fully
transparent) and alpha_cutoff_bias, solidify or the dark fill on the base,
meanLinear or darkenedAlphaBlend, and scaleAlphaToCoverage keeping every
cutout level's alpha-test coverage at the base level's.

tools/test_mipmap_parity.py runs vanilla's MipmapGenerator and the port on
every static 16x16 block texture and a sample of item textures.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:59:47 +02:00
rdbtCVS b48539197a Use the current framebuffer for GUI Scale cycle limits 2026-10-14 22:59:27 +01:00
Ludovic Marie 73a019967e Merge pull request #193 from ludovic111/fix/arrow-pickup-119
Arrows can be picked up (#119)
2026-10-14 23:46:15 +02:00
Ludovic Marie 3bf8387ca8 Merge pull request #189 from ludovic111/fix/arrow-fire-124
Burning arrows: lava, fire, Flame and what they light (#125, #124)
2026-10-14 23:45:54 +02:00
Ludovic Marie d6f729321b Merge pull request #187 from ludovic111/fix/arrow-water-127
Arrows get vanilla's water physics (#127)
2026-10-14 23:45:32 +02:00
Ludovic Marie de5fb47174 Merge pull request #186 from ludovic111/fix/water-fall-damage-180
Water cancels the player's fall damage (#180)
2026-10-14 23:45:20 +02:00
Ludovic MarieandClaude Opus 5.5 3319e6e204 The dragon fight's world fog, and void darkness from each dimension's floor
EnderDragonFight's boss event sets createWorldFog, and while such a bar
shows AtmosphericFogEnvironment.setupFog pulls the fog in (start at most
10, end at most 96, sky and cloud fog ending there): Horizon never did, so
the End stayed clear during the fight. FogInputs gains boss_world_fog,
set while the dragon's bar shows.

FogRenderer's void darkness starts voidDarknessOnsetRange (32) above
level.getMinY(), which is 0 in the Nether and the End; Horizon used the
Overworld's -64 everywhere, so the End's fog never darkened below y 32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:41:22 +02:00
Ludovic MarieandClaude Opus 5.5 ee697cadc6 End crystals from EndCrystalModel, lit, with their base and beams
The crystals were a hand-built mesh without normals (so unshaded), without
the bedrock base and without beams. They now draw the baked
minecraft:end_crystal#main layer under EndCrystalRenderer's transform
(scale 2, down 0.5, not flipped) with EndCrystalModel.setupAnim's poses
(end_crystal::poses: the base shown per showsBottom, the glass raised by
half the bob, outer glass, inner glass and cube turned with rotateBy), the
entity light at their block, and the beam to their target
(submitCrystalBeams from the target's centre up to the crystal) while the
spikes are being summoned back. Crystals placed outside the End draw too.

The entity model harness no longer skips end_crystal: the oracle runs
EndCrystalModel.setupAnim on EndCrystalRenderState and the fixture poses
the layer with end_crystal::poses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:40:18 +02:00
Ludovic MarieandClaude Opus 5.5 6ab958a423 Crosshair through the inverting CROSSHAIR pipeline, with the attack indicator
Hud.extractCrosshair draws every crosshair sprite with
RenderPipelines.CROSSHAIR, whose BlendFunction.INVERT (ONE_MINUS_DST_COLOR,
ONE_MINUS_SRC_COLOR, ONE, ZERO) inverts the colour under it; Horizon drew it
alpha-blended white. GuiDraw sprites now carry their pipeline, the HUD batch
records the vertex ranges that invert and the HUD renderer draws them with a
second pipeline.

The attack indicator (crosshair or hotbar, per the Attack Indicator option)
now shows Player.getAttackStrengthScale(0) from the attack strength ticker
instead of a constant full strength, and the full indicator shows at full
strength on a living entity under the crosshair with a weapon slower than
MIN_CROSSHAIR_ATTACK_SPEED.

The HUD harness records every sprite's pipeline on both sides and gives the
crosshair a stand-in crosshairPickEntity (alive or not) and attack delay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:32:36 +02:00
Ludovic MarieandClaude Opus 5.5 087b2526c6 Creepers swell for LAN guests and load ignited from NBT
The host sends Creeper.DATA_SWELL_DIR and DATA_IS_IGNITED with the
creeper's entity data, and a guest runs the client half of Creeper.tick
on them, so the swell and white flash CreeperRenderer draws now show on
the guest too. Creeper.readAdditionalSaveData's ignited lights the fuse
(summon creeper ~ ~ ~ {ignited:1b}).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:28:35 +02:00
rdbtCVS e8efd36e5b Integrate world management with Classic button click sounds 2026-10-14 22:20:20 +01:00
rdbtCVS e57a43745a Fix GUI Scale button in classic Video Settings 2026-10-14 22:20:13 +01:00
Ludovic MarieandClaude Opus 5.5 89eaaab2ee Arrows can be picked up (#119)
Players could never pick up arrows: arrows had no pickup rule and no
item, nothing checked a player touching them, and arrows from saves were
loaded into a separate list that never ticked.

Arrows now carry AbstractArrow's pickup rule and pickupItemStack. The
player's bow and crossbow give them the drawn ammo (ProjectileWeaponItem
.useAmmo: creative, Infinity and multishot's extra arrows draw intangible
ammo, so those arrows are CREATIVE_ONLY), setOwner(player) turns
DISALLOWED into ALLOWED, dispensers make them ALLOWED, mobs' stay
DISALLOWED. Each tick the player touches the arrows in its box grown by
(1, 0.5, 1) (Player.aiStep): a stuck arrow that has stopped shaking goes
into the inventory (ALLOWED), or just goes for a creative player
(CREATIVE_ONLY, or ALLOWED with a full inventory), with the item pickup
sound at the arrow.

Saved arrows load as live arrows (life, shake, inGround, inBlockState,
pickup, item, fire) and live arrows are saved, so stuck arrows survive a
reload and despawn on time; the legacy never-ticked arrow list is gone,
and /kill, /tp and /data reach the live arrows.

The arrow harness adds who shot the arrow, intangible ammo and
dispensers' ALLOWED, then touches in flight, while shaking and when stuck
by survival and creative players with empty, partly and fully filled
inventories, against the real setOwner and playerTouch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:17:07 +02:00
rdbtCVS 24d8c57932 Make filtered world selection regression deterministic 2026-10-14 22:06:48 +01:00
rdbtCVS f8341dc415 Ignore generated world backups 2026-10-14 22:06:48 +01:00
rdbtCVS aa5305772d Fix backup completion flow and localize world management 2026-10-14 22:06:48 +01:00
rdbtCVS b37db61713 Fix world selector clipping lint on Rust 1.99 2026-10-14 22:06:48 +01:00
rdbtCVS 329334723d Fix classic singleplayer world management UI parity 2026-10-14 22:06:48 +01:00
Ludovic Marie 94aeab9a42 Merge pull request #185 from Online5277/fix/prepare-relative-root
Resolve relative preparation roots before running Java exporters
2026-10-14 23:04:32 +02:00
Ludovic Marie cab02f7e5e Merge pull request #183 from Online5277/fix/rabbit-animation
Drive rabbit hop and idle animations from runtime controls
2026-10-14 23:04:26 +02:00
Ludovic Marie 237e9b9ff3 Merge pull request #181 from Online5277/fix/falling-block-model
Render falling blocks with their actual block models
2026-10-14 23:04:20 +02:00
Ludovic Marie c176b4f8bc Merge pull request #179 from Online5277/fix/dragon-egg-particles
Render dragon egg teleport particles
2026-10-14 23:04:15 +02:00
Ludovic Marie 7d1b2a5777 Merge pull request #176 from rdbtCVS/t3code/fix-screenshot-seed-actions
Fix screenshot, seed, and locate chat click actions
2026-10-14 23:04:09 +02:00
Ludovic Marie b857fc2b72 Merge pull request #182 from rdbtCVS/t3code/fix-classic-ui-menu-sounds
Fix classic menu click sounds to match vanilla
2026-10-14 23:04:03 +02:00
Ludovic Marie 4704e98bda Merge pull request #137 from ludovic111/feat/extras-reconnect
Horizon extras: auto-reconnect (off by default)
2026-10-14 23:00:07 +02:00
Cosmic 2f34bf7b46 Invert crosshair rendering and show attack cooldown indicators 2026-10-14 16:57:51 -04:00
rdbtCVS 42eda649a8 Render Classic menu panorama with vanilla cube-map filtering 2026-10-14 21:54:22 +01:00
Cosmic e7e35fa6e1 Correct resident fluid phase and breathing decisions 2026-10-14 16:53:10 -04:00
Cosmic 792a681497 Seed falling block models from their spawn cell 2026-10-14 16:51:30 -04:00
Cosmic f465e1b6ae Avoid verbatim preparation paths on Windows 2026-10-14 16:49:07 -04:00
Cosmic 07f1c34f96 Merge remote-tracking branch 'origin/main' into fix/dragon-egg-particles
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-14 16:49:02 -04:00
Ludovic MarieandClaude Opus 5.5 37043b3dff net harness: the Disconnected screen gained auto-reconnect's retry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:56 +02:00
Ludovic MarieandClaude Opus 5.5 228bdda201 Burning arrows: lava, fire, Flame and what they light (#125, #124)
Arrows never caught fire: nothing ignited them (lava, fire blocks, a
Flame bow's projectile_spawned ignite), so they never set mobs, TNT,
campfires or candles alight. Flame only worked for mobs' bows, as a
5-second ignite on hit.

Arrows now keep Entity.remainingFireTicks: the blocks along their path go
through InsideBlockEffectApplier.StepBasedCollector (fire and soul fire
fireIgnite to 8 s, lava LAVA_IGNITE to 15 s, water EXTINGUISH with the
extinguish sound), the player's bow and mobs' bonus ignite them for 100 s
at spawn, and the fire burns down in flight but not while stuck. A
burning arrow sets its target alight for 5 s, primes TNT, lights
campfires, candles and candle cakes (onProjectileHit, mob_griefing for
mobs' arrows), and blows up a TNT minecart; arrows now hit minecarts like
other entities (VehicleEntity damage). Burning arrows show
FlameFeatureRenderer's fire quads.

The arrow harness adds lava pools, lava/water walls, fire and soul fire,
Flame arrows, and burning/non-burning hits on TNT, campfires, candles and
candle cakes against the real blocks' onProjectileHit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:37 +02:00
Ludovic MarieandClaude Opus 5.5 1d93642094 Arrows get vanilla's water physics (#127)
Arrows flew through water with a point check for the 0.6 drag and nothing
else: no bubbles, no splash, no currents, no bubble columns, and the
stuck arrow logic (nudging back by the normalized motion, no falling
out, a 1200-tick age) was not AbstractArrow's.

Port AbstractArrow.tick for arrows (arrows.rs, state in
Projectile::arrow): the in-block check, the water inertia from the last
base tick's wasTouchingWater with four bubbles behind it, crit trails,
lerped rotation, the COLLIDER clip with entity hits first, the effects
of the blocks along the path (bubble columns push, water extinguishes),
air drag and gravity, then the base tick's updateFluidInteraction
(currents while not stuck, splash sound/particles), sticking 0.05 back
along each moving axis with the arrow's own random for the hit sound,
falling out when the block goes, and despawning 1200 ticks after
sticking.

New harness tools/test_arrow_parity.py drives real vanilla Arrow entities
(VanillaArrowOracle) and Horizon's arrows::tick (examples/arrow_probe.rs)
over the resident scene.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:31 +02:00
Ludovic MarieandClaude Opus 5.5 645109d427 Keep the scene motion scope text a valid string
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:31 +02:00
Ludovic MarieandClaude Opus 5.5 f442374e6d Format the player fluid binding
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:31 +02:00
Ludovic MarieandClaude Opus 5.5 60d9bffc86 Water cancels the player's fall damage (#180)
The player's movement treated every fluid as empty: is_in_water was
always false, LivingEntity.checkFallDamage's updateFluidInteraction was
a no-op and Entity.move's fall-reset ray ignored water. A player landing
in water (or a waterlogged block, a bubble column, kelp) still took the
whole fall.

Bind vanilla's Entity.updateFluidInteraction to the player over the
existing horizon-world kernels (EntityFluidInteraction heights, eyes and
currents, entity_fluid_tick, water_splash, fluid_flow): run it in the
base tick and from checkFallDamage, read wasTouchingWater for
isInWater, query the water's (first-query cached) shape in the
FALLDAMAGE_RESETTING ray, halve the fall in lava after the first tick,
and play the splash sound with bubble and splash particles.

The scene motion harness gains water/lava fixtures (fluid_tick plus
scripted falls through the real Entity.move) against the real vanilla
updateFluidInteraction with the #water/#lava tags bound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:31 +02:00
Ludovic MarieandClaude Opus 5.5 ce9c1d3a5c Auto-reconnect: never after account session refusals, retry when session servers are down
Classifies the online-mode login outcomes from #161: invalid session,
banned from playing online, multiplayer disabled, no account signed in
and the server failing to verify the username are never retried;
unreachable session servers (the client's or the server's) are. A retry
of an online-mode server joins with the account held in memory for the
whole run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:41:14 +02:00
Ludovic MarieandClaude Opus 5.5 5797cf2702 Auto-reconnect retries with the session's player name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:39:51 +02:00
Ludovic MarieandClaude Opus 5.5 43b45fabc7 Horizon extras: auto-reconnect, off by default
On the Disconnected screen after a lost multiplayer connection, a
countdown "Reconnecting in N s" with a Cancel button, then the same
address again. Attempts back off (5, 10, 20, 40, 60 s) and stop after
five; the count resets only after a minute connected. Only failed
connections and the server's shutdown, restart, time-out or full
reasons are retried, never kicks, bans, whitelists, version refusals or
duplicate logins: the network thread now says whether the server gave
the reason (login refusals included).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:39:36 +02:00
Ludovic Marie 21aab8b164 Merge pull request #136 from ludovic111/feat/extras-minimap
Horizon extras: minimap (off by default)
2026-10-14 22:38:32 +02:00
Ludovic Marie 35120b0e91 Merge pull request #135 from ludovic111/feat/extras-core
Horizon extras: zoom, FPS counter, keystrokes and the Extras page (off by default)
2026-10-14 22:38:01 +02:00
Ludovic Marie 294010913b Merge pull request #143 from ludovic111/ci/release-train-nightly
Release train 4/4: nightly parity audit with parity-regression issues
2026-10-14 22:28:57 +02:00
Ludovic Marie ae460db2e8 Merge pull request #141 from ludovic111/ci/release-train-launcher
Release train 3/4: automatic launcher releases, version built in from the tag
2026-10-14 22:28:36 +02:00
Ludovic Marie af6bb6bc8c Merge pull request #138 from ludovic111/ci/release-train-games
Release train 2/4: automatic game releases
2026-10-14 22:28:13 +02:00
Cosmic 7566a4753c Merge PR 186 fluid foundation and preserve swimming and fire behavior 2026-10-14 16:28:07 -04:00
Ludovic Marie 12e8d5b71e Merge pull request #133 from ludovic111/ci/release-train
Release train 1/4: cached verify, and a parity job on the Mac mini for what a PR can affect
2026-10-14 22:27:33 +02:00
rdbtCVS c166ea57d1 fix: match vanilla screenshot and seed link behavior 2026-10-14 21:22:19 +01:00
Ludovic MarieandClaude Opus 5.5 beac11eaa5 Nightly parity audit on the Mac mini, with an issue per regressed harness
nightly.yml runs every harness from scratch on main at 03:00 Paris time on the Mac mini's runner, after
preparing the resources again if the preparer changed. tools/ci/parity_issues.py compares the results with
the previous audit: a harness that passed and now fails gets one issue labelled parity-regression (an open
one is commented on), and an issue closes when its harness passes again. The bugs session picks them up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:18:31 +02:00
Ludovic MarieandClaude Opus 5.5 98ca844f07 launcher: the Microsoft sign-in's User-Agent says the built-in version too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:18:31 +02:00
Cosmic e57c32efb8 Merge current main and preserve axolotl damage behavior 2026-10-14 16:18:08 -04:00
Ludovic MarieandClaude Opus 5.5 76f3e7c2a8 launcher.yml: GPUI's runtime shaders on a Mac runner without the Metal toolchain (the Mac mini)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:46 +02:00
Ludovic MarieandClaude Opus 5.5 fa61f262c3 launcher: automatic releases, the version built in from the tag
launcher.yml releases the launcher by itself: after a push to main that changes launcher/,
crates/horizon-prepare or crates/horizon-theme, hourly, and by hand, with the game's rules
(tools/release_train.py decide --kind launcher: player-facing pull requests and a 3-hour cooldown,
release-now, skip-release, RELEASES_PAUSED). launcher-vX.Y.Z is the next patch, or the next minor after a
pull request labelled feature. Linux on ubuntu-22.04, Windows on GitHub, macOS on the Mac mini's runner;
published with --latest=false.

No version-bump commits: VERSION reads HORIZON_LAUNCHER_VERSION with option_env!, falling back to
Cargo.toml's, and --version, the User-Agent and the self-update comparison use it; each build checks
--version says the release's version.

With several game releases a day, the newest launcher release soon falls out of the newest 100 (launcher)
or 30 (website) releases: both now look the newest launcher-v tags up one by one when the list has none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:46 +02:00
Ludovic MarieandClaude Opus 5.5 6d759dc7bb release_train: name a feature split over several pull requests once in the title; no leading Fix
Today's main would have been titled "Controllers, controllers, controllers and 17 more changes".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 9e016b6235 vanilla-data.sh: clone the sounds into each preparation instead of linking them
Vanilla's resource packs refuse symbolic links inside a pack: with linked sounds the vanilla sound oracles found
no sound files while Rust did (sound_event, sound_engine on #133's parity run).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 fb256809a7 release: the macOS smoke run draws 1500 frames; 300 end before the world has loaded
Checked on the Mac mini: 1500 offscreen frames in 12 s, 81 chunks generated, 4153 sections meshed, a player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 cfad57d0b1 Mac runner: the game's sounds in each preparation; save_roundtrip Linux-only
The first audit on the Mac mini failed 7 audio harnesses (no sounds in the prepared resources: the Mac mini has
no official launcher for horizon-prepare sounds to copy from) and save_roundtrip (it needs vscreen). The
sounds depend only on the Minecraft version: kept once in shared-local/sounds/<version>/ and linked into each
preparation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 e994c8ab95 release: the macOS smoke run opens a world (from a scratch folder) and checks it drew one
Without --world the client stays on the title screen: 300 frames and no chunks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 7b82ee8c16 Two Horizon runners on the Mac mini: one preparation at a time; docs
A second runner (ludovics-mac-mini-2, same labels) shares the first one's vanilla data through a link, so
vanilla-data.sh prepares under a lock; a 30-minute audit no longer holds a release's macOS build back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 7adfe44f6c launcher: rustfmt the rollback test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 f6191fe8ee parity_hosts.json: linux_only, empty: the four harnesses that once differed on macOS pass there
options, chunk_generation, world_border_render and mob_ai all pass on the Mac mini (7.3 min).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 8aacb48798 release: automatic game releases after main's green checks
release.yml now releases the game by itself: after every green checks run of a push to main, every hour
(a release the cooldown held goes out without another merge) and by hand. tools/release_train.py decides
from the pull requests merged since the last published game release: a release when one changed what
players run and the last release is 3 hours old, at once for a pull request labelled release-now;
skip-release pull requests don't count. RELEASES_PAUSED=1 stops it. Tags vYYYY.MM.DD, then .1, .2 the
same UTC day.

Linux (ubuntu-22.04) and Windows build on GitHub with Cargo caches, macOS on the Mac mini's runner
(MAC_RUNNER=macos-latest sends it to GitHub). Each builds horizon-client, horizon-ctl and horizon-prepare
(all three named: --bin selects only the named binaries), checks all three exist, checks their libraries,
starts each one, packages with xtask and archives; the Mac also runs the game offscreen for 300 frames on
a new world with resources prepared from its own Minecraft. The notes: New (label feature), Fixed (the
rest), Known issues (docs/known-issues.md). A hand-started run off main, or with dry_run, builds
everything and writes the notes but publishes nothing.

docs/release-flow.md describes the flow, the labels, pausing and rolling back; the launcher gets a test
that a release turned back into a draft is never offered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 989f843b1c vanilla-data.sh: keep everything horizon-prepare writes per preparer version, beside the shared data
The first parity run kept only .local/horizon-client per preparer version; the exporters' outputs beside it
stayed in the checkout, and the dedicated server, which finds registries.json at
<resources>/../../datagen, didn't find it next to the prepared folder (world_spawn failed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 874d111d60 checks: cached verify, and a parity job on the Mac mini for the harnesses a PR can affect
verify keeps its dependencies and workspace build in a cache saved by main's runs (Swatinem/rust-cache).
The new parity job runs on the Mac mini's self-hosted runner (label horizon-game), which holds the
maintainer's own Minecraft files: tools/ci/vanilla-data.sh links .local/ to the runner's copy and prepares
the resources with the pull request's own horizon-prepare, once per preparer version; then
tools/parity.py --changed <base> runs only the harnesses whose traced dependencies changed since the merge
base and that have no pass for their current inputs. A fork's pull request runs it only when a maintainer
adds run-parity, once per label, on the commit it had then.

parity.py: --changed REF, and linux_only in tools/parity_hosts.json for harnesses that differ on other
platforms for a reason that is not a Horizon bug (skipped there with the reason, unless named).

docs/release-flow.md starts with the pull request checks and merging.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 03309dee95 Format the player fluid binding
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:13:34 +02:00
Ludovic Marie 7338f55b9a Merge pull request #164 from ludovic111/feat/msa-launcher
Microsoft account (c): launcher sign-in, keychain, session handoff to the game
2026-10-14 22:12:11 +02:00
Ludovic Marie 4559a92942 Merge pull request #161 from ludovic111/feat/msa-online-mode
Microsoft account (a): join online-mode servers (session join, RSA, AES/CFB8), skins over HTTPS
2026-10-14 22:10:58 +02:00
Ludovic MarieandClaude Opus 5.5 e9da898f97 Water cancels the player's fall damage (#180)
The player's movement treated every fluid as empty: is_in_water was
always false, LivingEntity.checkFallDamage's updateFluidInteraction was
a no-op and Entity.move's fall-reset ray ignored water. A player landing
in water (or a waterlogged block, a bubble column, kelp) still took the
whole fall.

Bind vanilla's Entity.updateFluidInteraction to the player over the
existing horizon-world kernels (EntityFluidInteraction heights, eyes and
currents, entity_fluid_tick, water_splash, fluid_flow): run it in the
base tick and from checkFallDamage, read wasTouchingWater for
isInWater, query the water's (first-query cached) shape in the
FALLDAMAGE_RESETTING ray, halve the fall in lava after the first tick,
and play the splash sound with bubble and splash particles.

The scene motion harness gains water/lava fixtures (fluid_tick plus
scripted falls through the real Entity.move) against the real vanilla
updateFluidInteraction with the #water/#lava tags bound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:07:48 +02:00
Cosmic 6c4ad9d825 Resolve preparation root before changing exporter directories 2026-10-14 16:06:18 -04:00
Ludovic Marie 70fd5f5c77 Merge pull request #178 from Online5277/fix/reuse-imported-minecraft
Reuse verified Minecraft imports when preparing new versions
2026-10-14 22:04:25 +02:00
Cosmic 88b9a82688 Select baby rabbit model for summoned negative ages 2026-10-14 16:01:30 -04:00
Ludovic Marie 9ae39fac28 Merge pull request #170 from rdbtCVS/fix/lan-host-tab-list
Fix tab list for singleplayer LAN hosts
2026-10-14 21:59:55 +02:00
Ludovic Marie f04d47e14d Merge pull request #165 from rdbtCVS/t3code/fix-inventory-banner-rendering
Fix inventory banner face depth ordering
2026-10-14 21:59:49 +02:00
Cosmic af8700bd20 Drive rabbit hop and idle animations from runtime controls 2026-10-14 15:46:01 -04:00
rdbtCVS d456dadafa Match vanilla inventory effect duration text color 2026-10-14 20:45:33 +01:00
Ludovic Marie d299d1f07a Merge pull request #162 from rdbtCVS/t3code/fix-classic-ui-background
Fix missing background in Classic menus
2026-10-14 21:43:28 +02:00
Ludovic Marie 7156602a7f Merge pull request #171 from rdbtCVS/t3code/disable-lan-on-leave
Fix LAN shutdown when leaving a singleplayer world
2026-10-14 21:43:23 +02:00
Ludovic Marie 1f8a2b28d6 Merge pull request #172 from rdbtCVS/t3code/fix-lan-world-menu-pause
Fix LAN worlds pausing when the host opens menus
2026-10-14 21:43:16 +02:00
Cosmic 8caee51897 Regenerate preparation metadata when reusing imports 2026-10-14 15:37:32 -04:00
rdbtCVS d69a380851 Fix classic menu click sounds to match vanilla 2026-10-14 20:35:30 +01:00
rdbtCVS 2fbbe1c5e2 Fix status effect icons and render inventory effect panels 2026-10-14 20:35:18 +01:00
rdbtCVS 79ab688bda Fix banner rendering test lint on Rust 1.99 2026-10-14 20:34:26 +01:00
rdbtCVS 322a87cf93 Fix Clippy warnings in LAN tab list regression tests 2026-10-14 20:34:25 +01:00
Cosmic 517e275c79 Render falling blocks with their baked models 2026-10-14 15:33:44 -04:00
Ludovic Marie cb3b5abf6b Merge pull request #175 from ludovic111/fix/maces-105
Maces: smash attacks, Density, Breach, Wind Burst
2026-10-14 21:33:02 +02:00
Cosmic 1df16591b4 fix(client): render dragon egg teleport particles 2026-10-14 15:24:41 -04:00
Ludovic MarieandClaude Opus 5.5 56aae0e902 Maces smash: fall damage bonus, Density, Breach, Wind Burst, knockback
A mace hit after falling more than 1.5 blocks is now a smash attack
(MaceItem): the fall-based damage bonus plus Density, mace_smash damage,
the stopped fall with its damage waived, the ground/air smash sounds, the
dust pillars (level event 2013) and the knockback of the creatures around
the target, then the fall reset and landing particles. Breach thins the
target's armor (CombatRules.getDamageAfterAbsorb), Wind Burst bursts at
the player's feet (ExplodeEffect, trigger), and weapons wear by their
weapon component and award the used statistic.

Vanilla details matched on the way: the hurt knockback is 0.4F and reads
the live knockback-resistance attribute, explosion exposure uses the
exact COLLIDER clip, a burst posts the explode game event and ends the
impulse grace unless it is a wind charge's, visible armor stands feel
bursts that affect block-like entities, attack sounds play at the player.

tools/test_mace_parity.py compares the hit with VanillaDispenserOracle's
real MaceItem / hurtServer / post-attack effects: 3000/3000.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 21:23:57 +02:00
Ludovic Marie e15c879fe7 Merge pull request #169 from ludovic111/fix/bed-explosions-101
Beds and straw beds blow up when used in the Nether and the End
2026-10-14 21:23:09 +02:00
Ludovic Marie 11b7038b39 Merge pull request #163 from ludovic111/fix/respawn-anchors-99
Respawn anchors charge, set the respawn point, explode outside the Nether
2026-10-14 21:17:29 +02:00
Cosmic 20988e810e fix(prepare): reuse verified imports from installed versions 2026-10-14 15:14:10 -04:00
rdbtCVS 9f07f64bff fix: unify locate teleport suggestion links 2026-10-14 20:13:59 +01:00
rdbtCVS d25d6184c2 fix: enable screenshot and seed chat click actions 2026-10-14 20:04:08 +01:00
Cosmic ba3f4003cd Merge current main and preserve PR 59 review fixes 2026-10-14 14:56:18 -04:00
rdbtCVS 0ed50ae6ae Fix LAN worlds pausing when the host opens menus 2026-10-14 19:53:45 +01:00
rdbtCVS 1b1e129112 Fix LAN shutdown when leaving a singleplayer world 2026-10-14 19:51:23 +01:00
rdbtCVS 72074eb080 Fix LAN host tab list to include connected guests 2026-10-14 19:50:21 +01:00
Cosmic 027ac3cdf1 Address final axolotl bucket and fire review corrections 2026-10-14 14:39:49 -04:00
Ludovic MarieandClaude Opus 5.5 190f89b214 Beds and straw beds blow up when used in the Nether and the End
A right click on a bed in the Nether or the End tried to sleep in it (and
set the respawn point) as in the Overworld.

AbstractBedBlock.useWithoutItem reads the dimension's bed rule, which in
the Nether and the End is destroy_on_use: BedBlock.destroyOnUse removes the
head, then the foot behind it if it is still the bed, and explodes at the
head (power 5, fire, BLOCK interaction, the bad_respawn_point damage source:
"Intentional Game Design"); StrawBedBlock.destroyBed plays
block.straw_bed.break_leave and removes it.

tools/test_use_item_on_parity.py clicks beds and straw beds (head or foot,
whole or missing their other half, among blocks) in the Nether and the End,
through vanilla's real explosion; the oracle gains the End.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:24:55 +02:00
Ludovic MarieandClaude Opus 5.5 1596210bba Respawn anchors charge, set the respawn point, explode outside the Nether
A right click with glowstone on a respawn anchor did nothing and a charged
anchor never became the respawn point: the block reported the use and the
game dropped it.

RespawnAnchorBlock is now played out (game/respawn_anchor.rs): useItemOn
charges with glowstone (the block change, block.respawn_anchor.charge, the
used item and the charge_respawn_anchor criterion), passing to the off
hand's glowstone; useWithoutItem sets the respawn point in the Nether
(block.minecraft.set_spawn once, respawn_anchor.set_spawn) and elsewhere
explodes: the anchor goes, then a power 5 fiery BLOCK explosion with the
bad_respawn_point damage source whose centre resists like water when water
would flow into it. Dying with an anchor as the respawn point travels to
its dimension and respawns at findStandUpPosition (DismountHelper's safe and
unsafe passes, facing the anchor with calculateLookAtYaw), using a charge
and playing respawn_anchor.deplete; a missing or empty anchor falls back to
the world spawn with block.minecraft.spawn.not_valid. The point is saved in
the player's respawn compound with its dimension; a bed or /spawnpoint
replaces it. Dying in the Nether without one now respawns in the Overworld.

Explosions gained what these need: a damage type, a block resistance
override, BaseFireBlock.getState for their fire, the explosion radius in the
block loot context (survives_explosion and explosion_decay drawing from the
table's random sequence) and TntBlock.wasExploded without a game event.

tools/test_use_item_on_parity.py now charges, uses and blows up anchors in
both dimensions through vanilla's real ServerExplosion on the stand-in
level, and respawns at anchors among blocks through
ServerPlayer.findRespawnAndUseSpawnBlock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:18:49 +02:00
rdbtCVS aab88753b6 Keep title panorama optional during full resource preparation 2026-10-14 19:15:16 +01:00
rdbtCVS 8987369b07 Fix inventory banner face depth ordering 2026-10-14 19:09:42 +01:00
Ludovic MarieandClaude Opus 5.5 d4e0dc4917 Settings subtitle mentions accounts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 6bed9b92ca launcher.yml: build in HORIZON_MSA_CLIENT_ID from the repository variable
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 324eb2836c Launcher README: Microsoft accounts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 82bee83dc5 Launcher: Microsoft account sign-in, keychain and the game's session
Device-code OAuth, Xbox Live, XSTS, Minecraft login, ownership and profile;
secrets in the OS keychain (oo7 on Linux, keyring on macOS/Windows), accounts.json
without tokens; Settings account rows, 'Playing as' on the Play page; CLI
'account' and MCP account tools; the game gets a 0600 session file it deletes.
Client ID is configuration (HORIZON_MSA_CLIENT_ID), empty = not configured.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 f38e8cc393 Skins over HTTPS: fetch textures with ureq (rustls)
Players' skins on online servers come from https://textures.minecraft.net;
the old plain-HTTP fetch left everyone with their default skin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:57:34 +02:00
Ludovic MarieandClaude Opus 5.5 0acd686720 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic MarieandClaude Opus 5.5 6957d74742 docs: Minecraft login endpoint
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic MarieandClaude Opus 5.5 ac97e9c0b9 Harness: online-mode login against vanilla's server and a mock session service
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic MarieandClaude Opus 5.5 89b2ca64a9 Join online-mode servers with the launcher's account
Read the signed-in account from a session file (--session-file, deleted
on read), answer ClientboundHelloPacket like vanilla: shared secret, server
hash, session join through the services discovery document (authlib's
error mapping and messages), RSA-wrapped key packet, AES/CFB8 both ways.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic MarieandClaude Opus 5.5 c8629f3378 Protocol: RSA public keys and PKCS#1 encryption, server hash, secrets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic MarieandClaude Opus 5.5 c6634830d3 WIP: Microsoft account design note and RSA (not wired yet)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic Marie f83bc0ab70 Merge pull request #146 from ludovic111/fix/spawners-95
Monster spawners spawn, spin their mob and take spawn eggs
2026-10-14 19:54:54 +02:00
rdbtCVS eebf5621bf Fix classic menu panorama resource preparation 2026-10-14 18:54:51 +01:00
Ludovic Marie d1517fa79b Merge pull request #158 from Online5277/fix/arrow-hit-sound
Play normal arrow impact audio for player hits
2026-10-14 19:54:48 +02:00
Ludovic Marie b7f14763a7 Merge pull request #149 from ludovic111/feat/controllers-rumble
Controllers: rumble
2026-10-14 19:43:33 +02:00
Ludovic MarieandClaude Opus 5.5 663f61c8c3 Horizon extras: a minimap, off by default
A corner map of the loaded chunks in vanilla's map colours with the map
item's height and water shading: square or round, three sizes, four
zooms, north up or turning with the player, with the player arrow. In
the Nether, underground and under a roof it maps the floor at the
player's height and cuts walls. Each loaded chunk keeps a 16x16 tile,
filled nearest first within 0.3 ms a frame and rescanned after block
changes; the image is recomposed only when the view or a tile changed
and uploaded into its own atlas slot. Nothing runs while it is off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:41:01 +02:00
Ludovic MarieandClaude Opus 5.5 365d35f53a Extras: the zoom key zooms only while playing and never takes keys
The zoom key went to the zoom on every screen, so with zoom on, Z could
not be typed in screens with text fields that share the game's key
handler. Now it zooms only while playing with no sign or book open, lets
go when a screen opens, and always passes the key on, like a vanilla key
mapping. A vanilla mapping bound to the same key still acts; the Extras
page marks the zoom key "Also bound" and names it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:41:01 +02:00
Ludovic MarieandClaude Opus 5.5 a08577e301 Extras: horizon-ctl key and mouse reach the zoom key and keystrokes
Agents press keys and buttons through the control socket, which skipped
the keyboard and mouse hooks; under control the window may also lack
focus without pausing, so the zoom no longer lets go then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:39:38 +02:00
Ludovic MarieandClaude Opus 5.5 c4b0e8efe0 Horizon extras: zoom, FPS counter and keystroke overlay, off by default
An Extras section in Horizon's Options holds optional additions saved in
horizon-extras.txt beside options.txt. Zoom divides the field-of-view
target vanilla's Camera.tickFov eases toward (the spyglass path), with a
wheel-adjustable level and lower mouse sensitivity; scoping keeps the
spyglass view. The FPS counter (with minimum and 1% low) and the keystroke
overlay (WASD, space, mouse buttons with clicks per second) draw in the
HUD layer, hidden with F1. docs/extras.md describes all the extras.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:39:20 +02:00
Ludovic Marie b7fc86279c Merge pull request #151 from rdbtCVS/t3code/fix-creative-fall-damage-sound
Fix fall damage sounds in creative mode
2026-10-14 19:37:28 +02:00
Ludovic MarieandClaude Opus 5.5 40fce014f1 Controllers: hurt rumble only within one player's life
The health baseline (HealthWatch) is forgotten when there is no player,
on the title screen, and whenever a new player entity takes over (spawn
and respawn, joining a server, End and Nether dimension changes), so
arriving with less health than the last player had isn't damage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:37:04 +02:00
Ludovic MarieandClaude Opus 5.5 ccdc4fe682 Controllers: rumble
Short, light pulses through gilrs force feedback, on by default
(Vibration in horizon-controller.txt): damage taken (stronger with the
damage, from any source, here or on a server), a block broken, an
explosion nearby (fading over four times its radius), a totem of undying.
Only the pad that is playing vibrates; macOS has no force feedback in
gilrs and stays still. pad status reports the last vibration asked for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:36:52 +02:00
Ludovic Marie b61455e562 Merge pull request #145 from ludovic111/feat/controllers-options
Controllers: Controller options, button binds and glyphs
2026-10-14 19:36:29 +02:00
Ludovic Marie d8ad1dad7a Merge pull request #156 from rdbtCVS/t3code/fix-classic-ui-scroll-limit
Fix classic options scroll limits
2026-10-14 19:34:45 +02:00
Ludovic Marie 3dcbd2228b Merge pull request #154 from rdbtCVS/t3code/fix-world-options-menu
Fix World Options pause and Escape behavior
2026-10-14 19:34:26 +02:00
Ludovic Marie 1be820b091 Merge pull request #150 from Online5277/fix/startup-settings
Apply saved audio and GUI settings at startup
2026-10-14 19:34:17 +02:00
Ludovic Marie 97a65cd5aa Merge pull request #155 from rdbtCVS/fix/keybind-category-translations
Fix keybind category translations
2026-10-14 19:34:10 +02:00
Ludovic Marie 61e5b70fde Merge pull request #153 from ludovic111/gitignore-run-files
Ignore the files the client writes to the repo root
2026-10-14 19:34:03 +02:00
Ludovic Marie 4ac53c0867 Merge pull request #152 from rdbtCVS/fix/options-held-input
Fix stuck keys when opening Options
2026-10-14 19:33:42 +02:00
Ludovic Marie afce3f7aa1 Merge pull request #56 from Online5277/fix/creative-inventory-toggle-drop
Fix creative inventory tab and toggle-sprint item drops
2026-10-14 19:33:37 +02:00
Ludovic MarieandClaude Opus 5.5 79e04e22d6 Monster spawners spawn, spin their mob and take spawn eggs
Spawners were never ticked: Horizon kept their block entity as loaded NBT,
and a freshly generated dungeon or mineshaft spawner lost even that, so no
mob ever came out, nothing turned inside and a spawn egg used on one spawned
a mob next to it instead of setting the spawner's.

The spawner is now a modelled block entity (mobs::spawner): BaseSpawner's
load and save with SpawnData's codec (normalised ids, custom spawn rules,
weighted potentials, the next data shared with the one potential when the
potentials are missing), serverTick (the player range, spawner_blocks_work,
the delay, getOrCreateNextSpawnData, Pos, the spawn box against blocks and
solid shulkers, custom light rules and peaceful, SpawnPlacements, the nearby
cap, the yaw, checkSpawnRules and checkSpawnObstruction, finalizeSpawn for
an id alone, the 2004 burst, entity_place and the spawn poof) and delay with
its block event, in the level random's order. The client copy keeps its own
delay for the spin and puffs smoke and flames; SpawnerRenderer draws the
next mob small and turning. SpawnEggItem.useOn on a spawner sets its mob
(setEntityId), tells the player when spawner_blocks_work is off and fails in
peaceful for monsters. Breaking one pops 15 + nextInt(15) + nextInt(15)
experience whatever the tool. LAN guests get the update tag, the block event
and the entity event.

tools/test_spawner_parity.py ticks vanilla's SpawnerBlockEntity on the
natural spawn oracle's level against mobs::spawner on the same worlds (6,000
cases); tools/test_use_item_on_parity.py now covers spawn eggs on spawners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:33:25 +02:00
Cosmic db1a895c05 fix(client): play the arrow impact sound for player hits 2026-10-14 13:22:12 -04:00
Ludovic Marie b5690ad295 Merge pull request #128 from ludovic111/perf/terrain-vertex-32
Terrain gets its own 28-byte vertex without a normal (was 36)
2026-10-14 19:20:17 +02:00
Ludovic MarieandClaude Opus 5.5 32bb3612fb Controllers: × for the PlayStation cross (the game fonts lack ✕)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:12:24 +02:00
Ludovic Marie e8ca05af19 Controllers: deadzone percentages rounded 2026-10-14 19:12:24 +02:00
Ludovic MarieandClaude Opus 5.5 ce85fd98ed Controllers: Controller options, button binds and glyphs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:12:24 +02:00
Ludovic Marie 2470ba9b83 Merge pull request #129 from ludovic111/feat/controllers-menus
Controllers: menus, screens and inventories (virtual cursor)
2026-10-14 19:12:05 +02:00
rdbtCVS 318988d767 Fix classic options scrolling at list boundaries 2026-10-14 18:10:11 +01:00
rdbtCVS 25d1d3cc07 Fix keybind category translations 2026-10-14 18:08:06 +01:00
Ludovic MarieandClaude Opus 5.5 840e8ad4a8 Give terrain its own 28-byte vertex without a normal
Since #52 the shared vertex carried an RGBA8_SNORM face normal for
entity.wgsl's directional light, so every section vertex grew from 32 to
36 bytes although vs_terrain never reads it. Vanilla's terrain uses
DefaultVertexFormat.BLOCK (position, colour, UV0, UV2: 28 bytes): the face
shade is baked into the colour by ModelBlockRenderer and terrain.vsh only
multiplies it by the lightmap.

Sections now mesh into TerrainVertex: position, UV, the sprite layer as a
u16 (bit 15 marks opaque leaves), the two light bytes as a u16 and the
RGBA8 colour, 28 bytes. Every value the shader reads is unchanged; entity
vertices keep their normal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:07:01 +02:00
Ludovic MarieandClaude Opus 5.5 ea93297579 Report the section arena and device memory in the control status
The status reply's renderer block gains the section vertex arena's buffer
bytes, the bytes resident sections use in it, and the backend allocator's
allocated/reserved device memory (Vulkan), to measure terrain memory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:06:49 +02:00
Ludovic Marie 51dc44f64f Merge pull request #121 from ludovic111/fix/creeper-white-flash
OverlayTexture texels from one tested table; creepers flash white while swelling
2026-10-14 19:06:16 +02:00
rdbtCVS 90115e5574 Fix World Options pause and Escape behavior 2026-10-14 18:05:30 +01:00
Ludovic MarieandClaude Opus 5.5 5cf5b6bfd2 Ignore the settings and files the client writes to the repo root
options.txt, horizon.txt, horizon-graphics.txt and the rest showed up as
untracked after running the game from the checkout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:05:30 +02:00
Ludovic Marie 02104eab29 Merge pull request #148 from ludovic111/release/launcher-0.1.5
Launcher 0.1.5
2026-10-14 19:04:55 +02:00
rdbtCVS 28b01e9eca Fix fall damage sounds in creative mode 2026-10-14 18:02:51 +01:00
rdbtCVS b6df5a91a9 Fix held keys getting stuck when opening Options 2026-10-14 18:02:35 +01:00
Ludovic MarieandClaude Opus 5.5 2edc69ef99 Launcher 0.1.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:59:23 +02:00
Cosmic 0dd8ed546a fix(client): apply saved settings before the title screen 2026-10-14 12:59:05 -04:00
Ludovic Marie 52975ad988 Merge pull request #132 from ludovic111/fix/release-yml-prepare
release.yml: build horizon-prepare's binary too
2026-10-14 18:46:54 +02:00
Ludovic MarieandClaude Opus 5.5 79ea17ffc5 Controllers: classic sliders are focus targets; the D-pad nudges with A held
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:42:19 +02:00
Ludovic MarieandClaude Opus 5.5 b58e96436a Controllers: Horizon-style menus' controls are focus targets too
Ui::interact records every enabled control (sidebar items, switches,
sliders, segments) as a D-pad focus target, apart from the labelled
button list horizon-ctl clicks by name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:40:31 +02:00
Ludovic MarieandClaude Opus 5.5 cbf4b53b0c Controllers: menus, screens and inventories with a virtual cursor
The left stick moves a cursor drawn over the screen, the D-pad steps
between slots and buttons (focus_step), and A / X / the right stick send
the window the pointer events a mouse would, so every screen keeps its
own mouse behaviour (drags, sliders, scrollbars). Y quick-moves in
inventories, LB/RB switch creative tabs, B and Start go back. Focus lands
on the first button, or the held hotbar slot, when a menu opens while the
pad is in use; moving the real mouse hands the pointer back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:40:31 +02:00
Ludovic Marie e145a32322 Merge pull request #120 from ludovic111/feat/controllers-input
Controllers: design note, gilrs input layer and gameplay
2026-10-14 18:40:10 +02:00
Ludovic MarieandClaude Opus 5.5 a28667923a release.yml: build horizon-prepare's binary too
With --bin flags, cargo builds only the named binaries of every selected
package, so -p horizon-prepare built nothing and the import checks failed on
target/release/horizon-prepare (every run since #92).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:38:13 +02:00
Cosmic f1e0b093a5 Merge newer main while retaining consumable guard and review fixes 2026-10-14 12:27:15 -04:00
Ludovic MarieandClaude Opus 5.5 e70b5e2882 Controllers: a pad press lasts at least one game tick
Vanilla samples keys once per tick, so a stick click shorter than 50 ms
(sprint) could come and go between two ticks and be lost: its release
waits for the next tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:25:33 +02:00
Ludovic Marie d1d727f838 Merge pull request #123 from ludovic111/feat/sounds-rust
horizon-prepare sounds: sound copy in Rust, run by the launcher (no Python for players)
2026-10-14 18:25:04 +02:00
Ludovic Marie 98c71567e3 Merge pull request #126 from ludovic111/feat/package-rust
xtask package: the release folder in Rust, replacing packaging/package.sh
2026-10-14 18:24:27 +02:00
Cosmic e9e4ef96a7 Merge main and address PR59 bucket, fire, mipmap and AI review 2026-10-14 12:19:54 -04:00
Ludovic MarieandClaude Opus 5.5 842d11efc4 xtask package: the release folder in Rust, replacing packaging/package.sh
`cargo run -p xtask -- package BINARY OUTDIR` does what package.sh did:
OUTDIR wiped and recreated, the `git archive HEAD tools
mods/horizon/src/test/java crates/horizon-prepare research-lock.json LICENSE`
tree extracted (the tar read in Rust, executables kept executable), the client,
horizon-ctl and horizon-prepare copied (.exe beside an .exe, the same error when
one is missing) and PLAY.txt. release.yml calls it; package.sh is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:16:20 +02:00
Ludovic Marie 425021dda5 Merge pull request #122 from ludovic111/fix/nether-portals-94
Nether portals: vanilla timing and linking; items and mobs travel
2026-10-14 18:15:59 +02:00
Ludovic MarieandClaude Opus 5.5 b7330ac0cc Controllers: gilrs input layer, in-game movement, camera, actions and hotbar
Vanilla 26.3 has no gamepad input, so this is Horizon's own, on Bedrock's
default layout (docs/controllers.md). gilrs reads the pads; the pure part
(bindings, radial deadzones, the look curve, button edges, the
horizon-controller.txt settings file) is in gamepad.rs with unit tests.

Buttons act as the vanilla key mapping they're bound to, through the same
path a rebound keyboard key takes (the keyboard's post-rebinding handling
moves into Game::mapped_key). The left stick is an analog move vector,
the right stick turns the view per frame, LB/RB step the hotbar, B and
Start leave screens and A respawns. horizon-ctl pad injects a virtual pad.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:15:40 +02:00
Cosmic e905c10400 Address creative inventory review on current main 2026-10-14 12:11:53 -04:00
Ludovic MarieandClaude Opus 5.5 8355a6ff14 horizon-prepare sounds: the sound copy in Rust, run by the launcher
tools/fetch_sounds.py (and the lab.py functions it used) becomes
`horizon-prepare sounds` (horizon_prepare::sounds): the pinned asset index,
sounds.json and every .ogg from the player's own launcher install, each SHA-1
checked, into .local/horizon-client/<version>. The launcher runs it as part of
Prepare (in-process, the version's own binary when its sources differ and it
knows the subcommand, in-process for older versions), and a launcher install
without the assets only logs that the game will be silent. Player docs no
longer mention Python for sound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:10:34 +02:00
Ludovic MarieandClaude Opus 5.5 74821bf188 Overlay texels from OverlayTexture, and the creeper's white flash
The hurt red and the white row's alphas come from overlay.rs, which
reproduces OverlayTexture's texels (checked against values printed by
the 26.3 JVM) and is prepended to entity.wgsl; a test parses and
validates the composed shader with naga.

Swelling creepers flash white as CreeperRenderer.getWhiteOverlayProgress
says (the white row's column, red winning while hurt; layers keep the
red only), and CreeperRenderer.scale's wobble uses the unclamped
swelling as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:07:31 +02:00
Ludovic MarieandClaude Opus 5.5 0818190ee4 docs: controller support design note
Vanilla 26.3 has no gamepad code; Horizon's own design follows Bedrock's
default controller layout. Mapping, menus, glyphs, options file, rumble,
Steam Deck and the PR plan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:06:50 +02:00
Cosmic 767ed4cb47 Use physical Ctrl state for stack dropping, not toggle sprint 2026-10-14 12:06:41 -04:00
Ludovic MarieandClaude Opus 5.5 1f06156649 Nether portals: vanilla timing, linking, and items and mobs travel
The player's portal timer reset to zero on any tick outside a portal and
came back from a trip with a 300-tick cooldown that only ran down outside
portals; the overworld exit search covered 32 blocks of loaded chunks;
nothing but the player could use a portal.

The player now follows Entity.setAsInsidePortal / handlePortal with
PortalProcessor (decay by 4, the cooldown of 10 that a portal keeps
resetting, players_nether_portal_default/creative_delay), and
NetherPortalBlock.getPortalDestination through the new
horizon_world::level::portal_travel: PortalForcer.findClosestPortalPosition
(16 / 128 blocks, loaded and saved chunks), createPortal,
BlockUtil.getLargestRectangleAround, PortalShape.getRelativePosition and
findCollisionFreePosition, with the turned yaw and motion and
block.portal.travel. LocalPlayer.handlePortalTransitionEffect closes
screens and plays block.portal.trigger. Items and mobs go through after one
tick with a 300-tick cooldown and wait as saved entities until their
dimension loads around the exit.

tools/test_nether_portal_parity.py compares the destination with vanilla's
getPortalDestination over two headless levels; the scene motion harness
gains portal fixtures for the player's processor and cooldown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:06:24 +02:00
Cosmic 9f0df29d6c Keep the inventory tab in the creative screen 2026-10-14 12:05:59 -04:00
Ludovic Marie b2462d54c4 Merge pull request #116 from ludovic111/fix/world-spawn-seed
Find a new world's spawn as vanilla does (NoiseSpawnFinder, setInitialSpawn, PlayerSpawnFinder)
2026-10-14 18:04:54 +02:00
Ludovic Marie eccb222e9f Merge pull request #115 from ludovic111/fix/sweet-berries-93
Sweet berries can be planted; picking and bone meal work
2026-10-14 18:04:47 +02:00
Ludovic Marie 6915757dd4 Merge pull request #118 from ludovic111/test/entity-model-normals
entity_model harness: compare per-vertex normals with vanilla
2026-10-14 18:04:39 +02:00
Ludovic Marie 1c957610bc Merge pull request #117 from ludovic111/fix/hurt-tint-order
Apply the entity hurt and white-flash overlay in vanilla's order
2026-10-14 18:04:26 +02:00
Ludovic MarieandClaude Opus 5.5 3f61995c8b test(entity_model): compare per-vertex normals with vanilla
The entity model parity harness now draws each posed model on both sides
under LivingEntityRenderer's root transform (body yaw, scale(-1, -1, 1),
the renderer's own scale) and compares every vertex's normal as the
signed bytes written to the vertex buffer: vanilla's ModelPart.render /
Cube.compile / PoseStack.Pose.transformNormal into a capturing
VertexConsumer, packed with BufferBuilder's own normalIntValue, against
Horizon's mobs::render::draw RGBA8_SNORM normals. A part-scale override
covers negative and non-uniform part scales (trusted sign flips and
untrusted 1/scale normalisation). A one-step difference is accepted only
where vanilla's float sits at a truncation step; a posed position
checksum per drawn part is compared too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:52:01 +02:00
Ludovic MarieandClaude Opus 5.5 a3d3297840 Apply the entity hurt/white overlay in vanilla's order
entity.fsh mixes the OverlayTexture texel into the textured, cardinal-lit
colour and only then multiplies by the lightmap. Horizon applied the lightmap
first and mixed a 0.3 red into the vertex colour, so the red was multiplied
by the texture and never darkened: hurt mobs glowed red at night and in caves.

The vertex stage now passes the lightmap colour and the overlay texel
(0xB2FF0000 hurt red, white alpha 63 for OverlayTexture.u(1.0)) to the
fragment stage, which mixes the overlay, then the lightmap, then glint and fog.

Layers vanilla submits with OverlayTexture.NO_OVERLAY no longer take the hurt
tint: armour (EquipmentLayerRenderer, glint stays), held items
(ItemInHandLayer) and the enderman's block (CarriedBlockLayer). The TNT
minecart flash uses the white overlay on the cart's light
(TntMinecartRenderer.submitWhiteSolidBlock) instead of plain full-bright white.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:50:22 +02:00
Ludovic MarieandClaude Opus 5.5 7ace13cc82 Find a new world's spawn as vanilla does
Horizon searched its own spiral of 48-block steps from 0 0 for a dry,
non-leaf top block, so seed 1891623539983467248 spawned at -48 -48 where
vanilla puts the world spawn at 144 71 144 (the player then lands within
the respawn radius, 152 145 in the report).

Vanilla (MinecraftServer.setInitialSpawn): NoiseBasedChunkGenerator.
getOrigin runs NoiseSpawnFinder over the noise settings' spawn_target
points (SpawnTargetPoint.sampleFitness, a 2048/512 then 512/32 radial
search biased towards 0 0); an 11 by 11 chunk spiral around that chunk
takes the first column PlayerSpawnFinder.getSpawnPosInChunk accepts
(getLevelRespawnPos: MOTION_BLOCKING, WORLD_SURFACE and OCEAN_FLOOR
heights, no liquid, a full-topped collision shape); without one, the
origin chunk's centre at the spawn height. The first player is then
placed by PlayerSpawnFinder.findSpawn: the respawn-radius square in a
random coprime order, a free player box with liquids colliding, else the
spawn with fixupSpawnHeight; adventure mode uses the spawn as is.

horizon-world gains worldgen::spawn_finder and OverworldGenerator::origin;
the client's world_spawn module and game/initial_spawn.rs follow
setInitialSpawn and PlayerSpawnFinder, for single player and the
dedicated server alike. Flat worlds use FlatLevelSource's origin and
spawn height.

tools/test_world_spawn_parity.py compares, for the report's seed, the
chunk harness's seeds and 50 random ones, the origins
(VanillaWorldSpawnOracle against the spawn_origin example) and the
level.dat spawn of worlds created by the vanilla dedicated server and by
Horizon's. Vanilla is not deterministic on chunk corners where trees of
neighbouring chunks overlap (decoration order follows its worker
threads), so a mismatching seed reruns vanilla up to three times.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:49:15 +02:00
Ludovic MarieandClaude Opus 5.5 80645bf0de Sweet berries, carrots and potatoes are planted instead of eaten
A right click with food that is also a block item tried to eat it before
placing it: in creative always, in survival whenever the player was
hungry. BlockItem.useOn places first and only eats a consumable that could
not be placed (counting the use, as ItemStack.useOn does). Picking a sweet
berry bush or cave vines dropped nothing: the harvest loot
(Block.dropFromBlockInteractLootTable) is now rolled and popped. Bone meal
on a bush that can still grow grows it (SweetBerryBushBlock.useItemOn
passes it on) instead of picking it, and counts the use with its
item_interact_finish vibration. Every block placement played its sound
twice; BlockItem.place's level event is now the only one.

tools/test_use_item_on_parity.py drives vanilla's real
ServerPlayerGameMode.useItemOn (and Minecraft.startUseItem's useItem
fallback) against use_item_as_player in a headless game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:43:24 +02:00
Ludovic Marie 45663c9e39 Merge pull request #98 from ludovic111/feat/logs-crash-reports
Logs and crash reports: game, launcher, CLI and MCP
2026-10-14 17:28:44 +02:00
Ludovic Marie f22e18114d Merge pull request #113 from ludovic111/feat/ctl-mouse-move
horizon-ctl: mouse-move, and turn/look-at --over through the player's mouse path
2026-10-14 17:27:01 +02:00
Cosmic 29fbebf3d7 Merge main and retain Dolphin Grace and Weaving effect flags 2026-10-14 11:25:55 -04:00
Ludovic MarieandClaude Opus 5.5 2cfeed118b horizon-ctl: mouse-move and eased turns through the player's mouse path
mouse-move DX DY (MCP mouse_move) adds relative motion to MouseHandler's
accumulated movement, so the next frame turns the player with
turnPlayer's sensitivity curve ((s*0.6F+0.2F)^3*8, no 8 while scoping),
the inverted axes and the cinematic camera, then Entity.turn. The motion
counts as a grabbed pointer in an active window until the real pointer
is grabbed or released again.

turn and look-at (and look-at-block/look-at-entity) take --over SECONDS:
the turn becomes that motion fed over the time with ease-in-out (the
shorter way round for look-at), replying once the last of it has been
turned. Without --over they still jump. turn is now an MCP tool. stop
cancels a turn in progress.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:18:12 +02:00
Ludovic Marie 452e2b7a9f Merge pull request #112 from ludovic111/fix/stuck-in-blocks
Cobwebs and sweet berry bushes slow the player
2026-10-14 17:16:19 +02:00
Ludovic MarieandClaude Opus 5.5 66c6bd6b13 F3 + C writes a crash report
The debug crash from #90 printed a stand-in report; it now ends the game
with a DebugCrash error that main reports as "Manually triggered debug
crash" with the stack where it was raised. The game keeps the error that
ends it as an error, not a string, so main can tell. Leading std backtrace
frames are dropped (macOS keeps them).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:15:20 +02:00
Ludovic Marie 6c210fe9fd Merge pull request #111 from rdbtCVS/fix/website-footer-links
Fix footer links blocked by decorative Horizon word
2026-10-14 17:13:29 +02:00
Ludovic MarieandClaude Opus 5.5 39e8bbb2f4 Cobwebs and sweet berry bushes slow the player
Entity.applyEffectsFromBlocks now runs WebBlock.entityInside and
SweetBerryBushBlock.entityInside for the player through
Player.makeStuckInBlock (no slowdown while flying, impulse context reset),
with Weaving's lighter cobweb hold and the sweet_berry_bush damage of a
grown bush while the known movement is at least 0.003 on X or Z. The scene
motion harness gains cobweb and berry-bush fixtures run through vanilla's
real applyEffectsFromBlocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:10:01 +02:00
Ludovic Marie 2b9674af8f Merge pull request #108 from ludovic111/fix/wayland-fullscreen-clipboard
Keep the compositor's fullscreen; copy and paste in every text field
2026-10-14 17:08:33 +02:00
rdbtCVS 9bc9a1e159 fix(website): allow clicks through decorative footer word 2026-10-14 16:03:35 +01:00
Cosmic e828354040 Apply fluid currents to primed TNT and show fire in inventory preview 2026-10-14 11:02:27 -04:00
Ludovic MarieandClaude Opus 5.5 744b61efc3 Crash stacks start at the panicking frame, without the build machine's paths; multi-line log messages as log4j writes them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:58:35 +02:00
Ludovic MarieandClaude Opus 5.5 1569572ba0 Game logs and crash reports; launcher crash notice; logs in the CLIs and MCP
The game writes logs/latest.log in its home like vanilla's log4j config
(client-1.21.2.xml): [HH:mm:ss] [thread/LEVEL]: message, rolled at start and
at midnight to logs/<date>-<n>.log.gz, seven a day. Messages go through the
log facade and still print their old text on stderr.

A main-thread panic or a fatal error writes
crash-reports/crash-<date>_<time>-client.txt (error, stack, Horizon and
Minecraft versions, OS, CPU, memory, GPU adapter, render settings, resource
packs, world, last 50 log lines). Development builds crash on purpose with
HORIZON_DEBUG_CRASH.

The launcher shows "Horizon crashed" when the game it started ends badly,
with Open report, Copy report, Open logs folder and Report on Discord;
Settings gets a Logs row. horizon-launcher logs and horizon-ctl logs
(--lines N, --crash) print the log's end or the newest report, and both MCP
servers have a logs tool.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:58:35 +02:00
Ludovic MarieandClaude Opus 5.5 832a8e3f46 Keep the compositor's fullscreen, and copy/paste in every text field
Fullscreen: applying settings (any option button, joining a world,
travelling, reloading packs) compared the Fullscreen option with what the
window reported and forced the window back to the option. A window made
fullscreen by the compositor (a Hyprland keybind or window rule) left
fullscreen on the next button click. The window now follows blaze3d
Window's fullscreenRequested/fullscreen pair: it is only asked to change
when the option changes (updateFullscreenIfChanged), a change the window
system makes is adopted by the option (updateFullscreenState ->
Minecraft.fullscreenStateChanged), and the window is created fullscreen
when options.txt says so.

Clipboard: Ctrl+A/C/V/X (Cmd on macOS) in the chat, signs, book and quill
(page and title), anvil, creative and recipe-book searches, the language
and pack searches, the LAN port, world name and seed, and the server name
and address boxes, through the arboard clipboard (Wayland data-control,
falling back to X11). Shortcuts follow InputWithModifiers.isCopy/isPaste/
isCut/isSelectAll on the modifier state and the layout's letter; pasted
text goes through each field's own vanilla rules: StringUtil.filterText
and setMaxLength for EditBox, line breaks kept and the 1024-character and
14-line limits for MultiLineEditBox, formatting stripped and whole-or-
nothing width checks for TextFieldHelper on signs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:57:49 +02:00
Ludovic Marie ead31a40f9 Merge pull request #100 from ludovic111/fix/launcher-self-update-message
Launcher: say the self-update succeeded (not "No such file or directory") and restart into it
2026-10-14 16:57:17 +02:00
Ludovic Marie ac96218bfe Merge pull request #104 from ludovic111/fix/particles-63-item-emitters
Particle emitters: waxing, scraping, sparks, eating, snowballs, eggs, slimes (part of #63, stacked on #96)
2026-10-14 16:51:59 +02:00
Ludovic Marie dc74a3fe71 Merge pull request #96 from ludovic111/fix/particles-63-missing-types
Particles: 29 missing vanilla particle types (part of #63)
2026-10-14 16:51:35 +02:00
Ludovic Marie 51601ba2a3 Merge pull request #91 from ludovic111/fix/particles-63-emitters
Entity particle bursts: five angry villager particles and the other bursts as in vanilla (part of #63)
2026-10-14 16:51:30 +02:00
Ludovic Marie b746580c50 Merge pull request #92 from ludovic111/rust/prepare
Prepare resources in Rust: horizon-prepare replaces the Python preparer
2026-10-14 16:43:36 +02:00
Ludovic MarieandClaude Opus 5.5 70583cbf2a Particle emitters: waxing, scraping, sparks, eating, snowballs, eggs, slimes
The new particle types now come from the events that spawn them in
vanilla.

- Level events 3003 (wax on), 3004 (wax off), 3005 (scrape):
  ParticleUtils.spawnParticlesOnBlockFaces, three to five particles on
  each face, from the client level's random. They are sent by honeycomb
  on a block (HoneycombItem.useOn), axe scraping and wax removal
  (BlockTransformer's TransformParticle.send), dispensed honeycomb, and
  copper golems being waxed or scraped.
- Level event 3002: a struck lightning rod sends sparks along its axis
  (ParticleUtils.spawnParticlesAlongAxis); without an axis they cover
  the block's faces.
- Eating: LivingEntity.spawnItemParticles, five crumbs every
  four ticks and sixteen when consumed (Consumable.onConsume), placed in
  front of the eyes and thrown along the look, as item particles in full
  colour. They were greyed terrain crumbs above the eyes. They now
  follow Consumable.shouldEmitParticlesAndSounds with the remaining ticks
  before the countdown, as LivingEntity does: six times over 32 ticks,
  not seven, each one a tick earlier.
- Snowballs (Snowball.handleEntityEvent(3)): eight item crumbs at rest.
  Eggs (ThrownEgg): eight scattered by up to 0.04 a tick.
- Slime, magma cube and sulfur cube landings (AbstractCubeMob.tick):
  size × 16 item_slime, flame or sulfur_cube_goo particles around the
  body, from draws the mob's random also makes on the server.

New harness tools/test_emitter_particles_parity.py: the real
ParticleUtils emitters and LivingEntity.spawnItemParticles.

Part of #63

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:38:38 +02:00
Cosmic cadc4698c1 Merge current main; restore player hurt sounds and bucket fluid updates 2026-10-14 10:33:06 -04:00
Ludovic MarieandClaude Opus 5.5 158e0c6346 Launcher: a self-update after the binary was replaced says it updated, and Restart re-runs it
On Linux, once the launcher's file has been replaced under the running
process (an update from the command line or another window), current_exe()
is "<path> (deleted)" and self_replace failed with "No such file or
directory". The new binary now goes to the path the launcher started from
(copy beside it, rename over it; Windows still uses self_replace), and an
update the file already has is not downloaded again. The message reads
"Updated to X. Restart the launcher to use it."; Restart execs the new
binary with the same arguments (spawn and exit on Windows) and says so if
it cannot start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:33:02 +02:00
Ludovic MarieandClaude Opus 5.5 63bb9bc37c compare_prepare.py: a tar of the Python tree for machines without the repository; launcher formatting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:29:33 +02:00
Ludovic MarieandClaude Opus 5.5 a995fd3b46 Prepare with horizon-prepare: launcher, release folders, messages and docs
The launcher prepares a version in-process when it ships the preparer sources
the launcher was built with, with the version's own horizon-prepare when they
differ, and with the Python script only for versions made before it. Release
folders ship horizon-prepare and its sources; resources are reused between
versions with the same preparer. The game's messages, PLAY.txt, the docs and
release builds name horizon-prepare, and tools/prepare_native_client.py is a
wrapper of it for the development tools.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:29:33 +02:00
Ludovic MarieandClaude Opus 5.5 9fa3a684c6 horizon-prepare: the resource preparation in Rust
A port of tools/prepare_native_client.py and the helpers it imported from
lab.py and the test_*_parity.py modules: the player's pinned game files are
imported and checked against research-lock.json, the client's assets and data
copied, the vanilla exporters compiled and run with the JDK (JAVA_HOME, the
PATH, or java_home on macOS, at the pinned java_major), and the resources and
their manifest written byte for byte as the script wrote them, with its
sha256-keyed skips. tools/compare_prepare.py checks that against the Python
preparer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:29:14 +02:00
Ludovic MarieandClaude Opus 5.5 efa2f0e3be Particles: 29 missing vanilla particle types
Horizon's engine knew 91 of vanilla's 128 particle types. These come from
their real providers, constructor to tick:

- sweep_attack (AttackSweepParticle)
- glow, wax_on, wax_off, electric_spark, scrape (GlowParticle and its
  providers)
- squid_ink, glow_squid_ink (SquidInkParticle: sinks through air)
- item, item_slime, item_snowball, item_cobweb, sulfur_cube_goo
  (BreakingItemParticle: a quarter of the item's ground-model sprite in
  full colour, unlike terrain crumbs)
- flash (FireworkParticles.OverlayParticle, its sine pulse and fading
  alpha), which firework explosions now show as vanilla's Starter does
- firefly (FireflyParticle: wanders, fades in and out, vanishes inside
  blocks)
- trial_spawner_detection(_ominous) (TrialSpawnerDetectionParticle)
- trial_omen, raid_omen (SpellParticle)
- ominous_spawning (FlyStraightTowardsParticle, colour lerped like
  ARGB.srgbLerp)
- spit (ExplodeParticle falling at 0.5), fishing (WakeParticle),
  dust_plume (DustPlumeParticle)
- red/orange/yellow_poplar_leaves (FallingLeavesParticle.PoplarProvider)
- pause_mob_growth, reset_mob_growth (SimpleVerticalParticle)
- block_marker (BlockMarker)

ParticleType.getOverrideLimiter now lists every type vanilla registers
with it (gust, sonic_boom, sculk charges, campfire smoke, vault_connection
and the new ones were missing), so they show beyond 32 blocks.

The /particle commands take the new types; item particles resolve the
stack's ground model.

The particle_providers harness now builds all 120 types Horizon implements
through the real providers (1428 cases).

Part of #63

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:25:24 +02:00
Ludovic MarieandClaude Opus 5.5 383fc116c9 Entity particle bursts: as many particles as vanilla, about the body
Villagers showed one angry, happy or heart particle where vanilla's
AbstractVillager.addParticlesAroundSelf shows five, spread over the body
with Gaussian motion. Taming and love bursts were spread about the middle
of the body rather than over it, and drew from the world random.

particles::around_self places a burst as the client handlers do: per
particle three Gaussian speeds, then getRandomX(1), getRandomY() + y,
getRandomZ(1). Burst holds the counts, speeds and heights vanilla uses,
and entity_event_burst maps entity events to them. The client side uses
the particle engine's random, so no server draw moves.

- villager hurt by a player, failed breeding (13), breeding hearts and
  the child's (12), trades, job sites and beds (14): five particles about
  the villager, not one 1.8 above its feet.
- wolf, cat, parrot, nautilus and horse taming (7/6): seven hearts or
  smoke about the body.
- allay duplication (18): three hearts about the allay.
- llama growth from food: one particle at rest about the body.
- animals in love: the heart moves at the Gaussian speeds already drawn,
  exactly where vanilla puts it; forced growth's particle sits where it
  was placed (sendParticles with no spread) instead of being jittered.

New harness tools/test_entity_particles_parity.py: the real client
handleEntityEvent of Villager, Animal, TamableAnimal, AbstractHorse,
Ocelot, Dolphin and Allay against entity_event_burst and around_self.

Part of #63

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:13:38 +02:00
Ludovic Marie 27fc327195 Merge pull request #90 from ludovic111/fix/f3-combos-73
F3 key combinations, as 26.3's KeyboardHandler runs them
2026-10-14 16:12:40 +02:00
Ludovic Marie ada71df410 Merge pull request #89 from ludovic111/fix/particles-63-providers
Particles: vanilla random streams, and fixes to 20 providers (part of #63)
2026-10-14 16:08:48 +02:00
Cosmic 2236c9607f Apply burning-time scaling to Fire Aspect ignition too 2026-10-14 10:08:37 -04:00
Ludovic MarieandClaude Opus 5.5 70fad91ee5 F3 key combinations, as 26.3's KeyboardHandler runs them
F3 only toggled the debug overlay. 26.3 makes the debug keys rebindable
mappings (key.debug.*): the modifier (F3) held with another key runs
handleDebugKeys, released alone toggles the overlay unless it was used as
a modifier; F3+Escape pauses without the menu; F3+C held ten seconds
crashes the game.

- debug_keys: KeyboardHandler.keyPress's state (usedDebugKeyAsModifier,
  the modifier's down state, debugCrashKeyTime) and tick, with the actions
  in handleDebugKeys' order; unit tests replay key sequences.
- gizmos: 26.3's gizmo lines (colour, width in pixels, near-plane clip as
  GizmoFeatureRenderer.buildLines), ChunkBorderRenderer's geometry and
  EntityHitboxDebugRenderer's boxes, eye lines and view arrows; a coloured
  line pipeline draws them, depth tested or always on top.
- game/debug_keys: the actions. F3+A rebuilds every section, B hitboxes,
  C copies the location (/execute in ... run tp @s, %.2f half up), D
  clears chat, G chunk borders, H advanced tooltips (options.txt), I copies
  /setblock or /summon with data (Shift: client side), N toggles spectator
  and the previous mode with /gamemode's feedback, P pause on lost focus,
  T reloads resource packs, V dumps the version, X improved transparency;
  F4 without permission says debug.gamemodes.error. Messages carry the bold
  [Debug]: prefix in yellow (red for the crash countdown).
- PauseScreen(false): only "Game Paused" at the top; the
  pauseOnLostFocus option now decides whether losing focus pauses.
- The clipboard through arboard; `horizon-ctl status` reports the debug
  state and the text last copied.

Fixes #73

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:08:26 +02:00
Cosmic f3dda9c412 Address PR59 review: axolotl timers, fluid and fire parity, persistence and Rust tests 2026-10-14 10:05:41 -04:00
Ludovic Marie f881917d91 Merge pull request #88 from ludovic111/fix/powdered-snow-62
Powder snow: sinking, freezing, leather boots, buckets and falling blocks
2026-10-14 15:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 d9461c6d2d Particles: build each one with vanilla's random streams, fix the providers that differed
Every vanilla Particle owns a RandomSource.create() of its own; providers
draw from ParticleEngine.random, a few from ClientLevel.random, and spells
from SpellParticle.RANDOM. Horizon drew everything from one generator, so
draws landed on the wrong stream. Particles now carry their own random,
and provider, level and spell draws use theirs, as in vanilla.

Fixed against the real providers:
- gust / small_gust: SingleQuadParticle(x, y, z) constructor, so the
  12..15 tick lifetime comes out of the right draws.
- explosion_emitter, gust emitters: NoRenderParticle draws no quad size.
- effect, instant_effect, entity_effect, witch, infested: x/z motion from
  SpellParticle.RANDOM; the witch's brightness from the provider.
- landing_honey, landing_obsidian_tear, falling_nectar, spore_blossom_air,
  crimson_spore, warped_spore, white_ash, sculk_charge(_pop), dolphin:
  provider draws (lifetimes, motion, alpha) from the provider random, after
  or before the sprite as the provider orders them; crimson spore motion
  scaled by the float constants; composter lifetime from the level random.
- enchanted_hit: MagicProvider's 0.3/0.8 tint after the constructor tick;
  damage_indicator lives 20 ticks (DamageIndicatorProvider).
- note: colour from Mth.sin's table.
- shriek: one random sprite, not animated.
- trail: Particle(x, y, z, xa, ya, za) constructor, ARGB.scaleRGB on the
  8-bit channels, Mth.lerp towards the target including the last tick.
- firework sparks: full bright (SimpleAnimatedParticle.getLightCoords).

New parity harness tools/test_particle_providers_parity.py: the real
ParticleResources providers build every type Horizon implements in a
stand-in ClientLevel (air, optionally a stone floor); the oracle recovers
each particle's clock-seeded random and the probe replays it; state,
requested particles and sounds are compared tick by tick.

Part of #63

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:53:34 +02:00
Ludovic MarieandClaude Opus 5.5 6ce83021b5 Powder snow: sinking, freezing, leather boots, buckets and falling blocks
Powder snow was inert: the player's collision context never carried its
fall distance or boots, block-inside effects never ran for the player,
frozen ticks stayed 0 and the empty bucket only took fluids.

Player (PlayerMotion, LivingEntity.aiStep order):
- PowderSnowBlock.getCollisionShape gets the player's fall distance and
  leather boots: it sinks through, walks on top in leather boots (sinks
  when crouching), lands on the 0.9-high shape after a fall of more
  than 2.5 blocks.
- Entity.applyEffectsFromBlocks over this tick's movement path (the
  ported block sweep, StepBasedCollector steps): entityInside makes it
  stuck (0.9, 1.5, 0.9) while its feet are in powder snow and sends the
  server's snowflake from the level random; FREEZE sets isInPowderSnow
  and adds a frozen tick per step; EXTINGUISH destroys the snow if the
  player burns and puts the fire out. ServerPlayer.onInsideBlock fires
  enter_block for every block entered (which also unlocks the boat
  recipes in water, and the end gateway advancement).
- baseTick's wasInPowderSnow lets leather boots climb out (living_air).
- Server freezing: thawing 2 a tick, the powder_snow frost modifier
  (-0.05 x percent frozen, not over air), freeze damage every 40 ticks
  when fully frozen; leather armor (#freeze_immune_wearables) and
  spectators don't freeze.
- HUD: the powder snow outline overlay and frozen hearts.

Items and blocks:
- BucketItem.use on powder snow: pickupBlock (2001), used statistic,
  fill sound, FLUID_PICKUP, createFilledResult, filled_bucket trigger.
- SolidBucketItem: the empty_powder_snow place sound and an empty bucket
  back in survival.
- Falling blocks land on powder snow, or end up inside it and break
  after a fall of more than 2.5 blocks.

test_native_scene_motion gains powder snow fixtures ("snow_tick": the
oracle runs vanilla's real applyEffectsFromBlocks and freezing) and
prints frozen ticks, the snow flags and the stuck multiplier.

Fixes #62

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:45:55 +02:00
Ludovic Marie 6f2823b652 Merge pull request #87 from ludovic111/fix/physical-shift-77
Read the physical Shift keys in container screens and drop a stack clicked outside
2026-10-14 15:44:49 +02:00
Ludovic Marie 4e36b2a217 Merge pull request #85 from ludovic111/fix/resource-packs-72
Resource packs: reload everything in game and merge stacked files like vanilla (fixes #72)
2026-10-14 15:44:44 +02:00
Ludovic Marie 4ce9a5642d Merge pull request #86 from ludovic111/fix/wolf-advancements-65
Match the variant components of entity predicates (taming grants only Best Friends Forever)
2026-10-14 15:42:26 +02:00
Ludovic Marie 2b5313b943 Merge pull request #81 from ludovic111/fix/feeding-sounds-68
Feeding sounds: each animal's own eating sound, or none, as in vanilla
2026-10-14 15:42:21 +02:00
Ludovic Marie 6ca6d0b919 Merge pull request #83 from ludovic111/fix/hand-sway-66
Hand sway: the first-person hands lag behind the view, as in vanilla
2026-10-14 15:42:12 +02:00
Ludovic Marie a56d63d03f Merge pull request #82 from ludovic111/fix/gamerules-persist-74
Game rules: save them with the world once, as they are now; save on Quit
2026-10-14 15:42:01 +02:00
Ludovic Marie 8dd63c69ef Merge pull request #84 from ludovic111/fix/fire-charge-pitch
Give the fire charge's use sound its own pitch
2026-10-14 15:41:51 +02:00
Ludovic MarieandClaude Opus 5.5 0b362e270f Match the variant components of entity predicates
Taming any wolf also granted The Whole Pack and A Complete Catalogue: entity
predicates treated minecraft:components as always true, so every
tame_animal criterion of those advancements (one per wolf or cat variant)
matched the first animal tamed. EntityPredicate tests them with
DataComponentExactPredicate against what the entity reports through
Entity.get (Wolf.get(WOLF_VARIANT) and so on): mobs now report their variant
component (wolf, cat, frog, pig, cow, chicken, zombie nautilus) and the
predicate compares it, an absent component never matching.

Checked against the real EntityPredicate of every criterion of the variant
advancements on real entities of every variant by
tools/test_entity_components_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:33:50 +02:00
Ludovic MarieandClaude Opus 5.5 0d0378916b Resource packs: reload everything in game and merge stacked files like vanilla (#72)
Choosing packs in the Resource Packs screen only reloaded the GUI atlas
(and only when it kept its size), item models and languages; block
textures and models changed after a restart, and sounds, clouds and the
world border never did. Block textures were also squeezed to 16x16, and
the pack overlay took every file from the top pack, so a pack's own
en_us.json, sounds.json, font or atlas definition replaced vanilla's
instead of adding to it (all other text, or every vanilla sound, gone).

- Minecraft.reloadResourcePacks: rebuild block sprites and models, mesh
  tables, the shared atlas, item models, sounds, languages, GUI
  metadata, clouds.png and forcefield.png from the new overlay; swap the
  mesher's context (workers read it per job), mesh every loaded section
  again (LevelRenderer.allChanged) and build a renderer with the new
  textures. Nothing switches if loading fails.
- The block sprite array takes the largest texture's resolution (power
  of two, at most 256 MiB), so 32x/64x packs keep their detail;
  animations upload at that size.
- Files vanilla reads as resource stacks are merged in the overlay:
  ClientLanguage (keys override), SoundManager (sounds add up unless an
  event replaces; a sounds.json Gson rejects is skipped whole),
  FontManager (top pack's providers first), SpriteSourceList (sources
  bottom up). Languages are read from every namespace, not only
  minecraft.
- Missing or broken block textures use vanilla's missing texture; packs
  that still break startup fall back to the default resources
  (Minecraft.rollbackResourcePacks).
- Open Pack Folder opens the folder on macOS and Windows too.

tools/test_resource_pack_parity.py checks pack discovery, overlays,
filters, which pack provides a file and the merged languages, sounds,
fonts and atlases against vanilla's FolderRepositorySource,
MultiPackResourceManager and reload listeners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:33:34 +02:00
Ludovic MarieandClaude Opus 5.5 d75ad778e3 Read the physical Shift keys in container screens and drop a stack clicked outside
Quick move, the recipe book's place-all, its search box and the hold-Shift
tooltips read self.keys, which holds remapped and toggled keys: with toggle
sneak, a Left Shift press behind a screen is dropped by the sneak mapping and
sync_toggle then removes ShiftLeft. Vanilla reads the event's own modifiers
(InputWithModifiers.hasShiftDown) or Screen.hasShiftDown: the physical keys.
Track them in physical_keys and read them through has_shift_down.

Clicking outside the window did nothing. AbstractContainerScreen.mouseClicked
sends slot -999 when hasClickedOutside (AbstractRecipeBookScreen's rule when
the screen has a book): a held stack starts a quick craft drag whose release
sends PICKUP -999, which AbstractContainerMenu.doClick turns into a drop of
the stack (left) or one item (right), thrown from the hand with a swing. The
mouse handling is now horizon_client::container_screen, checked against the
real screens by tools/test_container_screen_parity.py; servers get the same
ContainerClick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:31:49 +02:00
Ludovic MarieandClaude Opus 5.5 84adffc3f2 Hand sway: the first-person hands lag behind the view, as in vanilla
Vanilla keeps xBob and yBob on the LocalPlayer: applyInput eases them
halfway to the player's rotation every tick, and
FirstPersonHandsAndItemsRenderer turns both hands by
(viewXRot - xBob) * 0.1 degrees about X, then (viewYRot - yBob) * 0.1 about
Y, before drawing either (FirstPersonHandsAndItems.extractRenderState
interpolates the bobs). Horizon drew the hands fixed to the view.

- first_person::HandSway: the bobs, applyInput's tick and the rotation.
- The game ticks it with the hands and resets it where vanilla makes a new
  LocalPlayer (joining, respawning, changing dimension, leaving the End);
  hand_geometry turns the arm, held items and maps by it.

The first-person harness's steps can turn the player: the oracle sets the
LocalPlayer's rotation and calls the real applyInput, and both sides print
xBob, yBob, xBobO and yBobO every tick (turning scenarios plus random turns
in the random cases).

Fixes #66

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:30:30 +02:00
Ludovic MarieandClaude Opus 5.5 162e3d36f7 Give the fire charge's use sound its own pitch
Placing fire with a fire charge played item.firecharge.use at the flint and
steel's pitch (nextFloat * 0.4 + 0.8, one draw). FireChargeItem.playSound
uses (nextFloat - nextFloat) * 0.2 + 1.0, two draws from the level random.
The pitch is now horizon_client::items::fire_starter_pitch, checked against
the real FireChargeItem.playSound by tools/test_fire_starter_sound_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:27:13 +02:00
Ludovic MarieandClaude Opus 5.5 9bbb674184 Game rules: save them with the world once, as they are now; save on Quit
Game rules lived twice: Level (loaded when the world opened, written to
data/minecraft/game_rules.dat by Level::write) and the rules the game and
/gamerule use. Every save wrote the file first with the rules loaded at
open, then again with the current ones; a failed second write (on Windows a
file just renamed into place can be held briefly by the indexer or an
antivirus) was only logged, leaving the old rules on disk, so the world
opened with them again.

- sync_level copies the current rules into Level, and Level::write is the
  only writer of game_rules.dat (written as vanilla writes saved data:
  `_new`, then renamed over the old file).
- The world is saved when the application ends without a window close
  (ApplicationHandler::exiting): on macOS, Quit (Cmd+Q) ends the
  application through applicationWillTerminate, while vanilla's GLFW window
  turns it into a window close and Minecraft.stop saves the world. Window
  close, `horizon-ctl quit` and exiting share save_for_exit, which saves
  once.

Tests: a unit test writes changed rules through Level::write and reads them
back (GameRules::load and Level::read); the save round-trip harness has
Horizon's player change a rule with /gamerule before saving and requires the
new value in the world vanilla then loads.

Fixes #74

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:16:32 +02:00
Ludovic MarieandClaude Opus 5.5 f1cc118df0 Feeding sounds: each animal's own eating sound, or none, as in vanilla
Animal.mobInteract plays playEatingSound(), which is empty in Animal and
overridden only by armadillos, cats, foxes, frogs, goats, pigs, sniffers and
sulfur cubes (plus nautili). Horizon fell back to entity.generic.eat for every
other animal (cows, sheep, chickens, rabbits, turtles, bees...), with a level
random pitch instead of each class's volume and pitch.

- mobs::ai::eating_sound: the overrides with their exact volume and pitch
  (makeSound with the voice pitch, playSound(1, 1), or Level.playSound with
  Mth.randomBetween(0.8, 1.2)); silent otherwise.
- mobs::ai::feed_animal: Animal.mobInteract's food part, shared by the game
  and the mob AI probe; Strider.mobInteract adds entity.strider.eat.
- AgeableMob.ageUp(seconds, true): forcedAge and the forced-age particle
  timer, whose positions draw from the mob's random on the server.
- Tame wolves and cats healed with food: TamableAnimal.feed (wolves silent).
- Parrots: Parrot.isFood is false; wild ones take parrot food with
  entity.parrot.eat and a one-in-ten taming chance.
- Baby pigs and chickens use their sound variant's baby_sounds.

The mob AI harness gains a `feed TICK MOB ITEM` event (the oracle calls the
real Animal.mobInteract) and feeding scenarios for adult and baby cows, pigs,
sheep and chickens.

Fixes #68

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:15:15 +02:00
Ludovic Marie 66ce394d2d Merge pull request #80 from ludovic111/fix/composters-70
Composters take compostable items by hand, from hoppers and from droppers
2026-10-14 15:09:21 +02:00
Ludovic Marie b4c63fa207 Merge pull request #78 from ludovic111/fix/rain-length-71
Rain and snow: tile the weather texture like WeatherEffectRenderer (fixes #71)
2026-10-14 15:09:11 +02:00
Ludovic Marie 97d3282161 Merge pull request #79 from ludovic111/fix/cloud-distance-67
Clouds: draw to the Cloud Distance option, not the render distance (fixes #67)
2026-10-14 15:09:07 +02:00
Ludovic Marie 115b0b856b Merge pull request #76 from ludovic111/fix/furnace-progress-69
Furnaces keep their cooking progress across menu clicks
2026-10-14 15:09:03 +02:00
Ludovic MarieandClaude Opus 5.5 0d3628a035 Composters take compostable items by hand, from hoppers and from droppers
Right-clicking a composter with wheat (or any compostable item) did
nothing: ComposterBlock.useItemOn wasn't implemented, only emptying a
full composter. Hoppers and droppers didn't see the composter as a
container either, and a hopper under one sucked items through it.

- useItemOn: below level 7 a compostable item adds a layer with the
  chance of its compostable component's provider (evaluated in the
  block-interaction loot context, drawing from the level random), then
  level event 1500, the used statistic and one item consumed (none in
  creative); level 7 still consumes the action; a full composter falls
  through to useWithoutItem and empties.
- Level event 1500 (handleFill): composter.fill(_success) and ten
  composter particles above the compost.
- getContainer: the input container takes one compostable item from
  above (hoppers, droppers, crafters), the output container gives a
  hopper below the bone meal of a full composter, emptying it twice as
  vanilla's setChanged calls do (and losing it when the hopper is full);
  levels 7 and other faces refuse.

tools/test_composter_parity.py runs right-clicks, hoppers above and
below, and droppers into composters at every level through
VanillaDispenserOracle (new "use" and "hopper" actions) and the game's
dispense fixture.

Fixes #70

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:00:41 +02:00
Ludovic MarieandClaude Opus 5.5 12eca8a5c1 Clouds: draw to the Cloud Distance option, not the render distance (#67)
The cloud mesh radius came from the render distance, the far plane was
capped at max(4 x render distance, 1024) and the cloud fog end was a
constant 2048, so lowering the render distance cut the clouds short.
Vanilla ties all three to options.cloudRange: CloudRenderer.prepare
builds ceil(range * 16 / 12) cells, Camera.depthFar is
max(4 x render distance, range * 16) (also the hand projection, the
frustum and the world border), and AtmosphericFogEnvironment ends
cloud fog at min(range * 16, visual/cloud_fog_end_distance).

The cloud render harness now checks the radius for Cloud Distance
2..128 against the shipped mesh builder, and the environment harness
covers visual/cloud_fog_end_distance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:38:52 +02:00
Ludovic MarieandClaude Opus 5.5 8c0586db4b Furnaces keep their cooking progress across menu clicks
A click in the furnace menu reset the cooking timer whatever it did.
AbstractFurnaceBlockEntity.setItem restarts cooking only when the input
slot gets something other than the same item with the same components,
and menu clicks that take part of the input or merge into it never call
it. Furnace::input_clicked applies that after each click, with the new
input's total cook time.

test_menu_click_parity gains furnace_be, blast_furnace_be and smoker_be:
the server's furnace menus over real block entities, comparing the
cooking data slots after every click.

Fixes #69

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:34:32 +02:00
Ludovic MarieandClaude Opus 5.5 b723ef9828 Rain and snow: tile the weather texture like WeatherEffectRenderer (#71)
Horizon stretched a quarter of rain.png over each whole column, up to
20 blocks tall, so streaks were about five times too long. Vanilla
advances v by a quarter per block and lets the sampler repeat, so the
texture repeats every 4 blocks. Port createRainColumnInstance,
createSnowColumnInstance and prepareInstances exactly (seeded column
speed, game-time scroll, snow drift and brightening, camera-block size
table, square radius from the Weather Radius option, alpha falloff,
precipitation at the camera's y), and split each quad at the texture
seams so the atlas sprite repeats.

tools/test_weather_render_parity.py compares the columns and quads
with the shipped renderer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:34:26 +02:00
Cosmic 726f88fcfc Fix axolotl AI, fluid and fire integration, and world-local progress
Bind resident environment kernels and real fire clocks, fix sprite uploads and flame material, restore swim speed, throttle item lava audio, and emit fire extinguish events. Reset world-entry progress state without deleting saved data. Exact axolotl replay parity remains deferred to issue #64.
2026-10-14 02:20:13 -04:00
Cosmic 778890207a Merge remote-tracking branch 'origin/main' into fix/vanilla-item-use-animations 2026-10-13 22:41:21 -04:00
Cosmic 5903b5981d Fix item-use feedback, ranged poses, arrows and elytra rendering 2026-10-13 22:41:21 -04:00
Ludovic Marie 6c1000a374 Merge pull request #58 from ludovic111/remove-roadmap
Remove the roadmap
2026-10-14 02:24:25 +02:00
Ludovic MarieandClaude Sonnet 5.5 71cee5279c Remove the roadmap from the README, the website and the launcher
The README keeps the parity table, and its known differences move to a
Known issues section. The website drops the What's next section and its
nav links, and no longer fetches or requires a Roadmap section. The
launcher drops its What's next page, `next` subcommand and `roadmap`
MCP tool.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 02:18:29 +02:00
Ludovic Marie d1cf9b4930 Merge pull request #57 from ludovic111/website/contributors
Website: add a Contributors block to the community section
2026-10-14 02:06:21 +02:00
Ludovic MarieandClaude Sonnet 5.5 2b7c72255c Website: add a Contributors block to the community section
Lists the GitHub contributors of ludovic111/horizon (bots left out, the
owner first) with avatar, login, commit count and a link to the profile.
Built in by build.mjs like the releases (the build still succeeds when
GitHub does not answer, and the block stays hidden until the page loads
it) and refreshed by the page from the API. Adds a nav and menu entry,
lets avatars.githubusercontent.com through img-src, and updates the
README, LEGAL.md and the privacy note.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 02:01:43 +02:00
Ludovic Marie e5c061dba6 Merge branch 'pr-54' into merge-54 2026-10-14 01:07:56 +02:00
Ludovic Marie 3879ca8ec7 Merge remote-tracking branch 'origin/worldgen-trees' into merge-55 2026-10-14 00:51:14 +02:00
Ludovic MarieandClaude Opus 5.5 069acd9aba Streaming: wait only for lower-ranked chunks that can be decorated
A chunk in the decoration range whose diagonal neighbour falls outside the
generation range never gets its 3x3 protos, so it must not hold back the
higher-ranked chunks within two of it. Render distances 2, 5 and 12 load and
keep loading while flying without stalling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 00:19:43 +02:00
Cosmic 1499c2a888 Fix GUI item lighting, preview seams and glyph bleeding 2026-10-13 18:16:09 -04:00
Ludovic MarieandClaude Opus 5.5 2c57fcc09f decorated_region: compare fully decorated regions decorated in vanilla's order
A new harness decorates a 13x13-chunk area around each site, on both sides,
in the order vanilla's chunk scheduler uses around a player standing there
(chunk_dump HORIZON_LIST_ORDER prints decoration_order::vanilla_order), and
compares every block state, quart biome, block entity and scheduled tick:
the two reported spots on seed 1891623539983467248 (taiga trees at
204 -239; a stronghold with sculk and a geode at 4870 401), eleven tree-heavy
biomes and the nearest mineshaft, village, ancient city and trial chambers of
two seeds. 21 sites, 3,549 chunks, 0 differences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 00:09:59 +02:00
Ludovic MarieandClaude Opus 5.5 317da28481 Structure pieces schedule the fluid ticks of the fluids they place
StructurePiece.placeBlock schedules a tick for the fluid of every block it
sets (the lava of a stronghold's portal room, a fortress's lava wells, water
in a monument or a mineshaft); in a WorldGenRegion it lands in the proto
chunk and runs once the chunk is live. The port treated it as a no-op, so
those fluids sat still until a neighbour changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 00:04:56 +02:00
Ludovic MarieandClaude Opus 5.5 147ea6661f Decorate chunks around the player in vanilla's order
Biome decoration is order dependent: a chunk's features write into its
neighbours, so a tree, ore blob or sculk patch placed first changes where the
next chunk's features fit and how many random draws they take. Horizon's
streamer decorated whichever chunk had its 3x3 protos ready, nearest first but
spread over non-overlapping regions for parallelism; around a teleported
player that swapped 45% of the overlapping chunk pairs against vanilla (whose
own runs swap 6%), and grew visibly different forests (seed
1891623539983467248 at 204 108 -239), ore veins, sculk and geodes.

worldgen::decoration_order replays vanilla's chunk scheduler for a standing
player: loading tickets ring by ring in fastutil hash order, four in flight,
FULL tasks decorating their 3x3 squares x-outer by level, INITIALIZE_LIGHT
tasks themselves, a ticket released once its 5x5 square is full. The streamer
ranks chunks with it whenever the player's chunk or the render distance
changes and decorates a chunk only when no lower-ranked chunk within two of it
is still waiting, so chunks further apart still decorate in parallel. Around
that spot the decorated blocks that differ from a vanilla server fall from
about 9000 to about 1000 (two vanilla runs differ by about 200).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:55:01 +02:00
Ludovic Marie 19ec3213fd Merge remote-tracking branch 'origin/review-52' into merge-52 2026-10-13 23:38:55 +02:00
Ludovic MarieandClaude Opus 5.5 4c4e579d3f Generated chunks keep the ticks their features scheduled
VanillaTerrain::finish dropped the proto chunk's block and fluid ticks; vanilla's
LevelChunk takes them over from the ProtoChunk (unpackBlockTicks), so the leaf
re-measurements and the fluid ticks of lakes and springs run once the chunk is
live. They now travel in the chunk's ChunkExtras, like a saved chunk's ticks,
and register with the level's scheduler on arrival; block ticks are translated
from worldgen's default state ids to the game's block indices.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:38:02 +02:00
Ludovic MarieandClaude Opus 5.5 2c8de2b015 chunk_generation: compare scheduled ticks; HORIZON_ORDER decoration order
Both dumps list each proto chunk's scheduled block and fluid ticks in
scheduling order (`t block|fluid NAME X Y Z PRIORITY`) and the harness
compares them: they run once the chunk is live, and the leaf ticks of
updateShapeAtEdge were missing without any block differing at FEATURES.

HORIZON_ORDER names a file of `x z` lines both sides decorate in, instead of
row-major: decoration is order dependent (a chunk's features write into its
neighbours), and vanilla's chunk system decorates around a player in its own
order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:38:02 +02:00
Ludovic MarieandClaude Opus 5.5 897ca2aa5e Worldgen shape updates schedule their ticks in the proto chunks
StructureTemplate.updateShapeAtEdge (every tree, structure pieces) and
Block.updateFromNeighbourShapes run updateShape on a WorldGenRegion, where
LeavesBlock.updateShape schedules a one-tick block tick to re-measure its
distance and waterlogged blocks schedule fluid ticks: vanilla keeps them in
ProtoChunkTicks and runs them once the chunk is live. Horizon ran updateShape
against a throwaway tick scheduler and lost them, so leaves where trees
overlap kept the distance their own tree gave them (one more than vanilla's
for thousands of leaves in a taiga), and could decay where vanilla's don't.

level::Ticks gains a capture mode; the decoration world schedules what a
shape update captured, in order, before the update's own setBlock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:38:02 +02:00
Ludovic Marie 8ad1e52067 Merge remote-tracking branch 'origin/trading' into merge-50-53 2026-10-13 23:30:15 +02:00
Ludovic Marie 373fa31a35 Merge remote-tracking branch 'origin/agent-control' into merge-50-53 2026-10-13 23:30:15 +02:00
Ludovic MarieandClaude Opus 5.5 0944280fb3 Merge PR #52 (entity directional lighting) into review-52
Conflicts with #51: entity.wgsl keeps the crumbling sprite UVs and
samples with the per-face colour; gfx.rs keeps crumbling_sprite and
outline_params and puts the Nether flag in time.y; game.rs keeps #51's
breaking and cloud geometry. #51's new vertices (breaking decals,
cloud faces, the batching test) get the new zero normal, so they stay
unshaded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:18:40 +02:00
Ludovic Marie a72dd98e2a Merge pull request #51 from ludovic111/review-cosmic
Cosmic's camera, breaking, clouds and outline fixes (#42 + #47), integrated
2026-10-13 23:14:30 +02:00
Cosmic 7b21d4bc6c fix(client): restore directional lighting for mob models 2026-10-13 17:08:34 -04:00
Ludovic MarieandClaude Opus 5.5 c810a3213c Wandering traders trade again: only villagers refuse while asleep
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:07:02 +02:00
Ludovic Marie 193c08e90f Merge remote-tracking branch 'origin/main' into review-cosmic 2026-10-13 23:06:15 +02:00
Ludovic MarieandClaude Opus 5.5 393d113dfb Merchant screen: the restock tooltip over an out-of-stock result
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:02:59 +02:00
Ludovic MarieandClaude Opus 5.5 92209ddeee Trading on the dedicated server, compared with vanilla's
test_dedicated_server_parity summons a librarian beside the spawn and the
vanilla protocol client right-clicks it: the OpenScreen packet (title as
Entity.getDisplayName, UUIDs left out) and the MerchantOffers packet must
match vanilla's server. Villagers keep Xp, LastRestock and RestocksToday
before their offers are made (vanilla's null offers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:01:02 +02:00
Ludovic MarieandClaude Opus 5.5 5cb14ee496 Trade over the network: LAN guests and Horizon as a client
The host hands a trading screen a guest's interaction opens to that guest:
OpenScreen (minecraft:merchant), MerchantOffers when the offers change,
the select trade packet, container clicks run on the merchant menu engine,
the TRADE reputation event and prices for the guest's UUID, the payment back
on close. As a client, Horizon shows a server's trading window from its
MerchantOffers and sends select trade and clicks. Stored enchantments, dyed
colours and map ids now travel in item stacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:44:16 +02:00
Ludovic Marie 5a5e8ffa71 Merge pull request #49 from ludovic111/creative
Creative inventory: every tab and the search as vanilla builds them; scrollbar drag
2026-10-13 22:41:39 +02:00
Ludovic MarieandClaude Opus 5.5 d17e5da848 Merge origin/main into review-cosmic
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:32:35 +02:00
Ludovic MarieandClaude Opus 5.5 7fab5ec1ff test_creative_parity: no Rust scope (creative.rs reaches item_data and save)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:30:39 +02:00
Ludovic MarieandClaude Opus 5.5 43cff3b44e Trade in game through the verified merchant menu
The trading screen runs MerchantMenu on the menu engine with the villager's
merchant::Session: level-ups add offers while it is open, prices follow the
TRADE reputation event a tick later, orbs drop at the villager with its own
random, the yes/no voice and regeneration as vanilla; special prices stay
after closing. Offers use the world's RandomSequences (now saved as
random_sequences.dat), the villager's position, its namespaced type (desert,
snow... trades) and exploration maps found through /locate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:30:33 +02:00
Ludovic MarieandClaude Opus 5.5 2e7e249096 Merge #47 (selection outline width and depth) into review-cosmic
Resolve the gfx.rs conflict with #42 and keep the shared Globals uniform
consistent: Rust, terrain.wgsl and entity.wgsl now all end with
time, crumbling_sprite, outline_params. The automatic merge left
terrain.wgsl reading outline_params at crumbling_sprite's offset. A test
compares the three declarations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:29:31 +02:00
Ludovic Marie 59ce240633 Merge pull request #48 from ludovic111/sound-menus
Fix the looping grass-step sound; classic menus lead back to Horizon's
2026-10-13 22:28:47 +02:00
Ludovic MarieandClaude Opus 5.5 502c8b9311 agent_control_e2e: pick up a drop from within a block (it can land under the trunk)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:28:14 +02:00
Ludovic Marie 2ee7d7af9f Merge branch 'pr-42' into review-cosmic 2026-10-13 22:26:55 +02:00
Ludovic MarieandClaude Opus 5.5 a3dd34a8f3 Trading parity harness: offers, prices, restocks, the merchant menu and NBT against vanilla
VanillaTradingOracle runs villagers, wandering traders and MerchantMenu on a
real flat server; trading_fixture runs horizon_client::merchant and a new
merchant kind of the menu engine (MerchantContainer, MerchantResultSlot,
tryMoveItems, quick move) over a merchant::Session. Generation now covers
random dyes, random potions, stew effects, exploration maps (a MapMaker hook),
sum/binomial providers and the trade's max uses / xp / discount providers;
getRecipeFor honours the selection hint as vanilla, Offers leave defaults out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:25:27 +02:00
Ludovic MarieandClaude Opus 5.5 299280baa2 Classic menus: Menu Style on the Video Settings page leads back to Horizon's
Choosing Classic in Options > Interface left no way back. The classic
Video Settings page, which already starts with Horizon's graphics row,
gets a Horizon-only "Menu Style: Classic" button below it: it switches to
Horizon's menus, saves horizon.txt and opens Options > Interface. The
classic main Options screen keeps vanilla's exact layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:24:57 +02:00
Ludovic MarieandClaude Opus 5.5 60fc617402 Sound: player footsteps once per metre walked, not every tick
The client's MotionWorld.apply_movement_emission pushed a footstep event
on every Entity.move, and the game played the block's step sound for
each. Gravity moves a grounded player every tick, so standing on grass
played block.grass.step 20 times a second, overlapping into an endless
rustle (19 instances alive at once). The audio stream had been muted by a
saved WirePlumber setting until v2026.10.13.1, which hid it.

Route the player through horizon_world::movement_emission (vanilla's
applyMovementEmissionAndPlaySound: moveDist, nextStep, the supporting
block checks) with Player.getMovementEmission (nothing while flying or
sneaking on the ground). The status sound census now lists the events
holding channels, which is how the loop was found.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:24:57 +02:00
Ludovic MarieandClaude Opus 5.5 cac89a9995 Creative inventory: every tab and the search as vanilla builds them; scrollbar drag
The creative-tabs.json export failed to build the functional blocks tab (no
data-pack registries: banner patterns), and its fallback read decompiled
sources from a path that no longer exists, so the tab was empty and anvils,
crafting and smithing tables were missing from the tabs and the search. The
search also matched only an approximate display name.

VanillaCreativeTabsExporter now builds every tab with vanilla's own
CreativeModeTabs.tryRebuildTabContents over the vanilla data pack's
registries and writes creative-inventory.json: stacks with components, and
the search tab's creative tooltip lines and tags. The client searches them
as SessionSearchTrees does (FullTextSearchTree, IdSearchTree after '#').
Changing tab empties the search box, as in selectTab.

The scrollbar only jumped to the pressed point; holding it now scrolls as
the mouse moves, anywhere, until release (mouseClicked/mouseDragged), and
the wheel and the scroller follow ItemPickerMenu.

tools/test_creative_parity.py compares every tab and 6,793 searches with
the vanilla client's own search trees. horizon-ctl `type` also fills the
creative search box.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:24:55 +02:00
Cosmic 5ea60fbd8d fix(client): match vanilla block selection outline width and depth
Hardware LineList forced one-pixel strokes, while the guessed 0.002 endpoint growth did not separate the edges from block surfaces. Render screen-space strips with vanilla framebuffer width scaling and perspective/view depth layering, leaving the shape edges unchanged. Apply normal outline fog and add three regression tests.

Offline gate passed on this main-based branch. Actual vanilla GLSL vs Horizon WGSL GPU comparison covers 3,072 vertices; maximum projected difference is 0.0036621 pixels. No code from PR #42 is included.
2026-10-13 16:04:10 -04:00
Ludovic MarieandClaude Opus 5.5 c0f468a871 horizon-ctl: horizon-ctl - returns the last failure's status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:00:44 +02:00
Ludovic MarieandClaude Opus 5.5 6716b0d953 Docs: agent control with horizon-ctl and its MCP server
README gets an Agent control section (out of the roadmap: it's done), the
native client guide describes every command group, the blocking semantics,
exit codes, launch discovery, MCP and the two transports, and PLAY.txt tells
release players about horizon-ctl.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:59:35 +02:00
Ludovic MarieandClaude Opus 5.5 601fbb533a Ship horizon-ctl in the release folders
release.yml builds horizon-ctl beside the client (checked for system-only
imports like it), packaging/package.sh copies it from beside the client binary
and refuses to package without it (the macOS folders made by hand go through
the same script), and package_native_client.py adds it to private archives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:59:34 +02:00
Ludovic MarieandClaude Opus 5.5 772e0a7070 tools/agent_control_e2e.py: play a survival start through the CLI and MCP
On a virtual screen: create a world through the title menus, walk to a tree,
break a log, craft planks in the inventory screen, place one and read it
back, once with horizon-ctl commands and once over horizon-ctl mcp.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:57:09 +02:00
Ludovic MarieandClaude Opus 5.5 24a70e84a8 horizon-ctl: help for every command, launch from the launcher, MCP server
One command table drives horizon-ctl help, COMMAND --help (with examples)
and the MCP tools. Replies stay one JSON line; errors are error objects with
exit status 1 (game), 2 (usage) or 3 (unreachable). script runs a file or
stdin with shell-like quoting. launch finds the game beside horizon-ctl or
the newest launcher install (--version, --game), with its prepared resources
and the game home, and --wait waits for the world. horizon-ctl mcp serves
MCP over stdio in the launcher's style, one tool per command, with the
screenshot returned as an image. Existing commands and replies are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:57:09 +02:00
Ludovic MarieandClaude Opus 5.5 7bb3347edd Agent control: observe and act from the control socket
game/agent.rs adds commands an agent can play with: state, looking-at,
entities, block, blocks, find-block, inventory, screen and messages read the
game as JSON; move, jump, goto (A* over standable blocks), look-at, attack,
break, place, interact, use-item, select, drop, open-inventory, click-button,
click-at, command, respawn, pause, wait and stop act like a player. Actions
that take time run as a task advanced every frame and reply when done, with
what happened (break reports drops and pickups, place the block placed).

Buttons are clicked by label: the classic and Horizon button drawers record
what they draw each frame (widgets.rs), and a click is a synthesized pointer
move and press, so it behaves exactly like the mouse on every screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:57:09 +02:00
Ludovic MarieandClaude Opus 5.5 2df60cd388 Control socket: a token-guarded localhost TCP transport beside the Unix socket
Windows has no Unix domain sockets, so the control endpoint there is a random
127.0.0.1 port described by a JSON file (port and a 128-bit token, readable by
the user only); a client's first line is auth TOKEN. Linux and macOS keep the
Unix socket, and any endpoint path ending in .json picks the TCP transport, so
it is tested on Linux too. control::connect and Stream serve both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:57:09 +02:00
Ludovic Marie e67ccd5589 Merge pull request #46 from ludovic111/launcher-014
Launcher 0.1.4: reuse prepared resources across versions; clippy and fmt
2026-10-13 21:22:29 +02:00
Ludovic Marie 97245773d1 Merge pull request #45 from ludovic111/readme
README: new banner, current screenshots, what changed in v2026.10.13.1
2026-10-13 21:11:50 +02:00
Ludovic MarieandClaude Opus 5.5 1404dd5a0a Launcher: catch up versions an older launcher installed without preparing them
Launcher 0.1.3 auto-installed v2026.10.13.1 unprepared; after updating to 0.1.4 nothing installs
it again, so install's reuse never ran for it. backend::reuse_prepared_everywhere() applies the
same reuse (same compatibility checks, copied then renamed) to every installed version that is
not prepared, oldest first. It runs when the window starts (on a thread; the page refreshes when
it is done), in the CLI's `versions` and `update`, and in the MCP list_versions and check_updates
tools. The partial folder is now named per copy, and a version prepared meanwhile is left as it is,
since the window's start and an install can copy at the same time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:11:43 +02:00
Cosmic 870f2b45f7 Merge remote-tracking branch 'origin/main' into fix/vanilla-camera-cracks-clouds 2026-10-13 15:11:33 -04:00
Ludovic MarieandClaude Opus 5.5 8fa3eef809 Launcher 0.1.4: a new version reuses resources another installed version prepared
After installing a version (CLI install and update, the window's automatic install, MCP),
when it is not prepared and another installed version with the same Minecraft version is,
the launcher copies that version's prepared resources. Players who auto-updated to
v2026.10.13.1 no longer prepare again, and can play it without their Minecraft folder.

The prepared output depends on the Horizon version as well as Minecraft's: the manifest
records the hashes of the vanilla exporter sources it was made with, and
prepare_native_client.py writes every file and the manifest's schema. So resources are reused
only when both versions have the same pinned jars in research-lock.json, the same
prepare_native_client.py and the same exporter sources the manifest lists. They are copied
(prepare rewrites files in place, so hard links would tie the versions together), into a
partial folder renamed when complete. install and the MCP tool say when it is prepared already.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:08:06 +02:00
Ludovic MarieandClaude Opus 5.5 22756c28d8 README: retake the launcher's Play page, prepared and ready
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:07:17 +02:00
Ludovic MarieandClaude Opus 5.5 f0be91777f README: a new banner, current screenshots, what changed in v2026.10.13.1
- A banner in the website's design (Instrument Serif wordmark over a dimmed
  sunset, purple accent), rendered from docs/media/banner.html with Playwright.
- Screenshots of Horizon's own menus and graphics settings, the in-game scenes
  as WebP (from the website's media), and the launcher 0.1.3 Play and Patch
  notes pages; the JPEGs the README no longer uses are removed.
- A "What changed" section matching the v2026.10.13.1 release notes.
- Download only through the launcher (playhorizon.dev or launcher-v releases).
- Roadmap: performance optimisation, mod support and agent control.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:07:17 +02:00
Cosmic b797839f77 fix(client): follow block shapes for cracks and terrain particles
The crack overlay and particle emitters assumed a unit cube. Use selected baked model quads and projected decal UVs for cracks, outline bounds for mining crumbs, and per-box density for the destruction burst. Keep translucent overlays after translucent terrain.

Distinguish model offsets from outline offsets and preserve double-precision shape coordinates. Add regression tests and actual-client differential oracles: 2,740 model cases and 2,740 particle cases, each passing three runs after the upstream merge. Particle enqueue positions match exactly; model max error is 1.40395e-7. Offline gate, camera/first-person/cloud comparisons and Vulkan smoke checks pass.
2026-10-13 15:06:46 -04:00
Ludovic MarieandClaude Opus 5.5 3a522af067 Launcher: fix clippy's 7 warnings (unused re-exports, complex markdown types, large Err in send closures)
Behaviour is unchanged: the threads still ignore a closed channel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:06:39 +02:00
Ludovic MarieandClaude Opus 5.5 c989692bd9 Launcher: cargo fmt (formatting only)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:06:31 +02:00
Ludovic MarieandClaude Opus 5.5 a96580aa2e Tests: the supervise trial-merge test sets its own git identity (CI runners have none)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:06:28 +02:00
Ludovic Marie cfdcd7f7eb Merge pull request #44 from ludovic111/website
Website: playhorizon.dev, in the launcher's design language
2026-10-13 21:01:55 +02:00
Cosmic 7f7cdf01c2 Merge remote-tracking branch 'origin/main' into fix/vanilla-camera-cracks-clouds 2026-10-13 14:51:09 -04:00
Ludovic MarieandClaude Opus 5.5 6cd64ddcae Website: let the page fetch its own origin (robots.txt checks) and preconnect to GitHub
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:48:10 +02:00
Ludovic MarieandClaude Opus 5.5 f4719d974d Website: the social card keeps its text clear of the sun and names playhorizon.dev
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:47:03 +02:00
Ludovic MarieandClaude Opus 5.5 4ecb4c85e7 Launcher: use HORIZON_GITHUB_TOKEN for GitHub's API when given; CI passes its own
Shared CI runners hit GitHub's anonymous rate limit in the smoke test (launcher 0.1.2's
macOS build failed twice on it). Players never set the variable, so nothing changes for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:46:36 +02:00
Ludovic MarieandClaude Opus 5.5 189119a861 Website: a cinematic one-page site in the launcher's design language, on playhorizon.dev
The launcher's palette, violet accent, Instrument Serif and Geist, without its sidebar
and settings rows: a full-viewport hero over Horizon's own sunset (slow drift,
parallax, grain), then sections for what it is (a statement that lights up as it
scrolls), a screenshot mosaic, the parity count and a grid of the 14 subsystems, the
launcher beside Horizon's own menus, the latest patch notes, the roadmap, community
and the footer. Reveals, staggered letters and magnetic buttons stop under
prefers-reduced-motion; a translucent top bar and a full-screen phone menu replace
the sidebar.

playhorizon.dev: canonical, og:url, absolute og/twitter images, sitemap.xml and its
robots.txt line, and a 301 from *.up.railway.app (Railway's healthcheck host is still
served).

The full-page screenshots are no longer committed (about 17 MB): `npm run shots`
writes them to the gitignored shots/. The unused blurred background is gone; LEGAL.md
lists the hero and the settings screenshot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:46:19 +02:00
Ludovic Marie 3738c876bc Merge pull request #43 from ludovic111/domain
Add the playhorizon.dev website link
2026-10-13 20:40:03 +02:00
Ludovic MarieandClaude Sonnet 5.5 1c0a47e012 Website: playhorizon.dev in the README, docs, PLAY.txt and the launcher (0.1.3)
links.json gains a website key; the launcher shows a quiet Website link under
Join Discord and lists it in the links CLI command and MCP tool.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 20:35:22 +02:00
Ludovic Marie 2518075339 Merge remote-tracking branch 'origin/prepare-fix' into parity-base 2026-10-13 20:20:20 +02:00
Ludovic MarieandClaude Opus 5.5 6d7f11feb2 Prepare: require only this platform's libraries; accept republished 26.3 manifests
lab.prepare imported every client library in the version JSON, ignoring
its OS rules, so a Linux install failed on the macOS-only
java-objc-bridge the official launcher never downloads there. The rule
evaluation moves from runtime.py into lab.py and one selector,
client_library_artifacts, now serves preparation, the runtime and the
model baking harness: OS rules as the launcher applies them, and the
natives for this architecture (classifier suffixes, plain natives being
the x64 build only when an ARM variant is listed). Required libraries
are still checksummed and a missing one still errors.

Mojang republished 26.3.json twice (702fe591, then 11f1aefe, the current
one) with only assetIndex changed: non-English translations, same jars
and libraries. The lock pins the current file and accepts both earlier
ones; preparation takes the launcher's copy so it matches the asset
index installed with it, and an unknown republish errors clearly.
Fixes #41

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:20:17 +02:00
Cosmic 17f0029d08 Fix vertical camera flip, breaking crack blending and cloud face culling 2026-10-13 14:04:01 -04:00
Ludovic MarieandClaude Opus 5.5 f53f198f03 Website: one static page in the launcher's design, for Railway
website/ holds Horizon's site: the horizon-theme tokens and bundled fonts, the
launcher's sidebar, rows, buttons and segmented control, over Horizon's own frozen
landscape. The parity table, harness count and roadmap are generated from the README
at build time; releases, patch notes, the roadmap and links.json are refreshed in the
browser from GitHub, with the build's copy as fallback. The download button picks the
newest launcher-v* asset for the visitor's OS.

Legal: the Minecraft disclaimer in the hero, footer and 404; no Minecraft logo or
wordmark; no game files; the launcher's background (licence unconfirmed) and the
screenshots showing it are left out; LEGAL.md lists every asset and its licence. No
cookies, analytics or third-party assets.

Railway: root directory website, Dockerfile (Node build, Caddy on $PORT with a strict
CSP). Checked with Playwright at 360-1920 px and 1250x700 (docs/widths/); Lighthouse
96/100/96/92 on mobile, 100/100/96/92 on desktop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:03:56 +02:00
Ludovic Marie 383afd81eb Merge remote-tracking branch 'origin/graphics' into parity-base 2026-10-13 18:55:46 +02:00
Ludovic MarieandClaude Opus 5.5 3f3f294c52 Merge origin/main: game-bugs and launcher 0.1.2
Villager POI search: main's per-search state classification replaces this
branch's state table (same sections skipped, same positions in order).
status keeps main's mob and block-entity counts and this branch's pending
uploads.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:02:15 +02:00
974 changed files with 170812 additions and 9664 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
# Docker builds (the relay) need the Rust sources only.
target/
.local/
**/node_modules/
website/
benchmarks/
packs/
mods/
+3
View File
@@ -0,0 +1,3 @@
self-hosted-runner:
# The Mac mini's runner (ludovics-mac-mini), which holds the maintainer's own Minecraft files.
labels: [horizon-game, ludovics-mac-mini]
+19
View File
@@ -0,0 +1,19 @@
## What and why
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
## Vanilla reference
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
## Self-check
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
## Screenshots
<!-- For anything players see: before and after. Delete this section otherwise. -->
<!-- Please tick "Allow edits by maintainers": we push small fixups ourselves instead of a round trip.
See CONTRIBUTING.md. -->
+64 -3
View File
@@ -1,14 +1,25 @@
name: checks
# verify: every pull request and every push to main, on GitHub's runners (the repository is public).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
# maintainer's own Minecraft files (Horizon never downloads the game). A fork's pull request runs it only when
# a maintainer adds the run-parity label, once per label. See docs/release-flow.md.
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
permissions:
contents: read
concurrency:
# A new push to a pull request replaces its running checks; main's runs all finish (releases follow them).
group: checks-${{ github.event.pull_request.number || github.sha }}-${{ github.event.action == 'labeled' && 'label' || 'run' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
verify:
if: github.event.action != 'labeled'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
@@ -16,22 +27,71 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Keep game artifacts out of Git
run: |
python tools/check_public_tree.py
python tools/check_public_tree.py --history
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA headers the client's audio links against
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
with:
shared-key: verify
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check formatting and lint
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable fmt --all -- --check
cargo +stable clippy --workspace --all-targets -- -D warnings
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: cargo +stable test --workspace
parity:
if: >-
github.event_name == 'pull_request' && (
(github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'labeled') ||
(github.event.action == 'labeled' && github.event.label.name == 'run-parity'))
runs-on: [self-hosted, horizon-game]
timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini
steps:
- uses: actions/checkout@v4
with:
# The commit the event names: a fork's later pushes need another run-parity label.
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
clean: false
- uses: dtolnay/rust-toolchain@stable
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
run: bash tools/ci/vanilla-data.sh
- name: The harnesses this change can affect
env:
PARITY_HOSTS: ''
run: python3 tools/parity.py --changed ${{ github.event.pull_request.base.sha }} --timeout 1500
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
with:
name: parity-logs
path: .local/parity-runner/logs/
if-no-files-found: ignore
retention-days: 3
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
# (label horizon-game) holding the maintainer's own copy: the official launcher's
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
@@ -493,3 +553,4 @@ jobs:
dist/client-graphics-smoke.json
dist/client-graphics-macos-title.json
dist/client-graphics-macos-world.json
retention-days: 3
+202 -33
View File
@@ -1,86 +1,255 @@
name: launcher
# Builds the launcher on Linux, macOS and Windows. A pushed `launcher-vX.Y.Z` tag also puts the
# three archives on a draft GitHub release of that tag, to publish by hand. Horizon's own
# releases are tagged `vYYYY.MM.DD`; the launcher reads only `launcher-v*` releases for itself.
# Builds the launcher on Linux, macOS and Windows, and releases it by itself (docs/release-flow.md): after a
# push to main that changes the launcher or what it builds in (crates/horizon-prepare, crates/horizon-theme,
# crates/horizon-telemetry),
# every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as
# for the game.
# A release is launcher-vX.Y.Z: the patch after the last launcher tag, or the next minor when a merged pull
# request is labelled feature. The version is given to the build (HORIZON_LAUNCHER_VERSION): no bump commits.
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
# gh workflow run launcher --ref main -f dry_run=false -f force=true
on:
workflow_dispatch:
push:
branches: [launcher]
tags: ['launcher-v*']
branches: [main, launcher, 'launcher-*']
paths:
- 'launcher/**'
- 'crates/horizon-prepare/**'
- 'crates/horizon-theme/**'
- 'crates/horizon-telemetry/**'
- '.github/workflows/launcher.yml'
schedule:
- cron: '7-59/10 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: Build everything and write the notes, but publish nothing
type: boolean
default: true
force:
description: Release even without launcher changes, before merges settle or while paused
type: boolean
default: false
permissions:
contents: read
concurrency:
group: launcher-${{ github.ref == 'refs/heads/main' && 'release' || github.ref }}
cancel-in-progress: false
jobs:
decide:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
release: ${{ steps.decide.outputs.release }}
tag: ${{ steps.decide.outputs.tag }}
head: ${{ steps.decide.outputs.head }}
version: ${{ steps.decide.outputs.version }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: mode
# Off main every run is a dry run (a launcher branch's push only builds).
run: echo "dry_run=${{ github.ref != 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && inputs.dry_run) }}" >> "$GITHUB_OUTPUT"
- id: decide
env:
GH_TOKEN: ${{ github.token }}
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
# A push or a run off main builds its own commit; otherwise main's newest commit with green checks.
HEAD_SHA: ${{ (github.event_name == 'push' || steps.mode.outputs.dry_run == 'true') && github.sha || '' }}
FORCE: ${{ inputs.force && 'yes' || '' }}
run: |
python3 tools/release_train.py decide --kind launcher ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
tag=$(jq -r '.tag' decision.json)
echo "version=${tag#launcher-v}" >> "$GITHUB_OUTPUT"
# A dry run (and a launcher branch's push) builds whatever the decision.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then
echo "release=true" >> "$GITHUB_OUTPUT"
echo "head=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
fi
build:
needs: decide
if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
- name: linux
os: ubuntu-22.04
asset: horizon-launcher-linux-x86_64.tar.gz
binary: horizon-launcher
- os: macos-latest
- name: macos
mac: true
asset: horizon-launcher-macos-arm64.zip
binary: horizon-launcher
- os: windows-latest
- name: windows
os: windows-latest
asset: horizon-launcher-windows-x64.zip
binary: horizon-launcher.exe
runs-on: ${{ matrix.os }}
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
# which only the Visual C++ redistributable installs.
rustflags: -C target-feature=+crt-static
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
timeout-minutes: 40
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
# variable, not a secret. Unset, the launcher says sign-in is not configured.
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
# The release's version, built in (launcher/src/backend.rs VERSION); a launcher branch's build keeps
# Cargo.toml's.
HORIZON_LAUNCHER_VERSION: ${{ (github.event_name != 'push' || github.ref == 'refs/heads/main') && needs.decide.outputs.version || '' }}
defaults:
run:
working-directory: launcher
shell: bash
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
# The Mac mini's runner keeps its target/ between runs.
clean: ${{ runner.environment == 'github-hosted' }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: launcher
shared-key: launcher-${{ matrix.name }}
- name: Linux libraries GPUI links to
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
- name: The tag matches Cargo.toml
if: startsWith(github.ref, 'refs/tags/launcher-v')
run: grep -qx "version = \"${GITHUB_REF_NAME#launcher-v}\"" Cargo.toml
- run: cargo test --release --locked --lib
- run: cargo build --release --locked
- name: Metal shaders compiled now, or at run time without the Metal toolchain
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
if: runner.os == 'macOS'
run: |
if ! xcrun metal --version > /dev/null 2>&1; then
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
fi
- run: cargo test --release --locked --lib ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- name: The version is the release's
run: |
out=$(target/release/${{ matrix.binary }} --version)
echo "$out"
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
- name: Only system libraries
run: |
file=target/release/${{ matrix.binary }}
case "$RUNNER_OS" in
Windows) python ../tools/check_imports.py "$file" ;;
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::links $others"; exit 1; } ;;
esac
- name: Smoke test
env:
HORIZON_LAUNCHER_NO_SELF_UPDATE: '1'
HORIZON_GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
rm -rf "$HORIZON_LAUNCHER_HOME"
launcher="$PWD/target/release/${{ matrix.binary }}"
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
retry "$launcher" versions
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
"$launcher" menu-style classic
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
"$launcher" menu-style horizon
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
client="${clients[0]}"
echo "installed client: $client"
# The game's Linux build is the game's to keep portable (release.yml checks it): a
# release linked to a Nix store is a warning here, not a broken launcher.
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
if [[ "$RUNNER_OS" == "Windows" ]]; then
python ../tools/check_imports.py "$client"
elif [[ "$RUNNER_OS" == "Linux" ]]; then
soft python3 ../tools/check_imports.py "$client"
fi
# The client starts: --help prints before any window or GPU is needed.
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
soft help
rm -rf "$HORIZON_LAUNCHER_HOME"
- name: Package
run: |
mkdir -p dist
cp target/release/${{ matrix.binary }} dist/
cd dist
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
tar czf ../${{ matrix.asset }} ${{ matrix.binary }}
elif [[ "$RUNNER_OS" == "Windows" ]]; then
7z a ../${{ matrix.asset }} ${{ matrix.binary }}
rm -rf dist out && mkdir -p out
if [[ "$RUNNER_OS" == "macOS" ]]; then
# Horizon.app, zipped (self-update) and in a .dmg (installer): packaging/macos.
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
rm -rf out/Horizon.app
else
zip ../${{ matrix.asset }} ${{ matrix.binary }}
mkdir -p dist
cp target/release/${{ matrix.binary }} dist/
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
else
(cd dist && 7z a ../out/${{ matrix.asset }} ${{ matrix.binary }})
fi
fi
ls -l out
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: launcher/${{ matrix.asset }}
path: launcher/out/*
retention-days: 3
release:
if: startsWith(github.ref, 'refs/tags/launcher-v')
needs: build
needs: [decide, build]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Notes
env:
GH_TOKEN: ${{ github.token }}
run: |
python3 tools/release_train.py notes --kind launcher --tag "${{ needs.decide.outputs.tag }}" \
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
# A draft, and never marked latest: Horizon's own releases stay the repository's latest.
- uses: softprops/action-gh-release@v2
# Never marked latest: Horizon's own releases stay the repository's latest.
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --latest=false dist/horizon-launcher-*
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
with:
tag_name: ${{ github.ref_name }}
name: Horizon launcher ${{ github.ref_name }}
draft: true
make_latest: false
files: dist/*
name: launcher-release-notes
path: notes/
retention-days: 3
+79
View File
@@ -0,0 +1,79 @@
name: nightly audit
# Every vanilla harness, from scratch, on main, at 03:00 Paris time on the Mac mini's runner (it holds the
# maintainer's own Minecraft files). The resources are prepared again first when the preparer or its exporters
# changed (tools/ci/vanilla-data.sh). A harness that passed in the previous audit and fails now gets an issue
# labelled parity-regression (one per harness); its issue closes when it passes again
# (tools/ci/parity_issues.py). The bugs session picks those up. See docs/release-flow.md.
# gh workflow run "nightly audit" # now
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
on:
schedule:
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
- cron: '7 1 * * *'
- cron: '7 2 * * *'
workflow_dispatch:
inputs:
dry_run:
description: Run the audit, but only print what it would do with issues
type: boolean
default: false
permissions:
contents: read
issues: write
concurrency:
group: nightly-audit
cancel-in-progress: false
jobs:
audit:
runs-on: [self-hosted, horizon-game]
timeout-minutes: 150
steps:
- name: 03:00 in Paris
id: when
run: |
hour=$(TZ=Europe/Paris date +%H)
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
if: steps.when.outputs.run == 'true'
with:
fetch-depth: 0
persist-credentials: false
# Keeps .local/ (parity state, probe builds, the previous audit) and target/.
clean: false
- uses: dtolnay/rust-toolchain@stable
if: steps.when.outputs.run == 'true'
- name: Tools from the runner
if: steps.when.outputs.run == 'true'
run: bash tools/ci/runner-env.sh
- name: Vanilla data, prepared again if the preparer changed
if: steps.when.outputs.run == 'true'
run: bash tools/ci/vanilla-data.sh
- name: Every harness
id: audit
if: steps.when.outputs.run == 'true'
env:
PARITY_HOSTS: ''
run: |
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
set +e
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
- name: Issues for the harnesses that regressed
if: steps.when.outputs.run == 'true' && always() && steps.audit.outputs.since
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/parity_issues.py --since "${{ steps.audit.outputs.since }}" ${{ inputs.dry_run && '--dry-run' || '' }} | tee -a "$GITHUB_STEP_SUMMARY"
- name: Logs of the failed harnesses
if: steps.when.outputs.run == 'true' && steps.audit.outputs.exit != '0'
uses: actions/upload-artifact@v4
with:
name: nightly-parity-logs
path: .local/parity-runner/logs/
if-no-files-found: ignore
retention-days: 7
+215 -16
View File
@@ -1,27 +1,226 @@
name: release builds
# Started by hand: gh workflow run release.yml --ref <branch>
# Builds the Windows x64 client and uploads the playable folder as an artifact.
# Linux and macOS builds are made on the development machines.
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 15 minutes
# ago (the oldest an hour ago, if merges never pause), or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them.
# 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
# publishes vYYYY.MM.DD[.N] with notes from the merged pull requests.
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
on:
workflow_run:
workflows: [checks]
types: [completed]
branches: [main]
schedule:
- cron: '2-59/10 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: Build everything and write the notes, but publish nothing
type: boolean
default: true
force:
description: Release even without player-facing changes, before merges settle or while paused
type: boolean
default: false
permissions:
contents: read
concurrency:
group: game-release
cancel-in-progress: false
jobs:
windows:
runs-on: windows-latest
decide:
if: >-
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
release: ${{ steps.decide.outputs.release }}
tag: ${{ steps.decide.outputs.tag }}
head: ${{ steps.decide.outputs.head }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- name: Build the client
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
with:
fetch-depth: 0
- id: mode
# A dry run never publishes; a hand-started run off main is always one.
run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT"
- id: decide
env:
RUSTFLAGS: -C target-feature=+crt-static
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
- name: Package
GH_TOKEN: ${{ github.token }}
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
# The commit whose checks just passed; a dry run builds the branch it was started on; otherwise
# main's newest commit with green checks.
HEAD_SHA: ${{ github.event.workflow_run.head_sha || (steps.mode.outputs.dry_run == 'true' && github.sha) || '' }}
FORCE: ${{ inputs.force && 'yes' || '' }}
run: |
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
# A dry run builds whatever the decision, to test the builds.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
build:
needs: decide
if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: linux
os: ubuntu-22.04
dir: horizon-linux-x86_64
asset: horizon-linux-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
timeout-minutes: 40
env:
RUSTFLAGS: ${{ matrix.rustflags }}
defaults:
run:
shell: bash
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
# On the Mac mini's runner, keep target/ (incremental) and .local/ (the prepared resources).
clean: ${{ runner.environment == 'github-hosted' }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: release-${{ matrix.name }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
else
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --version
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
"$bin/horizon-server${{ matrix.exe }}" --version
- name: Tools from the runner
if: runner.environment == 'self-hosted'
run: bash tools/ci/runner-env.sh
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
run: bash tools/ci/vanilla-data.sh
- name: The game runs (offscreen, a new world, 1500 frames)
if: runner.environment == 'self-hosted'
run: |
set -euo pipefail
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
python3 - "$home/smoke.json" <<'EOF'
import json, sys
report = json.load(open(sys.argv[1]))
print(json.dumps(report))
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
else ditto -c -k --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"; fi ;;
esac
ls -l "${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
with:
name: horizon-windows-x64
path: dist/horizon-windows-x64
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 3
publish:
needs: [decide, build]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Notes
env:
GH_TOKEN: ${{ github.token }}
run: |
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
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
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
with:
name: release-notes
path: notes/
retention-days: 3
+34
View File
@@ -0,0 +1,34 @@
name: telemetry-server
# The ingest server is its own workspace (crates/horizon-telemetry-server), so the checks job doesn't build
# it: this one does, when it or the crate it shares with the launcher and the game changes.
on:
push:
branches: [main]
paths:
- 'crates/horizon-telemetry/**'
- 'crates/horizon-telemetry-server/**'
- '.github/workflows/telemetry-server.yml'
pull_request:
paths:
- 'crates/horizon-telemetry/**'
- 'crates/horizon-telemetry-server/**'
- '.github/workflows/telemetry-server.yml'
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: crates/horizon-telemetry-server
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-telemetry-server
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
+12
View File
@@ -16,5 +16,17 @@
/dist/
/mods/horizon/build/
/saves/
/backups/
/tools/logs/
.DS_Store
# What the client writes to its working directory when run from the repo root.
/options.txt
/horizon.txt
/horizon-graphics.txt
/horizon-controller.txt
/horizon-extras.txt
/horizon-player.txt
/servers.dat
/screenshots/
/crash-reports/
/debug/
+80
View File
@@ -0,0 +1,80 @@
# Contributing to Horizon
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
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.
## Build it
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
Horizon never downloads the game: it prepares its resources from your own copy of Minecraft 26.3, as described in
[Build from source](README.md#build-from-source).
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
The launcher is its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
## Vanilla first
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here
means Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a
**harness**: a `tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust
probe against Horizon, and compares their outputs. There are about 290 of them.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
Say in your pull request which vanilla classes and methods you followed.
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
off by default wherever they would change the vanilla look.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
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).
## Before asking for review
Run the self-check and paste the block it prints into your pull request:
```sh
python3 tools/pr_ready.py --base upstream/main # or origin/main if you work on the main repository
```
It looks at what your branch changes and runs what matters: rustfmt, clippy with warnings denied, the tests of the
crates you touched (and the launcher's), generated files, `Cargo.lock`, the Python tests, and the vanilla harnesses
your change can affect. Without the vanilla data on your machine it lists those harnesses instead; CI runs them.
`--quick` skips the harnesses. It exits non-zero when something fails.
## Pull requests
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
pieces that each make sense alone. Small pull requests get reviewed and merged the same day.
- **Fill in the template**: what and why, the vanilla reference, the pr_ready block, screenshots for anything you
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
ourselves instead of sending it back to you; only design questions come back.
- **Labels** (we set them if you can't): `feature` when players get something new (it appears under "New" in the
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
## Parity checks on forks
The `verify` check (formatting, lints, tests) runs on every pull request. The `parity` check runs the affected
harnesses on a maintainer's machine, which holds the game files, so it never runs a fork's code on its own: a
maintainer reads your change, then adds the `run-parity` label, which runs it once on your current commit. After
new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/ludovic111/horizon/issues)
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
Generated
+2568 -13
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol"]
exclude = ["launcher", "crates/horizon-telemetry-server"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "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-mod-sdk/examples/coords-hud", "xtask"]
resolver = "2"
[workspace.package]
+65 -35
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/media/banner.jpg" alt="Horizon: Minecraft Java Edition 26.3, rewritten from scratch in Rust" width="100%">
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
</p>
<p align="center">
@@ -12,7 +12,9 @@
</p>
<p align="center">
<a href="https://playhorizon.dev"><b>Website</b></a> ·
<a href="#download"><b>Download</b></a> ·
<a href="#what-changed-in-v202610131"><b>What changed</b></a> ·
<a href="#screenshots"><b>Screenshots</b></a> ·
<a href="#project-status"><b>Parity status</b></a> ·
<a href="docs/README.md"><b>Documentation</b></a>
@@ -26,9 +28,18 @@ Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS:
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
## What changed in v2026.10.13.1
The latest release ([notes](https://github.com/ludovic111/horizon/releases/tag/v2026.10.13.1)):
- **Horizon's own menus:** a new title screen, world list, options and pause menu in the launcher's style, over a live frozen landscape. The vanilla look is still available: Options → Interface → Menu style → Classic.
- **Graphics settings, Simple and Advanced:** presets (Performance, Balanced, Quality, Ultra), or every rendering, quality, display and performance setting, each with a short description and its cost, plus a performance overlay.
- **Much faster rendering:** for example, at render distance 8 over a village, 363 → 2,409 FPS on an RTX 4080. Render distance 32 still stutters in places.
- **Fixed:** no sound on Linux; Linux builds that only ran on the machine they were built on; a missing DLL on Windows (the launcher no longer needs the Visual C++ runtime); memory growing and the frame rate dropping near villages (mobs were duplicated when chunks reloaded); resource preparation failing on Linux with "Missing … java-objc-bridge" ([#41](https://github.com/ludovic111/horizon/issues/41)); the hand and held item at high FOV; missing swing animations.
## Download
**[Download the Horizon launcher](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true)** for Linux x86-64, macOS Apple Silicon or Windows x64: take the newest `launcher-v*` release (currently [launcher-v0.1.1](https://github.com/ludovic111/horizon/releases/tag/launcher-v0.1.1)); the launcher then updates itself. The launcher is the way to get Horizon: it installs and updates the game, prepares its resources from your own copy of Minecraft, and starts the client or a dedicated server.
Horizon is downloaded only through **the Horizon launcher**: get it from [playhorizon.dev](https://playhorizon.dev) or from [the launcher's releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true) (the newest `launcher-v*` release, currently [launcher-v0.1.3](https://github.com/ludovic111/horizon/releases/tag/launcher-v0.1.3)), for Linux x86-64, macOS Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its resources from your own copy of Minecraft, starts the client or a dedicated server, and updates itself.
**You need** Minecraft: Java Edition installed with the official launcher, with 26.3 played once; Python 3.10 or newer and Java 25 (a JDK, e.g. Eclipse Temurin 25) for the one-time resource preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac.
@@ -36,31 +47,47 @@ Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS:
<table>
<tr>
<td width="50%"><img src="launcher/docs/play.png" alt="The launcher's Play page"></td>
<td width="50%"><img src="launcher/docs/versions.png" alt="The launcher's Versions page"></td>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: v2026.10.14.1 ready to play, its summary, Client and Server, the version picker and Play, the resources and the Minecraft folder as a numbered list"></td>
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page, showing the notes of Horizon 2026.10.14.1"></td>
</tr>
</table>
It lists Horizon's releases and installs the build for your platform, shows each release's patch notes and the roadmap, updates Horizon and itself, and doubles as a command-line tool and an MCP server. See [the launcher's README](launcher/README.md).
It lists Horizon's releases and installs the build for your platform, shows each release's patch notes, updates Horizon and itself, and doubles as a command-line tool and an MCP server. See [the launcher's README](launcher/README.md).
## Screenshots
Captured in the Rust client itself, with vanilla assets and no mods or shaders.
### Menus and settings
<table>
<tr>
<td colspan="2"><img src="docs/media/sunset.jpg" alt="Sunset over a savanna river valley and a plains village"></td>
<td colspan="2"><img src="docs/media/menu-title.webp" alt="Horizon's title screen: the mark and the pages on the left, the Horizon title, Singleplayer and Multiplayer, and the last world played and the faint wordmark, over a veiled snowy landscape"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/village.jpg" alt="A plains village with farms, an iron golem and animals"></td>
<td width="50%"><img src="docs/media/survival.jpg" alt="Survival at dusk: a zombie attacks near the village"></td>
<td width="50%"><img src="docs/media/menu-worlds.webp" alt="The Singleplayer page: three worlds with their dates and game modes, and Create world and Play buttons"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="The Video settings in their Simple view: a preset, render distance, field of view, framerate, VSync and GUI scale, each with what it costs"></td>
</tr>
</table>
The vanilla menus are one setting away: Options → Interface → Menu style → Classic. See [Graphics settings](docs/graphics.md).
### In game
<table>
<tr>
<td colspan="2"><img src="docs/media/sunset.webp" alt="Sunset over a savanna river valley and a plains village"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/nether.jpg" alt="A crimson forest in the Nether with a zombified piglin"></td>
<td width="50%"><img src="docs/media/night.jpg" alt="Moonrise over a torch-lit village and river"></td>
<td width="50%"><img src="docs/media/village.webp" alt="A plains village with farms, an iron golem and animals"></td>
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie attacks near the village"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/underwater.jpg" alt="Salmon swimming in a river"></td>
<td width="50%"><img src="docs/media/nether.webp" alt="A crimson forest in the Nether with a zombified piglin"></td>
<td width="50%"><img src="docs/media/night.webp" alt="Moonrise over a torch-lit village and river"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/underwater.webp" alt="Salmon swimming in a river"></td>
<td width="50%" valign="middle">
Vanilla world generation ported to Rust (terrain, aquifers, carvers, features, villages and the Nether), every block model with smooth lighting and biome tints, the vanilla sky and fog, mobs, the HUD, chat and advancements: everything in these frames is drawn by Horizon.
</td>
@@ -86,9 +113,24 @@ The [native client](docs/native-client.md) opens on a vanilla-style title screen
- shields, maps, books, fireworks, beacons, advancements and statistics;
- Anvil saves, joining vanilla servers, and opening a world to LAN.
## Agent control
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
```sh
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
horizon-ctl state # position, health, food, target, screen...
horizon-ctl find-block birch_log # the nearest logs
horizon-ctl goto -0.5 65 -2.5 --near 2.5 # walks there along a path
horizon-ctl break -1 65 -3 # mines it; replies with the drops
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
```
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
## Build from source
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA development headers (`libasound2-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
@@ -98,18 +140,18 @@ Horizon contains no game files and never downloads them. It builds its resources
```sh
git clone https://github.com/ludovic111/horizon.git
cd horizon
python3 tools/prepare_native_client.py --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
python3 tools/fetch_sounds.py # optional: sounds from your launcher install
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
cargo run --release -p horizon-client
```
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
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 documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, playing and scripting through `horizon-ctl` and its MCP server, and the architecture are described in [the native client guide](docs/native-client.md). The rest of the documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
## Project status
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences and the final checks are listed in the roadmap.
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
### Java parity: 100%
@@ -138,39 +180,27 @@ them too (Rust uses Java's own float maths on each platform).
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
### Roadmap
### Known issues
**Known differences.** Online-mode servers (Mojang's session servers) are not supported; skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
**Final checks.** A from-scratch audit (`tools/parity.py --all`) on Linux and on macOS, and the launch checks on the packaged Linux, macOS and Windows builds listed in [Rust rewrite status](docs/rust-parity.md#completion-criteria-and-playable-builds).
**Next.** With vanilla parity as the base:
- **Performance:** optimize the client and the server as far as they can go (rendering, chunk generation and meshing, memory, multithreaded ticking) while the differential harnesses keep gameplay identical to vanilla.
- **Modding support:** a stable API for writing mods against Horizon.
- **Agent control:** an MCP server and a CLI so AI agents and scripts can play and drive the game, growing out of today's `horizon-ctl` scripting socket.
- **Built-in recording:** capture gameplay video in a keypress, without external tools.
- **Automatic version updates:** a pipeline that ports each new Minecraft release on its own: compare the new version with the pinned one, regenerate the data, rewrite the changed parts and prove them with the same differential harnesses.
Online-mode servers (Mojang's session servers) are not supported; skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
## Contributing
Issues and pull requests are welcome. Before opening a pull request, run the offline checks:
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels). Before opening a pull request, run the self-check and paste the block it prints:
```sh
git config core.hooksPath .githooks
python3 -m unittest discover -s tests -v
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
python3 tools/check_public_tree.py --history
python3 tools/pr_ready.py
```
CI runs these checks on GitHub's Linux runners. The vanilla comparisons need the game, which Horizon never downloads, so they run only when started by hand on a self-hosted runner holding the maintainer's own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a self-hosted runner holding the maintainer's own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
## Licence
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
Third-party credits and licenses: [Third-party notices](THIRD_PARTY_NOTICES.md).
## Author
Made by [Ludovic Marie](https://lu4ovic.com) ([@ludovic111](https://github.com/ludovic111)).
+31
View File
@@ -0,0 +1,31 @@
# 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.
```
@@ -0,0 +1,78 @@
# Entity model document loading
The three startup batches (boats/chest boats, minecarts, armor stands/player armor)
now share one parsed document. For this fixture, that replaces 43 full document
reads/parses (boat discovery plus 42 selected layers) with one. Selected models
still produce independent `Part` trees; the document is dropped after the batches
are built. Other entity loaders retain their existing behavior.
## Reproduce
From the repository root:
```sh
python3 benchmarks/entity-model-document-2026-10-14/generate_fixture.py /tmp/horizon-entity-model-document-fixture.json
ENTITY_MODEL_BENCH_DOCUMENT=/tmp/horizon-entity-model-document-fixture.json \
CARGO_TARGET_DIR=/home/bob/Documents/horizon/target \
cargo test -p horizon-client --profile fast --bin horizon-client \
entity_model_document_benchmark -- --ignored --nocapture
```
`CARGO_TARGET_DIR` is optional and can be changed for another machine. Supply a
prepared `entity-models.json` through `ENTITY_MODEL_BENCH_DOCUMENT` to benchmark
that document instead; the harness copies only that file to a temporary root.
`ENTITY_MODEL_BENCH_REPETITIONS` controls repetitions (default 15).
The opt-in Rust test calls the actual three optimized production loaders and
compares every selected part name, pose, polygon normal/vertex and child against
the previous algorithm using the compatible per-model `load_model` API. Timings
include reading, parsing, constructing, normalizing into a common result map, and
dropping the result. Each variant gets three warmups and 15 measured calls, with
order alternating. `black_box` consumes inputs/results. No other builds or
optimization benchmarks ran during the measured calls.
## Input and limitations
Prepared resources were unavailable in the repository and local Horizon asset
directories. This is a **synthetic** 2,473,695-byte document with 458 model layers,
each containing 12 posed parts with six exported quads and normals. It selects 22
boat/chest-boat layers, six minecart layers and 14 armor/player layers (42 total).
One wood name is synthetic (`test_wood`); boat discovery accepts arbitrary wood
names. The extra layers model the cost of parsing a full production-size document
for each selected model.
File data is warm in the OS page cache. Equipment files are absent, so equipment
texture loading is excluded. This measures the entity-model batch only and does
not establish overall startup, frame time or FPS improvement. Shapes/layer counts
in real prepared documents can produce different absolute timings.
Machine: AMD Ryzen 5 5600X (6 cores / 12 threads), Linux x86_64,
Rust 1.99.0 (b940084d7, LLVM 23.1.1), Cargo `fast` profile (opt-level 3,
16 codegen units, no LTO). Baseline algorithm corresponds to main
`73a019967e9665c6b96332988c13cb66679d8477`.
## Results
| Variant | Median batch time | Full document reads/parses |
| --- | ---: | ---: |
| Previous per-model algorithm | 1,434.974 ms | 43 |
| Shared document | 33.420 ms | 1 |
The measured batch is **42.94× faster** on this synthetic input. All 15 samples
for both variants, warmup count, document size and selected model count are in
[`results.json`](results.json).
## Validation
```sh
cargo fmt --all -- --check
CARGO_TARGET_DIR=/home/bob/Documents/horizon/target \
cargo test -p horizon-client --profile fast --bin horizon-client \
entity_model_loading_tests -- --nocapture
CARGO_TARGET_DIR=/home/bob/Documents/horizon/target \
cargo clippy -p horizon-client --profile fast --lib --bin horizon-client --tests -- -D warnings
```
The regular tests cover full/sparse documents, unrelated layers, a missing file,
invalid JSON, non-object JSON, missing model keys, independent model trees, and
equipment loading when the document is unavailable.
@@ -0,0 +1,31 @@
"""Generate a production-size synthetic document; no prepared assets required."""
import json
import pathlib
import sys
woods = "oak spruce birch jungle acacia dark_oak mangrove cherry bamboo pale_oak test_wood".split()
carts = "minecart chest_minecart hopper_minecart furnace_minecart tnt_minecart command_block_minecart".split()
keys = [f"minecraft:{prefix}/{wood}#main" for prefix in ("boat", "chest_boat") for wood in woods]
keys += [f"minecraft:{cart}#main" for cart in carts]
keys += [f"minecraft:{model}#{slot}" for model in ("player", "armor_stand", "armor_stand_small")
for slot in ("helmet", "chestplate", "leggings", "boots")]
keys += [f"minecraft:{model}#main" for model in ("armor_stand", "armor_stand_small")]
face = [[0, 0, 0, 0, 0], [16, 0, 0, 1, 0], [16, 16, 0, 1, 1], [0, 16, 0, 0, 1]]
part = {"pose": [1, 2, 3, 0.25, -0.5, 0.75, 1, 1, 1], "polygons": [face] * 6,
"normals": [[0, 0, 1]] * 6}
model = {"pose": [0, 0, 0, 0, 0, 0, 1, 1, 1],
"children": {f"part_{i}": part for i in range(12)}}
encoded = json.dumps(model, separators=(",", ":"))
entries = [json.dumps(key) + ":" + encoded for key in keys]
size = 2 + sum(map(len, entries)) + len(entries) - 1
index = 0
while size < 2_470_000:
entry = json.dumps(f"minecraft:fixture_{index}#main") + ":" + encoded
entries.append(entry)
size += 1 + len(entry)
index += 1
output = pathlib.Path(sys.argv[1])
output.write_text("{" + ",".join(entries) + "}")
print(json.dumps({"path": str(output), "bytes": output.stat().st_size,
"layers": len(entries), "selected_layers": len(keys)}))
@@ -0,0 +1,43 @@
{
"baseline_median_ms": 1434.973835,
"baseline_ms": [
1400.008286,
1406.761694,
1408.9131360000001,
1418.6680549999999,
1419.149658,
1427.082872,
1433.088092,
1434.973835,
1449.7688150000001,
1452.143883,
1453.987998,
1455.518231,
1457.0334540000001,
1458.588552,
1485.34133
],
"document_bytes": 2473695,
"optimized_median_ms": 33.419768000000005,
"optimized_ms": [
32.64795,
32.812732,
33.054488,
33.056914,
33.102999000000004,
33.23532,
33.28287,
33.419768000000005,
33.610569,
33.874749,
33.915657,
33.93877,
34.009893999999996,
34.550526,
36.096408000000004
],
"repetitions": 15,
"selected_models": 42,
"speedup": 42.937875421516985,
"warmup_pairs": 3
}
+546
View File
@@ -0,0 +1,546 @@
{
"label": "after-4080",
"date": "2026-10-13",
"commit": "9006566b",
"host": "ludodesktop",
"offscreen": true,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 133,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 941908352
},
"village": {
"avg_fps": 2409.179688612205,
"cpu": {
"avg_ms": 0.41004928978864236,
"max_ms": 18.15971565246582,
"p50_ms": 0.2718150019645691,
"p95_ms": 0.7112299799919128,
"p99_ms": 1.316559076309204
},
"frames": 28910,
"gpu": {
"avg_ms": 0.09866036293994015,
"max_ms": 0.25088000297546387,
"p50_ms": 0.08806400001049042,
"p95_ms": 0.1740799993276596,
"p99_ms": 0.19968000054359436
},
"low_1pct_fps": 160.80638022914025,
"max_ms": 18.175312042236328,
"p50_ms": 0.2760099768638611,
"p95_ms": 0.7161250114440918,
"p99_ms": 1.4078609943389893,
"phases_avg_ms": {
"prepare": 0.20953580908804606,
"render": 0.12439006986512069,
"tick": 0.05299293669455945,
"update": 0.023244019639293335
},
"seconds": 11.999935138359666,
"slow_phases_avg_ms": {
"prepare": 0.4052206484151298,
"render": 0.41893499056840766,
"tick": 5.252727109792939,
"update": 0.0936474929949076
}
},
"forest": {
"avg_fps": 1099.4635681039538,
"cpu": {
"avg_ms": 0.9003613499442573,
"max_ms": 23.31729507446289,
"p50_ms": 0.6657320261001587,
"p95_ms": 0.9898859858512878,
"p99_ms": 9.592857360839844
},
"frames": 13194,
"gpu": {
"avg_ms": 0.2639814413552015,
"max_ms": 3.0013439655303955,
"p50_ms": 0.2549760043621063,
"p95_ms": 0.3553279936313629,
"p99_ms": 0.37171199917793274
},
"low_1pct_fps": 82.87026130908886,
"max_ms": 27.990703582763672,
"p50_ms": 0.6705269813537598,
"p95_ms": 1.0082690715789795,
"p99_ms": 9.64776611328125,
"phases_avg_ms": {
"prepare": 0.571024256178027,
"render": 0.12442494783992657,
"tick": 0.1706307831885292,
"update": 0.03442592136820218
},
"seconds": 12.000397632777691,
"slow_phases_avg_ms": {
"prepare": 0.7136096191225629,
"render": 0.43175565880356415,
"tick": 10.713303016893791,
"update": 0.05390534066651581
}
},
"ocean": {
"avg_fps": 687.0806476294383,
"cpu": {
"avg_ms": 1.4490217101725757,
"max_ms": 29.75178337097168,
"p50_ms": 0.9425880312919617,
"p95_ms": 1.6511069536209106,
"p99_ms": 15.603099822998047
},
"frames": 8245,
"gpu": {
"avg_ms": 0.3711021953564546,
"max_ms": 1.5022079944610596,
"p50_ms": 0.3665919899940491,
"p95_ms": 0.43827199935913086,
"p99_ms": 0.4679679870605469
},
"low_1pct_fps": 47.486143092805435,
"max_ms": 29.770263671875,
"p50_ms": 0.947784960269928,
"p95_ms": 1.6628600358963013,
"p99_ms": 15.621638298034668,
"phases_avg_ms": {
"prepare": 0.8449565766073126,
"render": 0.1554101111230668,
"tick": 0.3840128544967872,
"update": 0.06481705411812297
},
"seconds": 12.000046906352043,
"slow_phases_avg_ms": {
"prepare": 1.2885668083845851,
"render": 0.45316039797771407,
"tick": 19.216593845781073,
"update": 0.08380924088947744
}
},
"mountain": {
"avg_fps": 343.77852072094186,
"cpu": {
"avg_ms": 1.5961282111390964,
"max_ms": 22.24184799194336,
"p50_ms": 0.8254669904708862,
"p95_ms": 11.660508155822754,
"p99_ms": 14.398658752441406
},
"frames": 4113,
"gpu": {
"avg_ms": 0.3449904050323872,
"max_ms": 2.9040639400482178,
"p50_ms": 0.3338240087032318,
"p95_ms": 0.43724799156188965,
"p99_ms": 0.5171200037002563
},
"low_1pct_fps": 29.95597087331344,
"max_ms": 33.422760009765625,
"p50_ms": 0.8299460411071777,
"p95_ms": 33.33942413330078,
"p99_ms": 33.37727355957031,
"phases_avg_ms": {
"prepare": 0.7147062004496516,
"render": 0.14707977256612884,
"tick": 0.6855548198381525,
"update": 0.04897363143349227
},
"seconds": 11.96409825539589,
"slow_phases_avg_ms": {
"prepare": 0.8949875491006034,
"render": 0.6183000469491595,
"tick": 14.63794419879005,
"update": 0.8699657806594457
}
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 0,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 16462208
},
"village": {
"avg_fps": 1041.3622747953375,
"cpu": {
"avg_ms": 0.9549695556642304,
"max_ms": 26.009796142578125,
"p50_ms": 0.4810290038585663,
"p95_ms": 2.5506999492645264,
"p99_ms": 9.879941940307617
},
"frames": 12496,
"gpu": {
"avg_ms": 0.22781361336253403,
"max_ms": 1.7469439506530762,
"p50_ms": 0.18534399569034576,
"p95_ms": 0.6113280057907104,
"p99_ms": 0.809984028339386
},
"low_1pct_fps": 64.04348583959742,
"max_ms": 26.032779693603516,
"p50_ms": 0.48582199215888977,
"p95_ms": 2.5584940910339355,
"p99_ms": 9.892412185668945,
"phases_avg_ms": {
"prepare": 0.37678885421859964,
"render": 0.3450905326050295,
"tick": 0.1742891420732452,
"update": 0.0590072420405441
},
"seconds": 11.999666496902703,
"slow_phases_avg_ms": {
"prepare": 1.4649118003845214,
"render": 1.1414484503269196,
"tick": 12.881806396484375,
"update": 0.11011978423595428
}
},
"forest": {
"avg_fps": 316.2068527731055,
"cpu": {
"avg_ms": 3.154168446387541,
"max_ms": 26.147607803344727,
"p50_ms": 2.2156569957733154,
"p95_ms": 12.254712104797363,
"p99_ms": 17.7707462310791
},
"frames": 3795,
"gpu": {
"avg_ms": 0.7502970771905776,
"max_ms": 2.599936008453369,
"p50_ms": 0.7239680290222168,
"p95_ms": 1.3158400058746338,
"p99_ms": 1.3772799968719482
},
"low_1pct_fps": 49.59272936233826,
"max_ms": 26.164094924926758,
"p50_ms": 2.222438097000122,
"p95_ms": 12.270298957824707,
"p99_ms": 17.786956787109375,
"phases_avg_ms": {
"prepare": 1.5666706890613822,
"render": 0.7056146899387927,
"tick": 0.7469899302897249,
"update": 0.13521884845002838
},
"seconds": 12.001637430429458,
"slow_phases_avg_ms": {
"prepare": 1.9408628783727948,
"render": 1.194917630208166,
"tick": 16.762235842253034,
"update": 0.24904731876756014
}
},
"ocean": {
"avg_fps": 385.09978163469907,
"cpu": {
"avg_ms": 2.589322079316407,
"max_ms": 18.984909057617188,
"p50_ms": 2.0551369190216064,
"p95_ms": 8.740593910217285,
"p99_ms": 11.559625625610352
},
"frames": 4622,
"gpu": {
"avg_ms": 0.6778284030938654,
"max_ms": 2.398207902908325,
"p50_ms": 0.7833600044250488,
"p95_ms": 0.9431040287017822,
"p99_ms": 1.511423945426941
},
"low_1pct_fps": 71.97424221133898,
"max_ms": 19.0070858001709,
"p50_ms": 2.061105966567993,
"p95_ms": 8.753417015075684,
"p99_ms": 11.577004432678223,
"phases_avg_ms": {
"prepare": 1.3568510900633204,
"render": 0.7359144338432817,
"tick": 0.41578289517198963,
"update": 0.0810434000275639
},
"seconds": 12.002084188103677,
"slow_phases_avg_ms": {
"prepare": 1.6523635336693296,
"render": 1.2426407299143203,
"tick": 10.818658037388579,
"update": 0.16372418847489864
}
},
"mountain": {
"avg_fps": 219.70895893367268,
"cpu": {
"avg_ms": 3.098228604710274,
"max_ms": 23.518693923950195,
"p50_ms": 2.0422279834747314,
"p95_ms": 10.511551856994629,
"p99_ms": 16.436803817749023
},
"frames": 2634,
"gpu": {
"avg_ms": 0.9377313036310374,
"max_ms": 9.588735580444336,
"p50_ms": 0.8488960266113281,
"p95_ms": 0.9728000164031982,
"p99_ms": 3.4201600551605225
},
"low_1pct_fps": 29.957383952355947,
"max_ms": 33.38852310180664,
"p50_ms": 2.0481808185577393,
"p95_ms": 33.341556549072266,
"p99_ms": 33.377445220947266,
"phases_avg_ms": {
"prepare": 1.3405477057013806,
"render": 0.8202416233113071,
"tick": 0.8190081106798682,
"update": 0.11872799601335143
},
"seconds": 11.988587141752243,
"slow_phases_avg_ms": {
"prepare": 1.648652986243919,
"render": 1.7585702317732352,
"tick": 14.372832368921351,
"update": 1.0560209235107456
}
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 212,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 255897088
},
"village": {
"avg_fps": 48.3055081533635,
"cpu": {
"avg_ms": 9.376506657416572,
"max_ms": 861.2249145507812,
"p50_ms": 3.0502548217773438,
"p95_ms": 32.94725036621094,
"p99_ms": 74.04153442382812
},
"frames": 1271,
"gpu": {
"avg_ms": 1.7700931664342303,
"max_ms": 20.69094467163086,
"p50_ms": 1.2410880327224731,
"p95_ms": 7.598080158233643,
"p99_ms": 15.925248146057129
},
"low_1pct_fps": 0.761712600806078,
"max_ms": 14330.3583984375,
"p50_ms": 3.071429967880249,
"p95_ms": 33.099002838134766,
"p99_ms": 75.7547378540039,
"phases_avg_ms": {
"prepare": 1.518610580008767,
"render": 1.6828305973582713,
"tick": 5.15484752775804,
"update": 1.020680267595335
},
"seconds": 26.31169919514656,
"slow_phases_avg_ms": {
"prepare": 0.9654910931220422,
"render": 3.5708756630237284,
"tick": 196.6683132465069,
"update": 14.971596607795128
}
},
"forest": {
"avg_fps": 27.53318156189366,
"cpu": {
"avg_ms": 40.13187835841161,
"max_ms": 1166.3994140625,
"p50_ms": 6.5535569190979,
"p95_ms": 63.877315521240234,
"p99_ms": 1108.11865234375
},
"frames": 271,
"gpu": {
"avg_ms": 2.297342118961785,
"max_ms": 22.554624557495117,
"p50_ms": 0.7065600156784058,
"p95_ms": 7.245823860168457,
"p99_ms": 9.055232048034668
},
"low_1pct_fps": 0.8848759980013957,
"max_ms": 1166.4168701171875,
"p50_ms": 6.675865173339844,
"p95_ms": 59.150550842285156,
"p99_ms": 1108.134521484375,
"phases_avg_ms": {
"prepare": 2.83869483858017,
"render": 2.5961379855321343,
"tick": 33.04425997872419,
"update": 1.6535338119387186
},
"seconds": 9.842669267654419,
"slow_phases_avg_ms": {
"prepare": 2.766425689061483,
"render": 4.919464270273845,
"tick": 1112.3431803385417,
"update": 10.057182629903158
}
},
"ocean": {
"avg_fps": 67.01857611699128,
"cpu": {
"avg_ms": 14.669945189483148,
"max_ms": 113.66764068603516,
"p50_ms": 4.473541736602783,
"p95_ms": 51.374263763427734,
"p99_ms": 94.75230407714844
},
"frames": 802,
"gpu": {
"avg_ms": 2.9074973299021734,
"max_ms": 22.526975631713867,
"p50_ms": 1.8206720352172852,
"p95_ms": 8.869888305664062,
"p99_ms": 11.596799850463867
},
"low_1pct_fps": 10.111656570136391,
"max_ms": 113.68443298339844,
"p50_ms": 4.488469123840332,
"p95_ms": 51.39033508300781,
"p99_ms": 94.76870727539062,
"phases_avg_ms": {
"prepare": 2.4291724538862556,
"render": 2.242118674918006,
"tick": 9.334139847333992,
"update": 0.6651962902814967
},
"seconds": 11.966831384181976,
"slow_phases_avg_ms": {
"prepare": 2.7512698968251548,
"render": 3.2687110900878906,
"tick": 90.47323608398438,
"update": 2.3865103986528187
}
},
"mountain": {
"avg_fps": 28.70327462003782,
"cpu": {
"avg_ms": 16.062305691622306,
"max_ms": 67.72319793701172,
"p50_ms": 10.215509414672852,
"p95_ms": 45.747154235839844,
"p99_ms": 63.64399337768555
},
"frames": 345,
"gpu": {
"avg_ms": 8.470281650363535,
"max_ms": 21.519359588623047,
"p50_ms": 8.845312118530273,
"p95_ms": 15.868927955627441,
"p99_ms": 17.557504653930664
},
"low_1pct_fps": 15.182693348952107,
"max_ms": 67.74352264404297,
"p50_ms": 33.36533737182617,
"p95_ms": 46.64082336425781,
"p99_ms": 63.65925979614258,
"phases_avg_ms": {
"prepare": 0.8729904708654984,
"render": 3.4296869785889337,
"tick": 8.941314909811826,
"update": 2.8190046198558116
},
"seconds": 12.01953451538086,
"slow_phases_avg_ms": {
"prepare": 0.8918330073356628,
"render": 4.432150959968567,
"tick": 50.667978286743164,
"update": 9.854933142662048
}
}
}
},
"load_before": [
1.86328125,
2.111328125,
3.9072265625
],
"load_after": [
8.4560546875,
5.08544921875,
4.68798828125
]
}
+546
View File
@@ -0,0 +1,546 @@
{
"label": "after-lavapipe",
"date": "2026-10-13",
"commit": "9006566b",
"host": "ludodesktop",
"offscreen": false,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 156,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 1278284288
},
"village": {
"avg_fps": 58.3354572261441,
"cpu": {
"avg_ms": 11.363309368576322,
"max_ms": 36.24496841430664,
"p50_ms": 13.213258743286133,
"p95_ms": 19.535802841186523,
"p99_ms": 24.83148956298828
},
"frames": 700,
"gpu": {
"avg_ms": 13.518205827985492,
"max_ms": 34.97639083862305,
"p50_ms": 13.195829391479492,
"p95_ms": 18.039182662963867,
"p99_ms": 23.378856658935547
},
"low_1pct_fps": 31.312386939419746,
"max_ms": 36.36259078979492,
"p50_ms": 16.02706527709961,
"p95_ms": 21.249738693237305,
"p99_ms": 29.130090713500977,
"phases_avg_ms": {
"prepare": 0.2306461141364915,
"render": 9.720663217795746,
"tick": 1.3112367039202113,
"update": 0.10169148096282567
},
"seconds": 11.99956310081482,
"slow_phases_avg_ms": {
"prepare": 0.35282185673713684,
"render": 25.05345916748047,
"tick": 3.1022580585309436,
"update": 0.38026727948869976
}
},
"forest": {
"avg_fps": 41.90001789962103,
"cpu": {
"avg_ms": 18.045572770611635,
"max_ms": 36.546627044677734,
"p50_ms": 20.543725967407227,
"p95_ms": 30.5231990814209,
"p99_ms": 34.017364501953125
},
"frames": 504,
"gpu": {
"avg_ms": 21.35815803588383,
"max_ms": 36.23066329956055,
"p50_ms": 19.825437545776367,
"p95_ms": 29.274858474731445,
"p99_ms": 33.32203674316406
},
"low_1pct_fps": 20.022920779459263,
"max_ms": 99.08029174804688,
"p50_ms": 22.576229095458984,
"p95_ms": 32.772239685058594,
"p99_ms": 36.67753982543945,
"phases_avg_ms": {
"prepare": 0.22351377572686898,
"render": 16.234788014183916,
"tick": 1.4387834655649459,
"update": 0.14943464463281017
},
"seconds": 12.028634479522704,
"slow_phases_avg_ms": {
"prepare": 0.2705736731489499,
"render": 32.015567461649574,
"tick": 2.741411516830946,
"update": 0.3076988315830628
}
},
"ocean": {
"avg_fps": 60.413862302111134,
"cpu": {
"avg_ms": 10.874024665643429,
"max_ms": 29.430055618286133,
"p50_ms": 12.944002151489258,
"p95_ms": 17.997587203979492,
"p99_ms": 25.568126678466797
},
"frames": 725,
"gpu": {
"avg_ms": 12.736994123787715,
"max_ms": 28.143625259399414,
"p50_ms": 12.234904289245605,
"p95_ms": 15.61594295501709,
"p99_ms": 24.476089477539062
},
"low_1pct_fps": 34.82401357005238,
"max_ms": 33.36631393432617,
"p50_ms": 16.01299476623535,
"p95_ms": 18.74709701538086,
"p99_ms": 26.6020565032959,
"phases_avg_ms": {
"prepare": 0.4042872669162421,
"render": 9.401332649341946,
"tick": 0.9126873913477977,
"update": 0.15663292961387798
},
"seconds": 12.000557030677795,
"slow_phases_avg_ms": {
"prepare": 0.3962315060198307,
"render": 26.506223678588867,
"tick": 0.0020812499860767275,
"update": 0.5454612672328949
}
},
"mountain": {
"avg_fps": 37.77761368324693,
"cpu": {
"avg_ms": 11.491443304826092,
"max_ms": 34.474151611328125,
"p50_ms": 13.871685981750488,
"p95_ms": 20.351163864135742,
"p99_ms": 23.77434539794922
},
"frames": 453,
"gpu": {
"avg_ms": 14.109076927302953,
"max_ms": 35.115882873535156,
"p50_ms": 13.555350303649902,
"p95_ms": 18.336809158325195,
"p99_ms": 30.865131378173828
},
"low_1pct_fps": 27.343844611059406,
"max_ms": 39.0676155090332,
"p50_ms": 33.33863830566406,
"p95_ms": 33.36062240600586,
"p99_ms": 34.297752380371094,
"phases_avg_ms": {
"prepare": 0.22927819613457778,
"render": 9.350584785242217,
"tick": 1.7383085893987211,
"update": 0.17420387428015405
},
"seconds": 11.991228556632995,
"slow_phases_avg_ms": {
"prepare": 0.2908208012580872,
"render": 25.648976135253907,
"tick": 1.9043863375205548,
"update": 0.7624145835638046
}
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 0,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 36807552
},
"village": {
"avg_fps": 20.701493975921895,
"cpu": {
"avg_ms": 31.808356682459515,
"max_ms": 74.47195434570312,
"p50_ms": 41.86561584472656,
"p95_ms": 56.75548553466797,
"p99_ms": 73.00084686279297
},
"frames": 249,
"gpu": {
"avg_ms": 40.500388481140135,
"max_ms": 67.22461700439453,
"p50_ms": 40.38219451904297,
"p95_ms": 50.01243209838867,
"p99_ms": 62.00723648071289
},
"low_1pct_fps": 8.83914293384798,
"max_ms": 133.2972869873047,
"p50_ms": 47.37559509277344,
"p95_ms": 61.3382682800293,
"p99_ms": 74.61792755126953,
"phases_avg_ms": {
"prepare": 0.30830518620081215,
"render": 25.584145257750667,
"tick": 5.116575629957185,
"update": 0.8002672145165592
},
"seconds": 12.028117404937744,
"slow_phases_avg_ms": {
"prepare": 0.3641819953918457,
"render": 44.77838750680288,
"tick": 6.285618623097737,
"update": 22.415442685286205
}
},
"forest": {
"avg_fps": 18.118065350089793,
"cpu": {
"avg_ms": 36.827518428137545,
"max_ms": 85.7597427368164,
"p50_ms": 49.536197662353516,
"p95_ms": 62.31198501586914,
"p99_ms": 66.591064453125
},
"frames": 218,
"gpu": {
"avg_ms": 47.82228247616269,
"max_ms": 76.9173355102539,
"p50_ms": 46.98957824707031,
"p95_ms": 57.49673843383789,
"p99_ms": 69.92066192626953
},
"low_1pct_fps": 11.528998473378772,
"max_ms": 93.62928009033203,
"p50_ms": 53.71598434448242,
"p95_ms": 66.69908905029297,
"p99_ms": 80.71000671386719,
"phases_avg_ms": {
"prepare": 0.29564340877423595,
"render": 30.678313442326466,
"tick": 4.647872053591402,
"update": 1.206680115073099
},
"seconds": 12.032189739227295,
"slow_phases_avg_ms": {
"prepare": 0.37793900569279987,
"render": 66.15962092081706,
"tick": 5.583541075388591,
"update": 1.5741689602533977
}
},
"ocean": {
"avg_fps": 8.272097120557788,
"cpu": {
"avg_ms": 96.70907358733974,
"max_ms": 208.84027099609375,
"p50_ms": 89.04510498046875,
"p95_ms": 169.3397979736328,
"p99_ms": 208.84027099609375
},
"frames": 98,
"gpu": {
"avg_ms": 57.298700176939676,
"max_ms": 66.62165832519531,
"p50_ms": 57.89077377319336,
"p95_ms": 65.73504638671875,
"p99_ms": 66.62165832519531
},
"low_1pct_fps": 4.785729156618201,
"max_ms": 208.95457458496094,
"p50_ms": 140.0601348876953,
"p95_ms": 178.57022094726562,
"p99_ms": 208.95457458496094,
"phases_avg_ms": {
"prepare": 3.3946731230434106,
"render": 34.994675042982,
"tick": 57.14290783599932,
"update": 1.177816742536973
},
"seconds": 11.847056262969971,
"slow_phases_avg_ms": {
"prepare": 7.7776899337768555,
"render": 70.36097717285156,
"tick": 128.46734619140625,
"update": 2.2351090908050537
}
},
"mountain": {
"avg_fps": 11.65883600782651,
"cpu": {
"avg_ms": 56.6912090659987,
"max_ms": 107.11215209960938,
"p50_ms": 77.04230499267578,
"p95_ms": 97.60908508300781,
"p99_ms": 104.08527374267578
},
"frames": 141,
"gpu": {
"avg_ms": 70.81221792731486,
"max_ms": 96.33728790283203,
"p50_ms": 69.16046905517578,
"p95_ms": 86.61634063720703,
"p99_ms": 92.56858825683594
},
"low_1pct_fps": 4.93309783391495,
"max_ms": 216.64273071289062,
"p50_ms": 81.59197235107422,
"p95_ms": 104.20377349853516,
"p99_ms": 188.7820281982422,
"phases_avg_ms": {
"prepare": 0.31791428133105554,
"render": 45.35119773817401,
"tick": 9.391353438086544,
"update": 1.6317692605104852
},
"seconds": 12.093831657409668,
"slow_phases_avg_ms": {
"prepare": 0.348672017455101,
"render": 86.63534545898438,
"tick": 13.065274715423584,
"update": 5.5503233671188354
}
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 119,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 222619264
},
"village": {
"avg_fps": 5.310476400246329,
"cpu": {
"avg_ms": 111.2176975607872,
"max_ms": 224.16696166992188,
"p50_ms": 76.4469985961914,
"p95_ms": 218.82273864746094,
"p99_ms": 224.16696166992188
},
"frames": 64,
"gpu": {
"avg_ms": 121.35315322875977,
"max_ms": 171.11207580566406,
"p50_ms": 125.45780944824219,
"p95_ms": 166.71322631835938,
"p99_ms": 171.11207580566406
},
"low_1pct_fps": 3.59762228435401,
"max_ms": 277.96136474609375,
"p50_ms": 192.1078338623047,
"p95_ms": 232.86318969726562,
"p99_ms": 277.96136474609375,
"phases_avg_ms": {
"prepare": 0.524940284434706,
"render": 56.89460512250662,
"tick": 38.0364211499691,
"update": 15.762759290635586
},
"seconds": 12.051649452209473,
"slow_phases_avg_ms": {
"prepare": 0.5129500031471252,
"render": 147.3928985595703,
"tick": 62.813438415527344,
"update": 13.448556900024414
}
},
"forest": {
"avg_fps": 3.899515654388116,
"cpu": {
"avg_ms": 155.46855975211935,
"max_ms": 270.7467041015625,
"p50_ms": 83.60679626464844,
"p95_ms": 268.6576843261719,
"p99_ms": 270.7467041015625
},
"frames": 47,
"gpu": {
"avg_ms": 192.77972445280656,
"max_ms": 225.69723510742188,
"p50_ms": 189.4962615966797,
"p95_ms": 217.36190795898438,
"p99_ms": 225.69723510742188
},
"low_1pct_fps": 3.3773198460417087,
"max_ms": 296.0927734375,
"p50_ms": 259.49017333984375,
"p95_ms": 283.4007568359375,
"p99_ms": 296.0927734375,
"phases_avg_ms": {
"prepare": 0.5114446409205173,
"render": 99.58217374821926,
"tick": 43.03971359577585,
"update": 12.336258025879555
},
"seconds": 12.052778900146484,
"slow_phases_avg_ms": {
"prepare": 0.5626839995384216,
"render": 208.71124267578125,
"tick": 53.47932052612305,
"update": 7.994467258453369
}
},
"ocean": {
"avg_fps": 2.9455219461811217,
"cpu": {
"avg_ms": 287.98993862376494,
"max_ms": 464.7646484375,
"p50_ms": 286.4447326660156,
"p95_ms": 444.6121520996094,
"p99_ms": 464.7646484375
},
"frames": 34,
"gpu": {
"avg_ms": 116.96576734270369,
"max_ms": 158.26394653320312,
"p50_ms": 106.45819854736328,
"p95_ms": 158.1454620361328,
"p99_ms": 158.26394653320312
},
"low_1pct_fps": 2.151113067092828,
"max_ms": 464.8756103515625,
"p50_ms": 315.1367492675781,
"p95_ms": 456.3304138183594,
"p99_ms": 464.8756103515625,
"phases_avg_ms": {
"prepare": 4.395756518139558,
"render": 69.52432030789993,
"tick": 206.13154108384077,
"update": 7.93927874284632
},
"seconds": 11.542945739746093,
"slow_phases_avg_ms": {
"prepare": 4.144052982330322,
"render": 166.14959716796875,
"tick": 285.5132751464844,
"update": 8.958683967590332
}
},
"mountain": {
"avg_fps": 3.983176689759444,
"cpu": {
"avg_ms": 171.41208145466257,
"max_ms": 413.8367919921875,
"p50_ms": 164.3318328857422,
"p95_ms": 360.7731628417969,
"p99_ms": 413.8367919921875
},
"frames": 47,
"gpu": {
"avg_ms": 166.80018520355225,
"max_ms": 304.8785400390625,
"p50_ms": 143.19728088378906,
"p95_ms": 275.3953552246094,
"p99_ms": 304.8785400390625
},
"low_1pct_fps": 2.418344167836982,
"max_ms": 413.5060729980469,
"p50_ms": 230.7030029296875,
"p95_ms": 365.4028625488281,
"p99_ms": 413.5060729980469,
"phases_avg_ms": {
"prepare": 0.496992789684458,
"render": 94.4349257743105,
"tick": 61.33542941478973,
"update": 15.14569672117842
},
"seconds": 11.799627197265625,
"slow_phases_avg_ms": {
"prepare": 0.4748440086841583,
"render": 312.3215637207031,
"tick": 86.37409973144531,
"update": 14.667168617248535
}
}
}
},
"load_before": [
8.4560546875,
5.08544921875,
4.68798828125
],
"load_after": [
10.98291015625,
7.201171875,
5.5615234375
]
}
+392
View File
@@ -0,0 +1,392 @@
{
"label": "baseline-4080",
"date": "2026-10-13",
"commit": "27a32cc3",
"host": "ludodesktop",
"offscreen": true,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 158,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 1137747200
},
"village": {
"avg_fps": 362.94072531330323,
"cpu": {
"avg_ms": 2.734311161203731,
"max_ms": 68.06377410888672,
"p50_ms": 1.4820960760116577,
"p95_ms": 7.922675609588623,
"p99_ms": 35.78989028930664
},
"frames": 4373,
"gpu": {
"avg_ms": 0.4188921034758625,
"max_ms": 2.5876479148864746,
"p50_ms": 0.4106239974498749,
"p95_ms": 0.5283839702606201,
"p99_ms": 0.5427200198173523
},
"low_1pct_fps": 21.64332698060583,
"max_ms": 68.08219146728516,
"p50_ms": 1.4894599914550781,
"p95_ms": 7.936410427093506,
"p99_ms": 36.54151153564453,
"seconds": 12.048799418210983
},
"forest": {
"avg_fps": 613.1553113846195,
"cpu": {
"avg_ms": 1.6186122214214547,
"max_ms": 216.98960876464844,
"p50_ms": 1.10076105594635,
"p95_ms": 3.0016868114471436,
"p99_ms": 10.144570350646973
},
"frames": 6746,
"gpu": {
"avg_ms": 0.395751640401594,
"max_ms": 1.99782395362854,
"p50_ms": 0.3983359932899475,
"p95_ms": 0.4833280146121979,
"p99_ms": 0.5375999808311462
},
"low_1pct_fps": 49.639170314777054,
"max_ms": 217.0211181640625,
"p50_ms": 1.109616994857788,
"p95_ms": 3.0161218643188477,
"p99_ms": 10.163111686706543,
"seconds": 11.002106439828873
},
"ocean": {
"avg_fps": 323.23791637473414,
"cpu": {
"avg_ms": 2.7230400277200597,
"max_ms": 4748.7744140625,
"p50_ms": 0.9233829975128174,
"p95_ms": 1.677724003791809,
"p99_ms": 7.7397589683532715
},
"frames": 3870,
"gpu": {
"avg_ms": 0.24116999250656182,
"max_ms": 0.954367995262146,
"p50_ms": 0.2754560112953186,
"p95_ms": 0.3389439880847931,
"p99_ms": 0.3532800078392029
},
"low_1pct_fps": 5.421154869716093,
"max_ms": 4748.80615234375,
"p50_ms": 0.9285120368003845,
"p95_ms": 1.7079449892044067,
"p99_ms": 33.33257293701172,
"seconds": 11.97260532861948
},
"mountain": {
"avg_fps": 28.19080143244723,
"cpu": {
"avg_ms": 12.358911560308298,
"max_ms": 187.3409881591797,
"p50_ms": 9.513923645019531,
"p95_ms": 25.783824920654297,
"p99_ms": 110.71585845947266
},
"frames": 338,
"gpu": {
"avg_ms": 0.46002139662497143,
"max_ms": 1.5626239776611328,
"p50_ms": 0.4546560049057007,
"p95_ms": 0.5140479803085327,
"p99_ms": 0.5959680080413818
},
"low_1pct_fps": 7.428141780247131,
"max_ms": 187.36033630371094,
"p50_ms": 33.334835052490234,
"p95_ms": 35.65903854370117,
"p99_ms": 110.73509216308594,
"seconds": 11.98972653579712
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 1,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 54284928
},
"village": {
"avg_fps": 7.569165639843409,
"cpu": {
"avg_ms": 138.25648041658624,
"max_ms": 877.6473999023438,
"p50_ms": 103.97663879394531,
"p95_ms": 318.5262145996094,
"p99_ms": 877.6473999023438
},
"frames": 86,
"gpu": {
"avg_ms": 3.585262137790059,
"max_ms": 8.415231704711914,
"p50_ms": 3.299328088760376,
"p95_ms": 7.171072006225586,
"p99_ms": 8.415231704711914
},
"low_1pct_fps": 2.44461512120682,
"max_ms": 409.0623474121094,
"p50_ms": 104.01687622070312,
"p95_ms": 318.5569763183594,
"p99_ms": 409.0623474121094,
"seconds": 11.361886381149292
},
"forest": {
"avg_fps": 27.65529078792617,
"cpu": {
"avg_ms": 37.926895780927815,
"max_ms": 3484.08349609375,
"p50_ms": 3.398620128631592,
"p95_ms": 44.56385040283203,
"p99_ms": 684.4393920898438
},
"frames": 314,
"gpu": {
"avg_ms": 2.683724634467986,
"max_ms": 9.881600379943848,
"p50_ms": 1.8923519849777222,
"p95_ms": 7.221248149871826,
"p99_ms": 9.04089641571045
},
"low_1pct_fps": 0.4753349245465373,
"max_ms": 3484.115234375,
"p50_ms": 3.5420219898223877,
"p95_ms": 44.591251373291016,
"p99_ms": 159.3817901611328,
"seconds": 11.354066113710404
},
"ocean": {
"avg_fps": 59.16125632179682,
"cpu": {
"avg_ms": 16.807428855239674,
"max_ms": 106.0348129272461,
"p50_ms": 9.335607528686523,
"p95_ms": 44.57380294799805,
"p99_ms": 84.97154998779297
},
"frames": 712,
"gpu": {
"avg_ms": 1.958949391112736,
"max_ms": 8.635392189025879,
"p50_ms": 1.1182080507278442,
"p95_ms": 5.493760108947754,
"p99_ms": 7.108607769012451
},
"low_1pct_fps": 10.542641239411056,
"max_ms": 106.06544494628906,
"p50_ms": 9.364562034606934,
"p95_ms": 45.14366912841797,
"p99_ms": 84.9927749633789,
"seconds": 12.034903317928315
},
"mountain": {
"avg_fps": 26.78175419456072,
"cpu": {
"avg_ms": 20.230309660251326,
"max_ms": 128.05508422851562,
"p50_ms": 15.369550704956055,
"p95_ms": 67.15973663330078,
"p99_ms": 104.6961898803711
},
"frames": 325,
"gpu": {
"avg_ms": 7.143625645270714,
"max_ms": 21.25823974609375,
"p50_ms": 7.187456130981445,
"p95_ms": 12.228608131408691,
"p99_ms": 19.310592651367188
},
"low_1pct_fps": 7.777799947825389,
"max_ms": 155.30685424804688,
"p50_ms": 33.335182189941406,
"p95_ms": 68.94293975830078,
"p99_ms": 109.09304809570312,
"seconds": 12.135127431869506
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 0,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 4829184
},
"village": {
"avg_fps": 4.1477100728464,
"cpu": {
"avg_ms": 244.17284844846142,
"max_ms": 386.7664489746094,
"p50_ms": 244.8783416748047,
"p95_ms": 363.0765380859375,
"p99_ms": 386.7664489746094
},
"frames": 49,
"gpu": {
"avg_ms": 10.357822744213806,
"max_ms": 23.010303497314453,
"p50_ms": 9.227264404296875,
"p95_ms": 19.309568405151367,
"p99_ms": 23.010303497314453
},
"low_1pct_fps": 2.5853636290905797,
"max_ms": 386.79278564453125,
"p50_ms": 230.8652801513672,
"p95_ms": 363.0941162109375,
"p99_ms": 386.79278564453125,
"seconds": 11.813747619628906
},
"forest": {
"avg_fps": 8.3624969613463,
"cpu": {
"avg_ms": 118.74922356747165,
"max_ms": 278.8187255859375,
"p50_ms": 123.03407287597656,
"p95_ms": 231.6608123779297,
"p99_ms": 260.8450622558594
},
"frames": 101,
"gpu": {
"avg_ms": 15.898816661669477,
"max_ms": 72.880126953125,
"p50_ms": 20.789247512817383,
"p95_ms": 28.441600799560547,
"p99_ms": 60.816383361816406
},
"low_1pct_fps": 3.7057290596141406,
"max_ms": 278.83917236328125,
"p50_ms": 123.19290161132812,
"p95_ms": 231.68026733398438,
"p99_ms": 260.8656921386719,
"seconds": 12.077732340812684
},
"ocean": {
"avg_fps": 2.3954947141714045,
"cpu": {
"avg_ms": 424.7621285574777,
"max_ms": 625.8532104492188,
"p50_ms": 435.64044189453125,
"p95_ms": 577.7593994140625,
"p99_ms": 625.8532104492188
},
"frames": 28,
"gpu": {
"avg_ms": 9.483117733682905,
"max_ms": 12.973055839538574,
"p50_ms": 9.90719985961914,
"p95_ms": 12.610560417175293,
"p99_ms": 12.973055839538574
},
"low_1pct_fps": 1.5977632874291703,
"max_ms": 625.8749389648438,
"p50_ms": 433.9859619140625,
"p95_ms": 577.7803955078125,
"p99_ms": 625.8749389648438,
"seconds": 11.688608551025391
},
"mountain": {
"avg_fps": 3.3756775332390268,
"cpu": {
"avg_ms": 300.512197249975,
"max_ms": 407.4363708496094,
"p50_ms": 292.483642578125,
"p95_ms": 394.8424377441406,
"p99_ms": 407.4363708496094
},
"frames": 39,
"gpu": {
"avg_ms": 9.728866460995796,
"max_ms": 18.930688858032227,
"p50_ms": 8.820735931396484,
"p95_ms": 17.399808883666992,
"p99_ms": 18.930688858032227
},
"low_1pct_fps": 2.4541792422631716,
"max_ms": 407.46820068359375,
"p50_ms": 292.4177551269531,
"p95_ms": 394.8649597167969,
"p99_ms": 407.46820068359375,
"seconds": 11.553236236572266
}
}
}
}
+392
View File
@@ -0,0 +1,392 @@
{
"label": "baseline-lavapipe",
"date": "2026-10-13",
"commit": "27a32cc3",
"host": "ludodesktop",
"offscreen": false,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 146,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 1288851072
},
"village": {
"avg_fps": 16.38225374384963,
"cpu": {
"avg_ms": 48.17248754105975,
"max_ms": 134.31028747558594,
"p50_ms": 44.57072067260742,
"p95_ms": 97.15985870361328,
"p99_ms": 127.31073760986328
},
"frames": 199,
"gpu": {
"avg_ms": 26.732369270324707,
"max_ms": 47.3107795715332,
"p50_ms": 26.284116744995117,
"p95_ms": 35.006874084472656,
"p99_ms": 39.38005828857422
},
"low_1pct_fps": 6.569673692138871,
"max_ms": 169.97352600097656,
"p50_ms": 56.791988372802734,
"p95_ms": 109.15220642089844,
"p99_ms": 134.45559692382812,
"seconds": 12.147290788650512
},
"forest": {
"avg_fps": 19.917172468484612,
"cpu": {
"avg_ms": 31.039839970568817,
"max_ms": 106.39506530761719,
"p50_ms": 33.26064682006836,
"p95_ms": 70.09876251220703,
"p99_ms": 86.21255493164062
},
"frames": 240,
"gpu": {
"avg_ms": 37.272586759195285,
"max_ms": 77.586669921875,
"p50_ms": 34.38500213623047,
"p95_ms": 59.649635314941406,
"p99_ms": 66.52396392822266
},
"low_1pct_fps": 9.909291086862112,
"max_ms": 106.57542419433594,
"p50_ms": 46.05862808227539,
"p95_ms": 80.38935852050781,
"p99_ms": 94.94140625,
"seconds": 12.049903186798096
},
"ocean": {
"avg_fps": 27.969763129786234,
"cpu": {
"avg_ms": 25.69895526429727,
"max_ms": 930.7354125976562,
"p50_ms": 18.981081008911133,
"p95_ms": 39.42117691040039,
"p99_ms": 168.07225036621094
},
"frames": 336,
"gpu": {
"avg_ms": 18.856842384451912,
"max_ms": 57.835872650146484,
"p50_ms": 18.172868728637695,
"p95_ms": 28.62451171875,
"p99_ms": 44.414398193359375
},
"low_1pct_fps": 1.527565223414601,
"max_ms": 930.8734741210938,
"p50_ms": 26.45800018310547,
"p95_ms": 50.053672790527344,
"p99_ms": 180.92478942871094,
"seconds": 12.012972667694092
},
"mountain": {
"avg_fps": 26.5942562164059,
"cpu": {
"avg_ms": 22.482411369495093,
"max_ms": 104.89199829101562,
"p50_ms": 22.033512115478516,
"p95_ms": 46.385555267333984,
"p99_ms": 61.15697479248047
},
"frames": 320,
"gpu": {
"avg_ms": 23.762589550018312,
"max_ms": 48.10239028930664,
"p50_ms": 22.580347061157227,
"p95_ms": 35.14606475830078,
"p99_ms": 42.47938919067383
},
"low_1pct_fps": 11.197907022053611,
"max_ms": 105.07585906982422,
"p50_ms": 33.362815856933594,
"p95_ms": 53.36695098876953,
"p99_ms": 74.80219268798828,
"seconds": 12.03267342376709
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 1,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 16106880
},
"village": {
"avg_fps": 10.754658718707336,
"cpu": {
"avg_ms": 69.22208800682655,
"max_ms": 193.21267700195312,
"p50_ms": 66.95130157470703,
"p95_ms": 154.44834899902344,
"p99_ms": 191.29208374023438
},
"frames": 130,
"gpu": {
"avg_ms": 49.041961078495945,
"max_ms": 73.83775329589844,
"p50_ms": 48.90928268432617,
"p95_ms": 60.60416793823242,
"p99_ms": 66.1044921875
},
"low_1pct_fps": 4.171866301284614,
"max_ms": 252.21458435058594,
"p50_ms": 79.8940200805664,
"p95_ms": 173.05947875976562,
"p99_ms": 227.18716430664062,
"seconds": 12.087784782409669
},
"forest": {
"avg_fps": 12.167278778001634,
"cpu": {
"avg_ms": 58.366991678873696,
"max_ms": 156.683349609375,
"p50_ms": 69.24014282226562,
"p95_ms": 110.83757019042969,
"p99_ms": 134.16554260253906
},
"frames": 147,
"gpu": {
"avg_ms": 56.45229888606716,
"max_ms": 78.26596069335938,
"p50_ms": 55.92947006225586,
"p95_ms": 63.54412841796875,
"p99_ms": 66.5202407836914
},
"low_1pct_fps": 5.722095670390665,
"max_ms": 212.99729919433594,
"p50_ms": 77.36986541748047,
"p95_ms": 122.84784698486328,
"p99_ms": 136.52499389648438,
"seconds": 12.081583950042724
},
"ocean": {
"avg_fps": 7.681804845753147,
"cpu": {
"avg_ms": 98.35419226740743,
"max_ms": 385.2493591308594,
"p50_ms": 78.85044860839844,
"p95_ms": 225.36231994628906,
"p99_ms": 385.2493591308594
},
"frames": 91,
"gpu": {
"avg_ms": 71.51263585297957,
"max_ms": 128.29212951660156,
"p50_ms": 67.92412567138672,
"p95_ms": 101.93219757080078,
"p99_ms": 128.29212951660156
},
"low_1pct_fps": 2.5948566279945817,
"max_ms": 385.37774658203125,
"p50_ms": 123.91693878173828,
"p95_ms": 225.49420166015625,
"p99_ms": 385.37774658203125,
"seconds": 11.846174411773681
},
"mountain": {
"avg_fps": 5.886844433101332,
"cpu": {
"avg_ms": 124.87309415575484,
"max_ms": 321.2157897949219,
"p50_ms": 117.2811050415039,
"p95_ms": 237.34304809570312,
"p99_ms": 321.2157897949219
},
"frames": 71,
"gpu": {
"avg_ms": 95.46965434816148,
"max_ms": 166.9488067626953,
"p50_ms": 83.54202270507812,
"p95_ms": 160.2403564453125,
"p99_ms": 166.9488067626953
},
"low_1pct_fps": 3.111272818975726,
"max_ms": 321.411865234375,
"p50_ms": 153.0963134765625,
"p95_ms": 275.37579345703125,
"p99_ms": 321.411865234375,
"seconds": 12.060790939331055
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 1,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 19154560
},
"village": {
"avg_fps": 2.5956714101148957,
"cpu": {
"avg_ms": 345.63314671670236,
"max_ms": 474.2013854980469,
"p50_ms": 347.07415771484375,
"p95_ms": 468.0090026855469,
"p99_ms": 474.2013854980469
},
"frames": 31,
"gpu": {
"avg_ms": 85.50063645839691,
"max_ms": 134.11460876464844,
"p50_ms": 82.40946960449219,
"p95_ms": 133.71929931640625,
"p99_ms": 134.11460876464844
},
"low_1pct_fps": 2.01406006678458,
"max_ms": 496.509521484375,
"p50_ms": 390.230224609375,
"p95_ms": 493.8070373535156,
"p99_ms": 496.509521484375,
"seconds": 11.942960067749024
},
"forest": {
"avg_fps": 2.2283930626963917,
"cpu": {
"avg_ms": 354.7813931978666,
"max_ms": 611.8099975585938,
"p50_ms": 293.61376953125,
"p95_ms": 601.3502197265625,
"p99_ms": 611.8099975585938
},
"frames": 26,
"gpu": {
"avg_ms": 201.54420654296874,
"max_ms": 357.0310363769531,
"p50_ms": 172.65980529785156,
"p95_ms": 341.8655700683594,
"p99_ms": 357.0310363769531
},
"low_1pct_fps": 1.52339962842959,
"max_ms": 656.4265747070312,
"p50_ms": 407.1051330566406,
"p95_ms": 611.97119140625,
"p99_ms": 656.4265747070312,
"seconds": 11.667600494384766
},
"ocean": {
"avg_fps": 2.6137193379326398,
"cpu": {
"avg_ms": 330.783615620931,
"max_ms": 730.7304077148438,
"p50_ms": 296.65692138671875,
"p95_ms": 680.8717041015625,
"p99_ms": 730.7304077148438
},
"frames": 30,
"gpu": {
"avg_ms": 113.80178985595703,
"max_ms": 216.57342529296875,
"p50_ms": 104.23703002929688,
"p95_ms": 193.67672729492188,
"p99_ms": 216.57342529296875
},
"low_1pct_fps": 1.1784160273210693,
"max_ms": 848.5967407226562,
"p50_ms": 346.120849609375,
"p95_ms": 681.0716552734375,
"p99_ms": 848.5967407226562,
"seconds": 11.477896484375
},
"mountain": {
"avg_fps": 2.72441042065798,
"cpu": {
"avg_ms": 325.24547207740045,
"max_ms": 578.7177124023438,
"p50_ms": 295.25457763671875,
"p95_ms": 569.8099975585938,
"p99_ms": 578.7177124023438
},
"frames": 31,
"gpu": {
"avg_ms": 104.99177145957947,
"max_ms": 163.95635986328125,
"p50_ms": 99.96784210205078,
"p95_ms": 160.04824829101562,
"p99_ms": 163.95635986328125
},
"low_1pct_fps": 1.7544071931551564,
"max_ms": 569.9931030273438,
"p50_ms": 378.246337890625,
"p95_ms": 555.5369873046875,
"p99_ms": 569.9931030273438,
"seconds": 11.378608657836914
}
}
}
}
@@ -0,0 +1,69 @@
# Reuse parsed model states
`models::prepare` parsed `model-states.json` to select blocks, then
`StateModels::load` reopened and parsed the same document for binding and baking.
`StateModels::from_facts` now borrows the first parsed map. The public `load`
entry point still reads, parses and validates its own document. Both paths retain
the `fs::read` + `serde_json::from_slice` strategy merged in PR #205.
The shared map uses `BTreeMap`, preserving the original catalog loader's JSON
validation, including rejection of top-level `null`. The old selection map was
`serde_json::Map`, also backed by a BTreeMap with the project's current features.
Catalog binding, model selection and baking are unchanged. Preparation no longer
holds a second complete JSON tree while constructing the catalog; no persistent
cache or extra clones were added.
## Measurement
The committed `model_states_bench` example runs main's two-parse sequence after PR #205
(first a serde JSON map, then `StateModels::load`) and the new one-parse sequence
(first a BTreeMap, then `StateModels::from_facts`). Both select blocks from the
same generated document with slice parsing and call the actual catalog binding/baking code. Timing
includes file reads, parsing, filtering, binding, baking and result destruction.
Fixture generation and equality checks run outside the timed region.
Prepared Minecraft resources were unavailable locally, so this is a synthetic
2,836,696-byte document containing 843 block schemas and 53,952 states. Schemas
combine integer and named properties. Blockstate resources use weighted and
conditional multipart choices with rotations and UV locking, sharing one model
with a baked face. Its absent texture uses the normal missing-sprite fallback.
Both paths read identical files with a warm OS cache. The workload does not
represent the full diversity of production model and texture assets.
On AMD Ryzen 5 5600X, Linux 7.2.8-zen1-2-zen x86_64, rustc 1.99.0
(b940084d7 2026-09-28), Cargo `fast` profile, serial execution:
| Path | Median (15 samples) |
| --- | ---: |
| Before: parse twice, with #205 slice readers | 96.080 ms |
| After: reuse first parse, with #205 slice readers | 73.585 ms |
**1.306x speedup; 23.41% less elapsed time** for this loading segment after PR #205. Each path
has two warmups, sample order alternates, and inputs/results use `black_box`.
This excludes gallery placements, GPU work and overall application startup.
These measurements supersede the original unbuffered-reader timings
(2732.004 ms → 1386.060 ms, 1.971x) after rebasing onto main at
`e96e1571`. The reviewer measured approximately 1.05x with real prepared resources;
the generated fixture's balance of parsing and binding differs from production.
The change still removes one complete document read, parse and JSON tree.
Equality validation compares every state's selected variants and baked geometry
at three positions, plus sprite layers and opacity/translucency flags. The
focused test also checks matching schema/registry errors, malformed/missing
source files, rejection of `null`, and successful shared-facts loading after the
source file has been deleted.
## Reproduce
```sh
export CARGO_TARGET_DIR=/home/bob/Documents/horizon/target
export CARGO_INCREMENTAL=0
cargo run -p horizon-client --profile fast --example model_states_bench -- 15
cargo test -p horizon-client --profile fast --example model_states_bench
cargo clippy -p horizon-client --all-targets --profile fast -- -D warnings
cargo fmt --all -- --check
```
The target directory can be changed for another machine. The example creates and
removes its fixtures in the system temporary directory.
@@ -0,0 +1,54 @@
{
"date": "2026-10-15",
"environment": "Linux x86_64/ext4/NVMe, Ryzen5 5600X, Rust1.99.0, Cargo fast",
"scope": "actual resource_root vs original wrapper/builder; warmed storage; output equivalence outside timing",
"warmups": 2,
"median_samples": 11,
"prepared_minecraft": "26.3",
"prepared_schema": 7,
"prepared_manifest_sha256": "fd03c686302748748e132ef3978b7b0bcf42024257e802985a5cc7175cc74298",
"packs": [
{
"project": "faithful-32x",
"version": "lDYpMiqk",
"version_name": "[26.3] Faithful 32x \u2013 September 2026 Release",
"publisher_page": "https://modrinth.com/resourcepack/faithful-32x/version/lDYpMiqk",
"download_url": "https://cdn.modrinth.com/data/w0TnApzs/versions/lDYpMiqk/Faithful%2032x%20-%2026.3.zip",
"zip_bytes": 13116019,
"zip_sha512": "e90ec63af7f239dbb99aa20c98b23d830e76898f65d3e31b7f0b53bb36db12dcd8c38d6930c743237c9c3db7bf708423fa1d1b2fd497dd6c281dbe216b779db9",
"asset_files": 5063,
"assets_uncompressed_bytes": 12524266,
"original_ms": 159.827,
"cold_ms": 162.711,
"hit_ms": 0.707
},
{
"project": "default-hd-128x",
"version": "rErHu3BE",
"version_name": "Default HD 128x Demo/Alpha 1.8.2.7",
"publisher_page": "https://modrinth.com/resourcepack/default-hd-128x/version/rErHu3BE",
"download_url": "https://cdn.modrinth.com/data/orfTV6nk/versions/rErHu3BE/Default%20HD%20128x%20Demo%201.8.2.7.zip",
"zip_bytes": 16876419,
"zip_sha512": "f5f9606e92b986cd9c8b8e5ff2bb52db720cc7b38e013355bdc90089e01975a08d09750ef2fd727f753d88ec3fbcd067355179b490587f667b2995f8be81640f",
"asset_files": 2318,
"assets_uncompressed_bytes": 17315416,
"original_ms": 111.501,
"cold_ms": 114.386,
"hit_ms": 0.31
},
{
"project": "fpbr",
"version": "dynf82xk",
"version_name": "support 1.21.9 and newer",
"publisher_page": "https://modrinth.com/resourcepack/fpbr/version/dynf82xk",
"download_url": "https://cdn.modrinth.com/data/nvEpbd8t/versions/dynf82xk/FPBR-Main-1.21.9-R1.3.zip",
"zip_bytes": 46737770,
"zip_sha512": "64f1bbd036e205118f1d9c49a79558db2c1356ee988fb939c0023db1967d78e1bd05047f341ce6d65355717ba7ddb1330af57edbb4d5b8713c96ae62987274c5",
"asset_files": 7001,
"assets_uncompressed_bytes": 56356903,
"original_ms": 347.639,
"cold_ms": 348.012,
"hit_ms": 0.448
}
]
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "cargo-horizon-mod"
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)"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
serde_json = "1.0.151"
[lints]
workspace = true
+201
View File
@@ -0,0 +1,201 @@
//! `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/`.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
//! the mod; run `build` again and the game reloads it.
use horizon_mods::{
Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
};
use std::{
path::{Path, PathBuf},
process::{Command, ExitCode, Stdio},
};
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
let mut args: Vec<String> = std::env::args().skip(1).collect();
if args.first().is_some_and(|a| a == "horizon-mod") {
args.remove(0);
}
let result = match args.first().map(String::as_str) {
Some("new") => new(&args[1..]),
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
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()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
/// The value after `flag` in `args`.
fn flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
let i = args.iter().position(|a| a == flag)?;
args.get(i + 1).map(String::as_str)
}
fn new(args: &[String]) -> Result<(), String> {
let path = args
.first()
.filter(|a| !a.starts_with("--"))
.ok_or("cargo horizon-mod new <path>")?;
let dir = Path::new(path);
if dir.exists() {
return Err(format!("{} already exists", dir.display()));
}
let base = dir
.file_name()
.and_then(|n| n.to_str())
.ok_or("the path needs a folder name")?;
let id: String = base
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let sdk = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
format!("{{ path = {:?} }}", p.display().to_string())
}
None => SDK_GIT.to_string(),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
};
let files = [
("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 {
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())?)?;
println!(
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
dir.display()
);
Ok(())
}
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
fn build(release: bool) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(MANIFEST)
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
let manifest = Manifest::parse(&manifest_text)?;
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.args([
"build",
"--lib",
"--target",
TARGET,
"--message-format=json-render-diagnostics",
]);
if release {
cmd.arg("--release");
}
let out = cmd
.stderr(Stdio::inherit())
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(format!(
"cargo build failed (is the target installed? rustup target add {TARGET})"
));
}
let core = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|m| m["reason"] == "compiler-artifact")
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
.filter_map(|f| f.as_str().map(PathBuf::from))
.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)
}
fn run(args: &[String]) -> Result<(), String> {
let path = build(true)?;
let game = flag(args, "--game")
.map(str::to_string)
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
.unwrap_or_else(|| "horizon-client".into());
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
println!(
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
path.display()
);
let status = Command::new(&game)
.arg("--dev-mod")
.arg(&path)
.args(extra)
.status()
.map_err(|e| {
format!("{game}: {e} (pass --game <path to horizon-client> or set HORIZON_CLIENT)")
})?;
status
.success()
.then_some(())
.ok_or_else(|| format!("the game exited with {status}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
let path = dir.join("My-Mod");
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(
(manifest.id.as_str(), manifest.name.as_str()),
("my_mod", "My Mod")
);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
assert!(
new(&[path.display().to_string()]).is_err(),
"never overwrites"
);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -0,0 +1,14 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
horizon-mod-sdk = {sdk}
[profile.release]
opt-level = "s"
lto = true
@@ -0,0 +1 @@
/target
@@ -0,0 +1,12 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the SDK targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -0,0 +1,19 @@
use horizon_mod_sdk::{Mod, Registration, chat};
struct MyMod {
greeted: u32,
}
impl Mod for MyMod {
fn init() -> Result<(Self, Registration), String> {
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
}
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
self.greeted += 1;
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
Ok(None)
}
}
horizon_mod_sdk::export_mod!(MyMod);
+36 -18
View File
@@ -50,29 +50,47 @@ impl ModelLibrary {
/// A library that also knows `builtin/generated`, the parent of every
/// flat item model; the native client registers it as a special model.
pub fn with_item_generator(mut models: BTreeMap<String, CuboidModel>) -> Result<Self> {
models.insert(
GENERATED_MODEL.to_owned(),
CuboidModel {
parent: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
"particle".to_owned(),
TextureSlot::Reference("layer0".to_owned()),
)]),
elements: None,
display: None,
},
);
models.insert(GENERATED_MODEL.to_owned(), Self::item_generator_model());
Self::new(models)
}
/// Register the item built-in while reusing already decoded resources.
pub fn with_shared_item_generator(
mut models: BTreeMap<String, Arc<CuboidModel>>,
) -> Result<Self> {
models.insert(
GENERATED_MODEL.to_owned(),
Arc::new(Self::item_generator_model()),
);
Self::from_shared(models)
}
fn item_generator_model() -> CuboidModel {
CuboidModel {
parent: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
"particle".to_owned(),
TextureSlot::Reference("layer0".to_owned()),
)]),
elements: None,
display: None,
}
}
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
Self::from_models(
models
.into_iter()
.map(|(name, model)| (name, Arc::new(model))),
)
}
/// Build an immutable resolution view without copying model geometry.
pub fn from_shared(models: BTreeMap<String, Arc<CuboidModel>>) -> Result<Self> {
Self::from_models(models)
}
fn from_models(models: impl IntoIterator<Item = (String, Arc<CuboidModel>)>) -> Result<Self> {
let mut decoded = BTreeMap::new();
for (name, model) in models {
if decoded
.insert(identifier(&name)?, Arc::new(model))
.is_some()
{
if decoded.insert(identifier(&name)?, model).is_some() {
return Err(error("duplicate normalized model identifier"));
}
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "horizon-bench"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "The benchmark report format (docs/benchmark-report.md), shared by the game, the launcher and the upload"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+259
View File
@@ -0,0 +1,259 @@
//! 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 serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Hardware {
/// `macos`, `linux`, `windows`.
pub os: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os_version: Option<String>,
pub arch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_threads: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ram_mb: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu: Option<Gpu>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display: Option<Display>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Gpu {
pub name: String,
pub vendor: String,
pub driver: String,
/// `metal`, `vulkan`, `dx12`, `gl`.
pub backend: String,
/// `integrated`, `discrete`, `virtual`, `cpu`, `other`.
pub kind: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Display {
/// Physical pixels.
pub width: u32,
pub height: u32,
pub scale: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_hz: Option<u32>,
}
impl Hardware {
pub fn detect() -> Hardware {
Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
ram_mb: ram_bytes().map(|b| b >> 20),
gpu: None,
display: None,
}
}
}
/// The one-minute load average: above about the number of threads the machine was busy.
pub fn load_average() -> Option<f64> {
if cfg!(target_os = "macos") {
// "{ 1.23 1.10 1.00 }"
output("sysctl", &["-n", "vm.loadavg"])?
.trim_start_matches('{')
.split_whitespace()
.next()?
.parse()
.ok()
} else {
std::fs::read_to_string("/proc/loadavg")
.ok()?
.split_whitespace()
.next()?
.parse()
.ok()
}
}
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
if cfg!(target_os = "macos") {
let batt = output("pmset", &["-g", "batt"])?;
return Some(if batt.contains("'Battery Power'") {
"battery"
} else {
"ac"
});
}
let mut on_battery = None;
for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
let read =
|name: &str| std::fs::read_to_string(entry.path().join(name)).unwrap_or_default();
match read("type").trim() {
"Mains" if read("online").trim() == "1" => return Some("ac"),
"Battery" => on_battery = Some("battery"),
_ => {}
}
}
on_battery
}
/// Resident memory of a process, in bytes (macOS, Linux).
pub fn rss_bytes(pid: u32) -> Option<u64> {
if cfg!(target_os = "macos") {
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024);
}
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
line.split_whitespace()
.nth(1)?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024)
}
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
pub fn cpu_seconds(pid: u32) -> Option<f64> {
if cfg!(target_os = "macos") {
// "[[dd-]hh:]mm:ss.cc"
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
let (rest, days) = match time.split_once('-') {
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
None => (time, 0.0),
};
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
})?;
return Some(days * 86_400.0 + seconds);
}
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
Some(ticks / 100.0)
}
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"])
}
#[cfg(target_os = "macos")]
fn cpu() -> Option<String> {
output("sysctl", &["-n", "machdep.cpu.brand_string"])
}
#[cfg(target_os = "macos")]
fn ram_bytes() -> Option<u64> {
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
}
#[cfg(target_os = "linux")]
fn os_version() -> Option<String> {
let release = std::fs::read_to_string("/etc/os-release").ok()?;
let name = key_value(&release, "PRETTY_NAME", '=')?
.trim_matches('"')
.to_owned();
Some(match output("uname", &["-r"]) {
Some(kernel) => format!("{name} (kernel {kernel})"),
None => name,
})
}
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; ARM boards name the machine once.
key_value(&info, "model name", ':')
.or_else(|| key_value(&info, "Model", ':'))
.map(str::to_owned)
}
#[cfg(target_os = "linux")]
fn ram_bytes() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let kb = key_value(&info, "MemTotal", ':')?
.trim_end_matches("kB")
.trim();
kb.parse::<u64>().ok().map(|kb| kb * 1024)
}
#[cfg(target_os = "linux")]
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
text.lines().find_map(|line| {
let (k, v) = line.split_once(sep)?;
(k.trim() == key).then(|| v.trim())
})
}
#[cfg(target_os = "windows")]
fn os_version() -> Option<String> {
output("cmd", &["/C", "ver"])
}
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
std::env::var("PROCESSOR_IDENTIFIER").ok()
}
#[cfg(target_os = "windows")]
fn ram_bytes() -> Option<u64> {
let out = output(
"powershell",
&[
"-NoProfile",
"-Command",
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
],
)?;
out.parse().ok()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn os_version() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn cpu() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn ram_bytes() -> Option<u64> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_this_machine() {
let hw = Hardware::detect();
assert_eq!(hw.os, std::env::consts::OS);
assert!(hw.cpu_threads.unwrap() >= 1);
if cfg!(any(target_os = "macos", target_os = "linux")) {
assert!(hw.cpu.is_some(), "{hw:?}");
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
assert!(hw.os_version.is_some(), "{hw:?}");
assert!(load_average().is_some());
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
}
+457
View File
@@ -0,0 +1,457 @@
//! The benchmark report format, version 1 (docs/benchmark-report.md).
//!
//! One JSON file per run, the same for the game, the launcher and `tools/perf_bench.py`: the local log
//! a player can attach, and, with consent, what the telemetry upload sends ([`Report::for_upload`]).
//! It holds summaries and fixed-edge histogram counts, never raw frame streams.
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
mod hardware;
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
pub const FORMAT: &str = "horizon-benchmark-report";
pub const VERSION: u32 = 1;
/// Lower edges of the frame-time histogram buckets, in milliseconds; the last bucket is 1000 ms and
/// over. Fixed by the format version so histograms from any number of reports add up.
pub const HISTOGRAM_EDGES_MS: [f64; 24] = [
0.0, 1.0, 2.0, 3.0, 4.17, 5.0, 6.0, 7.0, 8.33, 10.0, 12.0, 14.0, 16.67, 20.0, 25.0, 33.33,
40.0, 50.0, 66.67, 100.0, 150.0, 250.0, 500.0, 1000.0,
];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Game,
Launcher,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct App {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mods: Vec<Mod>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mod {
pub name: String,
pub version: String,
}
/// What has to match for two reports to be compared. Every field is optional.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Conditions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scenario: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub render_distance: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub simulation_distance: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window: Option<[u32; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vsync: Option<bool>,
/// Absent: unlimited.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fps_cap: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offscreen: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repeats: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warmup_s: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub power: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub load_avg_1m: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<String>,
}
/// A measurement over a run's repeats.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Summary {
pub n: usize,
pub mean: f64,
/// Sample standard deviation; `None` for a single value.
pub stdev: Option<f64>,
pub min: f64,
pub max: f64,
/// Half-width of the 95% confidence interval of the mean (Student's t); `None` for a single value.
pub ci95: Option<f64>,
}
impl Summary {
/// `None` for no values.
pub fn of(values: &[f64]) -> Option<Summary> {
let n = values.len();
if n == 0 {
return None;
}
let mean = values.iter().sum::<f64>() / n as f64;
let (stdev, ci95) = if n > 1 {
let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
let sd = var.sqrt();
(Some(sd), Some(t95(n - 1) * sd / (n as f64).sqrt()))
} else {
(None, None)
};
Some(Summary {
n,
mean: round(mean),
stdev: stdev.map(round),
min: round(values.iter().copied().fold(f64::INFINITY, f64::min)),
max: round(values.iter().copied().fold(f64::NEG_INFINITY, f64::max)),
ci95: ci95.map(round),
})
}
}
/// Two-sided 95% critical value of Student's t for `df` degrees of freedom.
fn t95(df: usize) -> f64 {
const TABLE: [f64; 30] = [
12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, 2.201, 2.179, 2.160,
2.145, 2.131, 2.120, 2.110, 2.101, 2.093, 2.086, 2.080, 2.074, 2.069, 2.064, 2.060, 2.056,
2.052, 2.048, 2.045, 2.042,
];
TABLE.get(df.wrapping_sub(1)).copied().unwrap_or(1.96)
}
/// One measured phase of one repeat.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Frames {
pub phase: String,
pub repeat: u32,
pub frames: usize,
pub seconds: f64,
pub avg_fps: f64,
pub low_1pct_fps: f64,
pub low_01pct_fps: f64,
pub p50_ms: f64,
pub p95_ms: f64,
pub p99_ms: f64,
pub p999_ms: f64,
pub max_ms: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_p50_ms: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu_p50_ms: Option<f64>,
/// Frame counts per bucket of [`HISTOGRAM_EDGES_MS`].
pub histogram: Vec<u64>,
}
impl Frames {
/// The figures of a phase from its frame times in milliseconds (time between the starts of
/// consecutive frames); `None` for no frames. CPU and GPU times are per frame too, when measured.
pub fn of(
phase: &str,
repeat: u32,
frame_ms: &[f32],
cpu_ms: &[f32],
gpu_ms: &[f32],
) -> Option<Frames> {
let times = sorted(frame_ms);
let n = times.len();
if n == 0 {
return None;
}
let total: f64 = times.iter().sum();
// The average frame time of the slowest `share` of frames, as frames per second.
let low = |share: f64| {
let slowest = &times[n - ((n as f64 * share).ceil() as usize).max(1)..];
1000.0 * slowest.len() as f64 / slowest.iter().sum::<f64>()
};
let mut histogram = vec![0; HISTOGRAM_EDGES_MS.len()];
for &t in &times {
histogram[HISTOGRAM_EDGES_MS.partition_point(|&edge| edge <= t).max(1) - 1] += 1;
}
Some(Frames {
phase: phase.to_owned(),
repeat,
frames: n,
seconds: round(total / 1000.0),
avg_fps: round(n as f64 * 1000.0 / total),
low_1pct_fps: round(low(0.01)),
low_01pct_fps: round(low(0.001)),
p50_ms: round(percentile(&times, 0.5)),
p95_ms: round(percentile(&times, 0.95)),
p99_ms: round(percentile(&times, 0.99)),
p999_ms: round(percentile(&times, 0.999)),
max_ms: round(times[n - 1]),
cpu_p50_ms: (!cpu_ms.is_empty()).then(|| round(percentile(&sorted(cpu_ms), 0.5))),
gpu_p50_ms: (!gpu_ms.is_empty()).then(|| round(percentile(&sorted(gpu_ms), 0.5))),
histogram,
})
}
}
fn sorted(ms: &[f32]) -> Vec<f64> {
let mut v: Vec<f64> = ms
.iter()
.map(|&m| f64::from(m))
.filter(|m| m.is_finite())
.collect();
v.sort_by(f64::total_cmp);
v
}
/// Three decimals: plenty for milliseconds and FPS, short in JSON, and parsed back exactly.
fn round(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
/// Nearest-rank percentile of sorted, non-empty values.
fn percentile(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
sorted[((n as f64 * p).ceil() as usize).clamp(1, n) - 1]
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Report {
pub format: String,
pub version: u32,
pub kind: Kind,
/// When the run ended, UTC, `YYYY-MM-DDTHH:MM:SSZ`.
pub created: String,
pub app: App,
pub hardware: Hardware,
#[serde(default)]
pub conditions: Conditions,
/// Scalar measurements by name; the unit is the name's suffix (`_ms`, `_mb`...).
#[serde(default)]
pub metrics: BTreeMap<String, Summary>,
#[serde(default)]
pub frames: Vec<Frames>,
/// Local only: a name the person running it chose.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// Local only: logs the run wrote next to the report.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub local: Vec<PathBuf>,
}
impl Report {
/// An empty report for this machine, created now.
pub fn new(kind: Kind, app: App) -> Report {
Report {
format: FORMAT.to_owned(),
version: VERSION,
kind,
created: utc_now(),
app,
hardware: Hardware::detect(),
conditions: Conditions::default(),
metrics: BTreeMap::new(),
frames: Vec::new(),
label: None,
local: Vec::new(),
}
}
/// Records a measurement over the repeats; no values records nothing.
pub fn metric(&mut self, name: &str, values: &[f64]) {
if let Some(summary) = Summary::of(values) {
self.metrics.insert(name.to_owned(), summary);
}
}
/// Reads a report, rejecting other formats and newer versions.
pub fn parse(json: &str) -> Result<Report, String> {
let report: Report = serde_json::from_str(json).map_err(|e| e.to_string())?;
if report.format != FORMAT {
return Err(format!(
"not a benchmark report: format {:?}",
report.format
));
}
if report.version > VERSION {
return Err(format!(
"report version {} is newer than {VERSION}",
report.version
));
}
Ok(report)
}
/// The report without its local-only fields, dated to the day: what an upload sends.
pub fn for_upload(&self) -> Report {
let day = self.created.get(..10).unwrap_or_default();
Report {
label: None,
local: Vec::new(),
created: format!("{day}T00:00:00Z"),
..self.clone()
}
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).expect("a report always serializes")
}
/// Writes `<dir>/<kind>-<created>-<scenario>.json` and returns its path.
pub fn write_to(&self, dir: &Path) -> io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let kind = match self.kind {
Kind::Game => "game",
Kind::Launcher => "launcher",
};
let stamp: String = self.created.chars().filter(char::is_ascii_digit).collect();
let scenario: String = self
.conditions
.scenario
.as_deref()
.unwrap_or("run")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
let path = dir.join(format!("{kind}-{stamp}-{scenario}.json"));
std::fs::write(&path, self.to_json() + "\n")?;
Ok(path)
}
}
/// The current time, UTC, `YYYY-MM-DDTHH:MM:SSZ`.
pub fn utc_now() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
utc(secs)
}
fn utc(secs: u64) -> String {
let (days, rem) = (secs / 86_400, secs % 86_400);
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
rem / 3600,
rem / 60 % 60,
rem % 60
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utc_dates() {
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
assert_eq!(utc(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(utc(1_791_983_007), "2026-10-14T13:03:27Z");
}
#[test]
fn summary_of_repeats() {
assert_eq!(Summary::of(&[]), None);
let one = Summary::of(&[5.0]).unwrap();
assert_eq!((one.n, one.mean, one.stdev, one.ci95), (1, 5.0, None, None));
let s = Summary::of(&[2.0, 4.0, 6.0]).unwrap();
assert_eq!((s.mean, s.min, s.max), (4.0, 2.0, 6.0));
assert_eq!(s.stdev, Some(2.0));
assert_eq!(s.ci95, Some(4.969), "4.303 * 2 / sqrt(3)");
}
#[test]
fn frame_figures_and_histogram() {
// 998 frames of 2 ms, one of 20 ms, one of 1500 ms.
let mut ms = vec![2.0f32; 998];
ms.extend([20.0, 1500.0]);
let f = Frames::of("flight", 0, &ms, &[], &[]).unwrap();
assert_eq!(f.frames, 1000);
assert_eq!(f.seconds, 3.516);
assert_eq!((f.p50_ms, f.p999_ms, f.max_ms), (2.0, 20.0, 1500.0));
// Slowest 1% = 10 frames: 8 of 2 ms, 20 ms, 1500 ms.
assert_eq!(f.low_1pct_fps, 6.51, "1000 * 10 / 1536");
// Slowest 0.1% = the 1500 ms frame.
assert_eq!(f.low_01pct_fps, 0.667);
assert_eq!(f.histogram.len(), HISTOGRAM_EDGES_MS.len());
assert_eq!(f.histogram[2], 998, "2 ms falls in [2, 3)");
assert_eq!(f.histogram[13], 1, "20 ms falls in [20, 25)");
assert_eq!(f.histogram[23], 1, "1500 ms falls in [1000, inf)");
assert_eq!(f.histogram.iter().sum::<u64>(), 1000);
assert_eq!((f.cpu_p50_ms, f.gpu_p50_ms), (None, None));
assert_eq!(Frames::of("x", 0, &[], &[], &[]), None);
}
#[test]
fn round_trip_and_upload_strips_local_fields() {
let mut r = Report::new(
Kind::Game,
App {
name: "horizon-client".into(),
version: "0.1.0".into(),
..App::default()
},
);
r.conditions.scenario = Some("flight".into());
r.metric("startup.title_ms", &[800.0, 820.0]);
r.frames
.extend(Frames::of("village", 0, &[2.0, 3.0], &[1.0], &[]));
r.label = Some("my run".into());
r.local.push("/home/someone/logs/latest.log".into());
let back = Report::parse(&r.to_json()).unwrap();
assert_eq!(back, r);
r.created = "2026-10-15T14:03:27Z".into();
let upload = r.for_upload();
assert_eq!(upload.created, "2026-10-15T00:00:00Z", "the day only");
let upload = upload.to_json();
assert!(
!upload.contains("my run") && !upload.contains("someone"),
"{upload}"
);
}
#[test]
fn rejects_other_formats_and_newer_versions() {
let r = Report::new(Kind::Launcher, App::default());
let newer = r.to_json().replace("\"version\": 1", "\"version\": 2");
assert!(Report::parse(&newer).unwrap_err().contains("newer"));
let other = r.to_json().replace(FORMAT, "something-else");
assert!(Report::parse(&other).is_err());
}
#[test]
fn writes_into_a_folder() {
let dir = std::env::temp_dir().join(format!("horizon-bench-test-{}", std::process::id()));
let mut r = Report::new(Kind::Launcher, App::default());
r.created = "2026-10-15T14:03:27Z".into();
r.conditions.scenario = Some("cold start".into());
let path = r.write_to(&dir).unwrap();
assert_eq!(
path.file_name().unwrap(),
"launcher-20261015140327-cold_start.json"
);
assert_eq!(
Report::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(),
r
);
std::fs::remove_dir_all(dir).unwrap();
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "horizon-bots"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Load-test bots for Minecraft Java Edition 26.3 servers (docs/server.md, section 9)"
[dependencies]
horizon-protocol = { path = "../horizon-protocol" }
serde_json = "1.0.151"
[lints]
workspace = true
+589
View File
@@ -0,0 +1,589 @@
//! `horizon-bots`: load-test bots for any Minecraft Java Edition 26.3 server, vanilla, Paper or Horizon
//! (docs/server.md, section 9). Each bot logs in offline, goes through configuration as vanilla's client
//! does, answers keep-alives and pings, accepts teleports, acknowledges chunk batches, and moves at 20 Hz.
//!
//! Bots fly a little above the ground, so the server must give them creative mode (`gamemode=creative`
//! and `force-gamemode=true`): they never touch blocks, so they need no world of their own, and no server
//! can refuse their moves for walking into terrain. Every behaviour is deterministic from `--seed`.
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
use horizon_protocol::{
PROTOCOL_VERSION,
buf::{Reader, Writer},
connection::{ConnWriter, Connection},
crypto::offline_uuid,
ids,
packets::{
Empty, configuration,
handshake::{self, Intention},
login::{self, ServerHello},
play,
},
};
use serde_json::json;
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
time::{Duration, Instant},
};
const HELP: &str = "\
horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline mode, creative)
--server HOST:PORT default 127.0.0.1:25565
--bots N default 10
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed
--join-every SECONDS default 1
--seconds N how long to run after the first bot joins; default 60
--view-distance N what the bots ask for; default 10
--seed N default 12345
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--report FILE a JSON summary at the end
";
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
Idle,
Walk,
Explore,
}
impl Behaviour {
fn name(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Walk => "walk",
Self::Explore => "explore",
}
}
}
struct Options {
host: String,
port: u16,
bots: usize,
behaviour: String,
join_every: f64,
seconds: u64,
view_distance: i8,
seed: u64,
name: String,
report: Option<String>,
}
fn options() -> Result<Options, String> {
let mut o = Options {
host: "127.0.0.1".into(),
port: 25565,
bots: 10,
behaviour: "mixed".into(),
join_every: 1.0,
seconds: 60,
view_distance: 10,
seed: 12345,
name: "Bot".into(),
report: None,
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "-h" || arg == "--help" {
print!("{HELP}");
std::process::exit(0);
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
match arg.as_str() {
"--server" => {
let (host, port) = value.rsplit_once(':').unwrap_or((&value, "25565"));
o.host = host.into();
o.port = port.parse().map_err(|_| number("a port"))?;
}
"--bots" => o.bots = value.parse().map_err(|_| number("a number"))?,
"--behaviour" => o.behaviour = value,
"--join-every" => o.join_every = value.parse().map_err(|_| number("seconds"))?,
"--seconds" => o.seconds = value.parse().map_err(|_| number("seconds"))?,
"--view-distance" => o.view_distance = value.parse().map_err(|_| number("2 to 32"))?,
"--seed" => o.seed = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
return Err(format!(
"--behaviour {}: use idle, walk, explore or mixed",
o.behaviour
));
}
Ok(o)
}
/// What one bot is doing, shared between its reader and its ticks.
#[derive(Default)]
struct Bot {
online: AtomicBool,
chunks: AtomicU64,
/// Where the server last put the bot (a teleport), to start from.
teleport: Mutex<Option<[f64; 3]>>,
error: Mutex<Option<String>>,
joined_after_ms: AtomicU64,
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
struct Random(u64);
impl Random {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn unit(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
}
}
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
let mut w = Writer::new();
w.varint(id);
write(&mut w);
w.data
}
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
/// Handshake, offline login and configuration; returns the connection in the play state.
fn join(o: &Options, name: &str) -> Result<Connection, String> {
let mut c = Connection::connect(&o.host, o.port, Duration::from_secs(10)).map_err(err)?;
c.send(
&handshake::Serverbound::Intention(Intention {
protocol: PROTOCOL_VERSION,
host: o.host.clone(),
port: o.port,
intent: handshake::LOGIN,
})
.encode(),
)
.map_err(err)?;
c.send(
&login::Serverbound::Hello(ServerHello {
name: name.into(),
uuid: offline_uuid(name),
})
.encode(),
)
.map_err(err)?;
loop {
let (id, body) = c.recv().map_err(err)?;
match login::Clientbound::decode(id, &body).map_err(err)? {
login::Clientbound::Compression(p) => c.set_compression(p.threshold),
login::Clientbound::LoginFinished(_) => {
c.send(&login::Serverbound::LoginAcknowledged(Empty).encode())
.map_err(err)?;
break;
}
login::Clientbound::Disconnect(p) => return Err(format!("refused: {}", p.reason_json)),
login::Clientbound::Hello(_) => {
return Err("the server is in online mode; bots need online-mode=false".into());
}
login::Clientbound::CustomQuery(p) => c
.send(
&login::Serverbound::CustomQueryAnswer(login::CustomQueryAnswer {
transaction: p.transaction,
payload: None,
})
.encode(),
)
.map_err(err)?,
login::Clientbound::CookieRequest(p) => c
.send(
&login::Serverbound::CookieResponse(login::CookieResponse {
key: p.key,
payload: None,
})
.encode(),
)
.map_err(err)?,
login::Clientbound::Unknown(..) => {}
}
}
use ids::configuration::clientbound as cb;
c.send(
&configuration::Serverbound::ClientInformation(configuration::ClientInformation::new(
o.view_distance,
))
.encode(),
)
.map_err(err)?;
loop {
let (id, body) = c.recv().map_err(err)?;
let reply = match id {
cb::SELECT_KNOWN_PACKS
| cb::KEEP_ALIVE
| cb::PING
| cb::CODE_OF_CONDUCT
| cb::COOKIE_REQUEST
| cb::DISCONNECT
| cb::FINISH_CONFIGURATION => {
match configuration::Clientbound::decode(id, &body).map_err(err)? {
configuration::Clientbound::SelectKnownPacks(p) => {
configuration::Serverbound::SelectKnownPacks(p)
}
configuration::Clientbound::KeepAlive(p) => {
configuration::Serverbound::KeepAlive(p)
}
configuration::Clientbound::Ping(p) => configuration::Serverbound::Pong(p),
configuration::Clientbound::CodeOfConduct(_) => {
configuration::Serverbound::AcceptCodeOfConduct(Empty)
}
configuration::Clientbound::CookieRequest(p) => {
configuration::Serverbound::CookieResponse(configuration::CookieResponse {
key: p.key,
payload: None,
})
}
configuration::Clientbound::Disconnect(_) => {
return Err("disconnected during configuration".into());
}
configuration::Clientbound::FinishConfiguration(_) => {
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
.map_err(err)?;
return Ok(c);
}
_ => continue,
}
}
_ => continue,
};
c.send(&reply.encode()).map_err(err)?;
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
loop {
let (id, body) = match reader.recv() {
Ok(packet) => packet,
Err(e) => {
if bot.online.swap(false, Ordering::SeqCst) {
*bot.error.lock().unwrap() = Some(e.to_string());
}
return;
}
};
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
Ok(v) => send(raw(sb::KEEP_ALIVE, |w| w.i64(v))),
Err(_) => Ok(()),
},
cb::PING => match r.i32() {
Ok(v) => send(raw(sb::PONG, |w| w.i32(v))),
Err(_) => Ok(()),
},
cb::LEVEL_CHUNK_WITH_LIGHT => {
bot.chunks.fetch_add(1, Ordering::Relaxed);
Ok(())
}
cb::CHUNK_BATCH_FINISHED => send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: 25.0,
})
.encode(),
),
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
Ok(play::Clientbound::PlayerPosition(p)) => {
let mut at = bot.teleport.lock().unwrap();
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| {
if p.relatives & (1 << i) != 0 {
previous[i] + p.position[i]
} else {
p.position[i]
}
});
*at = Some(pos);
send(
play::Serverbound::AcceptTeleportation(play::AcceptTeleportation {
id: p.teleport,
x: pos[0],
y: pos[1],
z: pos[2],
yaw: p.yaw,
pitch: p.pitch,
})
.encode(),
)
}
_ => Ok(()),
},
cb::DISCONNECT | cb::START_CONFIGURATION => {
bot.online.store(false, Ordering::SeqCst);
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
format!(
"disconnected: {}",
String::from_utf8_lossy(&body)
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
)
} else {
"sent back to configuration".into()
});
return;
}
_ => Ok(()),
};
if sent.is_err() {
bot.online.store(false, Ordering::SeqCst);
return;
}
}
}
/// One bot: join, then move every 50 ms until `until`.
fn run_bot(
o: Arc<Options>,
index: usize,
behaviour: Behaviour,
bot: Arc<Bot>,
until: Arc<Mutex<Option<Instant>>>,
) {
let name = format!("{}{index}", o.name);
let asked = Instant::now();
let connection = match join(&o, &name) {
Ok(c) => c,
Err(e) => {
*bot.error.lock().unwrap() = Some(e);
return;
}
};
let (reader, writer) = connection.split();
let writer = Arc::new(Mutex::new(writer));
bot.online.store(true, Ordering::SeqCst);
{
let (writer, bot) = (writer.clone(), bot.clone());
thread::spawn(move || read(reader, writer, bot));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
let spawn = loop {
if let Some(at) = *bot.teleport.lock().unwrap() {
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());
}
return;
}
thread::sleep(Duration::from_millis(20));
};
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();
send(raw(sb::PLAYER_LOADED, |_| {}));
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
let cruise = spawn[1] + 24.0;
let heading = random.unit() * std::f64::consts::TAU;
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)
{
break;
}
// A teleport from the server wins over where the bot thought it was.
if let Some(at) = bot.teleport.lock().unwrap().take() {
pos = at;
}
if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
} else {
match behaviour {
Behaviour::Idle => {}
Behaviour::Walk => {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
if distance < 1.0 {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 8.0 + random.unit() * 40.0;
target = [
spawn[0] + angle.cos() * reach,
cruise,
spawn[2] + angle.sin() * reach,
];
} else {
// Sprinting: 5.6 blocks a second.
let step = 0.28_f64.min(distance);
pos[0] += dx / distance * step;
pos[2] += dz / distance * step;
}
}
Behaviour::Explore => {
// Flying fast in creative: about 22 blocks a second, which keeps new chunks coming.
pos[0] += heading.cos() * 1.1;
pos[2] += heading.sin() * 1.1;
}
}
}
let ok = send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
y: pos[1],
z: pos[2],
flags: 0,
})
.encode(),
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
if !ok {
bot.online.store(false, Ordering::SeqCst);
break;
}
next += Duration::from_millis(50);
let now = Instant::now();
if next > now {
thread::sleep(next - now);
} else if now - next > Duration::from_secs(1) {
next = now;
}
}
// Offline first, so the reader doesn't count the closed connection as a failure.
bot.online.store(false, Ordering::SeqCst);
writer.lock().unwrap().shutdown();
}
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
match mode {
"idle" => Behaviour::Idle,
"walk" => Behaviour::Walk,
"explore" => Behaviour::Explore,
// mixed: of every five bots, three walk, one explores, one stands.
_ => match index % 5 {
0 | 2 | 4 => Behaviour::Walk,
1 => Behaviour::Explore,
_ => Behaviour::Idle,
},
}
}
fn main() {
let o = match options() {
Ok(o) => Arc::new(o),
Err(e) => {
eprintln!("horizon-bots: {e}");
std::process::exit(2);
}
};
let start = Instant::now();
let until: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
let bots: Vec<Arc<Bot>> = (0..o.bots).map(|_| Arc::new(Bot::default())).collect();
let launcher = {
let (o, bots, until) = (o.clone(), bots.clone(), until.clone());
thread::spawn(move || {
let mut handles = Vec::new();
for (i, bot) in bots.iter().enumerate() {
let (o2, bot, until) = (o.clone(), bot.clone(), until.clone());
let behaviour = behaviour_of(&o.behaviour, i);
handles.push(thread::spawn(move || run_bot(o2, i, behaviour, bot, until)));
thread::sleep(Duration::from_secs_f64(o.join_every));
}
handles
})
};
// The run lasts --seconds after the first bot is in.
let mut previous_chunks = 0;
let mut first = None;
loop {
thread::sleep(Duration::from_secs(1));
let online = bots
.iter()
.filter(|b| b.online.load(Ordering::SeqCst))
.count();
let chunks: u64 = bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum();
let failed = bots
.iter()
.filter(|b| b.error.lock().unwrap().is_some())
.count();
if first.is_none() && online > 0 {
first = Some(Instant::now());
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
}
println!(
"{}",
json!({
"t": start.elapsed().as_secs(),
"online": online,
"failed": failed,
"chunks": chunks,
"chunks_per_second": chunks - previous_chunks,
})
);
previous_chunks = chunks;
let over = until.lock().unwrap().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());
break;
}
}
for handle in launcher.join().unwrap_or_default() {
let _ = handle.join();
}
let summary = json!({
"server": format!("{}:{}", o.host, o.port),
"bots": o.bots,
"behaviour": o.behaviour,
"seed": o.seed,
"view_distance": o.view_distance,
"seconds": o.seconds,
"joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(),
"chunks": bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum::<u64>(),
"per_bot": bots.iter().enumerate().map(|(i, b)| json!({
"name": format!("{}{i}", o.name),
"behaviour": behaviour_of(&o.behaviour, i).name(),
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
"chunks": b.chunks.load(Ordering::Relaxed),
"error": *b.error.lock().unwrap(),
})).collect::<Vec<_>>(),
});
if let Some(path) = &o.report {
if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) {
eprintln!("horizon-bots: {path}: {e}");
}
}
let errors: Vec<String> = bots
.iter()
.enumerate()
.filter_map(|(i, b)| {
b.error
.lock()
.unwrap()
.clone()
.map(|e| format!("{}{i}: {e}", o.name))
})
.collect();
for e in errors.iter().take(10) {
eprintln!("horizon-bots: {e}");
}
std::process::exit(if errors.is_empty() { 0 } else { 1 });
}
+32
View File
@@ -12,16 +12,48 @@ horizon-assets = { path = "../horizon-assets" }
horizon-core = { path = "../horizon-core" }
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" }
horizon-friends = { path = "../horizon-friends" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
horizon-mods = { path = "../horizon-mods" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
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"
naga = { version = "30.0.1", features = ["wgsl-in"] }
# Java (OptiFine/Iris) shader packs, translated to WGSL.
horizon-shaderpack = { path = "../horizon-shaderpack" }
cpal = "0.16"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["rust_backend"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input).
gilrs = "0.11"
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense" }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
[target.'cfg(windows)'.build-dependencies]
# The icon resource in the .exe (build.rs).
winresource = "0.1"
[features]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
+15
View File
@@ -0,0 +1,15 @@
//! Embeds Horizon's icon (`packaging/icons/horizon.ico`) in the Windows executables, as icon
//! resource 1: Explorer and the task bar show it, and the window takes it as its icon.
fn main() {
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
println!("cargo:rerun-if-changed={ico}");
#[cfg(windows)]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut resource = winresource::WindowsResource::new();
resource.set_icon(&ico);
if let Err(e) = resource.compile() {
panic!("embedding {ico}: {e}");
}
}
}
@@ -131,6 +131,7 @@ fn main() {
let tick = TickContext {
player_underwater: ambience.was_underwater(),
player_removed: removed,
..TickContext::default()
};
engine.tick(false, &tick, &mut backend);
}
@@ -0,0 +1,364 @@
//! Arrows over the client's resident scene (`arrows::tick`), for
//! tools/test_arrow_parity.py: one line of state and one of events per operation.
#![forbid(unsafe_code)]
use horizon_client::{
Result,
arrows::{
self, ArrowEvent, ArrowParticle, ArrowScene, ArrowSound, BlockReaction, EntityHit, Pickup,
Touch,
},
assets::Assets,
blocks::BlockFacts,
collisions,
items::ItemStack,
mobs::Who,
motion::{LAVA, MotionResources, WATER},
projectiles::{Hit, Projectile, ProjectileKind},
world::World,
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource},
};
use serde_json::Value;
use std::{
fs,
io::{BufRead, Write},
path::PathBuf,
};
fn number(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn vector(v: &Value) -> Vec3 {
Vec3::new(number(&v[0]), number(&v[1]), number(&v[2]))
}
fn bits(v: Vec3) -> String {
format!(
"{:016x},{:016x},{:016x}",
v.x.to_bits(),
v.y.to_bits(),
v.z.to_bits()
)
}
fn seed(v: &Value) -> i64 {
v.as_str().unwrap().parse().unwrap()
}
fn particle(k: ArrowParticle) -> &'static str {
match k {
ArrowParticle::Bubble => "bubble",
ArrowParticle::Crit => "crit",
ArrowParticle::Splash => "splash",
}
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
if args.len() != 3 {
return Err("usage: arrow_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into());
}
let root = PathBuf::from(&args[0]);
let dir = PathBuf::from(&args[1]);
let assets = Assets::load(&root)?;
let mut scene = World::test_scene(&assets, 1)?;
fs::write(
dir.join("scene.json"),
serde_json::to_vec(
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":assets.model_gallery}),
)?,
)?;
if args[2] == "--describe" {
return Ok(());
}
let binding = collisions::load(&root, assets.registry)?;
let motion = MotionResources::load(&root, &assets)?;
let facts = BlockFacts::load(&root, assets.registry)?;
let side = &args[2];
let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?);
let mut level_random = LegacyRandom::new(0);
for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() {
let f: Value = serde_json::from_str(&line?)?;
let kind = ProjectileKind::Arrow {
base_damage: 2.0,
critical: f["crit"].as_bool().unwrap(),
effect: None,
};
let mut arrow = Projectile::new(kind, vector(&f["position"]), None)
.with_arrow_random(LegacyRandom::new(seed(&f["seed"])));
arrow.velocity = vector(&f["velocity"]);
arrow.arrow.as_mut().unwrap().no_physics = f["no_physics"].as_bool().unwrap();
// ProjectileWeaponItem.useAmmo's stack, intangible when no ammo was used.
arrow.arrow.as_mut().unwrap().set_item(&arrows::drawn_ammo(
&ItemStack::new("minecraft:arrow", 1),
f["intangible"].as_bool().unwrap(),
));
// The entity in the way: its box and whether it takes the damage.
let mut target: Option<(Aabb, bool)> = None;
for op in f["ops"].as_array().unwrap() {
let mut events = Vec::new();
let mut touched = "-".to_string();
match op["mode"].as_str().unwrap() {
"tick" => {
// The game's part of an entity hit: the damage it deals
// (recorded after the tick's own events).
let mut hurt = None;
if !arrow.arrow.as_ref().unwrap().removed {
let no_rain = |_| false;
let view = ArrowScene {
world: &scene,
registry: assets.registry,
facts: &facts,
collision: &binding,
fluids: &motion.fluids,
raining: &no_rain,
mob_griefing: true,
tnt_explodes: true,
};
let targets: Vec<_> = target.iter().map(|t| (Who::Mob(2), t.0)).collect();
let (_, hit) = arrows::tick(&mut arrow, &view, &targets);
if let (Hit::Entity { .. }, Some((_, takes))) = (hit, target) {
let mut st = arrow.arrow.take().unwrap();
let damage = arrow.arrow_damage(&mut st.random);
hurt = Some((st.events.len(), damage));
arrow.arrow = Some(st);
let outcome = if takes {
EntityHit::Hurt
} else {
EntityHit::Refused
};
arrows::finish_entity_hit(&mut arrow, &view, outcome);
}
}
let a = arrow.arrow.as_mut().unwrap();
let (drop_at, dropped) = (arrow.position, a.pickup_item.count);
let raised = std::mem::take(&mut a.events);
let count = raised.len();
for (i, event) in raised.into_iter().map(Some).chain([None]).enumerate() {
if let Some((at, damage)) = hurt
&& at == i
{
events.push(format!("hurt {:08x}", damage.to_bits()));
}
let Some(event) = event else {
debug_assert_eq!(i, count);
break;
};
match event {
ArrowEvent::Particle {
kind,
position,
velocity,
} => events.push(format!(
"particle {} {} {}",
particle(kind),
bits(position),
bits(velocity)
)),
ArrowEvent::Sound {
sound,
volume,
pitch,
} => events.push(format!(
"sound {} {:08x} {:08x}",
match sound {
ArrowSound::HitGround => "hit",
ArrowSound::Splash => "splash",
ArrowSound::Extinguish => "extinguish",
},
volume.to_bits(),
pitch.to_bits()
)),
ArrowEvent::BubbleColumnParticles { pos } => {
for _ in 0..2 {
let x = f64::from(pos.x) + level_random.next_double();
let z = f64::from(pos.z) + level_random.next_double();
let at = Vec3::new(x, f64::from(pos.y + 1), z);
events.push(format!(
"sent splash {} {} {:016x}",
bits(at),
bits(Vec3::ZERO),
1.0f64.to_bits()
));
let x = f64::from(pos.x) + level_random.next_double();
let z = f64::from(pos.z) + level_random.next_double();
let at = Vec3::new(x, f64::from(pos.y + 1), z);
events.push(format!(
"sent bubble {} {} {:016x}",
bits(at),
bits(Vec3::new(0.0, 0.01, 0.0)),
0.2f64.to_bits()
));
}
}
ArrowEvent::Block { pos, reaction } => match reaction {
BlockReaction::PrimeTnt => {
// TntBlock.prime: PrimedTnt draws its push from the
// level random, then removeBlock(pos, false).
let _ = level_random.next_double();
let at = Vec3::new(
f64::from(pos.x) + 0.5,
f64::from(pos.y),
f64::from(pos.z) + 0.5,
);
events.push(format!("spawn tnt {} 80", bits(at)));
events.push(format!(
"sound tnt_primed {:08x} {:08x}",
1.0f32.to_bits(),
1.0f32.to_bits()
));
events.push("prime-event".into());
let air =
assets.registry.default_state("minecraft:air").unwrap();
scene.set(pos.x, pos.y, pos.z, air)?;
events.push(format!(
"write {},{},{} {} 3",
pos.x, pos.y, pos.z, air.0
));
}
BlockReaction::Light(lit) => {
scene.set(pos.x, pos.y, pos.z, lit)?;
events.push(format!(
"write {},{},{} {} 11",
pos.x, pos.y, pos.z, lit.0
));
}
},
ArrowEvent::SplashEvent => events.push("splash-event".into()),
ArrowEvent::ShootEvent => events.push("shoot-event".into()),
ArrowEvent::Drop => events.push(format!(
"drop {} {dropped}",
bits(Vec3::new(
drop_at.x,
drop_at.y + f64::from(0.1f32),
drop_at.z
))
)),
}
}
}
"target" => {
// EntityDimensions.scalable(0.6, 1.8).makeBoundingBox.
let at = vector(&op["position"]);
let w = f64::from(0.6f32) / 2.0;
let h = f64::from(1.8f32);
let b = Aabb::new(
Vec3::new(at.x - w, at.y, at.z - w),
Vec3::new(at.x + w, at.y + h, at.z + w),
);
target = Some((b, op["takes"].as_bool().unwrap()));
}
"block" => {
let p = &op["position"];
let mut state = assets
.registry
.default_state(op["name"].as_str().unwrap())
.ok_or("missing fixture block")?;
if let Some(properties) = op["properties"].as_object() {
let mut definition = assets.registry.state(state).unwrap().clone();
for (name, value) in properties {
definition
.properties
.insert(name.clone(), value.as_str().unwrap().into());
}
state = assets
.registry
.id(&definition)
.ok_or("missing fixture state")?;
}
scene.set(
p[0].as_i64().unwrap() as i32,
p[1].as_i64().unwrap() as i32,
p[2].as_i64().unwrap() as i32,
state,
)?;
}
"velocity" => arrow.velocity = vector(&op["value"]),
"ignite" => arrow
.arrow
.as_mut()
.unwrap()
.ignite_for_seconds(f32::from_bits(
u32::from_str_radix(op["value"].as_str().unwrap(), 16).unwrap(),
)),
"level_seed" => level_random.set_seed(seed(&op["value"])),
"owner" => arrow
.arrow
.as_mut()
.unwrap()
.set_owner(match op["value"].as_str() {
Some("player") => Some(Who::Player),
Some("mob") => Some(Who::Mob(1)),
_ => None,
}),
"pickup" => {
arrow.arrow.as_mut().unwrap().pickup =
Pickup::from_byte(op["value"].as_i64().unwrap())
}
// The game's touch: the inventory takes the arrow unless full,
// and a creative player takes it anyway.
"touch" => {
let creative = op["creative"].as_bool().unwrap();
let full = op["inventory"] == "full";
let a = arrow.arrow.as_mut().unwrap();
let (taken, arrows) = match a.player_touch(creative) {
Touch::Ignore => (false, 0),
Touch::Take => (true, 0),
Touch::Give(_) => (!full || creative, u32::from(!full)),
};
if taken {
a.removed = true;
}
touched = format!("{taken} {arrows}");
}
other => return Err(format!("unknown operation {other}").into()),
}
let a = arrow.arrow.as_ref().unwrap();
writeln!(
out,
"arrow {} {} {:08x} {:08x} {} {} {} {} {} {} {} {} {} {:016x} {:016x} {} {} {:016x} {}",
bits(arrow.position),
bits(arrow.velocity),
arrow.pitch.to_bits(),
arrow.yaw.to_bits(),
a.in_ground,
a.in_ground_time,
a.life,
a.shake,
a.last_state.map_or("none".into(), |s| s.0.to_string()),
a.fire_ticks,
a.was_touching_water,
a.was_eye_in_water,
a.first_tick,
a.fluids.fluid_height(&Tags, WATER).to_bits(),
a.fluids.fluid_height(&Tags, LAVA).to_bits(),
a.removed,
arrow.critical(),
a.fall_distance.to_bits(),
bits(arrow.previous)
)?;
writeln!(out, "events {}", events.join(" | "))?;
let a = arrow.arrow.as_ref().unwrap();
let intangible = a
.pickup_item
.data()
.and_then(|d| d.other(arrows::INTANGIBLE))
.is_some();
writeln!(out, "pickup {} {intangible} {touched}", a.pickup.byte())?;
}
writeln!(out, "end")?;
}
Ok(())
}
struct Tags;
impl horizon_world::entity_fluid::FluidMembership for Tags {
fn fluid_in_tag(
&self,
kind: horizon_world::fluid::FluidKind,
tag: horizon_world::entity_fluid::FluidTagId,
) -> bool {
use horizon_world::fluid::FluidKind;
if tag == WATER {
matches!(kind, FluidKind::Water | FluidKind::FlowingWater)
} else {
matches!(kind, FluidKind::Lava | FluidKind::FlowingLava)
}
}
}
@@ -100,6 +100,7 @@ fn main() -> Result<()> {
player.auto_jump_enabled = p["enabled"].as_bool().unwrap();
player.auto_jump_time = p["time"].as_i64().unwrap() as i32;
MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
@@ -0,0 +1,104 @@
//! Full production fact loading, including file reads, interning and temporary destruction.
//! cargo run --profile fast -p horizon-client --example block_facts_bench -- ROOT [SAMPLES]
//! Use SAMPLES=1 with an OS peak-RSS wrapper; `digest` checks all facts
//! and the shape sharing partition (including sharing between outlines and edges).
use horizon_client::blocks::BlockFacts;
use horizon_world::{DATA_VERSION, nbt::Tag, registry::BlockRegistry};
use serde_json::Value;
use std::{
collections::{HashMap, hash_map::DefaultHasher},
fs,
hash::{Hash, Hasher},
hint::black_box,
path::Path,
time::Instant,
};
type Result<T> = horizon_client::Result<T>;
fn tag(value: &Value) -> Result<Tag> {
Ok(match value {
Value::String(s) => Tag::String(s.as_str().into()),
Value::Number(n) => Tag::Int(i32::try_from(n.as_i64().ok_or("registry number")?)?),
Value::Array(a) => Tag::List(a.iter().map(tag).collect::<Result<_>>()?),
Value::Object(o) => Tag::Compound(
o.iter()
.map(|(k, v)| Ok((k.as_str().into(), tag(v)?)))
.collect::<Result<_>>()?,
),
_ => return Err("registry value".into()),
})
}
fn registry(root: &Path) -> Result<BlockRegistry> {
let facts: Value = serde_json::from_slice(&fs::read(root.join("facts.json"))?)?;
Ok(BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(
facts["states"]
.as_array()
.ok_or("registry states")?
.iter()
.map(|s| tag(&s[4]))
.collect::<Result<_>>()?,
),
),
("defaults", tag(&facts["defaults"])?),
]))?)
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
let root = Path::new(
args.first()
.ok_or("usage: block_facts_bench ROOT [SAMPLES|digest]")?,
);
let setup = Instant::now();
let registry = registry(root)?;
println!(
"registry_setup_ms={:.3}",
setup.elapsed().as_secs_f64() * 1000.0
);
if args.get(1).is_some_and(|s| s == "digest") {
let facts = BlockFacts::load(root, &registry)?;
let mut hash = DefaultHasher::new();
let mut shapes = HashMap::new();
facts.air.hash(&mut hash);
for state in &facts.states {
format!("{state:?}").hash(&mut hash);
for shape in [&state.outline, &state.outline_edges] {
let next = shapes.len();
shapes
.entry((shape.as_ptr(), shape.len()))
.or_insert(next)
.hash(&mut hash);
}
}
println!(
"states={} shared_shapes={} digest={:016x}",
facts.states.len(),
shapes.len(),
hash.finish()
);
return Ok(());
}
let samples: usize = args.get(1).map(|s| s.parse()).transpose()?.unwrap_or(9);
assert!(samples > 0);
// First load is reported separately; OS file caches are not forcibly evicted.
let load = || -> Result<f64> {
let start = Instant::now();
let facts = black_box(BlockFacts::load(black_box(root), black_box(&registry))?);
let ms = start.elapsed().as_secs_f64() * 1000.0;
drop(facts);
Ok(ms)
};
println!("first_load_ms={:.3}", load()?);
if samples > 1 {
load()?;
load()?;
let mut times = (0..samples).map(|_| load()).collect::<Result<Vec<_>>>()?;
println!("samples_ms={times:.3?}");
times.sort_by(f64::total_cmp);
println!("median_ms={:.3}", times[times.len() / 2]);
}
Ok(())
}
@@ -0,0 +1,61 @@
//! Transport for ClientLevel shape-aware terrain-particle placement.
use horizon_client::{assets::Assets, blocks::BlockFacts, mesher::MeshTables, models, particles};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::registry::BlockStateId;
use serde_json::{Value, json};
use std::{
fs,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().collect();
let root = Path::new(&args[1]);
let assets = Assets::load(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let tables = MeshTables::new(
&assets,
&facts,
models::load_visibility(root, assets.registry)?,
)?;
let mut output = BufWriter::new(fs::File::create(&args[3])?);
for line in BufReader::new(fs::File::open(&args[2])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let state = BlockStateId(row["state"].as_u64().unwrap() as u32);
let pos: [i32; 3] = serde_json::from_value(row["pos"].clone())?;
let offset = if facts.get(state).outline_offset {
tables.offset(state, pos[0], pos[2]).unwrap_or([0.0; 3])
} else {
[0.0; 3]
};
let boxes: Vec<[f64; 6]> = facts
.get(state)
.outline
.iter()
.map(|b| std::array::from_fn(|i| b[i] + offset[i % 3]))
.collect();
let mut hits = Vec::new();
let mut next = Vec::new();
for face in [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
] {
let mut random = LegacyRandom::new(row["seed"].as_i64().unwrap());
hits.push(particles::breaking_position(&mut random, pos, face, &boxes));
next.push(random.next_long());
}
let mut destroy = Vec::new();
particles::destroy_positions(pos, &boxes, |p, _| destroy.push(p));
serde_json::to_writer(
&mut output,
&json!({"case":row["case"],"hits":hits,"next":next,"destroy":destroy}),
)?;
output.write_all(&[10])?;
}
output.flush()?;
Ok(())
}
@@ -0,0 +1,74 @@
//! Headless transport for real model-shaped breaking overlays.
use horizon_client::{
assets::Assets,
blocks::{BlockFacts, RenderShape},
breaking,
mesher::MeshTables,
models,
};
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
use serde_json::{Value, json};
use std::{
fs,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().collect();
let root = Path::new(&args[1]);
let assets = Assets::load(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let tables = MeshTables::new(
&assets,
&facts,
models::load_visibility(root, assets.registry)?,
)?;
let mut output = BufWriter::new(fs::File::create(&args[3])?);
for line in BufReader::new(fs::File::open(&args[2])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let state = BlockStateId(row["state"].as_u64().unwrap() as u32);
let p: [i32; 3] = serde_json::from_value(row["pos"].clone())?;
let camera: [f64; 3] = serde_json::from_value(row["camera"].clone())?;
let mut vertices = Vec::new();
let mut translucent = false;
if facts.get(state).render_shape == RenderShape::Model {
let models = assets
.model_catalog
.collect(state, BlockPos::new(p[0], p[1], p[2]))?;
let offset = tables.offset(state, p[0], p[2]).unwrap_or([0.0; 3]);
let origin = (glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))
- glam::DVec3::from(camera))
.as_vec3()
+ glam::DVec3::from(offset).as_vec3();
translucent = models
.iter()
.any(|(_, m)| m.faces.iter().any(|f| f.translucent));
for (_, m) in models {
breaking::geometry(m, origin, translucent, &mut vertices);
}
}
let quads: Vec<_> = vertices
.as_chunks::<4>()
.0
.iter()
.map(|q| {
q.iter()
.map(|v| {
assert_eq!(v.color, 0xffff_ffff);
assert_eq!(v.light, 240 | (240 << 8));
assert_eq!(v.layer, if translucent { 12 } else { 11 });
[v.pos[0], v.pos[1], v.pos[2], v.uv[0], v.uv[1]]
})
.collect::<Vec<_>>()
})
.collect();
serde_json::to_writer(
&mut output,
&json!({"case":row["case"],"translucent":translucent,"quads":quads}),
)?;
output.write_all(&[10])?;
}
output.flush()?;
Ok(())
}
@@ -87,6 +87,7 @@ fn main() -> horizon_client::Result<()> {
recipes: &recipes,
brewing: &brewing,
banners: &banners,
trade: None,
};
let mut out = String::new();
for line in input.lines() {
@@ -9,7 +9,7 @@ use horizon_client::{
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS},
light::{LightTables, light_chunk},
light::{LightTables, apply_lighting, light_chunk},
mesher::{MeshTables, Mesher},
models,
streaming::{ChunkGenerator, Proto},
@@ -105,7 +105,7 @@ fn main() -> Result<()> {
);
let mut chunks: Vec<Chunk> = chunks.iter().map(|c| (**c).clone()).collect();
for ((x, z), sections) in lit {
chunks[((z - 1) * inner + x - 1) as usize].sections = sections;
apply_lighting(&mut chunks[((z - 1) * inner + x - 1) as usize], sections);
}
let chunks: Vec<Arc<Chunk>> = chunks.into_iter().map(Arc::new).collect();
let at = |x: i32, z: i32| chunks[((z - 1) * inner + x - 1) as usize].clone();
@@ -139,6 +139,7 @@ fn main() -> Result<()> {
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
cull_leaves: false,
};
let mut best = f64::MAX;
let mut quads = 0usize;
@@ -0,0 +1,39 @@
//! CloudRenderer face selection and mesh traversal, without a GPU.
use horizon_client::clouds;
use std::{fmt::Write, fs};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().collect();
let mut output = String::new();
for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() {
let values: Vec<i32> = line
.split_whitespace()
.map(|v| v.parse().unwrap())
.collect();
let [kind, x, z, bottom, neighbors] = values.as_slice() else {
panic!("invalid case")
};
write!(output, "{case}")?;
let mut cell = |x, z, neighbors| {
let empty = std::array::from_fn(|i| neighbors & (1 << i) != 0);
for (direction, inside) in clouds::faces(clouds::face_mask(x, z, *bottom as f32, empty))
{
write!(output, " {x},{z},{direction},{}", u8::from(inside)).unwrap();
}
};
let radius = if *kind == 2 {
clouds::radius_cells(*x)
} else {
*x
};
if *kind == 0 {
cell(*x, *z, *neighbors);
} else {
for (x, z) in clouds::cells(radius) {
cell(x, z, 0);
}
}
output.push('\n');
}
fs::write(&args[2], output)?;
Ok(())
}
@@ -0,0 +1,86 @@
#![forbid(unsafe_code)]
//! Runs tools/test_container_screen_parity.py's screens and mouse events
//! through `horizon_client::container_screen`.
use horizon_client::{
Result,
container_screen::{Book, Button, Clicks, Image, Release, Target, clicked_outside},
menu::Input,
};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn input_name(input: Input) -> &'static str {
match input {
Input::Pickup => "PICKUP",
Input::QuickMove => "QUICK_MOVE",
Input::Swap => "SWAP",
Input::Clone => "CLONE",
Input::Throw => "THROW",
Input::QuickCraft => "QUICK_CRAFT",
Input::PickupAll => "PICKUP_ALL",
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let int = |v: &Value| v.as_i64().unwrap() as i32;
let image = Image {
left: int(&c["image"][0]),
top: int(&c["image"][1]),
width: int(&c["image"][2]),
height: int(&c["image"][3]),
};
let book = c.get("book").map(|b| Book {
visible: b[0].as_bool().unwrap(),
selected_tab_hovered: b[1].as_bool().unwrap(),
});
let mut clicks = Clicks::default();
for ev in c["events"].as_array().unwrap() {
let name = ev[0].as_str().unwrap();
let (x, y) = (ev[1].as_f64().unwrap(), ev[2].as_f64().unwrap());
let outside = clicked_outside(x, y, image, book);
let mut line = format!("{name} {outside}");
if name != "point" {
// The case says whether the event is over the menu's slot (7).
let on_slot = ev[6].as_bool().unwrap();
line += &format!(" {on_slot}");
let target = Target::of(on_slot.then_some(7), outside);
let button = match ev[3].as_i64().unwrap() {
1 => Button::Left,
3 => Button::Right,
_ => Button::Middle,
};
let shift = ev[4].as_i64().unwrap() & 3 != 0;
let carried_empty = !ev[5].as_bool().unwrap();
let click = if name == "press" {
clicks.press(&target, button, carried_empty, shift, false)
} else {
match clicks.release(&target, button, carried_empty, false, shift, false) {
Release::Click(input) => Some(input),
Release::QuickCraft => panic!("no drags in these cases"),
Release::Nothing => None,
}
};
if let Some(input) = click {
let id = match target {
Target::Slot(s) => s,
Target::Outside => -999,
Target::Nothing => -1,
};
line += &format!(" click {id} {} {}", button.menu_button(), input_name(input));
}
}
writeln!(out, "{line}")?;
}
}
out.flush()?;
Ok(())
}
@@ -0,0 +1,53 @@
//! The creative inventory, for tools/test_creative_parity.py: loads an
//! exported `creative-inventory.json` into `horizon_client::creative::Creative`
//! and prints the tabs it shows (canonical stacks) and the search tab's
//! results for every query of the vanilla oracle's output.
//!
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
use horizon_client::{
creative::{Creative, Names},
item_data::canon_stack,
};
use serde_json::{Value, json};
use std::{fs, path::Path};
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
args.get(2).ok_or(usage)?,
)?)?)?;
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
let tabs: Vec<Value> = creative
.tabs
.iter()
.map(|t| {
json!({
"id": t.id,
"title": t.title,
"icon": t.icon,
"row": if t.top { "TOP" } else { "BOTTOM" },
"column": t.column,
"items": t.items.iter().map(|s| canon_stack(Some(s))).collect::<Vec<_>>(),
})
})
.collect();
let searches: Vec<Value> = vanilla["searches"]
.as_array()
.ok_or("searches")?
.iter()
.map(|s| {
let query = s["query"].as_str().unwrap_or_default();
json!({"query": query, "results": creative.search_results(query, &names)})
})
.collect();
let search: Vec<String> = creative
.search_items()
.iter()
.map(|s| canon_stack(Some(s)))
.collect();
let out = json!({"tabs": tabs, "search": search, "searches": searches});
fs::write(args.get(4).ok_or(usage)?, out.to_string())?;
Ok(())
}
@@ -0,0 +1,74 @@
#![forbid(unsafe_code)]
//! Runs tools/test_emitter_particles_parity.py's emitters through
//! `particles::on_block_faces`, `particles::along_axis` and
//! `particles::item_use_particles`.
use horizon_client::{Result, particles};
use horizon_core::random::{LegacyRandom, RandomSource};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn d(v: f64) -> String {
format!("{:016x}", v.to_bits())
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in BufReader::new(File::open(&args[1])?).lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let mut random = LegacyRandom::new(c["seed"].as_i64().ok_or("seed")?);
let mut lines = Vec::new();
let record = |kind: &str| {
let kind = kind.to_owned();
move |p: [f64; 3], v: [f64; 3]| {
format!(
"+ {kind} {} {} {} {} {} {}",
d(p[0]),
d(p[1]),
d(p[2]),
d(v[0]),
d(v[1]),
d(v[2])
)
}
};
if c["kind"] == "item_use" {
let p: [f64; 3] = std::array::from_fn(|i| c["pos"][i].as_f64().unwrap_or(0.0));
let f = |k: &str| c[k].as_f64().unwrap_or(0.0) as f32;
let line = record("item");
particles::item_use_particles(
&mut random,
f("pitch"),
f("yaw"),
p,
p[1] + f64::from(f("eye")),
c["count"].as_u64().unwrap_or(0) as u32,
|p, v| lines.push(line(p, v)),
);
} else {
let p: [i32; 3] = std::array::from_fn(|i| c["pos"][i].as_i64().unwrap_or(0) as i32);
match c["axis"].as_i64() {
Some(axis @ 0..=2) => {
let line = record("electric_spark");
particles::along_axis(&mut random, axis as usize, p, |p, v| {
lines.push(line(p, v))
});
}
_ => {
let line = record("wax_on");
particles::on_block_faces(&mut random, p, |p, v| lines.push(line(p, v)));
}
}
}
for l in lines {
writeln!(out, "{l}")?;
}
writeln!(out, "next {}", random.next_long())?;
}
out.flush()?;
Ok(())
}
@@ -1,14 +1,17 @@
//! Replays the render states of the vanilla entity-animation oracle through
//! Horizon's `mobs::anim::setup` and writes every part's pose.
//! Horizon's `mobs::anim::setup` and writes every part's pose, then draws the
//! posed model with `mobs::render::draw` under the case's living-entity root
//! transform and writes each drawn part's packed vertex normals.
//!
//! cargo run --profile fast -p horizon-client --example entity_anim_fixture -- \
//! RESOURCES VANILLA.jsonl OUT.jsonl
use horizon_client::mesher::Vertex;
use horizon_client::mobs::{
Mob, MobData, MobTypes, Part, Species, allay,
anim::{self, Poses, State},
aquatic, body_anim as b_anim, equine, guardian,
keyframes::AnimationState,
shulker, sniffer, species, tame,
render, shulker, sniffer, species, tame,
};
use horizon_core::geometry::Vec3;
use serde_json::{Value, json};
@@ -44,6 +47,61 @@ fn walk(part: &Part, path: &str, poses: &Poses, out: &mut BTreeMap<String, Value
}
}
/// Splits the vertices `render::draw` emitted for `part` into its drawn parts,
/// following draw's walk: a hidden or zero-scaled part ends its branch, a
/// skip-draw part keeps its children. Per part: n = packed normal bytes per
/// vertex, p = sum of x + 2y + 3z over the positions.
fn drawn(
part: &Part,
path: &str,
poses: &Poses,
vertices: &[Vertex],
cursor: &mut usize,
out: &mut BTreeMap<String, Value>,
) {
let p = poses.pose(part);
if poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
return;
}
let count = if poses.skips(part) {
0
} else {
part.polygons.len() * 4
};
if count > 0 {
let own = &vertices[*cursor..*cursor + count];
let normals: Vec<i8> = own.iter().flat_map(|v| v.normal[..3].to_vec()).collect();
let sum: f64 = own
.iter()
.map(|v| f64::from(v.pos[0]) + 2.0 * f64::from(v.pos[1]) + 3.0 * f64::from(v.pos[2]))
.sum();
out.insert(path.to_owned(), json!({"n": normals, "p": sum}));
}
*cursor += count;
for c in &part.children {
let child = if path.is_empty() {
c.name.clone()
} else {
format!("{path}/{}", c.name)
};
drawn(c, &child, poses, vertices, cursor, out);
}
}
/// LivingEntityRenderer's root transform without the entity's position:
/// rotate 180 - yaw about Y, scale(-1, -1, 1), the renderer's own scale, then
/// translate(0, -1.501, 0) (as `render::base_transform`).
fn root_transform(case: &Value) -> glam::Mat4 {
let yaw = case["@rootYaw"].as_f64().unwrap_or(0.0) as f32;
let scale = case["@rootScale"].as_array().map_or(glam::Vec3::ONE, |s| {
glam::Vec3::from_array(std::array::from_fn(|i| s[i].as_f64().unwrap_or(1.0) as f32))
});
glam::Mat4::from_rotation_y((180.0 - yaw).to_radians())
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_scale(scale)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0))
}
/// The entity-side values behind the render-state fields the case sets, for
/// the species whose Horizon animation reads them from the `Mob`.
fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
@@ -236,12 +294,26 @@ fn main() -> horizon_client::Result<()> {
let types = MobTypes::load(&root)?;
let animations = MobData::load_animations(&root);
let mut out = String::new();
let crystal = horizon_client::mobs::load_model(&root, "minecraft:end_crystal#main");
for line in fs::read_to_string(&args[1])?.lines() {
let v: Value = serde_json::from_str(line)?;
let layer = v["layer"]
.as_str()
.unwrap_or_default()
.trim_start_matches("minecraft:");
// EndCrystalModel is not a mob's: horizon_client::end_crystal poses it.
if layer == "end_crystal#main"
&& let Some(model) = &crystal
{
let case = &v["case"];
let mut poses = horizon_client::end_crystal::poses(
model,
case["ageInTicks"].as_f64().unwrap_or(0.0) as f32,
case["showsBottom"].as_bool().unwrap_or(false),
);
out.push_str(&format!("{}\n", emit(model, &mut poses, case, layer)));
continue;
}
let baby = layer.contains("_baby#");
let adult = layer.replace("_baby#", "#");
// Variant layers (sizes, hatless villagers, climate cows...) animate
@@ -518,10 +590,38 @@ fn main() -> horizon_client::Result<()> {
}
_ => anim::setup(&mob, s, p, &animations),
}
let mut parts = BTreeMap::new();
walk(&model, "", &poses, &mut parts);
out.push_str(&format!("{}\n", json!({"parts": parts})));
out.push_str(&format!("{}\n", emit(&model, &mut poses, case, layer)));
}
fs::write(&args[2], out)?;
Ok(())
}
/// Every part's pose, and the vertices drawn under the harness's root transform.
fn emit(model: &Part, poses: &mut Poses, case: &Value, layer: &str) -> Value {
// The harness's part-scale override, set after setupAnim as the oracle does.
if let Some(scale) = case["@partScale"].as_array() {
let pose = poses.get(scale[0].as_str().unwrap_or_default());
for i in 0..3 {
pose[6 + i] = scale[1 + i].as_f64().unwrap_or(1.0) as f32;
}
}
let poses = &*poses;
let mut parts = BTreeMap::new();
walk(model, "", poses, &mut parts);
let mut emitted = Vec::new();
render::draw(
model,
root_transform(case),
poses,
[0.0, 0.0, 1.0, 1.0],
0xFFFF_FFFF,
240 | 240 << 8,
0,
&mut emitted,
);
let mut vertices = BTreeMap::new();
let mut cursor = 0;
drawn(model, "", poses, &emitted, &mut cursor, &mut vertices);
assert_eq!(cursor, emitted.len(), "{layer}: draw's walk changed");
json!({"parts": parts, "vertices": vertices})
}
@@ -0,0 +1,89 @@
#![forbid(unsafe_code)]
//! Runs tools/test_entity_components_parity.py's entities and advancement
//! entity predicates through `horizon_client::advancements::entity`, with the
//! variant component `mobs::finalize::species_variant_component` reports.
use horizon_client::{
Result,
advancements::{EntityView, World, entity},
mobs::{
Species,
finalize::{species_variant_component, variant_index},
},
};
use serde_json::Value;
use std::{
collections::BTreeMap,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
/// Nothing but the entity: these predicates read only its type and components.
struct Empty;
impl World for Empty {
fn dimension(&self) -> &str {
"minecraft:overworld"
}
fn biome(&self, _: [i32; 3]) -> String {
"minecraft:plains".into()
}
fn structures(&self, _: [i32; 3]) -> Vec<String> {
Vec::new()
}
fn block(&self, _: [i32; 3]) -> (String, BTreeMap<String, String>) {
("minecraft:air".into(), BTreeMap::new())
}
fn fluid(&self, _: [i32; 3]) -> Option<String> {
None
}
fn can_see_sky(&self, _: [i32; 3]) -> bool {
true
}
fn light(&self, _: [i32; 3]) -> i32 {
15
}
fn smokey(&self, _: [i32; 3]) -> bool {
false
}
fn in_tag(&self, _: &str, _: &str, _: &str) -> bool {
false
}
}
fn species(id: &str) -> Species {
match id {
"minecraft:wolf" => Species::Wolf,
"minecraft:cat" => Species::Cat,
"minecraft:frog" => Species::Frog,
"minecraft:pig" => Species::Pig,
"minecraft:cow" => Species::Cow,
"minecraft:chicken" => Species::Chicken,
other => panic!("no species for {other}"),
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let kind = c["type"].as_str().unwrap();
let s = species(kind);
let variant = variant_index(s, c["variant"].as_str().unwrap());
let view = EntityView {
kind: kind.to_owned(),
position: [0.5, 64.0, 0.5],
components: species_variant_component(s, variant)
.map(|(id, v)| (id.to_owned(), v))
.into_iter()
.collect(),
..Default::default()
};
for p in c["predicates"].as_array().unwrap() {
writeln!(out, "{}", entity(p, &view, &Empty, [2.5, 64.0, 0.5]))?;
}
}
out.flush()?;
Ok(())
}
@@ -21,6 +21,7 @@ fn stack(spec: &str) -> LootStack {
enchantments: Vec::new(),
potion: None,
map: false,
exploration: None,
ominous_amplifier: None,
};
for e in parts {
@@ -0,0 +1,140 @@
//! Isolated production mob-model builder benchmark; no assets, Game, GPU or full-frame work.
//! CARGO_TARGET_DIR=/home/bob/Documents/horizon/target CARGO_INCREMENTAL=0 \
//! cargo run --profile fast -p horizon-client --example entity_geometry_bench
use horizon_client::{
mesher::Vertex,
mobs::{Part, Polygon, anim::Poses, render},
};
use std::{hint::black_box, time::Instant};
fn cube(name: String, position: f32) -> Part {
let polygons = (0..6)
.map(|face| {
let axis = face / 2;
let side = (face % 2) as f32;
let mut normal = [0.0; 3];
normal[axis] = side * 2.0 - 1.0;
Polygon {
normal,
vertices: [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]].map(|uv| {
let mut vertex = [0.0; 5];
vertex[axis] = side * 8.0;
vertex[(axis + 1) % 3] = uv[0] * 8.0;
vertex[(axis + 2) % 3] = uv[1] * 8.0;
vertex[3] = uv[0];
vertex[4] = uv[1];
vertex
}),
}
})
.collect();
Part {
name,
pose: [position, 0.0, 0.0, 0.1, 0.2, 0.0, 1.0, 1.0, 1.0],
polygons,
children: Vec::new(),
}
}
fn build(model: &Part, poses: &Poses, count: usize, out: &mut Vec<Vertex>) {
for i in 0..count {
render::draw(
black_box(model),
glam::Mat4::from_translation(glam::Vec3::new((i % 32) as f32, 0.0, (i / 32) as f32)),
black_box(poses),
[0.125, 0.25, 0.5, 0.75],
0xffc0_d0e0,
0xf0f0,
0,
out,
);
}
black_box(out.as_slice());
}
fn measure(model: &Part, poses: &Poses, count: usize, iterations: usize, reuse: bool) -> f64 {
let mut retained = Vec::new();
build(model, poses, count, &mut retained); // Untimed warmup.
let start = Instant::now();
for _ in 0..iterations {
let mut out = if reuse {
std::mem::take(&mut retained)
} else {
Vec::new()
};
out.clear();
build(model, poses, count, &mut out);
if reuse {
retained = out;
}
}
start.elapsed().as_secs_f64() * 1e6 / iterations as f64
}
fn main() {
let mut model = cube("body".into(), 0.0);
model.children = (1..6).map(|i| cube(format!("part{i}"), i as f32)).collect();
let poses = Poses::new(&model);
for count in [32, 256, 1024, 4096, 8192] {
let mut expected = Vec::new();
build(&model, &poses, count, &mut expected);
let mut retained = Vec::new();
build(&model, &poses, count, &mut retained);
let (pointer, capacity) = (retained.as_ptr(), retained.capacity());
// Populated -> empty/reset -> smaller -> populated must never leave stale vertices.
for n in [0, count / 2, count, count] {
retained.clear();
build(&model, &poses, n, &mut retained);
let mut fresh = Vec::new();
build(&model, &poses, n, &mut fresh);
assert_eq!(
bytemuck::cast_slice::<Vertex, u8>(&retained),
bytemuck::cast_slice::<Vertex, u8>(&fresh)
);
assert_eq!(
(retained.as_ptr(), retained.capacity()),
(pointer, capacity)
);
}
assert_eq!(
bytemuck::cast_slice::<Vertex, u8>(&retained),
bytemuck::cast_slice::<Vertex, u8>(&expected)
);
let iterations = (100_000 / count).clamp(40, 1000);
let mut fresh = Vec::new();
let mut reused = Vec::new();
let mut paired_speedups = Vec::new();
// Alternate order, reporting medians over eleven warmed batches.
for batch in 0..11 {
let mut pair = [0.0; 2];
for reuse in if batch % 2 == 0 {
[false, true]
} else {
[true, false]
} {
let time = measure(&model, &poses, count, iterations, reuse);
pair[usize::from(reuse)] = time;
if reuse {
reused.push(time);
} else {
fresh.push(time);
}
}
paired_speedups.push(pair[0] / pair[1]);
}
paired_speedups.sort_by(f64::total_cmp);
fresh.sort_by(f64::total_cmp);
reused.sort_by(f64::total_cmp);
println!(
"entities={count} vertices={} capacity={capacity} retained_bytes={} fresh_us={:.3} reused_us={:.3} speedup={:.3}x paired_speedup_median={:.3}x paired_speedup_range={:.3}..{:.3}x iterations={iterations} batches=11 byte_equivalence=pass pointer_capacity_reuse=pass",
retained.len(),
capacity * size_of::<Vertex>(),
fresh[5],
reused[5],
fresh[5] / reused[5],
paired_speedups[5],
paired_speedups[0],
paired_speedups[10]
);
}
}
@@ -0,0 +1,51 @@
#![forbid(unsafe_code)]
//! Runs tools/test_entity_particles_parity.py's entity events through
//! `particles::entity_event_burst` and `particles::around_self`.
use horizon_client::{Result, particles};
use horizon_core::random::{LegacyRandom, RandomSource};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn d(v: f64) -> String {
format!("{:016x}", v.to_bits())
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in BufReader::new(File::open(&args[1])?).lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let mut random = LegacyRandom::new(c["seed"].as_i64().ok_or("seed")?);
let feet: [f64; 3] = std::array::from_fn(|i| c["feet"][i].as_f64().unwrap_or(0.0));
// EntityDimensions hold floats.
let width = f64::from(c["width"].as_f64().unwrap_or(0.0) as f32);
let height = f64::from(c["height"].as_f64().unwrap_or(0.0) as f32);
let entity = c["entity"].as_str().unwrap_or("");
if let Some((kind, burst)) =
particles::entity_event_burst(entity, c["event"].as_u64().unwrap_or(0) as u8)
{
let mut lines = Vec::new();
particles::around_self(&mut random, feet, width, height, burst, |p, v| {
lines.push(format!(
"+ {kind} {} {} {} {} {} {}",
d(p[0]),
d(p[1]),
d(p[2]),
d(v[0]),
d(v[1]),
d(v[2])
));
});
for l in lines {
writeln!(out, "{l}")?;
}
}
writeln!(out, "next {}", random.next_long())?;
}
out.flush()?;
Ok(())
}
@@ -64,6 +64,10 @@ fn main() -> horizon_client::Result<()> {
);
put("visual/fog_start_distance", format!("{:?}", sky.fog_start));
put("visual/fog_end_distance", format!("{:?}", sky.fog_end));
put(
"visual/cloud_fog_end_distance",
format!("{:?}", sky.cloud_fog_end),
);
out.push_str(&row);
out.push_str("}\n");
}
@@ -0,0 +1,32 @@
#![forbid(unsafe_code)]
//! Runs tools/test_fire_starter_sound_parity.py's level random seeds through
//! the fire charge's use sound (`horizon_client::items::fire_starter_pitch`).
use horizon_client::{
Result,
items::{fire_starter_pitch, fire_starter_sound},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for line in input.lines() {
let mut random = LegacyRandom::new(line?.trim().parse()?);
let pitch = fire_starter_pitch(true, &mut random);
writeln!(
out,
"{} {} {} {}",
fire_starter_sound(true),
1.0f32.to_bits() as i32,
pitch.to_bits() as i32,
random.next_long()
)?;
}
out.flush()?;
Ok(())
}
@@ -5,7 +5,7 @@ use horizon_client::{
Result,
attack_strength::{AttackStrength, HeldStack},
first_person::{
Clicks, Hand, HandHeights, HandWorld, Hit, SwingAnimation, SwingState, UseResult,
Clicks, Hand, HandHeights, HandSway, HandWorld, Hit, SwingAnimation, SwingState, UseResult,
swing_duration,
},
};
@@ -308,6 +308,9 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
let mut strength = AttackStrength::default();
// FirstPersonHandsAndItems' visible items start as ItemStack.EMPTY.
let mut visible = [0usize, 0usize];
// The player's xRot and yRot, and the hands' lag behind them.
let mut rot = [0.0f32; 2];
let mut sway = HandSway::default();
for (s, step) in case["steps"].as_array().unwrap().iter().enumerate() {
probe.step = step;
probe.events.clear();
@@ -327,6 +330,10 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
step["ud"].as_bool().unwrap(),
);
probe.swing.tick();
if let Some(r) = step["rot"].as_array() {
rot = [fl(&r[0]), fl(&r[1])];
}
sway.tick(rot[0], rot[1]);
let main = probe.stack(Hand::Main).clone();
strength.tick(held(&main));
// FirstPersonHandsAndItems.tick: shouldInstantlyReplaceVisibleItem
@@ -363,7 +370,7 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
let d = &clicks.destroying;
writeln!(
out,
"tick {s} {current} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
"tick {s} {current} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
sw.ticks,
fb(sw.animation),
fb(sw.old_animation),
@@ -379,7 +386,11 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
clicks.right_click_delay,
d.delay,
d.active,
fb(d.progress)
fb(d.progress),
fb(sway.x_bob),
fb(sway.y_bob),
fb(sway.x_bob_o),
fb(sway.y_bob_o)
)?;
}
Ok(())
@@ -93,8 +93,9 @@ fn encode(draws: &[GuiDraw]) -> Vec<Value> {
h,
region,
color,
blend,
} => {
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "region": region, "color": *color as i32})
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "region": region, "color": *color as i32, "pipeline": blend.pipeline()})
}
GuiDraw::AtlasSprite {
sprite,
@@ -190,7 +191,12 @@ fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
// Spectators only see it over a spectatable target (none here).
if c["crosshair"].as_bool().unwrap_or(false) && mode != "spectator" {
let attack = (indicator == "CROSSHAIR").then(|| float("attackScale", 1.0));
draws.extend(hud_layout::crosshair(w, h, attack, false));
// crosshairPickEntity: absent, or a living entity alive or not.
let target = c["target"].as_bool();
let full = attack.is_some_and(|a| {
hud_layout::full_attack_indicator(a, float("attackDelay", 5.0), target)
});
draws.extend(hud_layout::crosshair(w, h, attack, full));
}
if mode != "spectator" {
let pop = |v: &Value| v.as_f64().map(|p| p as f32 - partial);
@@ -0,0 +1,124 @@
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
use serde_json::Value;
use std::{
fs::{self, File},
hint::black_box,
io::BufReader,
path::Path,
time::{Duration, Instant},
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn reader(path: &Path) -> Result<Value> {
Ok(serde_json::from_reader(BufReader::new(File::open(path)?))?)
}
fn slice(path: &Path) -> Result<Value> {
Ok(serde_json::from_slice(&fs::read(path)?)?)
}
fn measure(path: &Path, load: fn(&Path) -> Result<Value>) -> Result<Duration> {
let start = Instant::now();
let value = black_box(load(black_box(path))?);
let elapsed = start.elapsed();
// Value destruction is excluded; the temporary byte buffer is released by slice().
drop(value);
Ok(elapsed)
}
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let root = args
.next()
.ok_or("usage: json_load_bench ROOT [REPETITIONS]")?;
let repetitions: usize = args.next().map_or(Ok(15), |s| s.parse())?;
if repetitions == 0 {
return Err("REPETITIONS must be positive".into());
}
for name in ["gameplay-facts.json", "facts.json"] {
let path = Path::new(&root).join(name);
// Validate complete parsed trees on the exact files being timed.
assert_eq!(reader(&path)?, slice(&path)?);
for _ in 0..2 {
measure(&path, reader)?;
measure(&path, slice)?;
}
let (mut before, mut after) = (Vec::new(), Vec::new());
for i in 0..repetitions {
// Alternate order to reduce filesystem cache / frequency bias.
if i % 2 == 0 {
before.push(measure(&path, reader)?);
after.push(measure(&path, slice)?);
} else {
after.push(measure(&path, slice)?);
before.push(measure(&path, reader)?);
}
}
before.sort_unstable();
after.sort_unstable();
let median = |samples: &[Duration]| {
(samples[(samples.len() - 1) / 2].as_secs_f64()
+ samples[samples.len() / 2].as_secs_f64())
* 500.0
};
let (before, after) = (median(&before), median(&after));
println!(
"{name}: {} bytes; {repetitions} samples; reader {before:.3} ms; slice {after:.3} ms; {:.2}x",
fs::metadata(&path)?.len(),
before / after
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reader_and_slice_preserve_values_and_error_categories() -> Result<()> {
let path =
std::env::temp_dir().join(format!("horizon-json-load-{}.json", std::process::id()));
let check = || -> Result<()> {
fs::write(
&path,
br#"{"states":[{"id":0,"hardness":-1.5,"name":"escaped\n\u2603","outline":[[0,0,0,1,1,1]],"enabled":true}],"defaults":null}"#,
)?;
assert_eq!(reader(&path)?, slice(&path)?);
for invalid in [
&b"{\"states\":["[..],
&b"{\"states\":tru}"[..],
&b"{} trailing"[..],
&b"{\"name\":\"\xff\"}"[..],
] {
fs::write(&path, invalid)?;
let before = reader(&path).unwrap_err();
let after = slice(&path).unwrap_err();
assert_eq!(
before
.downcast_ref::<serde_json::Error>()
.unwrap()
.classify(),
after
.downcast_ref::<serde_json::Error>()
.unwrap()
.classify()
);
}
fs::remove_file(&path)?;
for load in [reader, slice] {
let error = load(&path).unwrap_err();
assert_eq!(
error.downcast_ref::<std::io::Error>().unwrap().kind(),
std::io::ErrorKind::NotFound
);
}
Ok(())
};
let result = check();
let _ = fs::remove_file(path);
result
}
}
@@ -0,0 +1,205 @@
//! Compare the exact read/deserialize sequences used by the model loaders.
//! Run with `cargo run -p horizon-client --profile fast --example json_readers_bench`.
//! Optional arguments: resource directory, repetitions (default 9).
use serde_json::{Map, Value, json};
use std::{
collections::BTreeMap,
fmt::Debug,
fs::{self, File},
hint::black_box,
io::BufReader,
path::{Path, PathBuf},
time::Instant,
};
type Result<T> = horizon_client::Result<T>;
#[derive(Clone, Copy)]
enum Reader {
File,
Buffered,
Slice,
}
const READERS: [Reader; 3] = [Reader::File, Reader::Buffered, Reader::Slice];
// These match the map types and reader expressions at all three production sites.
macro_rules! reader {
($name:ident, $map:ty) => {
fn $name(path: &Path, reader: Reader) -> Result<$map> {
Ok(match reader {
Reader::File => serde_json::from_reader(File::open(path)?)?,
Reader::Buffered => serde_json::from_reader(BufReader::new(File::open(path)?))?,
Reader::Slice => serde_json::from_slice(&fs::read(path)?)?,
})
}
};
}
reader!(prepare_facts, Map<String, Value>);
reader!(catalog_facts, BTreeMap<String, Value>);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Result<Self> {
let root =
std::env::temp_dir().join(format!("horizon-json-readers-{}", std::process::id()));
fs::create_dir(&root)?;
Ok(Self(root))
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn fixtures(root: &Path) -> Result<()> {
let states: Vec<_> = (0..56)
.map(|i| {
json!({"facing": (["north", "east", "south", "west"][i % 4]),
"powered": if i / 4 % 2 == 0 { "false" } else { "true" },
"age": (i / 8).to_string()})
})
.collect();
let mut models = Map::new();
let mut render = Map::new();
for i in 0..1024 {
let name = format!("minecraft:fixture_block_{i:04}");
models.insert(
name.clone(),
json!({
"schema": {"facing": {"named": ["north", "east", "south", "west"]},
"powered": {"named": ["false", "true"]}, "age": {"integer": [0, 6]}},
"states": states
}),
);
render.insert(
name,
json!({"rule": "never", "can_occlude": true, "bars_tag": false}),
);
}
fs::write(root.join("model-states.json"), serde_json::to_vec(&models)?)?;
fs::write(
root.join("render-definitions.json"),
serde_json::to_vec(&render)?,
)?;
Ok(())
}
fn benchmark<T: PartialEq + Debug>(
label: &str,
path: &Path,
repetitions: usize,
read: impl Fn(&Path, Reader) -> Result<T>,
) -> Result<()> {
let expected = read(path, Reader::File)?;
for reader in READERS {
assert_eq!(expected, read(path, reader)?);
for _ in 0..2 {
black_box(read(black_box(path), reader)?);
}
}
drop(expected);
let mut samples: [Vec<f64>; 3] = std::array::from_fn(|_| Vec::new());
for repetition in 0..repetitions {
// Rotate reader order to distribute drift across implementations.
for offset in 0..3 {
let index = (repetition + offset) % 3;
let start = Instant::now();
let value = black_box(read(black_box(path), READERS[index])?);
samples[index].push(start.elapsed().as_secs_f64() * 1000.0);
drop(value); // Match the loaders: retain the parsed tree after loading.
}
}
let median: Vec<_> = samples
.iter_mut()
.map(|s| {
s.sort_by(f64::total_cmp);
s[s.len() / 2]
})
.collect();
println!(
"{label}: {} bytes, {repetitions} samples/reader, median ms: File={:.3}, BufReader={:.3}, read+slice={:.3}; slice speedup={:.2}x",
fs::metadata(path)?.len(),
median[0],
median[1],
median[2],
median[0] / median[2]
);
Ok(())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
let fixture = if args.is_empty() {
Some(Fixture::new()?)
} else {
None
};
let root = fixture
.as_ref()
.map(|f| f.0.clone())
.unwrap_or_else(|| PathBuf::from(&args[0]));
if fixture.is_some() {
fixtures(&root)?;
println!(
"Generated representative fixtures: 1024 blocks, 57344 model states; warm filesystem cache."
);
}
let repetitions = args.get(1).map(|s| s.parse()).transpose()?.unwrap_or(9);
assert!(repetitions > 0, "repetitions must be positive");
benchmark(
"prepare model states",
&root.join("model-states.json"),
repetitions,
prepare_facts,
)?;
benchmark(
"catalog model states",
&root.join("model-states.json"),
repetitions,
catalog_facts,
)?;
benchmark(
"render definitions",
&root.join("render-definitions.json"),
repetitions,
catalog_facts,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reader_equivalence_and_errors() -> Result<()> {
let fixture = Fixture::new()?;
let path = fixture.0.join("test.json");
fs::write(&path, br#"{"minecraft:stone":{"schema":{},"states":[{}]},"unicode":"\u2603","duplicate":1,"duplicate":2}"#)?;
let expected = prepare_facts(&path, Reader::File)?;
for reader in READERS {
assert_eq!(expected, prepare_facts(&path, reader)?);
assert_eq!(
expected.iter().collect::<BTreeMap<_, _>>(),
catalog_facts(&path, reader)?.iter().collect()
);
}
for invalid in [b"{\"a\": ".as_slice(), b"{} trailing", b"{\"a\":\"\xff\"}"] {
fs::write(&path, invalid)?;
for reader in READERS {
let error = prepare_facts(&path, reader).unwrap_err();
assert!(error.downcast_ref::<serde_json::Error>().is_some());
assert!(catalog_facts(&path, reader).is_err());
}
}
fs::remove_file(&path)?;
for reader in READERS {
let error = prepare_facts(&path, reader).unwrap_err();
assert_eq!(
error.downcast_ref::<std::io::Error>().unwrap().kind(),
std::io::ErrorKind::NotFound
);
}
Ok(())
}
}
@@ -9,7 +9,7 @@ use horizon_client::{
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS, index},
light::{LightTables, light_chunk},
light::{LightTables, apply_lighting, light_chunk},
save,
};
use horizon_core::position::ChunkPos;
@@ -101,7 +101,7 @@ fn main() -> horizon_client::Result<()> {
.unwrap_or((None, None));
let nib = |a: &Option<Vec<u8>>| a.as_ref().map(|a| (a[i >> 1] >> ((i & 1) * 4)) & 15);
let id = chunks[&(x, z)].get(wx & 15, wy, wz & 15);
let packed = lit[si].light(i);
let packed = lit[si].get(i);
println!(
"{wy}: {} horizon sky {} block {} vanilla sky {:?} block {:?}",
facts.states[id as usize].name,
@@ -157,7 +157,7 @@ fn main() -> horizon_client::Result<()> {
.map_or(15, |a| (a[(i & 255) >> 1] >> ((i & 1) * 4)) & 15),
};
let vb = nibble(&block, 0);
let packed = section.light(i);
let packed = section.get(i);
let (hs, hb) = (packed >> 4, packed & 15);
total += 1;
if hs != vs {
@@ -228,7 +228,7 @@ fn incremental(
std::array::from_fn(|dx| chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1)))
});
let mut c = chunks[&(x, z)].clone();
c.sections = light_chunk(&region, tables, sky_light);
apply_lighting(&mut c, light_chunk(&region, tables, sky_light));
c.lit = true;
world.insert((x, z), Arc::new(c));
}
@@ -299,7 +299,7 @@ fn incremental(
let full = light_chunk(&region, tables, sky_light);
for (si, s) in full.iter().enumerate() {
for i in 0..4096 {
let (a, b) = (s.light(i), world[&(kx, kz)].sections[si].light(i));
let (a, b) = (s.get(i), world[&(kx, kz)].sections[si].light(i));
if a != b {
bad += 1;
if bad < 20 {
+46 -4
View File
@@ -1,9 +1,11 @@
//! Menu clicks for tools/test_menu_click_parity.py: reads one JSON case per
//! line (menu, creative, slots, carried, data, ops) and prints the oracle's
//! state lines after every click.
//! state lines after every click. The `*_be` furnace menus run over a furnace
//! block entity: its data slots follow `Furnace::input_clicked`.
//!
//! usage: menu_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
containers::{Furnace, FurnaceKind},
crafting::Recipes,
item_data::{canon_stack, parse_stack},
items::Items,
@@ -41,11 +43,25 @@ fn main() -> horizon_client::Result<()> {
recipes: &recipes,
brewing: &brewing,
banners: &banners,
trade: None,
};
let mut out = String::new();
for line in input.lines() {
let case: Value = serde_json::from_str(line)?;
let kind = Kind::parse(case["menu"].as_str().unwrap_or_default()).ok_or("menu kind")?;
let name = case["menu"].as_str().unwrap_or_default();
// FurnaceMenu over the server's AbstractFurnaceBlockEntity.
let furnace_kind = match name {
"furnace_be" => Some(FurnaceKind::Furnace),
"blast_furnace_be" => Some(FurnaceKind::BlastFurnace),
"smoker_be" => Some(FurnaceKind::Smoker),
_ => None,
};
let kind = Kind::parse(
name.strip_suffix("_be")
.filter(|_| furnace_kind.is_some())
.unwrap_or(name),
)
.ok_or("menu kind")?;
let mut menu = Menu::new(kind);
menu.creative = case["creative"].as_bool().unwrap_or(false);
for (i, slot) in case["slots"].as_array().ok_or("slots")?.iter().enumerate() {
@@ -56,6 +72,15 @@ fn main() -> horizon_client::Result<()> {
for (i, v) in case["data"].as_array().ok_or("data")?.iter().enumerate() {
menu.data[i] = v.as_i64().unwrap_or(0) as i32;
}
let mut furnace = furnace_kind.map(|kind| Furnace {
kind,
items: [0, 1, 2].map(|i| menu.slot(i).cloned()),
lit_remaining: menu.data[0],
lit_total: menu.data[1],
cooking_timer: menu.data[2],
cooking_total: menu.data[3],
speed: 1.0,
});
menu.drops.clear();
out += &format!("init {}\n", state(&menu));
for op in case["ops"].as_array().ok_or("ops")? {
@@ -78,8 +103,25 @@ fn main() -> horizon_client::Result<()> {
}
_ => {}
}
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
match menu.click(&env, slot, button, kind) {
let clicked = if op[2] == "QUICK_MOVE_ALL" {
// A Shift double click: lastQuickMoved is the op's fourth field.
let moved = parse_stack(op[3].as_str().unwrap_or("-"))?;
match moved {
Some(moved) => menu.quick_move_all(&env, slot as usize, &moved),
None => Ok(()),
}
} else {
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
menu.click(&env, slot, button, kind)
};
if let Some(f) = furnace.as_mut() {
let before = f.items[0].clone();
f.items = [0, 1, 2].map(|i| menu.slot(i).cloned());
f.input_clicked(&recipes, before);
menu.data[2] = f.cooking_timer;
menu.data[3] = f.cooking_total;
}
match clicked {
Ok(()) => out += &format!("{}\n", state(&menu)),
Err(name) => out += &format!("error {name}\n"),
}
@@ -0,0 +1,47 @@
//! Horizon's sprite mip levels for tools/test_mipmap_parity.py: each sprite
//! loads through `sprites::Sprites` (its .mcmeta settings and transparency)
//! and `mipmap::generate` makes its levels down to 1x1, printed as RGBA hex
//! rows like VanillaMipmapOracle.
use std::{fmt::Write as _, fs, path::Path};
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let input = fs::read_to_string(&args[0])?;
let mut lines = input.lines();
let root = Path::new(lines.next().ok_or("no resources root")?);
let names: Vec<&str> = lines.collect();
let mut sprites = horizon_client::sprites::Sprites::default();
let layers = names
.iter()
.map(|name| sprites.insert(root, name))
.collect::<horizon_client::Result<Vec<u32>>>()?;
sprites.finish();
let size = sprites.size;
let bytes = (size * size * 4) as usize;
let mut out = String::new();
for (name, layer) in names.iter().zip(layers) {
let start = layer as usize * bytes;
let base = horizon_client::mipmap::Image {
width: size,
height: size,
pixels: sprites.pixels[start..start + bytes]
.as_chunks::<4>()
.0
.to_vec(),
};
let s = sprites.mips[layer as usize];
writeln!(out, "== {name}")?;
for mip in horizon_client::mipmap::generate(base, size.trailing_zeros(), s) {
write!(out, "{}x{}", mip.width, mip.height)?;
for row in mip.pixels.chunks(mip.width as usize) {
out.push(' ');
for p in row {
write!(out, "{:02x}{:02x}{:02x}{:02x}", p[0], p[1], p[2], p[3])?;
}
}
out.push('\n');
}
}
fs::write(&args[1], out)?;
Ok(())
}
+253 -12
View File
@@ -5,20 +5,21 @@
//! usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT
use horizon_client::{
assets::Assets,
beehive::HiveHost,
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, HEIGHT, MIN_Y},
collisions,
containers::{BlockEntity, CreakingHeart},
items::{ItemStack, Items},
light::{LightTables, light_chunk},
light::{LightTables, apply_lighting, light_chunk},
mobs::{
Damage, MobType, creaking,
creaking_heart::{self, HeartEvent, HeartHost},
},
mobs::{
Mob, MobData, MobEvent, MobTypes, MobWorld, PlayerView, Seen, Species, Who,
control::Operation, spawning::LocalDifficulty,
control::Operation, seen_grid::SeenGrid, spawning::LocalDifficulty,
},
world::World,
};
@@ -27,7 +28,13 @@ use horizon_core::{
random::LegacyRandom,
};
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
use std::{
collections::{BTreeMap, HashMap},
fmt::Write as _,
fs,
path::Path,
sync::Arc,
};
struct MobSpec {
kind: String,
@@ -54,8 +61,13 @@ struct Scenario {
hearts: Vec<Vec<String>>,
/// `ServerLevel.isSpawningMonsters`.
spawning: bool,
/// The day time moves on with every tick.
daycycle: bool,
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
hurts: Vec<(i32, usize)>,
/// The player feeds an animal at the start of a tick (Animal.mobInteract):
/// (tick, mob index); the scenario only feeds each animal its food.
feeds: Vec<(i32, usize)>,
hand: Option<String>,
mobs: Vec<MobSpec>,
}
@@ -82,6 +94,7 @@ fn parse(text: &str) -> Vec<Scenario> {
"thunder" => s.thunder = true,
"levelseed" => {}
"spawning" => s.spawning = true,
"daycycle" => s.daycycle = true,
_ => panic!("unknown scenario option {kv}"),
}
}
@@ -92,6 +105,7 @@ fn parse(text: &str) -> Vec<Scenario> {
}
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
"feed" => s.feeds.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
"player" => {
s.player = Some([
f[1].parse().unwrap(),
@@ -403,6 +417,65 @@ impl HeartHost for Host<'_> {
self.spawning
}
}
/// A scenario's beehives as `BeehiveBlockEntity.serverTick` sees the level:
/// the bees let out are newcomers seeded as the oracle seeds them.
struct ProbeHive<'a, 'w> {
w: &'a MobWorld<'w>,
random: &'a mut LegacyRandom,
types: &'a HashMap<String, Arc<MobType>>,
/// The index of the first newcomer of this tick.
first: usize,
added: &'a mut Vec<Mob>,
honey: &'a mut Vec<([i32; 3], i32)>,
events: &'a mut Vec<MobEvent>,
}
impl HiveHost for ProbeHive<'_, '_> {
fn random(&mut self) -> &mut LegacyRandom {
self.random
}
fn bees_stay_in_hive(&self, _at: [i32; 3]) -> bool {
horizon_client::mobs::bee::bees_stay_in_hive(self.w)
}
fn blocked(&self, at: [i32; 3]) -> bool {
!self.w.block_collision_boxes(at[0], at[1], at[2]).is_empty()
}
fn set_honey_level(&mut self, at: [i32; 3], level: i32) {
self.honey.push((at, level));
}
fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<Mob> {
let index = self.first + self.added.len();
let mut m =
horizon_client::entity_nbt::mob_from_nbt(tag, self.types, 100_000 + index as u32)?;
if m.species() != Species::Bee {
return None;
}
m.random = LegacyRandom::new(spawn_seed(index));
horizon_client::mobs::bee::draw_cooldowns(&mut m);
Some(m)
}
fn add(&mut self, m: Mob) -> bool {
self.added.push(m);
true
}
fn sound(&mut self, name: &'static str, at: [f64; 3]) {
self.events.push(MobEvent::Sound(name, at, 1.0, 1.0));
}
}
/// The hive's honey level and front from its block state.
fn hive_state(registry: &BlockRegistry, world: &World, at: [i32; 3]) -> (Option<i32>, [i32; 2]) {
let props = registry
.state(world.block(at[0], at[1], at[2]))
.map(|s| s.properties.clone())
.unwrap_or_default();
let honey = props.get("honey_level").and_then(|v| v.parse().ok());
let facing = horizon_client::beehive::facing_step(props.get("facing").map(String::as_str));
(honey, facing)
}
fn is_hive(b: &[String]) -> bool {
b[0] == "block"
&& (b[4].starts_with("minecraft:beehive") || b[4].starts_with("minecraft:bee_nest"))
}
/// The heart's state, block entity and the level random, as the oracle
/// prints them.
fn print_heart(
@@ -466,7 +539,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
/// `player_down`: the scenario's player is no valid target (creative, or
/// killed this tick).
fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool) {
fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i64) {
let _ = write!(out, "{t} {i}");
if m.removed {
// Entity.RemovalReason: the end of the death animation, else a discard.
@@ -585,6 +658,21 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool) {
.map_or("-".into(), |p| format!("{},{},{}", p[0], p[1], p[2]));
let _ = write!(out, "\tcg {} {held} {opened}", m.extra.variant3);
}
if let Some(b) = horizon_client::mobs::bee::get(m) {
let anger = if b.anger_end > 0 {
b.anger_end - now
} else {
-1
};
let flower = b
.flower
.map_or("-".into(), |p| format!("{},{},{}", p[0], p[1], p[2]));
let _ = write!(
out,
"\tbee {} {anger} {} {} {flower}",
b.flags, b.ticks_without_nectar, b.flower_cooldown
);
}
out.push('\n');
}
@@ -680,8 +768,15 @@ fn main() -> horizon_client::Result<()> {
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let motion = horizon_client::motion::MotionResources::load(root, &assets)?;
let data = MobData::load(root, &facts);
let types = MobTypes::load(root)?;
// The types a bee leaving its hive is loaded with (EntityType.loadEntityRecursive).
let arc_types: HashMap<String, Arc<MobType>> = types
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let biomes = Biomes::load(root)?;
let items = Items::load(root, registry, &facts)?;
let tables = LightTables::load(root, registry, &facts)?;
@@ -724,6 +819,11 @@ fn main() -> horizon_client::Result<()> {
let n = |i: usize| b[i].parse::<i32>().unwrap();
if b[0] == "block" || b[0] == "chest" {
world.set(n(1), n(2), n(3), state(registry, &b[4]))?;
if is_hive(b) {
world
.block_entities
.insert((n(1), n(2), n(3)), BlockEntity::Beehive(Box::default()));
}
if b[0] == "chest" {
// chest X Y Z STATE SLOTS: item*count or - per slot.
let mut slots = vec![None; 27];
@@ -774,7 +874,7 @@ fn main() -> horizon_client::Result<()> {
})
});
let mut c = (*world.chunks[&(x, z)]).clone();
c.sections = light_chunk(&region, &tables, true);
apply_lighting(&mut c, light_chunk(&region, &tables, true));
c.lit = true;
((x, z), c)
})
@@ -802,6 +902,10 @@ fn main() -> horizon_client::Result<()> {
for flag in &spec.flags {
match flag.as_str() {
"persistent" => m.persistent = true,
"ridden" => {
m.extra.head = Some("minecraft:red_harness".into());
m.rider = Some(horizon_client::mobs::equine::Rider::default());
}
"noai" => m.no_ai = true,
// Warden.finalizeSpawn's own part.
"digcooldown" | "emerge" => horizon_client::mobs::warden::finalize(
@@ -810,6 +914,7 @@ fn main() -> horizon_client::Result<()> {
&mut Vec::new(),
),
"inlove" => m.extra.in_love = 600,
"nectar" => horizon_client::mobs::bee::set_has_nectar(&mut m, true),
"tame" => m.extra.tame = true,
x if x.starts_with("age=") => m.extra.age = x[4..].parse().unwrap(),
x if x.starts_with("strength=") => m.extra.variant2 = x[9..].parse().unwrap(),
@@ -830,6 +935,10 @@ fn main() -> horizon_client::Result<()> {
}
}
m.random = LegacyRandom::new(spec.seed);
if m.species() == Species::Bee {
// The oracle draws the constructor's cooldowns again once seeded.
horizon_client::mobs::bee::draw_cooldowns(&mut m);
}
if horizon_client::mobs::monster_brain::has_brain(m.species()) {
// The oracle delays the sensors again with the seeded random
// and seeds RunOne's shuffles with the mob's seed.
@@ -893,22 +1002,30 @@ fn main() -> horizon_client::Result<()> {
}
});
let mut scratch = LegacyRandom::new(0);
// The level's random for eating sounds: only goats and sniffers draw
// from it, and the scenarios feed neither.
let mut level_random = LegacyRandom::new(0);
for (i, m) in mobs.iter().enumerate() {
print(&mut out, 0, i, m, s.creative);
print(&mut out, 0, i, m, s.creative, world.game_time);
}
// The MobWorld every step sees, over a snapshot of the mobs.
macro_rules! mob_world {
($snapshot:expr) => {
mob_world!($snapshot, None)
};
($snapshot:expr, $grid:expr) => {
MobWorld {
teams: Default::default(),
world: &world,
registry,
facts: &facts,
movement: &motion.blocks,
collision: &collision,
data: &data,
player: player.clone(),
others: $snapshot,
daylight: (s.time.rem_euclid(24000)) < 12000,
others_grid: $grid,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
game_time: world.game_time,
day_time: world.day_time,
raining: s.rain,
@@ -928,6 +1045,9 @@ fn main() -> horizon_client::Result<()> {
}
for t in 1..=s.ticks {
world.game_time += 1;
if s.daycycle {
world.day_time = s.time + i64::from(t);
}
let mut heart_events_later: Vec<HeartEvent> = Vec::new();
// Creaking.tick reads the heart's block entity.
for (at, h) in &hearts {
@@ -949,6 +1069,8 @@ fn main() -> horizon_client::Result<()> {
p.yaw = yaw;
}
let mut snapshot: Vec<Seen> = mobs.iter().filter(|m| !m.removed).map(seen).collect();
// The snapshot by position, kept as the game keeps it.
let mut grid = SeenGrid::new(snapshot.iter().map(|e| e.bounds));
let w = mob_world!(&snapshot);
let mut events = Vec::new();
let mut heart_events = Vec::new();
@@ -996,6 +1118,16 @@ fn main() -> horizon_client::Result<()> {
}
events.append(&mut mobs[i].outbox);
}
for &(tick, i) in &s.feeds {
if tick != t || i >= mobs.len() || mobs[i].removed {
continue;
}
if let Some(Some((name, volume, pitch))) =
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
{
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch));
}
}
#[allow(unused_mut)] // filled only with the rng-trace feature
let mut traces: Vec<String> = Vec::new();
let mut pushes = Vec::new();
@@ -1004,7 +1136,7 @@ fn main() -> horizon_client::Result<()> {
if mobs[i].removed {
continue;
}
let w = mob_world!(&snapshot);
let w = mob_world!(&snapshot, Some(&grid));
let m = &mut mobs[i];
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
let from = events.len();
@@ -1083,11 +1215,47 @@ fn main() -> horizon_client::Result<()> {
_ => {}
}
}
// BeeEnterHiveGoal.start: BeehiveBlockEntity.addOccupant.
let entering: Vec<[i32; 3]> = events[from.min(events.len())..]
.iter()
.filter_map(|e| match e {
MobEvent::EnterHive { hive, .. } => Some(*hive),
_ => None,
})
.collect();
for at in entering {
let key = (at[0], at[1], at[2]);
let Some(BlockEntity::Beehive(mut hive)) = world.block_entities.remove(&key)
else {
continue;
};
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None);
let flower = horizon_client::mobs::bee::get(&mobs[i]).and_then(|b| b.flower);
let mut honey = Vec::new();
let stored = {
let w = mob_world!(&snapshot);
let mut host = ProbeHive {
w: &w,
random: &mut scratch,
types: &arc_types,
first: 0,
added: &mut Vec::new(),
honey: &mut honey,
events: &mut events,
};
hive.add_occupant(at, tag, flower, &mut host)
};
world.block_entities.insert(key, BlockEntity::Beehive(hive));
if stored {
mobs[i].removed = true;
}
}
// ServerLevel ticks entities one after another: the ones
// after this mob see it as it is now.
if let Some(e) = snapshot.iter_mut().find(|e| e.id == mobs[i].id) {
*e = seen(&mobs[i]);
e.alive &= !mobs[i].removed;
if let Some(k) = snapshot.iter().position(|e| e.id == mobs[i].id) {
snapshot[k] = seen(&mobs[i]);
snapshot[k].alive &= !mobs[i].removed;
grid.update(k, snapshot[k].bounds);
}
}
for m in &mut mobs {
@@ -1115,6 +1283,43 @@ fn main() -> horizon_client::Result<()> {
added.append(&mut host.added);
}
drop(w);
// Then the hives (BeehiveBlockEntity.serverTick).
let mut hive_keys: Vec<(i32, i32, i32)> = world
.block_entities
.iter()
.filter(|(_, e)| matches!(e, BlockEntity::Beehive(_)))
.map(|(k, _)| *k)
.collect();
hive_keys.sort_unstable();
let mut honey = Vec::new();
for key in hive_keys {
let at = [key.0, key.1, key.2];
let (level, facing) = hive_state(registry, &world, at);
let Some(BlockEntity::Beehive(mut hive)) = world.block_entities.remove(&key) else {
continue;
};
{
let w = mob_world!(&snapshot);
let mut host = ProbeHive {
w: &w,
random: &mut scratch,
types: &arc_types,
first: mobs.len() + born.len(),
added: &mut added,
honey: &mut honey,
events: &mut events,
};
hive.server_tick(at, facing, level, &mut host);
}
world.block_entities.insert(key, BlockEntity::Beehive(hive));
}
for (at, level) in honey {
if let Some(mut st) = registry.state(world.block(at[0], at[1], at[2])).cloned() {
st.properties
.insert("honey_level".to_owned(), level.to_string());
world.set(at[0], at[1], at[2], registry.resolve(&st)?)?;
}
}
mobs.append(&mut born);
mobs.append(&mut added);
for e in &heart_events {
@@ -1133,7 +1338,14 @@ fn main() -> horizon_client::Result<()> {
.iter()
.any(|e| player_hurt(player.as_ref(), e) && player_damage(e) >= 20.0);
for (i, m) in mobs.iter().enumerate() {
print(&mut out, t, i, m, s.creative || player_killed);
print(
&mut out,
t,
i,
m,
s.creative || player_killed,
world.game_time,
);
}
for e in &events {
if let MobEvent::Sound(name, _, volume, pitch) = e {
@@ -1190,6 +1402,35 @@ fn main() -> horizon_client::Result<()> {
}
);
}
for b in s.blocks.iter().filter(|b| is_hive(b)) {
let n = |i: usize| b[i].parse::<i32>().unwrap();
let Some(BlockEntity::Beehive(hive)) =
world.block_entities.get(&(n(1), n(2), n(3)))
else {
continue;
};
let (level, _) = hive_state(registry, &world, [n(1), n(2), n(3)]);
let bees: Vec<String> = hive
.bees
.iter()
.map(|b| format!("{}/{}", b.ticks_in_hive, b.min_ticks_in_hive))
.collect();
let _ = writeln!(
out,
"{t} hive {},{},{} honey {} bees {} flower {}",
b[1],
b[2],
b[3],
level.unwrap_or(-1),
if bees.is_empty() {
"-".into()
} else {
bees.join(",")
},
hive.flower
.map_or("-".into(), |f| format!("{},{},{}", f[0], f[1], f[2]))
);
}
for line in traces {
let _ = writeln!(out, "{line}");
}
@@ -0,0 +1,129 @@
//! Full block/assets + item startup. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1
//! for live requested heap bytes; leave it unset for latency measurements.
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
use horizon_client::{assets::Assets, item_render::ItemRenderer};
use std::{
alloc::{GlobalAlloc, Layout, System},
hash::{Hash, Hasher},
path::Path,
sync::atomic::{AtomicUsize, Ordering::Relaxed},
time::Instant,
};
struct CountingAllocator;
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
static LIVE: AtomicUsize = AtomicUsize::new(0);
// The allocator delegates every operation and layout unchanged to System.
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let ptr = unsafe { System.alloc(layout) };
if TRACK && !ptr.is_null() {
LIVE.fetch_add(layout.size(), Relaxed);
}
ptr
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
let ptr = unsafe { System.alloc_zeroed(layout) };
if TRACK && !ptr.is_null() {
LIVE.fetch_add(layout.size(), Relaxed);
}
ptr
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
if TRACK {
LIVE.fetch_sub(layout.size(), Relaxed);
}
unsafe { System.dealloc(ptr, layout) };
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
let out = unsafe { System.realloc(ptr, layout, size) };
if TRACK && !out.is_null() {
LIVE.fetch_sub(layout.size(), Relaxed);
LIVE.fetch_add(size, Relaxed);
}
out
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("usage: model_library_bench ROOT [--parity]")?;
let root = Path::new(&root);
let initial = LIVE.load(Relaxed);
let start = Instant::now();
let assets = Assets::load(root)?;
let block_ms = start.elapsed().as_secs_f64() * 1000.;
let block_retained = LIVE.load(Relaxed) - initial;
let start_items = Instant::now();
let items = ItemRenderer::load_with_library(root, assets.model_library.clone())?;
let item_ms = start_items.elapsed().as_secs_f64() * 1000.;
let total_ms = start.elapsed().as_secs_f64() * 1000.;
let retained = LIVE.load(Relaxed) - initial;
println!(
"block_ms={block_ms:.3} item_ms={item_ms:.3} total_ms={total_ms:.3} retained_bytes={retained} block_retained_bytes={block_retained}"
);
if std::env::args().any(|a| a == "--parity") {
let mut hash = std::collections::hash_map::DefaultHasher::new();
for id in 0..assets.registry.len() {
let id = horizon_world::registry::BlockStateId(id as u32);
for pos in [
horizon_core::position::BlockPos::new(0, 0, 0),
horizon_core::position::BlockPos::new(-17, 65, 31),
] {
if let Ok(selected) = assets.model_catalog.collect(id, pos) {
for (variant, model) in selected {
format!("{variant:?}").hash(&mut hash);
model.ambient_occlusion.hash(&mut hash);
for face in &model.faces {
format!("{:?}", face.quad).hash(&mut hash);
face.layer.hash(&mut hash);
face.opaque.hash(&mut hash);
face.translucent.hash(&mut hash);
}
}
}
}
}
let mut names = Vec::new();
for ns in std::fs::read_dir(root.join("assets"))? {
let ns = ns?;
let base = ns.path().join("models");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries {
let path = entry?.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "json") {
names.push(format!(
"{}:{}",
ns.file_name().to_string_lossy(),
path.strip_prefix(&base)?
.with_extension("")
.to_string_lossy()
.replace('\\', "/")
));
}
}
}
}
names.sort();
for name in &names {
format!("{:?}", items.bake(name)).hash(&mut hash);
}
println!(
"parity_models={} fingerprint={:016x}",
names.len(),
hash.finish()
);
}
std::hint::black_box((&assets, &items));
Ok(())
}
@@ -0,0 +1,291 @@
//! Compare the old two-parse loading path with catalog construction from shared facts.
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
use horizon_client::{Result, models::StateModels, sprites::Sprites};
use horizon_core::position::BlockPos;
use horizon_world::{
DATA_VERSION,
chunk::BlockState,
nbt::Tag,
registry::{BlockRegistry, BlockStateId},
};
use serde_json::{Value, json};
use std::{collections::BTreeMap, fs, hint::black_box, path::PathBuf, time::Instant};
struct Fixture {
root: PathBuf,
registry: BlockRegistry,
bytes: usize,
blocks: usize,
}
impl Fixture {
fn new(target_bytes: usize) -> Result<Self> {
let root =
std::env::temp_dir().join(format!("horizon-model-states-{}", std::process::id()));
fs::create_dir_all(root.join("assets/minecraft/blockstates"))?;
fs::create_dir_all(root.join("assets/minecraft/models/block"))?;
let mut possible = Vec::new();
for age in 0..8 {
for facing in ["north", "south", "east", "west"] {
for waterlogged in ["false", "true"] {
possible.push(
json!({"age":age.to_string(), "facing":facing, "waterlogged":waterlogged}),
);
}
}
}
let fact = json!({"schema":{
"age":{"integer":[0,7]},
"facing":{"named":["north","south","east","west"]},
"waterlogged":{"named":["false","true"]}
},"states":possible});
let blocks = (target_bytes / (serde_json::to_vec(&fact)?.len() + 26)).max(1);
let mut facts = BTreeMap::new();
let mut states = Vec::new();
let mut defaults = BTreeMap::new();
for i in 0..blocks {
let block = format!("fixture_{i:04}");
let name = format!("minecraft:{block}");
defaults.insert(name.as_str().into(), Tag::Int(states.len() as i32));
for properties in &possible {
let state = BlockState {
name: name.clone(),
properties: serde_json::from_value(properties.clone())?,
};
states.push(state.to_nbt());
}
facts.insert(name, fact.clone());
fs::write(
root.join(format!("assets/minecraft/blockstates/{block}.json")),
r#"{"multipart":[{"apply":[{"model":"minecraft:block/fixture","weight":2},{"model":"minecraft:block/fixture","y":90,"weight":1}]},{"when":{"waterlogged":"true"},"apply":{"model":"minecraft:block/fixture","y":180,"uvlock":true}}]}"#,
)?;
}
fs::write(
root.join("assets/minecraft/models/block/fixture.json"),
r##"{"textures":{"all":"minecraft:block/fixture"},"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"north":{"texture":"#all","cullface":"north"}}}]}"##,
)?;
let document = serde_json::to_vec(&facts)?;
let bytes = document.len();
fs::write(root.join("model-states.json"), document)?;
let registry = BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
("states", Tag::List(states)),
("defaults", Tag::Compound(defaults)),
]))?;
Ok(Self {
root,
registry,
bytes,
blocks,
})
}
fn facts(&self) -> Result<BTreeMap<String, Value>> {
Ok(serde_json::from_slice(&fs::read(
self.root.join("model-states.json"),
)?)?)
}
fn block_names<'a>(&self, names: impl Iterator<Item = &'a String>) -> Vec<&'a str> {
names
.filter(|name| self.registry.default_state(name).is_some())
.filter_map(|name| name.strip_prefix("minecraft:"))
.collect()
}
fn load(&self, reuse: bool) -> Result<(StateModels, Sprites)> {
// The first parse and block filtering match models::prepare in both paths.
let mut sprites = Sprites::default();
let catalog = if reuse {
let facts = self.facts()?;
let blocks = self.block_names(facts.keys());
StateModels::from_facts(&self.root, &self.registry, &blocks, &mut sprites, &facts)?
} else {
let facts: serde_json::Map<String, Value> =
serde_json::from_slice(&fs::read(self.root.join("model-states.json"))?)?;
let blocks = self.block_names(facts.keys());
StateModels::load(&self.root, &self.registry, &blocks, &mut sprites)?
};
Ok((catalog, sprites))
}
fn verify(&self) -> Result<()> {
let (before, before_sprites) = self.load(false)?;
let (after, after_sprites) = self.load(true)?;
assert_eq!(before_sprites.layers, after_sprites.layers);
assert_eq!(before_sprites.opaque, after_sprites.opaque);
assert_eq!(before_sprites.translucent, after_sprites.translucent);
for id in 0..self.registry.len() {
for position in [
BlockPos::new(0, 0, 0),
BlockPos::new(12, -4, 7),
BlockPos::new(42, 9, -19),
] {
let a = before.collect(BlockStateId(id as u32), position)?;
let b = after.collect(BlockStateId(id as u32), position)?;
assert_eq!(a.len(), b.len());
for ((av, am), (bv, bm)) in a.into_iter().zip(b) {
assert_eq!(av, bv);
assert_eq!(am.ambient_occlusion, bm.ambient_occlusion);
assert_eq!(am.cube, bm.cube);
assert_eq!(am.faces.len(), bm.faces.len());
for (af, bf) in am.faces.iter().zip(&bm.faces) {
assert_eq!(af.quad, bf.quad);
assert_eq!(
(af.layer, af.opaque, af.translucent),
(bf.layer, bf.opaque, bf.translucent)
);
}
}
}
}
Ok(())
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn median(values: &mut [f64]) -> f64 {
values.sort_by(f64::total_cmp);
values[values.len() / 2]
}
fn main() -> Result<()> {
let repetitions: usize = std::env::args()
.nth(1)
.map(|s| s.parse())
.transpose()?
.unwrap_or(15);
if repetitions == 0 {
return Err("repetitions must be positive".into());
}
let fixture = Fixture::new(2_840_000)?;
fixture.verify()?;
for _ in 0..2 {
for reuse in [false, true] {
drop(black_box(fixture.load(reuse)?));
}
}
let mut before = Vec::new();
let mut after = Vec::new();
for iteration in 0..repetitions {
// Alternate order to limit cache/order bias. Both paths use slice parsing.
for reuse in if iteration % 2 == 0 {
[false, true]
} else {
[true, false]
} {
let started = Instant::now();
drop(black_box(fixture.load(black_box(reuse))?));
let ms = started.elapsed().as_secs_f64() * 1000.0;
if reuse {
after.push(ms);
} else {
before.push(ms);
}
}
}
let before = median(&mut before);
let after = median(&mut after);
println!(
"fixture: {} bytes, {} blocks, {} states; equality: all states at 3 positions",
fixture.bytes,
fixture.blocks,
fixture.registry.len()
);
println!(
"2 warmups/path, {repetitions} samples/path; median before {before:.3} ms, after {after:.3} ms; {:.3}x speedup ({:.2}% less time)",
before / after,
(1.0 - after / before) * 100.0
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_facts_match_load_and_preserve_errors() -> Result<()> {
let fixture = Fixture::new(1)?;
fixture.verify()?;
let mut facts = fixture.facts()?;
let blocks = ["fixture_0000"];
let load_error = |facts: &BTreeMap<String, Value>| -> Result<String> {
fs::write(
fixture.root.join("model-states.json"),
serde_json::to_vec(facts)?,
)?;
let a = StateModels::load(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default(),
)
.err()
.ok_or("expected load error")?
.to_string();
let b = StateModels::from_facts(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default(),
facts,
)
.err()
.ok_or("expected shared facts error")?
.to_string();
assert_eq!(a, b);
Ok(a)
};
let good = facts.clone();
facts.clear();
assert_eq!(load_error(&facts)?, "missing block model state schema");
facts = good.clone();
facts.get_mut("minecraft:fixture_0000").unwrap()["schema"] = Value::Null;
assert_eq!(load_error(&facts)?, "invalid model schema");
facts = good.clone();
facts.get_mut("minecraft:fixture_0000").unwrap()["states"]
.as_array_mut()
.unwrap()
.reverse();
assert_eq!(
load_error(&facts)?,
"model state definitions disagree with the imported block registry"
);
fs::remove_file(fixture.root.join("model-states.json"))?;
// A parsed document remains usable without reopening its source file.
StateModels::from_facts(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default(),
&good,
)?;
assert!(
StateModels::load(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default()
)
.is_err()
);
fs::write(fixture.root.join("model-states.json"), b"null")?;
assert!(
StateModels::load(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default()
)
.is_err()
);
fs::write(fixture.root.join("model-states.json"), b"{")?;
assert!(
StateModels::load(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default()
)
.is_err()
);
Ok(())
}
}
@@ -9,13 +9,24 @@
//! 1.0), foxes and axolotls from the third, sulfur cubes have size 2, salmon
//! are small, slimes and magma cubes keep size 1, tropical fish school.
//!
//! Spawner cases (`mode=spawner`, tools/test_spawner_parity.py) tick a
//! `mobs::spawner::Spawner` loaded from the case's SNBT on the same world and
//! print its events tick by tick.
//!
//! Trial spawner cases (`mode=trial`, tools/test_trial_spawner_parity.py) tick
//! a `mobs::trial_spawner::TrialSpawner` with the game time advancing and
//! print its states, events, spawns and ejected items.
//!
//! Vault cases (`mode=vault`, tools/test_vault_parity.py) tick a
//! `vault::Vault` the same way, with players moving and using keys on it.
//!
//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
assets::Assets,
biome::{Biomes, SpawnEntry},
blocks::BlockFacts,
collisions::{self, LookupCollisions, no_collision_empty},
items::Items,
items::{ItemStack, Items},
mobs::{
MobTypes, Species,
data::MobData,
@@ -25,19 +36,25 @@ use horizon_client::{
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
spawn_box,
},
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerLevel, SpawnerSpawn},
species::{self, Baby},
trial_spawner::{
Omen, TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner,
TrialSpawnerLevel, TrialState,
},
},
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
};
use horizon_core::{
geometry::Aabb,
random::{LegacyRandom, RandomSource},
};
use horizon_world::{
block_collision::BlockCollisionRegistry, chunk::BlockState, registry::BlockStateId,
block_collision::BlockCollisionRegistry, chunk::BlockState, nbt::Tag, registry::BlockStateId,
};
use serde_json::Value;
use std::{
cell::RefCell,
cell::{Cell, RefCell},
collections::{BTreeMap, HashMap, HashSet},
fmt::Write as _,
fs,
@@ -73,6 +90,8 @@ struct Shared<'a> {
structures: &'a StructureSpawns,
collision: &'a BlockCollisionRegistry<'a>,
air: BlockStateId,
/// Every entity type id (`entity-types.json`).
entity_types: &'a HashSet<String>,
}
struct World<'a> {
s: &'a Shared<'a>,
@@ -95,8 +114,22 @@ struct World<'a> {
respawn: Option<[i32; 3]>,
unloaded: HashSet<(i32, i32)>,
spawn_mobs: bool,
/// Boxes of living entities (case entities, then spawned mobs).
entities: RefCell<Vec<Aabb>>,
/// The `spawner_blocks_work` game rule.
spawners_work: bool,
/// Types and boxes of living entities (case entities, then spawned mobs).
entities: RefCell<Vec<(String, Aabb)>>,
/// Blocks set since the case started (a trial spawner's states).
placed: RefCell<Vec<([i32; 3], BlockStateId)>>,
/// The game time (trial cases advance it).
time: Cell<i64>,
/// The mobs a trial spawner added: UUID, position, height and box.
trial_mobs: RefCell<Vec<([i32; 4], [f64; 3], f32, Aabb)>>,
/// How many mobs a trial spawner added (their UUIDs count up).
trial_count: Cell<i32>,
/// Each player's omen and its amplifier (`/trial_omen:A`, `/bad_omen:A`).
omens: RefCell<Vec<Option<(Omen, i32)>>>,
/// Loot tables (trial cases).
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
}
impl World<'_> {
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
@@ -120,6 +153,15 @@ impl World<'_> {
}
impl SpawnLevel for World<'_> {
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
if let Some((_, s)) = self
.placed
.borrow()
.iter()
.rev()
.find(|(p, _)| *p == [x, y, z])
{
return *s;
}
self.blocks
.iter()
.rev()
@@ -280,10 +322,21 @@ impl NaturalLevel for World<'_> {
block: &lookup,
binding: self.s.collision,
};
// getEntityCollisions(null, box): living shulkers are solid.
let grown = bounds.inflate(horizon_core::geometry::Vec3::new(1e-7, 1e-7, 1e-7));
no_collision_empty(&mut world, bounds)
&& !self
.entities
.borrow()
.iter()
.any(|(id, b)| id == "minecraft:shulker" && b.intersects(grown))
}
fn unobstructed(&self, bounds: Aabb) -> bool {
!self.entities.borrow().iter().any(|b| b.intersects(bounds))
!self
.entities
.borrow()
.iter()
.any(|(_, b)| b.intersects(bounds))
}
fn spawn_mobs(&self) -> bool {
self.spawn_mobs
@@ -299,6 +352,111 @@ impl NaturalLevel for World<'_> {
}
}
impl SpawnerLevel for World<'_> {
fn alive_player_near(&self, x: f64, y: f64, z: f64, range: f64) -> bool {
self.players.iter().any(|(p, mode)| {
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
mode != "spectator" && (range < 0.0 || d < range * range)
})
}
fn spawner_blocks_work(&self) -> bool {
self.spawners_work
}
fn entity_exists(&self, id: &str) -> bool {
self.s.types.types.contains_key(id) || self.s.entity_types.contains(id)
}
fn count_entities(&self, id: &str, bounds: Aabb) -> usize {
self.entities
.borrow()
.iter()
.filter(|(k, b)| k == id && b.intersects(bounds))
.count()
}
}
impl TrialSpawnerLevel for World<'_> {
fn game_time(&self) -> i64 {
self.time.get()
}
fn mob_spawning(&self) -> bool {
self.spawn_mobs
}
fn players(&self) -> Vec<TrialPlayer> {
self.players
.iter()
.enumerate()
.map(|(i, (p, mode))| TrialPlayer {
uuid: [0, 0, 0, i as i32 + 1],
block: p.map(|v| v.floor() as i32),
eye: [p[0], p[1] + f64::from(1.62f32), p[2]],
creative: mode == "creative",
spectator: mode == "spectator",
position: *p,
height: 1.8,
alive: true,
omen: self
.omens
.borrow()
.get(i)
.copied()
.flatten()
.map(|(o, _)| o),
})
.collect()
}
fn visual_hit(&self, from: [f64; 3], to: [f64; 3]) -> Option<[i32; 3]> {
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
let mut scene = LookupCollisions {
block: &lookup,
binding: self.s.collision,
};
let v = |p: [f64; 3]| horizon_core::geometry::Vec3::new(p[0], p[1], p[2]);
horizon_client::mobs::visual_clip(
self.s.facts,
self.s.collision,
&mut scene,
&lookup,
v(from),
v(to),
)
.flatten()
}
fn entity_block(&self, uuid: [i32; 4]) -> Option<[i32; 3]> {
let mobs = self.trial_mobs.borrow();
let (_, p, ..) = mobs.iter().find(|(u, ..)| *u == uuid)?;
Some(p.map(|v| v.floor() as i32))
}
fn entity_box(&self, uuid: [i32; 4]) -> Option<([f64; 3], f32)> {
let mobs = self.trial_mobs.borrow();
let (_, p, h, _) = mobs.iter().find(|(u, ..)| *u == uuid)?;
Some((*p, *h))
}
fn no_block_collision(&self, block: [i32; 3]) -> bool {
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
let mut scene = LookupCollisions {
block: &lookup,
binding: self.s.collision,
};
let state = self.state(block[0], block[1], block[2]);
self.s
.collision
.collision_shape(&mut scene, state, block)
.map_or(true, |shape| shape.is_empty())
}
fn loot_items(&self, table: &str, seed: i64) -> Vec<horizon_client::items::ItemStack> {
let Some(loot) = &self.loot else {
return Vec::new();
};
loot.roll_split(table, &mut LegacyRandom::new(seed))
.iter()
.map(horizon_client::structure_blocks::loot_item_with_components)
.collect()
}
fn seed(&self) -> i64 {
self.seed
}
}
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
fn neutral_box(l: &World, r: &SpawnRequest) -> Aabb {
let t = &l.s.types.types[r.entity];
@@ -365,10 +523,28 @@ fn main() -> horizon_client::Result<()> {
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
// Trial spawner rewards: the loot tables and the configs.
let loot = if input.contains("mode=trial") || input.contains("mode=vault") {
horizon_client::mobs::trial_spawner::load_registry(root);
horizon_client::structure_blocks::loot_data(root)
} else {
None
};
let types = MobTypes::load(root)?;
let biomes = Biomes::load(root)?;
let structures = StructureSpawns::load(root);
let collision = collisions::load(root, registry)?;
let entity_types: HashSet<String> = fs::read(root.join("entity-types.json"))
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| {
v.as_object().map(|o| {
o.keys()
.map(|k| horizon_client::command::args::normalize(k))
.collect()
})
})
.unwrap_or_default();
let shared = Shared {
facts: &facts,
data: &data,
@@ -378,6 +554,7 @@ fn main() -> horizon_client::Result<()> {
structures: &structures,
collision: &collision,
air: facts.air,
entity_types: &entity_types,
};
let mut adaptation: HashMap<String, bool> = HashMap::new();
let mut out = String::new();
@@ -456,6 +633,19 @@ fn main() -> horizon_client::Result<()> {
bounds: Bx(b),
});
}
// A player's third field: an omen and its amplifier.
let omens = indexed("p")
.iter()
.map(|p| {
let (effect, amplifier) = p.split('/').nth(2)?.split_once(':')?;
let omen = if effect == "trial_omen" {
Omen::Trial
} else {
Omen::Bad
};
Some((omen, amplifier.parse().ok()?))
})
.collect();
let players = indexed("p")
.into_iter()
.map(|p| {
@@ -538,12 +728,24 @@ fn main() -> horizon_client::Result<()> {
respawn,
unloaded,
spawn_mobs: get("rules", "1") != "0",
spawners_work: get("spawners", "1") != "0",
placed: RefCell::new(Vec::new()),
time: Cell::new(0),
trial_mobs: RefCell::new(Vec::new()),
trial_count: Cell::new(0),
omens: RefCell::new(omens),
loot: loot.clone(),
entities: RefCell::new(
case_entities
.iter()
.filter_map(|(t, p, _)| {
let t = types.types.get(t)?;
Some(spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32))
.filter_map(|(id, p, _)| {
let t = types.types.get(id)?;
// Entity.setPos (a shulker sits at its block's centre).
let p = horizon_client::mobs::spawner::entity_position(t.species, *p);
Some((
id.clone(),
spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32),
))
})
.collect(),
),
@@ -560,14 +762,22 @@ fn main() -> horizon_client::Result<()> {
let mut log: Vec<String> = Vec::new();
let mut spawn = |l: &World, r: &SpawnRequest| -> Option<Spawned> {
let bounds = neutral_box(l, r);
l.entities.borrow_mut().push(bounds);
l.entities.borrow_mut().push((r.entity.to_owned(), bounds));
Some(Spawned {
bounds,
school: true,
})
};
let mode = get("mode", "chunk");
if mode == "gen" {
if mode == "spawner" {
spawner_case(&world, &get, &mut random, &mut log)?;
} else if mode == "trial" {
let loot = loot.as_ref().ok_or("no loot tables")?;
trial_case(&world, registry, loot, &get, &mut random, &mut log)?;
} else if mode == "vault" {
let loot = loot.as_ref().ok_or("no loot tables")?;
vault_case(world.players.clone(), loot, &get, &mut random, &mut log)?;
} else if mode == "gen" {
let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?);
// The level random of the spawn predicates: no creature's predicate
// draws from it (the oracle's answers fixed values).
@@ -686,3 +896,529 @@ fn main() -> horizon_client::Result<()> {
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
/// `SpawnerBlockEntity.serverTick` `ticks` times at `spos` for the spawner in
/// `snbt` (base64 SNBT), as the oracle logs it.
fn spawner_case(
world: &World,
get: &dyn Fn(&str, &'static str) -> String,
random: &mut LegacyRandom,
log: &mut Vec<String>,
) -> horizon_client::Result<()> {
let v: Vec<i32> = get("spos", "0,0,0")
.split(',')
.map(|n| n.parse().unwrap_or(0))
.collect();
let pos = [v[0], v[1], v[2]];
let text = horizon_client::skins::base64_decode(&get("snbt", "")).ok_or("bad snbt")?;
let tag = horizon_world::nbt::parse_snbt(&String::from_utf8(text)?)?.0;
let mut spawner = Spawner::load(&tag);
let ticks: i32 = get("ticks", "1").parse()?;
for tick in 0..ticks {
let mut events = Vec::new();
let mut spawned = std::collections::VecDeque::new();
let mut spawn = |l: &World, r: &SpawnerSpawn| -> Option<SpawnerAdded> {
let t = l.s.types.types.get(r.id)?;
let [x, y, z] = r.position;
let bounds = spawn_box(x, y, z, t.width as f32, t.height as f32);
l.entities.borrow_mut().push((r.id.to_owned(), bounds));
spawned.push_back(format!(
"{tick} spawn {} {} {} {} {}",
r.id,
bits(x),
bits(y),
bits(z),
// Shulker.finalizeSpawn faces it south.
yaw_text(
r.id,
if r.finalize && r.id == "minecraft:shulker" {
0.0
} else {
r.yaw
}
)
));
Some(SpawnerAdded {
id: spawned.len() as u32,
bounds,
mob: true,
})
};
spawner.server_tick(world, random, pos, &mut spawn, &mut events);
for e in events {
match e {
SpawnerEvent::BlockUpdated => log.push(format!("{tick} update")),
SpawnerEvent::BlockEvent(id) => log.push(format!("{tick} bevent {id} 0")),
// Each spawn's level event follows its tryAddFreshEntityWithPassengers.
SpawnerEvent::LevelEvent(id) => {
log.extend(spawned.pop_front());
log.push(format!("{tick} levent {id} 0"));
}
SpawnerEvent::EntityPlace(_, p) => log.push(format!(
"{tick} gevent minecraft:entity_place {} {} {}",
p[0], p[1], p[2]
)),
SpawnerEvent::SpawnAnim(_) => log.push(format!("{tick} eevent 20")),
}
}
}
let next = spawner
.next_data()
.and_then(|d| d.id())
.unwrap_or_else(|| "-".into());
let pots: Vec<String> = spawner
.potentials
.iter()
.map(|(d, w)| format!("{}*{w}", d.id().unwrap_or_else(|| "-".into())))
.collect();
log.push(format!(
"end delay={} next={next} pots={}",
spawner.spawn_delay as i16,
pots.join(",")
));
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
Ok(())
}
/// `TrialSpawner.tickServer` `ticks` times from game time `t0` at `spos` on a
/// trial spawner block in state `state` (ominous with `ominous=1`) loaded from
/// `snbt` (base64 SNBT), as the oracle logs it. Every `kill` ticks the mobs it
/// spawned die.
fn trial_case(
world: &World,
registry: &horizon_world::registry::BlockRegistry,
loot: &horizon_world::loot::LootData,
get: &dyn Fn(&str, &'static str) -> String,
random: &mut LegacyRandom,
log: &mut Vec<String>,
) -> horizon_client::Result<()> {
let v: Vec<i32> = get("spos", "0,0,0")
.split(',')
.map(|n| n.parse().unwrap_or(0))
.collect();
let pos = [v[0], v[1], v[2]];
let block_state = |state: TrialState, ominous: bool| {
parse_state(&format!(
"minecraft:trial_spawner[trial_spawner_state={},ominous={ominous}]",
state.name()
))
};
let mut state =
TrialState::from_name(&get("state", "waiting_for_players")).ok_or("bad state")?;
let mut ominous = get("ominous", "0") == "1";
world
.placed
.borrow_mut()
.push((pos, registry.resolve(&block_state(state, ominous))?));
let text = horizon_client::skins::base64_decode(&get("snbt", "")).ok_or("bad snbt")?;
let tag = horizon_world::nbt::parse_snbt(&String::from_utf8(text)?)?.0;
let mut spawner = TrialSpawner::load(&tag);
let ticks: i64 = get("ticks", "1").parse()?;
let t0: i64 = get("t0", "0").parse()?;
let kill: i64 = get("kill", "0").parse()?;
let seed = world.seed;
let mut sequences = horizon_client::random_sequences::RandomSequences::default();
let center = pos.map(|v| f64::from(v) + 0.5);
for tick in 0..ticks {
world.time.set(t0 + tick);
let mut events = Vec::new();
let mut spawned = std::collections::VecDeque::new();
let mut items = Vec::new();
let mut spawn = |l: &World, r: &SpawnerSpawn| {
let t = l.s.types.types.get(r.id)?;
// Slime.readAdditionalSaveData: size Size + 1, clamped to 1..=127.
let size = match r.entity.get(&horizon_world::nbt::JavaString::from("Size")) {
Some(Tag::Byte(v)) => i32::from(*v),
Some(Tag::Short(v)) => i32::from(*v),
Some(Tag::Int(v)) => *v,
Some(Tag::Long(v)) => *v as i32,
_ => 0,
};
let size = if matches!(r.id, "minecraft:slime" | "minecraft:magma_cube") {
size.saturating_add(1).clamp(1, 127)
} else {
1
};
let [x, y, z] = r.position;
let scale = size as f32;
let bounds = spawn_box(x, y, z, t.width as f32 * scale, t.height as f32 * scale);
l.entities.borrow_mut().push((r.id.to_owned(), bounds));
let uuid = [1, 0, 0, l.trial_count.get()];
l.trial_count.set(l.trial_count.get() + 1);
// Zombie.readAdditionalSaveData: IsBaby halves the box
// (getAgeScale); finalizeSpawn's fixed random never makes one.
let baby = match r
.entity
.get(&horizon_world::nbt::JavaString::from("IsBaby"))
{
Some(Tag::Byte(v)) => *v != 0,
Some(Tag::Int(v)) => *v != 0,
_ => false,
};
let height = t.height as f32 * scale * if baby { 0.5 } else { 1.0 };
l.trial_mobs
.borrow_mut()
.push((uuid, r.position, height, bounds));
spawned.push_back(format!(
"{tick} spawn {} {} {} {} {}",
r.id,
bits(x),
bits(y),
bits(z),
if size == 1 {
yaw_text(r.id, r.yaw)
} else {
"head".to_owned()
}
));
Some(uuid)
};
let mut eject = |table: &str, random: &mut LegacyRandom| {
let drops = match loot.random_sequence(table) {
Some(s) => loot.roll_split(table, sequences.get(s, seed)),
None => loot.roll_split(table, random),
};
for stack in &drops {
random.next_double();
let spread = 0.017_227_5 * 2.0;
let v = [
random.triangle(0.0, spread),
random.triangle(0.2, spread),
random.triangle(0.0, spread),
];
items.push(format!(
"{tick} item {} {} {} {} {} {} {} {}",
stack.item,
stack.count,
bits(center[0]),
bits(center[1] + 0.7 - 0.125),
bits(center[2]),
bits(v[0]),
bits(v[1]),
bits(v[2])
));
}
!drops.is_empty()
};
let next = spawner.server_tick(
world,
random,
pos,
state,
ominous,
&mut spawn,
&mut eject,
&mut events,
);
for e in events {
match e {
TrialEvent::BlockUpdated => log.push(format!("{tick} update")),
TrialEvent::LevelEvent(id, p, data) => {
// A spawn's events follow its tryAddFreshEntityWithPassengers,
// a reward's its items.
match id {
3011 => log.extend(spawned.pop_front()),
3014 => log.append(&mut items),
_ => {}
}
match id {
3020 | 3012 => log.push(format!(
"{tick} levent {id} {data} at {},{},{}",
p[0], p[1], p[2]
)),
_ => log.push(format!("{tick} levent {id} {data}")),
}
}
TrialEvent::Sound(name) => log.push(format!(
"{tick} sound minecraft:{name} {} {} {} 1.0 1.0",
bits(center[0]),
bits(center[1]),
bits(center[2])
)),
TrialEvent::EntityPlace(_, p) => log.push(format!(
"{tick} gevent minecraft:entity_place {} {} {}",
p[0], p[1], p[2]
)),
TrialEvent::TrialOmen(uuid) => {
let i = (uuid[3] - 1) as usize;
let mut omens = world.omens.borrow_mut();
let amplifier = omens.get(i).copied().flatten().map_or(0, |(_, a)| a);
log.push(format!("{tick} effect remove minecraft:bad_omen"));
log.push(format!(
"{tick} effect add minecraft:trial_omen {} 0",
TRIAL_OMEN_TICKS_PER_LEVEL * (amplifier + 1)
));
if let Some(o) = omens.get_mut(i) {
*o = Some((Omen::Trial, 0));
}
}
TrialEvent::Discard(uuid) => {
let mut mobs = world.trial_mobs.borrow_mut();
if let Some(i) = mobs.iter().position(|(u, ..)| *u == uuid) {
let (.., b) = mobs.remove(i);
world.entities.borrow_mut().retain(|(_, e)| *e != b);
}
}
TrialEvent::SoundAt(name, b, pitch) => log.push(format!(
"{tick} sound minecraft:{name} {} {} {} 1.0 {pitch:?}",
bits(f64::from(b[0]) + 0.5),
bits(f64::from(b[1]) + 0.5),
bits(f64::from(b[2]) + 0.5)
)),
TrialEvent::ItemSpawner {
item,
position,
delay,
} => log.push(format!(
"{tick} ispawner {} {} {} {} {} {delay}",
item.item,
item.count,
bits(position[0]),
bits(position[1]),
bits(position[2])
)),
TrialEvent::OminousSet(set) => {
ominous = set;
world
.placed
.borrow_mut()
.push((pos, registry.resolve(&block_state(state, ominous))?));
log.push(format!("{tick} state {} {ominous}", state.name()));
}
}
}
if next != state || spawner.is_ominous != ominous {
state = next;
ominous = spawner.is_ominous;
world
.placed
.borrow_mut()
.push((pos, registry.resolve(&block_state(state, ominous))?));
log.push(format!("{tick} state {} {ominous}", state.name()));
}
if kill > 0 && tick % kill == kill - 1 {
let dead: Vec<Aabb> = world
.trial_mobs
.borrow_mut()
.drain(..)
.map(|(.., b)| b)
.collect();
world
.entities
.borrow_mut()
.retain(|(_, b)| !dead.iter().any(|d| d == b));
if !dead.is_empty() {
log.push(format!("{tick} kill {}", dead.len()));
}
}
}
log.push(format!(
"end state={} players={} mobs={} cooldown={} next_spawn={} total={} data={} eject={}",
state.name(),
spawner.detected_players.len(),
spawner.current_mobs.len(),
spawner.cooldown_ends_at,
spawner.next_mob_spawns_at,
spawner.total_mobs_spawned,
spawner
.next_spawn_data
.as_ref()
.and_then(|d| d.id())
.unwrap_or_else(|| "-".into()),
spawner.ejecting_loot_table.as_deref().unwrap_or("-")
));
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
Ok(())
}
/// What a vault reads: the game time and the case's players, moved by `move`.
struct VaultWorld {
time: i64,
players: Vec<([f64; 3], String)>,
}
impl VaultLevel for VaultWorld {
fn game_time(&self) -> i64 {
self.time
}
fn players(&self) -> Vec<VaultPlayer> {
self.players
.iter()
.enumerate()
.map(|(i, (p, mode))| VaultPlayer {
uuid: [0, 0, 0, i as i32 + 1],
block: p.map(|v| v.floor() as i32),
spectator: mode == "spectator",
})
.collect()
}
}
/// A UUID's low 64 bits, as the oracle prints players.
fn uuid_low(u: &[i32; 4]) -> String {
((i64::from(u[2]) << 32) | i64::from(u[3] as u32)).to_string()
}
/// The display item and the connected players as the oracle prints them.
fn vault_shared(v: &Vault) -> String {
let mut connected: Vec<String> = v.connected_players.iter().map(uuid_low).collect();
connected.sort();
format!(
"{} [{}]",
v.display_item
.as_ref()
.map_or_else(|| "-".to_owned(), |s| format!("{}*{}", s.item, s.count)),
connected.join(",")
)
}
/// `VaultBlockEntity.Server.tick` `ticks` times from game time `t0` at `spos`
/// on a vault block in state `state` (ominous with `ominous=1`) loaded from
/// `snbt` (base64 SNBT), with the case's key uses (`use`) and player moves
/// (`move`) before each tick, as the oracle logs it.
fn vault_case(
players: Vec<([f64; 3], String)>,
loot: &horizon_world::loot::LootData,
get: &dyn Fn(&str, &'static str) -> String,
random: &mut LegacyRandom,
log: &mut Vec<String>,
) -> horizon_client::Result<()> {
let v: Vec<i32> = get("spos", "0,0,0")
.split(',')
.map(|n| n.parse().unwrap_or(0))
.collect();
let pos = [v[0], v[1], v[2]];
let mut state = VaultState::from_name(&get("state", "inactive")).ok_or("bad state")?;
let ominous = get("ominous", "0") == "1";
let text = horizon_client::skins::base64_decode(&get("snbt", "")).ok_or("bad snbt")?;
let tag = horizon_world::nbt::parse_snbt(&String::from_utf8(text)?)?.0;
let mut vault = Vault::load(&tag);
let schedule = |key: &str| -> Vec<Vec<String>> {
get(key, "")
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.split('/').map(str::to_owned).collect())
.collect()
};
let (uses, moves) = (schedule("use"), schedule("move"));
let ticks: i64 = get("ticks", "1").parse()?;
let t0: i64 = get("t0", "0").parse()?;
let seed: i64 = get("wseed", "0").parse()?;
let mut sequences = horizon_client::random_sequences::RandomSequences::default();
let mut world = VaultWorld { time: t0, players };
let center = pos.map(|v| f64::from(v) + 0.5);
for tick in 0..ticks {
world.time = t0 + tick;
let at = |s: &[String]| s[0].parse() == Ok(tick);
for m in moves.iter().filter(|m| at(m)) {
let v: Vec<f64> = m[2].split(',').map(|n| n.parse().unwrap_or(0.0)).collect();
world.players[m[1].parse::<usize>()?].0 = [v[0], v[1], v[2]];
}
let mut events = Vec::new();
let mut roll = |table: &str, random: &mut LegacyRandom, sequence: bool| {
let drops = match loot.random_sequence(table).filter(|_| sequence) {
Some(s) => loot.roll_split(table, sequences.get(s, seed)),
None => loot.roll_split(table, random),
};
drops
.iter()
.map(horizon_client::structure_blocks::loot_item_with_components)
.collect()
};
for u in uses.iter().filter(|u| at(u)) {
let player: usize = u[1].parse()?;
let mut stack = ItemStack::new(&u[2], u[3].parse()?);
let used = vault.use_item_on(
&world,
random,
pos,
state,
ominous,
[0, 0, 0, player as i32 + 1],
Some(&stack),
&mut roll,
&mut events,
);
if world.players[player].1 != "creative" {
stack.count -= used.unwrap_or(0);
}
vault_events(&mut events, &mut state, ominous, tick, center, &vault, log);
log.push(format!(
"{tick} use {} {}",
if used.is_some() { "ok" } else { "pass" },
stack.count
));
}
vault.server_tick(&world, random, pos, state, ominous, &mut roll, &mut events);
vault_events(&mut events, &mut state, ominous, tick, center, &vault, log);
}
let saved = vault.save();
let config = saved
.iter()
.find(|(k, _)| *k == "config")
.and_then(|(_, t)| match t {
Tag::Compound(c) => {
let mut keys: Vec<String> = c.keys().map(|k| k.to_string_lossy()).collect();
keys.sort();
Some(keys.join(","))
}
_ => None,
})
.unwrap_or_default();
let eject: Vec<String> = vault
.items_to_eject
.iter()
.map(|s| format!("{}*{}", s.item, s.count))
.collect();
log.push(format!(
"end state={} rewarded={} resumes={} eject={} total={} shared={} config={config}",
state.name(),
vault
.rewarded_players
.iter()
.map(uuid_low)
.collect::<Vec<_>>()
.join(","),
vault.state_updating_resumes_at,
eject.join(","),
vault.total_ejections_needed,
vault_shared(&vault),
));
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
Ok(())
}
/// Logs a vault's events and applies its state changes.
fn vault_events(
events: &mut Vec<VaultEvent>,
state: &mut VaultState,
ominous: bool,
tick: i64,
center: [f64; 3],
vault: &Vault,
log: &mut Vec<String>,
) {
for e in events.drain(..) {
match e {
VaultEvent::State(s) => {
*state = s;
log.push(format!("{tick} state {} {ominous}", s.name()));
}
VaultEvent::BlockUpdated => log.push(format!("{tick} update {}", vault_shared(vault))),
VaultEvent::LevelEvent(id, data) => log.push(format!("{tick} levent {id} {data}")),
VaultEvent::Sound(name, volume, pitch) => log.push(format!(
"{tick} sound minecraft:{name} {} {} {} {volume:?} {pitch:?}",
bits(center[0]),
bits(center[1]),
bits(center[2])
)),
VaultEvent::Eject(stack, v) => log.push(format!(
"{tick} item {} {} {} {} {} {} {} {}",
stack.item,
stack.count,
bits(center[0]),
bits(center[1] + 0.7 - 0.125),
bits(center[2]),
bits(v[0]),
bits(v[1]),
bits(v[2])
)),
}
}
}
@@ -0,0 +1,92 @@
//! The resource root Horizon builds for a pack selection (`packs::build_overlay`)
//! and what its loaders read from it, for VanillaPackResourcesOracle.
use horizon_client::{lang::Language, packs, sound_events::SoundRegistry};
use std::{fmt::Write, fs, path::PathBuf};
fn fnv(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| {
(h ^ u64::from(*b)).wrapping_mul(0x100_0000_01b3)
})
}
/// `namespace:path` to a file under the root's `assets/`.
fn asset(root: &std::path::Path, id: &str, prefix: &str, suffix: &str) -> PathBuf {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
root.join("assets")
.join(namespace)
.join(format!("{prefix}{path}{suffix}"))
}
fn types(root: &std::path::Path, file: PathBuf, key: &str) -> String {
let _ = root;
let value: serde_json::Value = fs::read(file)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
value[key]
.as_array()
.into_iter()
.flatten()
.map(|v| {
let t = v["type"].as_str().unwrap_or("?");
format!(" {}", t.strip_prefix("minecraft:").unwrap_or(t))
})
.collect()
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().collect();
let input = fs::read_to_string(&args[1])?;
let mut lines = input.lines();
let root: Vec<&str> = lines.next().ok_or("empty input")?.split(' ').collect();
let vanilla = PathBuf::from(root[1]);
let ids: Vec<String> = root[3..].iter().map(|s| (*s).to_owned()).collect();
let all = packs::discover(&vanilla, &PathBuf::from(root[2]));
let enabled = packs::enabled(&all, &ids);
let overlay = PathBuf::from(&args[2]).with_extension("overlay");
packs::build_overlay(&vanilla, &enabled, &overlay)?;
let language = Language::load(&[&overlay], "en_us");
let sounds = SoundRegistry::load(&overlay);
let mut out = String::new();
for line in lines {
let (kind, arg) = line.split_once(' ').unwrap_or((line, ""));
write!(out, "{line} =>")?;
match kind {
"file" => match fs::read(asset(&overlay, arg, "", "")) {
Ok(bytes) => write!(out, " {:x}", fnv(&bytes))?,
Err(_) => out.push_str(" missing"),
},
"lang" => write!(out, " {}", language.get(arg).unwrap_or(arg))?,
"sound" => {
let event = if arg.contains(':') {
arg.to_owned()
} else {
format!("minecraft:{arg}")
};
match sounds.get(&event) {
None => out.push_str(" missing"),
Some(e) => {
write!(out, " subtitle={}", e.subtitle.as_deref().unwrap_or("none"))?;
for s in &e.sounds {
if s.event {
out.push_str(" event");
} else {
write!(out, " {}*{}", s.location, s.weight)?;
}
}
}
}
}
"font" => out.push_str(&types(
&overlay,
asset(&overlay, arg, "font/", ".json"),
"providers",
)),
"atlas" => out.push_str(&types(
&overlay,
asset(&overlay, arg, "atlases/", ".json"),
"sources",
)),
_ => return Err(format!("unknown query {line}").into()),
}
out.push('\n');
}
fs::write(&args[2], out)?;
Ok(())
}
@@ -0,0 +1,233 @@
#![forbid(unsafe_code)]
//! Runs tools/test_particle_providers_parity.py's particles through
//! `ParticleEngine`: built with the seeds the vanilla oracle recovered, then
//! ticked over air or a stone floor at y 63, printing their state each tick.
use horizon_client::{
Result,
assets::Assets,
blocks::BlockFacts,
chunk::Chunk,
collisions,
particles::{ParticleEngine, ParticleLevel, ParticleOptions, ParticleSnapshot, ParticleType},
world::World,
};
use serde_json::Value;
use std::{
collections::HashMap,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
sync::Arc,
};
fn d(v: f64) -> String {
format!("{:016x}", v.to_bits())
}
fn f(v: f32) -> String {
format!("{:08x}", v.to_bits())
}
fn color(v: &Value, default: u32) -> u32 {
v.as_u64().map_or(default, |c| c as u32)
}
fn rgb(c: u32) -> [f32; 3] {
[
((c >> 16) & 255) as f32 / 255.0,
((c >> 8) & 255) as f32 / 255.0,
(c & 255) as f32 / 255.0,
]
}
fn options(case: &Value) -> Option<ParticleOptions> {
let ty = ParticleType::from_name(case["type"].as_str()?)?;
let o = &case["options"];
let scale = o["scale"].as_f64().unwrap_or(1.0) as f32;
let c = color(&o["color"], 0xFFFF_FFFF);
let argb = {
let [r, g, b] = rgb(c);
[r, g, b, ((c >> 24) & 255) as f32 / 255.0]
};
Some(match ty {
ParticleType::Block
| ParticleType::BlockCrumble
| ParticleType::DustPillar
| ParticleType::BlockMarker => ParticleOptions::block(ty, "terrain".into(), 0xFF_FFFF),
// Stone's map colour: FallingDustParticle.Provider's tint without a tint source.
ParticleType::FallingDust => ParticleOptions::block(ty, "terrain".into(), 0x70_7070),
ParticleType::Dust => ParticleOptions::dust(rgb(c), scale),
ParticleType::DustColorTransition => {
ParticleOptions::dust_transition(rgb(c), rgb(color(&o["to"], 0)), scale)
}
ParticleType::Effect | ParticleType::InstantEffect => ParticleOptions {
scale,
..ParticleOptions::colored(ty, argb)
},
// The oracle's item models: one layer whose sprite is named after the item.
ParticleType::Item => {
let item = o["item"].as_str()?;
ParticleOptions::item(vec![format!(
"item_{}",
item.strip_prefix("minecraft:").unwrap_or(item)
)])
}
ParticleType::EntityEffect | ParticleType::TintedLeaves | ParticleType::Flash => {
ParticleOptions::colored(ty, argb)
}
ParticleType::DragonBreath => ParticleOptions {
scale,
..ParticleOptions::simple(ty)
},
ParticleType::SculkCharge => ParticleOptions::sculk_charge(scale),
ParticleType::Shriek => ParticleOptions::shriek(scale as i32),
ParticleType::Trail => {
let t = &o["target"];
ParticleOptions::trail(
[t[0].as_f64()?, t[1].as_f64()?, t[2].as_f64()?],
c & 0xFF_FFFF,
o["duration"].as_i64()? as i32,
)
}
_ => ParticleOptions::simple(ty),
})
}
fn state(s: &ParticleSnapshot) -> String {
let mut out = format!(
"{} {} {} {}",
s.age,
s.lifetime,
u8::from(s.removed),
u8::from(s.on_ground)
);
for v in s.position.iter().chain(&s.velocity).chain(&s.previous) {
out += &format!(" {}", d(*v));
}
if let Some(q) = &s.quad {
out += &format!(" | {} {}", f(q.size[0]), f(q.size[1]));
for v in q.rgba.iter().chain(&q.roll) {
out += &format!(" {}", f(*v));
}
out += &format!(" {}", q.sprite);
for v in q.uv {
out += &format!(" {}", f(v));
}
out += &format!(" {:x}", q.light);
}
out
}
fn world(assets: &Assets, facts: &BlockFacts, floor: bool) -> Result<World> {
let mut world = World::new(facts.air, 0);
let stone = assets
.registry
.default_state("minecraft:stone")
.ok_or("missing stone")?;
for cz in -3..=2 {
for cx in -3..=2 {
world
.chunks
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
if floor {
for z in 0..16 {
for x in 0..16 {
world.set(cx * 16 + x, 63, cz * 16 + z, stone)?;
}
}
}
}
}
Ok(world)
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(".local/horizon-client/26.3");
let assets = Assets::load(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let collision = collisions::load(root, assets.registry)?;
let worlds = [
world(&assets, &facts, false)?,
world(&assets, &facts, true)?,
];
let mut engine = ParticleEngine::new(root, &HashMap::new());
for (ty, item) in [
(ParticleType::ItemSlime, "item_slime_ball"),
(ParticleType::ItemSnowball, "item_snowball"),
(ParticleType::ItemCobweb, "item_cobweb"),
] {
engine.item_sprites.insert(ty, vec![item.to_owned()]);
}
// The oracle's output: the seed line of each case.
let mut seeds = Vec::new();
for line in BufReader::new(File::open(&args[2])?).lines() {
let line = line?;
if line.starts_with("seed ") || line.starts_with("null ") || line == "no provider" {
seeds.push(line);
}
}
let mut out = BufWriter::new(File::create(&args[3])?);
for (n, line) in BufReader::new(File::open(&args[1])?).lines().enumerate() {
let case: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let Some(options) = options(&case) else {
writeln!(out, "no provider")?;
continue;
};
let seed = case["seed"].as_i64().ok_or("seed")?;
engine.seed_randoms(seed, seed ^ 0x6C_6576_656C, seed ^ 0x73_7065_6C6C);
engine.requested = Some(Vec::new());
engine.sounds.clear();
let level = ParticleLevel {
world: &worlds[usize::from(case["floor"].as_bool() == Some(true))],
facts: &facts,
collision: &collision,
player: None,
};
let v = |key: &str| -> [f64; 3] {
std::array::from_fn(|i| case[key][i].as_f64().unwrap_or(0.0))
};
let seed_line = seeds.get(n).cloned().unwrap_or_default();
let local = seed_line
.split(' ')
.nth(1)
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let Some(mut particle) = engine.probe_create(&options, v("pos"), v("vel"), local, &level)
else {
writeln!(out, "null {}", engine.next_randoms()[0])?;
continue;
};
writeln!(out, "{seed_line}")?;
let [a, b, c] = engine.next_randoms();
writeln!(out, "next {a} {b} {c}")?;
let events = |engine: &mut ParticleEngine, out: &mut BufWriter<File>| -> Result<()> {
for (o, p, v) in engine.requested.replace(Vec::new()).unwrap_or_default() {
write!(out, "+ {}", o.ty.name())?;
for x in p.iter().chain(&v) {
write!(out, " {}", d(*x))?;
}
writeln!(out)?;
}
for (name, p, volume, pitch) in std::mem::take(&mut engine.sounds) {
writeln!(
out,
"~ {name} {} {} {} {} {}",
d(p[0]),
d(p[1]),
d(p[2]),
f(volume),
f(pitch)
)?;
}
Ok(())
};
writeln!(out, "0 {}", state(&engine.snapshot(&particle)))?;
events(&mut engine, &mut out)?;
let ticks = case["ticks"].as_i64().unwrap_or(0);
let mut t = 1;
while t <= ticks && particle.is_alive() {
engine.probe_tick(&mut particle, &level);
writeln!(out, "{t} {}", state(&engine.snapshot(&particle)))?;
events(&mut engine, &mut out)?;
t += 1;
}
}
out.flush()?;
Ok(())
}
@@ -0,0 +1,96 @@
//! Block entity data on items for tools/test_pick_block_data_parity.py: reads
//! one JSON case per line (see VanillaPickBlockDataOracle) and prints the
//! picked item's component patch, a tab, then the placed block entity.
//!
//! usage: pick_block_data_fixture INPUT OUTPUT
use horizon_client::block_entity_item::{pick, place};
use horizon_client::block_entity_nbt::canonical;
use horizon_client::java_text::{double_to_string, float_to_string};
use horizon_world::nbt::{Tag, parse_snbt};
use serde_json::Value;
use std::fs;
/// ParityData.canon: sorted keys, typed numbers, Java's float text.
fn canon(tag: &Tag, out: &mut String) {
let join = |out: &mut String, prefix: &str, items: Vec<String>| {
out.push_str(prefix);
out.push_str(&items.join(","));
out.push(']');
};
match tag {
Tag::Float(v) => out.push_str(&format!("{}f", float_to_string(f32::from_bits(*v)))),
Tag::Double(v) => out.push_str(&format!("{}d", double_to_string(f64::from_bits(*v)))),
Tag::List(items) => join(
out,
"[",
items
.iter()
.map(|t| {
let mut s = String::new();
canon(t, &mut s);
s
})
.collect(),
),
Tag::Compound(map) => {
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
keys.sort_by(|a, b| a.0.cmp(&b.0));
out.push('{');
for (i, (k, v)) in keys.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&k);
out.push(':');
canon(v, out);
}
out.push('}');
}
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
}
}
fn snbt(value: &Value) -> Option<Tag> {
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
}
fn case(line: &str) -> Option<String> {
let case: Value = serde_json::from_str(line).ok()?;
let saved = canonical(&snbt(&case["nbt"])?)?;
let picked = pick(&saved);
let patch = match case.get("patch") {
Some(p) => match snbt(p)? {
Tag::Compound(c) => c,
_ => return None,
},
None => picked.clone(),
};
let target = canonical(&snbt(&case["target"])?)?;
let Tag::Compound(mut placed) = place(&target, &patch, case["op"].as_bool()?) else {
return None;
};
// keepPacked is the chunk's, not saveWithFullMetadata's.
placed.remove(&"keepPacked".into());
let mut out = String::new();
canon(&Tag::Compound(picked), &mut out);
out.push('\t');
canon(&Tag::Compound(placed), &mut out);
Some(out)
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: pick_block_data_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -43,6 +43,7 @@ fn main() -> horizon_client::Result<()> {
recipes: &recipes,
brewing: &brewing,
banners: &banners,
trade: None,
};
let mut out = String::new();
for (line, order) in cases.lines().zip(orders.lines()) {
@@ -0,0 +1,149 @@
//! Selection cost only (not block simulation). Run with prepared client resources.
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, Section},
fast_hash::FxHashMap,
random_tick_sections::RandomTickSections,
};
use horizon_world::registry::BlockStateId;
use std::{hint::black_box, path::Path, sync::Arc, time::Instant};
fn select_baseline(chunks: &FxHashMap<(i32, i32), Arc<Chunk>>, facts: &BlockFacts) -> usize {
let mut ticking = Vec::new();
for (&(x, z), chunk) in chunks {
if x.abs() > 8 || z.abs() > 8 || !chunk.lit {
continue;
}
let sections: Vec<i32> = chunk
.sections
.iter()
.enumerate()
.filter(|(_, s)| {
s.blocks
.maybe_has(|id| facts.get(BlockStateId(id)).random_ticks)
})
.map(|(i, _)| MIN_Y + i as i32 * 16)
.collect();
ticking.push((x, z, sections));
}
black_box(ticking).iter().map(|(_, _, s)| s.len()).sum()
}
fn select_cached(
chunks: &FxHashMap<(i32, i32), Arc<Chunk>>,
facts: &BlockFacts,
cache: &mut RandomTickSections,
) -> usize {
let mut ticking = Vec::new();
for (&(x, z), chunk) in chunks {
if x.abs() > 8 || z.abs() > 8 || !chunk.lit {
continue;
}
ticking.push((x, z, cache.get((x, z), chunk, facts)));
}
black_box(ticking).iter().map(|(_, _, s)| s.len()).sum()
}
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("expected prepared client resource directory")?;
let start = Instant::now();
let registry = horizon_world::level::data::load_registry(Path::new(&root))?;
let facts = BlockFacts::load(Path::new(&root), &registry)?;
println!(
"resource_load_ms={:.3}",
start.elapsed().as_secs_f64() * 1000.0
);
let inactive: Vec<u16> = facts
.states
.iter()
.enumerate()
.filter(|(_, f)| !f.random_ticks)
.take(128)
.map(|(i, _)| i as u16)
.collect();
let active = registry.default_state("minecraft:grass_block").unwrap().0 as u16;
assert!(facts.get(BlockStateId(u32::from(active))).random_ticks);
for (name, palette) in [("sparse", 1), ("dense", 128)] {
let start = Instant::now();
let mut chunks = FxHashMap::default();
for z in -8..=8 {
for x in -8..=8 {
let mut chunk = Chunk::empty(x, z);
chunk.lit = true;
for (i, section) in chunk.sections.iter_mut().enumerate() {
let mut values = std::array::from_fn(|j| inactive[j % palette]);
if i % 3 == 0 {
values[4095] = active;
}
Arc::make_mut(section).blocks = Section::from_blocks(&values);
}
chunks.insert((x, z), Arc::new(chunk));
}
}
println!(
"{name} fixture_ms={:.3}",
start.elapsed().as_secs_f64() * 1000.0
);
for (workload, changed) in [
("steady", 0),
("cold", 0),
("reload", 0),
("edit_1pct", 3),
("edit_all", 289),
] {
let keys: Vec<_> = chunks.keys().copied().collect();
let mut baseline = Vec::new();
let mut candidate = Vec::new();
let mut cache = RandomTickSections::default();
for sample in 0..8 {
// Alternate order; first pair warms both paths and is discarded.
for cached in if sample % 2 == 0 {
[false, true]
} else {
[true, false]
} {
let start = Instant::now();
for tick in 0..300 {
if cached && workload == "cold" {
cache = RandomTickSections::default();
}
if cached && workload == "reload" {
cache.clear();
}
for key in keys.iter().take(changed) {
Arc::make_mut(chunks.get_mut(key).unwrap()).set(
0,
MIN_Y,
0,
inactive[tick % 2],
);
}
black_box(if cached {
select_cached(&chunks, &facts, &mut cache)
} else {
select_baseline(&chunks, &facts)
});
}
// Include teardown, and remove weak handles before baseline edits.
if cached {
cache = RandomTickSections::default();
}
if sample > 0 {
let us = start.elapsed().as_secs_f64() * 1e6 / 300.0;
if cached {
candidate.push(us);
} else {
baseline.push(us);
}
}
}
}
println!("{name} {workload} baseline_us={baseline:.3?} candidate_us={candidate:.3?}");
}
}
println!("chunk_wrapper_bytes={}", std::mem::size_of::<Chunk>());
Ok(())
}
@@ -0,0 +1,260 @@
//! Synthetic village/storage save batches, using the production snapshot and
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
use horizon_client::{
biome::Biomes,
chunk::{BLOCK_STRATEGY, Chunk},
containers::BlockEntity,
items::ItemStack,
save::{self, ChunkExtras},
};
use horizon_core::paletted::PalettedContainer;
use horizon_world::{
DATA_VERSION,
nbt::{self, Limits, Tag},
registry::BlockRegistry,
};
use std::{collections::HashMap, hint::black_box, sync::Arc, time::Instant};
type Entities = HashMap<(i32, i32, i32), BlockEntity>;
type Names = HashMap<(i32, i32, i32), String>;
const BLOCKS: [&str; 5] = [
"minecraft:air",
"minecraft:stone",
"minecraft:chest",
"minecraft:furnace",
"minecraft:oak_sign",
];
// The pre-change with_entities path, with the same Chunk clone and outputs.
fn baseline(chunks: &[Arc<Chunk>], entities: &Entities, names: &Names) -> Vec<Chunk> {
chunks
.iter()
.map(|chunk| {
let mut c = (**chunk).clone();
c.block_entities = entities
.iter()
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
.map(|(p, e)| (*p, e.clone()))
.collect();
c.block_names = names
.iter()
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
.map(|(p, n)| (*p, n.clone()))
.collect();
c
})
.collect()
}
fn fixture(per_chunk: usize) -> (Vec<Arc<Chunk>>, Entities, Names) {
let mut chunks = Vec::new();
let mut entities = Entities::new();
let mut names = Names::new();
let stack = ItemStack {
item: "minecraft:stone".into(),
count: 64,
damage: 0,
extra: horizon_client::item_data::decode_components(Some(&Tag::compound([(
"minecraft:custom_data",
Tag::compound([("fixture", Tag::String("save batch".into()))]),
)])))
.1,
};
let mut template = Chunk::empty(0, 0);
for s in &mut template.sections[..8] {
Arc::make_mut(s).blocks = PalettedContainer::new(BLOCK_STRATEGY, 1);
}
template.heights = Arc::new([64; 256]);
for i in 0..256 {
let x = i % 16 - 8;
let z = i / 16 - 8;
let mut c = template.clone();
c.x = x;
c.z = z;
let mut extras = ChunkExtras::default();
extras.root.insert("InhabitedTime".into(), Tag::Long(12000));
for j in 0..per_chunk {
let p = (
x * 16 + (j % 16) as i32,
64 + (j / 256) as i32,
z * 16 + ((j / 16) % 16) as i32,
);
let (block, mut e) = match j % 4 {
0 => (4, BlockEntity::for_block(BLOCKS[4]).unwrap()),
1 => (3, BlockEntity::for_block(BLOCKS[3]).unwrap()),
_ => (2, BlockEntity::Chest(vec![Some(stack.clone()); 27])),
};
if let BlockEntity::Furnace(f) = &mut e {
f.items[0] = Some(stack.clone());
f.lit_remaining = 120;
f.cooking_timer = 45;
}
if let BlockEntity::Sign(s) = &mut e {
s.front.lines[0] = "Village storage".into();
}
c.set(p.0 & 15, p.1, p.2 & 15, block);
if j % 4 == 2 {
names.insert(p, format!("Storage {x},{z}/{j}"));
}
// Exercise the existing NBT overlay and saved list order as well.
if j % 8 == 2 {
let mut raw = save::encode_block_entity(p, &e, BLOCKS[block as usize]);
if let Tag::Compound(map) = &mut raw {
map.insert(
"LootTable".into(),
Tag::String("minecraft:chests/simple_dungeon".into()),
);
}
extras.block_entity_sources.push((p, raw));
}
extras.block_entity_order.push(p);
entities.insert(p, e);
}
// An unmodelled block entity, retained from the original chunk.
extras.block_entities.push(Tag::compound([
("id", Tag::String("minecraft:end_gateway".into())),
("x", Tag::Int(x * 16)),
("y", Tag::Int(80)),
("z", Tag::Int(z * 16)),
("Age", Tag::Long(200)),
]));
c.saved = Some(Arc::new(extras));
chunks.push(Arc::new(c));
}
(chunks, entities, names)
}
fn encoding_fixture() -> horizon_client::Result<(BlockRegistry, Biomes)> {
let registry = BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(BLOCKS.iter().map(|n| Tag::String((*n).into())).collect()),
),
(
"defaults",
Tag::compound(
BLOCKS
.iter()
.enumerate()
.map(|(i, n)| (*n, Tag::Int(i as i32))),
),
),
]))?;
let temp = std::env::temp_dir().join(format!("horizon-save-batch-{}", std::process::id()));
let result = (|| {
let textures = temp.join("assets/minecraft/textures/colormap");
let biomes = temp.join("data/minecraft/worldgen/biome");
std::fs::create_dir_all(&textures)?;
std::fs::create_dir_all(&biomes)?;
for name in ["grass", "foliage", "dry_foliage"] {
let mut encoder = png::Encoder::new(
std::fs::File::create(textures.join(format!("{name}.png")))?,
1,
1,
);
encoder.set_color(png::ColorType::Rgb);
encoder.write_header()?.write_image_data(&[0, 128, 0])?;
}
std::fs::write(biomes.join("plains.json"), b"{}")?;
Biomes::load(&temp)
})();
std::fs::remove_dir_all(&temp)?;
Ok((registry, result?))
}
fn serialize(chunks: &[Chunk], registry: &BlockRegistry, biomes: &Biomes) -> Vec<Vec<u8>> {
chunks
.iter()
.map(|c| nbt::write_named(&save::encode(c, registry, biomes), Limits::default()).unwrap())
.collect()
}
fn measure(mut f: impl FnMut(), repetitions: usize) -> f64 {
let start = Instant::now();
for _ in 0..repetitions {
f();
}
start.elapsed().as_secs_f64() * 1e6 / repetitions as f64
}
fn compare(mut before: impl FnMut(), mut after: impl FnMut()) -> (f64, f64, usize) {
for _ in 0..5 {
before();
after();
}
let estimate = measure(&mut before, 1).max(measure(&mut after, 1));
let repetitions = (10_000.0 / estimate.max(0.01)).ceil().clamp(1.0, 10_000.0) as usize;
let mut old = Vec::new();
let mut new = Vec::new();
for i in 0..21 {
if i % 2 == 0 {
old.push(measure(&mut before, repetitions));
new.push(measure(&mut after, repetitions));
} else {
new.push(measure(&mut after, repetitions));
old.push(measure(&mut before, repetitions));
}
}
old.sort_by(f64::total_cmp);
new.sort_by(f64::total_cmp);
(old[10], new[10], repetitions)
}
fn main() -> horizon_client::Result<()> {
let include_nbt = std::env::args().any(|arg| arg == "--nbt");
let (registry, biomes) = encoding_fixture()?;
println!(
"stage,fixture,chunks,world_entities,world_names,saved_entities,bytes,before_us,after_us,speedup,samples,reps"
);
for (name, density) in [("village", 12), ("storage", 48)] {
let (chunks, entities, names) = fixture(density);
for count in [0, 1, 16, 64, 256] {
let batch = &chunks[..count];
let before = baseline(batch, &entities, &names);
let after = save::chunks_with_block_entities(batch, &entities, &names);
for (a, b) in before.iter().zip(&after) {
assert_eq!(a.block_names, b.block_names);
}
let encoded = serialize(&before, &registry, &biomes);
assert_eq!(
encoded,
serialize(&after, &registry, &biomes),
"exact NBT bytes: {name}/{count}"
);
let bytes: usize = encoded.iter().map(Vec::len).sum();
for encode in [false, true] {
if encode && !include_nbt {
continue;
}
let run = |optimized| {
let snapshots = if optimized {
save::chunks_with_block_entities(
black_box(batch),
black_box(&entities),
black_box(&names),
)
} else {
baseline(black_box(batch), black_box(&entities), black_box(&names))
};
if encode {
black_box(serialize(black_box(&snapshots), &registry, &biomes));
}
black_box(snapshots);
};
let (old, new, reps) = compare(|| run(false), || run(true));
println!(
"{},{name},{count},{},{},{},{bytes},{old:.3},{new:.3},{:.3},21,{reps}",
if encode { "snapshot+nbt" } else { "snapshot" },
entities.len(),
names.len(),
count * density,
old / new
);
}
}
}
Ok(())
}
@@ -10,7 +10,7 @@ use horizon_client::{
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS},
light::{LightTables, light_chunk},
light::{LightTables, apply_lighting, light_chunk},
save::{self, SaveTables},
};
use horizon_core::position::ChunkPos;
@@ -157,7 +157,7 @@ fn main() -> horizon_client::Result<()> {
std::array::from_fn(|dx| chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1)))
});
let mut chunk = chunks[&(x, z)].clone();
chunk.sections = light_chunk(&region, &lights, sky_light);
apply_lighting(&mut chunk, light_chunk(&region, &lights, sky_light));
chunk.lit = true;
let ours = save::encode_with(&chunk, assets.registry, &biomes, Some(&tables), sky_light);
let theirs = &originals[&(x, z)];
@@ -2,6 +2,11 @@
#![forbid(unsafe_code)]
use horizon_client::{assets, collisions, world};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::{
collision_context::CollisionContext,
entity_obstruction::EntityIdentity,
world_movement::{self, CollisionCheckRequest, MovementRequest},
};
use serde_json::Value;
use std::{
fs,
@@ -40,6 +45,7 @@ fn main() -> Result<()> {
let mut scene = collisions::SceneCollisions {
world: &world,
binding: &binding,
subject: None,
};
fs::write(
dir.join("scene.json"),
@@ -56,21 +62,55 @@ fn main() -> Result<()> {
f["step"].as_str().ok_or("expected step bits")?,
16,
)?);
let movement = scene.resolve(
bounds,
delta,
step,
f["grounded"].as_bool().ok_or("expected grounded flag")?,
let entities = f["entities"]
.as_array()
.into_iter()
.flatten()
.map(|v| {
let a = v.as_array().ok_or("expected entity box")?;
Ok(Aabb::new(
vector(&Value::Array(a[..3].to_vec()))?,
vector(&Value::Array(a[3..].to_vec()))?,
))
})
.collect::<Result<Vec<_>>>()?;
let mut selected = collisions::EntityCollisions {
blocks: scene,
entities: &entities,
};
let movement = world_movement::resolve_world_movement(
&mut selected,
MovementRequest {
entity: EntityIdentity(0),
context: CollisionContext::position(bounds.min_position().y),
bounds,
movement: delta,
max_step: step,
on_ground: f["grounded"].as_bool().ok_or("expected grounded flag")?,
candidate_budget: 1_000_000,
},
)?;
let mut unobstructed = |bounds: Aabb| {
world_movement::no_world_collision(
&mut selected,
CollisionCheckRequest {
entity: EntityIdentity(0),
context: CollisionContext::position(bounds.min_position().y),
bounds,
candidate_budget: 1_000_000,
},
)
};
writeln!(
output,
"move {:016x} {:016x} {:016x} clear {} {}",
movement.x.to_bits(),
movement.y.to_bits(),
movement.z.to_bits(),
scene.unobstructed(bounds)?,
scene.unobstructed(bounds.move_by(movement))?
unobstructed(bounds)?,
unobstructed(bounds.move_by(movement))?
)?;
scene = selected.blocks;
}
Ok(())
}
@@ -4,7 +4,7 @@ use horizon_client::{
Result,
assets::Assets,
collisions,
motion::{MotionEvent, MotionResources, MotionWorld, PlayerMotion},
motion::{LAVA, MotionEvent, MotionResources, MotionWorld, PlayerMotion, WATER},
world::World,
};
use horizon_core::geometry::Vec3;
@@ -165,6 +165,64 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
.filter_map(|e| match e {
MotionEvent::Fall(e) => Some(e.trace()),
MotionEvent::Health(e) => Some(e.trace()),
MotionEvent::Snowflake { position, velocity } => Some(format!(
"snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
position.x.to_bits(),
position.y.to_bits(),
position.z.to_bits(),
velocity.x.to_bits(),
velocity.y.to_bits(),
velocity.z.to_bits()
)),
MotionEvent::Splash {
high_speed,
volume,
pitch,
} => Some(format!(
"sound {} entity {:08x} {:08x}",
if *high_speed { "splash-high" } else { "splash" },
volume.to_bits(),
pitch.to_bits()
)),
MotionEvent::SplashParticle {
bubble,
position,
velocity,
} => Some(format!(
"splash-particle {} {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
if *bubble { "bubble" } else { "splash" },
position.x.to_bits(),
position.y.to_bits(),
position.z.to_bits(),
velocity.x.to_bits(),
velocity.y.to_bits(),
velocity.z.to_bits()
)),
MotionEvent::SplashEvent => Some("splash-event".into()),
MotionEvent::Swim { volume, pitch } => Some(format!(
"sound swim entity {:08x} {:08x}",
volume.to_bits(),
pitch.to_bits()
)),
MotionEvent::Emission {
state,
in_water: true,
..
} => Some(format!("muffled-step {}", state.0)),
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
"column-particle {} {:016x},{:016x},{:016x} 1 {:016x},{:016x},{:016x} {:016x}",
if *bubble { "bubble" } else { "splash" },
position.x.to_bits(),
position.y.to_bits(),
position.z.to_bits(),
0f64.to_bits(),
if *bubble { 0.01f64 } else { 0.0 }.to_bits(),
0f64.to_bits(),
if *bubble { 0.2f64 } else { 1.0 }.to_bits()
)),
MotionEvent::Drown => Some("broadcast 67".into()),
// The oracle marks every getAdvancements call (ENTER_BLOCK.trigger) so.
MotionEvent::EnterBlock { .. } => Some("hurt-criterion".into()),
MotionEvent::Waypoint { receiver, enabled } => Some(format!(
"waypoint {} {enabled}",
if *receiver { "receive" } else { "transmit" }
@@ -232,8 +290,15 @@ fn main() -> Result<()> {
instances.next().expect("unexpected instance allocation")
}),
)?;
// The oracle runs block-inside effects only in "snow_tick".
player.block_effects = false;
// Entity.portalCooldown and portalProcess, and the entry of the last
// portal_tick that went through.
let mut portal = horizon_client::nether_portal::PortalState::default();
let mut travel: Option<horizon_core::position::BlockPos> = None;
for op in f["ops"].as_array().unwrap() {
let mut world = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
@@ -261,6 +326,7 @@ fn main() -> Result<()> {
jump: mask & 16 != 0,
shift: mask & 32 != 0,
sprint: mask & 64 != 0,
analog: None,
})?;
}
"movement_statistics" => world.check_movement_statistics(vector(&op["value"]))?,
@@ -356,7 +422,8 @@ fn main() -> Result<()> {
Some(vector(&op["value"]))
},
),
"block" => {
// A block, or every block of the box from position to "to".
"block" | "fill" => {
let p = &op["position"];
let mut state = assets
.registry
@@ -374,12 +441,15 @@ fn main() -> Result<()> {
.id(&definition)
.ok_or("missing fixture state")?;
}
world.scene.set(
p[0].as_i64().unwrap() as i32,
p[1].as_i64().unwrap() as i32,
p[2].as_i64().unwrap() as i32,
state,
)?;
let at = |v: &Value, i: usize| v[i].as_i64().unwrap() as i32;
let to = if op["to"].is_array() { &op["to"] } else { p };
for x in at(p, 0)..=at(to, 0) {
for y in at(p, 1)..=at(to, 1) {
for z in at(p, 2)..=at(to, 2) {
world.scene.set(x, y, z, state)?;
}
}
}
}
"landing" => {
let p = &op["position"];
@@ -397,6 +467,26 @@ fn main() -> Result<()> {
p,
)?;
}
// Entity.baseTick's fluid phase alone (wasEyeInWater,
// updateFluidInteraction, lava fall halving, firstTick).
"fluid_tick" => world.fluid_base_tick()?,
// LivingEntity.baseTick's air supply alone.
"air_tick" => world.tick_environment_damage()?,
"air" => world.player.air = op["value"].as_i64().unwrap() as i32,
"pitch" => world.player.pitch = single(&op["value"]),
"swimming" => world.player.swimming = op["value"].as_bool().unwrap(),
"effect" => {
let on = op["value"].as_bool().unwrap();
let status = &mut world.player.status;
match op["name"].as_str().unwrap() {
"minecraft:dolphins_grace" => status.dolphins_grace = on,
"minecraft:water_breathing" | "minecraft:conduit_power" => {
status.water_breathing = on
}
"minecraft:breath_of_the_nautilus" => status.breath_of_the_nautilus = on,
other => return Err(format!("unknown fixture effect {other}").into()),
}
}
"travel" => world.travel_in_air(vector(&op["value"]))?,
"tick" => {
let v = vector(&op["value"]);
@@ -409,6 +499,40 @@ fn main() -> Result<()> {
op["jumping"].as_bool().unwrap(),
)?;
}
"snow_tick" => {
// The clock, then the player tick: baseTick's powder snow
// flags lead PlayerMotion::player_movement_tick.
world.player.health.advance_clock();
let v = vector(&op["value"]);
world.player.block_effects = true;
let ticked = world.player_movement_tick(
[v.x as f32, v.z as f32],
op["jumping"].as_bool().unwrap(),
);
world.player.block_effects = false;
ticked?;
}
"boots" => {
// Leather boots are also freeze-immune wearables.
let boots = op["value"].as_bool().unwrap();
world.player.leather_boots = boots;
world.player.freeze_immune = boots;
}
"frozen" => world.player.ticks_frozen = op["value"].as_i64().unwrap() as i32,
"portal_tick" => {
// Entity.handlePortal without the destination: players wait
// the creative delay (0) when invulnerable, else 80 ticks.
let creative = op["creative"].as_bool().unwrap();
let transition = if creative { 0 } else { 80 };
let alive = !world.player.health.dead_or_dying();
travel = portal.handle(alive, transition, 10);
}
"weaving" => world.player.status.weaving = op["value"].as_bool().unwrap(),
"flying" => {
let flying = op["value"].as_bool().unwrap();
world.player.may_fly = op["may_fly"].as_bool().unwrap_or(flying);
world.player.flying = flying;
}
"trigger" => world.player.jump_trigger_time = op["value"].as_i64().unwrap() as i32,
"delay" => world.player.no_jump_delay = op["value"].as_i64().unwrap() as i32,
"move" => world.move_self(vector(&op["value"]))?,
@@ -476,9 +600,49 @@ fn main() -> Result<()> {
}
_ => return Err("unknown motion operation".into()),
}
// NetherPortalBlock.entityInside: canUsePortal, setAsInsidePortal.
for e in &player.events {
if let MotionEvent::InsidePortal { pos } = e
&& !player.health.dead_or_dying()
{
portal.set_inside(*pos, 10);
}
}
let check_block = scene.block(25, 3, 25);
snapshot(&mut out, &player)?;
writeln!(out, "block {}", check_block.0)?;
let stuck = player.stuck_multiplier;
writeln!(
out,
"block {} frozen {} {} {} {:016x},{:016x},{:016x} fluid {} {} {} {} {:016x} {:016x} swim {} {} air {}",
check_block.0,
player.ticks_frozen,
player.in_powder_snow,
player.was_in_powder_snow,
stuck.x.to_bits(),
stuck.y.to_bits(),
stuck.z.to_bits(),
player.fluids.was_touching_water,
player.fluids.was_eye_in_water,
player.is_eye_in_fluid(WATER),
player.fluids.first_tick,
player.fluid_height(WATER).to_bits(),
player.fluid_height(LAVA).to_bits(),
player.swimming,
player.fluids.was_eye_in_water,
player.air
)?;
writeln!(
out,
"portal {} {} {}",
portal.cooldown,
portal.process.map_or("none".into(), |p| format!(
"{},{},{} {} {}",
p.entry.x, p.entry.y, p.entry.z, p.time, p.inside
)),
travel
.take()
.map_or("-".into(), |t| format!("{},{},{}", t.x, t.y, t.z))
)?;
let h = &player.health;
let canonical = |v: f32| {
if v.is_nan() {
@@ -7,7 +7,7 @@ use horizon_client::{
biome::{BiomeInfo, Biomes, GrassModifier},
blocks::BlockFacts,
chunk::{Chunk, Light, MIN_Y, index},
mesher::{MeshTables, Mesher, OPAQUE_LAYER, Vertex},
mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
models,
};
use horizon_core::biome::BiomeZoom;
@@ -33,7 +33,8 @@ fn argb(color: u32) -> i32 {
}
/// Horizon keeps sky*16 in the low byte and block*16 above it; vanilla's light
/// coords keep block in the low half-word and sky in the high one.
fn light_coords(light: u32) -> i32 {
fn light_coords(light: u16) -> i32 {
let light = u32::from(light);
((light & 0xFF) << 16 | (light >> 8 & 0xFF)) as i32
}
/// Scenes cover the 3x3 chunk region around chunk (0, 0).
@@ -133,6 +134,7 @@ fn scene(
blend_radius,
smooth_lighting,
cutout_leaves,
cull_leaves: false,
};
let section_y = int(&row["section_y"])?;
let started = Instant::now();
@@ -146,9 +148,12 @@ fn scene(
}
for quad in vertices {
let sprite = names
.get(&(quad[0].layer & !OPAQUE_LAYER))
.get(&u32::from(quad[0].layer & !OPAQUE_LAYER))
.ok_or("unknown sprite layer")?;
if quad.iter().any(|v: &Vertex| v.layer != quad[0].layer) {
if quad
.iter()
.any(|v: &TerrainVertex| v.layer != quad[0].layer)
{
return Err("quad mixes sprite layers".into());
}
let vertices: Vec<Value> = quad
@@ -5,7 +5,7 @@
mod common;
use common::{Fake, d, describe, f, flush, result_name};
use horizon_client::sound_engine::{
Attenuation, Backend, InstanceId, SoundEngine, SoundInstance, TickContext,
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundInstance, TickContext,
};
use horizon_client::sound_events::SoundRegistry;
use horizon_core::random::LegacyRandom;
@@ -19,6 +19,9 @@ fn main() {
let mut backend = Fake::default();
let mut ctx = TickContext::default();
let mut instances: BTreeMap<String, InstanceId> = BTreeMap::new();
// Script cart names to entity ids (a removed cart leaves the level).
let mut cart_ids: BTreeMap<String, u32> = BTreeMap::new();
let mut removed: BTreeMap<u32, CartSound> = BTreeMap::new();
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
@@ -49,6 +52,21 @@ fn main() {
"loop" => SoundInstance::biome_loop(&event, random),
"uloop" => SoundInstance::under_liquid_loop(&event, random),
"usub" => SoundInstance::under_liquid_sub(&event, random),
"cart" | "rcart" | "rcartu" => {
let id = cart_ids[a[3]];
let cart = ctx
.carts
.iter()
.find(|c| c.id == id)
.or_else(|| removed.get(&id))
.copied()
.expect("cart");
if a[2] == "cart" {
SoundInstance::minecart(&cart, random)
} else {
SoundInstance::riding_minecart(&cart, a[2] == "rcartu", random)
}
}
_ => panic!("{line}"),
};
if let Some(old) = instances.remove(a[1]) {
@@ -92,6 +110,26 @@ fn main() {
ctx.player_underwater = a[1] == "1";
ctx.player_removed = a[2] == "1";
}
"cart" => {
let next = cart_ids.len() as u32 + 1;
let id = *cart_ids.entry(a[1].to_owned()).or_insert(next);
ctx.carts.retain(|c| c.id != id);
let cart = CartSound {
id,
position: [d(a[2]), d(a[3]), d(a[4])],
// Vec3.horizontalDistance.
speed: (d(a[5]) * d(a[5]) + d(a[7]) * d(a[7])).sqrt(),
};
if a[8] == "1" {
// Out of the level, still there for a later play.
removed.insert(id, cart);
} else {
removed.remove(&id);
ctx.carts.push(cart);
}
}
"ride" => ctx.vehicle = cart_ids.get(a[1]).copied(),
"freeze" => ctx.frozen = a[1] == "1",
"tick" => engine.tick(a[1] == "1", &ctx, &mut backend),
"forget" => {
if let Some(id) = instances.remove(a[1]) {
@@ -17,6 +17,7 @@ fn keys(mask: u64) -> Keys {
jump: mask & 16 != 0,
shift: mask & 32 != 0,
sprint: mask & 64 != 0,
analog: None,
}
}
fn main() -> Result<()> {
@@ -0,0 +1,227 @@
//! The chunk pipeline (generation, decoration, lighting, meshing) on its worker
//! pool, without a window: how fast a render distance fills with N workers, and
//! what the render thread pays for it in `Streaming::update`.
//!
//! streaming_bench RESOURCES [--threads N] [--radius R] [--seed S] [--fly CHUNKS_PER_S] [--seconds T] [--hz F]
//!
//! First the area around (0, 0) fills; then, with `--fly`, the centre moves along +x
//! at that speed for T seconds while frames run at F per second. Each phase reports
//! its wall time, chunks generated and sections meshed, and the time `update`
//! took on the calling thread (the render thread in the game): total, median,
//! p99 and slowest. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1 to count the
//! allocations made on the calling thread during updates and by everything.
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
light::LightTables,
mesher::MeshTables,
models,
streaming::{Streaming, WorkerContext},
vanillagen::VanillaTerrain,
world::World,
};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
path::PathBuf,
sync::{
Arc,
atomic::{AtomicU64, Ordering::Relaxed},
},
time::{Duration, Instant},
};
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
static ALLOCATIONS: AtomicU64 = AtomicU64::new(0);
static BYTES: AtomicU64 = AtomicU64::new(0);
thread_local! {
static LOCAL: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
}
fn count(size: usize) {
if TRACK {
ALLOCATIONS.fetch_add(1, Relaxed);
BYTES.fetch_add(size as u64, Relaxed);
let _ = LOCAL.try_with(|c| {
let (n, b) = c.get();
c.set((n + 1, b + size as u64));
});
}
}
struct CountingAllocator;
// Delegates every operation and layout unchanged to System.
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
count(layout.size());
unsafe { System.alloc(layout) }
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
count(layout.size());
unsafe { System.alloc_zeroed(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) };
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
count(size);
unsafe { System.realloc(ptr, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
#[derive(Default)]
struct Phase {
updates: Vec<f64>,
local: (u64, u64),
uploads: usize,
}
impl Phase {
fn update(&mut self, streaming: &mut Streaming, world: &mut World) {
let before = LOCAL.with(Cell::get);
let started = Instant::now();
let (uploads, _) = streaming.update(world);
self.updates.push(started.elapsed().as_secs_f64() * 1e3);
let after = LOCAL.with(Cell::get);
self.local.0 += after.0 - before.0;
self.local.1 += after.1 - before.1;
self.uploads += uploads.len();
drop(uploads);
}
fn report(&self, name: &str, wall: Duration, streaming: &Streaming, generated: u64) {
let mut sorted = self.updates.clone();
sorted.sort_by(f64::total_cmp);
let at = |p: f64| sorted[((sorted.len() as f64 * p) as usize).min(sorted.len() - 1)];
let total: f64 = sorted.iter().sum();
println!(
"{name}: {:.2} s, {} chunks generated, {} sections meshed; update {:.1} ms total over {} calls, p50 {:.3} p99 {:.3} max {:.2} ms",
wall.as_secs_f64(),
streaming.generated_total - generated,
self.uploads,
total,
sorted.len(),
at(0.5),
at(0.99),
sorted.last().copied().unwrap_or(0.0),
);
if TRACK {
println!(
" update allocations: {} ({:.1} MiB)",
self.local.0,
self.local.1 as f64 / 1048576.0
);
}
}
}
/// Nothing queued or in flight. Sections at the edge of the area stay dirty
/// (their outer neighbours are never lit), so they don't count.
fn settled(streaming: &Streaming) -> bool {
streaming
.stages()
.iter()
.all(|&(name, n)| name == "protos" || n == 0)
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let root = PathBuf::from(args.first().ok_or("usage: streaming_bench RESOURCES")?);
let flag = |name: &str, default: f64| -> f64 {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1))
.and_then(|v| v.parse().ok())
.unwrap_or(default)
};
let auto = std::thread::available_parallelism().map_or(2, |n| n.get() - 1);
let threads = flag("--threads", auto as f64) as usize;
let radius = flag("--radius", 8.0) as i32;
let seed = flag("--seed", 1234.0) as i64;
let fly = flag("--fly", 0.0);
let seconds = flag("--seconds", 10.0);
let hz = flag("--hz", 240.0);
let assets = Arc::new(Assets::load(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let biomes = Arc::new(Biomes::load(&root)?);
let tables = Arc::new(MeshTables::new(
&assets,
&facts,
models::load_visibility(&root, assets.registry)?,
)?);
let light = Arc::new(LightTables::load(&root, assets.registry, &facts)?);
let terrain = Arc::new(VanillaTerrain::new(
&root.join("worldgen"),
assets.registry,
&biomes,
seed,
)?);
let context = Arc::new(WorkerContext {
assets,
tables,
biomes,
light,
});
let mut world = World::new(facts.air, seed);
let mut streaming = Streaming::new(terrain, context, threads);
streaming.radius = radius;
streaming.center = (0, 0);
println!("{threads} workers, radius {radius}, seed {seed}");
let (allocations, bytes) = (ALLOCATIONS.load(Relaxed), BYTES.load(Relaxed));
// Fill: update as often as the game would uncapped, until settled.
let mut phase = Phase::default();
let started = Instant::now();
let mut quiet = 0;
while quiet < 20 {
let uploads = phase.uploads;
phase.update(&mut streaming, &mut world);
quiet = if settled(&streaming) && phase.uploads == uploads {
quiet + 1
} else {
0
};
std::thread::sleep(Duration::from_micros(500));
}
phase.report("fill", started.elapsed(), &streaming, 0);
if fly > 0.0 {
let mut phase = Phase::default();
let generated = streaming.generated_total;
let frame = Duration::from_secs_f64(1.0 / hz);
let started = Instant::now();
let mut behind = Vec::new();
while started.elapsed().as_secs_f64() < seconds {
let next = Instant::now() + frame;
let x = (started.elapsed().as_secs_f64() * fly) as i32;
streaming.center = (x, 0);
phase.update(&mut streaming, &mut world);
// Columns within 2 chunks of the player still missing their meshes.
let near = (-2..=2)
.flat_map(|dz| (-2..=2).map(move |dx| (x + dx, dz)))
.filter(|&(cx, cz)| !streaming.is_lit(cx, cz))
.count();
behind.push(near);
if let Some(wait) = next.checked_duration_since(Instant::now()) {
std::thread::sleep(wait);
}
}
phase.report("fly", started.elapsed(), &streaming, generated);
let unlit = behind.iter().filter(|&&n| n > 0).count();
println!(
" frames with an unlit column within 2 chunks of the player: {unlit} of {} ({:.1}%)",
behind.len(),
unlit as f64 * 100.0 / behind.len().max(1) as f64
);
}
if TRACK {
println!(
"all threads: {} allocations ({:.1} MiB)",
ALLOCATIONS.load(Relaxed) - allocations,
(BYTES.load(Relaxed) - bytes) as f64 / 1048576.0
);
}
Ok(())
}
@@ -0,0 +1,143 @@
//! Resident-key synchronization used by Game (registration callback work excluded).
//! Run: CARGO_INCREMENTAL=0 cargo run -p horizon-client --profile fast --example tick_chunk_membership_bench
use horizon_client::{
chunk::Chunk, chunk_map::ChunkMap, tick_chunk_membership::TickChunkMembership,
};
use std::{collections::HashSet, hint::black_box, sync::Arc, time::Instant};
type Key = (i32, i32);
#[derive(Default)]
struct Old {
keys: HashSet<Key>,
}
impl Old {
fn sync(&mut self, map: &ChunkMap) -> (usize, usize) {
let loaded: HashSet<_> = map.keys().copied().collect();
let mut counts = (0, 0);
for key in loaded.difference(&self.keys.clone()) {
black_box(key);
counts.0 += 1;
}
for key in self.keys.difference(&loaded) {
black_box(key);
counts.1 += 1;
}
self.keys = loaded;
counts
}
}
fn new_sync(cache: &mut TickChunkMembership, map: &ChunkMap) -> (usize, usize) {
if !cache.synchronize(map) {
return (0, 0);
}
let added = cache.take_added();
for key in &added {
black_box(key);
}
for key in cache.removed() {
black_box(key);
}
let counts = (added.len(), cache.removed().len());
cache.recycle_added(added);
counts
}
fn map(offset: i32) -> ChunkMap {
let mut map = ChunkMap::default();
// Share a payload: the synchronization path reads only keys, not block data.
let chunk = Arc::new(Chunk::empty(0, 0));
for x in -16..=16 {
for z in -16..=16 {
map.insert((x + offset, z), chunk.clone());
}
}
map
}
fn run(new: bool, scenario: usize, iterations: usize) -> (f64, (usize, usize)) {
let mut a = map(0);
let b = map(33);
let chunk = Arc::new(Chunk::empty(0, 0));
let mut old = Old::default();
let mut cache = TickChunkMembership::default();
for _ in 0..64 {
if new {
black_box(new_sync(&mut cache, &a));
} else {
black_box(old.sync(&a));
}
}
let mut total = (0, 0);
let start = Instant::now();
for i in 0..iterations {
let resident = match scenario {
0 => &a,
1 => {
let (previous, next) = if i % 2 == 0 {
((-16, -16), (-17, -16))
} else {
((-17, -16), (-16, -16))
};
a.remove(&previous);
a.insert(next, chunk.clone());
&a
}
2 => {
if i % 2 == 0 {
&b
} else {
&a
}
}
_ => unreachable!(),
};
let counts = if new {
new_sync(black_box(&mut cache), black_box(resident))
} else {
old.sync(black_box(resident))
};
total.0 += counts.0;
total.1 += counts.1;
}
(
start.elapsed().as_secs_f64() * 1e9 / iterations as f64,
black_box(total),
)
}
fn main() {
println!("1089 keys, 64 warmup calls, 9 samples; ns/sync, median");
for (scenario, name, iterations) in [
(0, "stable", 10_000),
(1, "one-key same-count replacement", 2_000),
(2, "whole-map replacement", 1_000),
] {
let mut before = Vec::new();
let mut after = Vec::new();
for sample in 0..9 {
let (old, new) = if sample % 2 == 0 {
(
run(false, scenario, iterations),
run(true, scenario, iterations),
)
} else {
let new = run(true, scenario, iterations);
(run(false, scenario, iterations), new)
};
assert_eq!(old.1, new.1);
let expected = match scenario {
0 => (0, 0),
1 => (iterations, iterations),
_ => (1089 * iterations, 1089 * iterations),
};
assert_eq!(new.1, expected);
before.push(old.0);
after.push(new.0);
}
before.sort_by(f64::total_cmp);
after.sort_by(f64::total_cmp);
println!(
"{name}: {:.1} -> {:.1} ns, {:.2}x; {iterations} iterations/sample",
before[4],
after[4],
before[4] / after[4]
);
}
}
@@ -0,0 +1,548 @@
//! Trading for tools/test_trading_parity.py: runs each case of INPUT as
//! VanillaTradingOracle does (offer generation, special prices, restocks,
//! the merchant menu, NBT) and prints the same lines.
//!
//! usage: trading_fixture RESOURCES INPUT VANILLA_OUTPUT OUTPUT
use horizon_client::{
enchant::Enchantments,
entity_nbt,
gossip::{GossipType, Gossips},
items::{ItemStack, Items},
menu::{Env, Input, Kind, Menu},
merchant::{self, Event, MerchantOffer, Session, TradeContext, TradeEnv, Trader, Trades},
random_sequences::RandomSequences,
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::nbt::Tag;
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
const PLAYER: [i32; 4] = [0, 0x1234, 0, 0x5678];
/// Canonical NBT text, as VanillaTradingOracle prints it.
fn canon(t: &Tag, out: &mut String) {
match t {
Tag::Compound(c) => {
out.push('{');
let mut keys: Vec<String> = c.keys().map(|k| k.to_string_lossy()).collect();
keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
for (i, k) in keys.iter().enumerate() {
if i > 0 {
out.push(',');
}
quote(k, out);
out.push(':');
canon(t.get(k).expect("key"), out);
}
out.push('}');
}
Tag::List(l) => {
out.push('[');
for (i, v) in l.iter().enumerate() {
if i > 0 {
out.push(',');
}
canon(v, out);
}
out.push(']');
}
Tag::String(s) => quote(&s.to_string_lossy(), out),
Tag::Byte(v) => write!(out, "{v}b").unwrap(),
Tag::Short(v) => write!(out, "{v}s").unwrap(),
Tag::Int(v) => write!(out, "{v}").unwrap(),
Tag::Long(v) => write!(out, "{v}L").unwrap(),
Tag::Float(v) => write!(out, "f:{v:x}").unwrap(),
Tag::Double(v) => write!(out, "d:{v:x}").unwrap(),
Tag::ByteArray(v) => {
out.push_str("[B;");
let s: Vec<String> = v.iter().map(|b| (*b as i8).to_string()).collect();
out.push_str(&s.join(","));
out.push(']');
}
Tag::IntArray(v) => {
out.push_str("[I;");
let s: Vec<String> = v.iter().map(i32::to_string).collect();
out.push_str(&s.join(","));
out.push(']');
}
Tag::LongArray(v) => {
out.push_str("[L;");
let s: Vec<String> = v.iter().map(i64::to_string).collect();
out.push_str(&s.join(","));
out.push(']');
}
Tag::End => out.push_str("?End"),
}
}
fn quote(s: &str, out: &mut String) {
out.push('"');
for c in s.chars() {
if c == '"' || c == '\\' {
out.push('\\');
}
out.push(c);
}
out.push('"');
}
fn canon_str(t: &Tag) -> String {
let mut s = String::new();
canon(t, &mut s);
s
}
fn stack(s: Option<&ItemStack>) -> String {
match s {
None => "-".into(),
Some(s) => canon_str(&horizon_client::save::item_to_nbt(s)),
}
}
struct World<'a> {
items: &'a Items,
enchantments: &'a Enchantments,
trades: &'a Trades,
sequences: RandomSequences,
seed: i64,
}
/// A merchant from PROF:TYPE:LEVEL or "wt", generated level by level.
struct Merchant {
villager: bool,
profession: String,
variant: String,
level: i32,
trader: Trader,
}
impl World<'_> {
fn reset(&mut self, seed: i64) {
self.sequences = RandomSequences::default();
self.seed = seed;
}
fn add_set(
&mut self,
offers: &mut Vec<MerchantOffer>,
set: &str,
variant: &str,
origin: [f64; 3],
) {
let Some(sequence) = self.trades.sequence(set) else {
return;
};
let ctx = TradeContext {
items: self.items,
enchantments: self.enchantments,
variant,
origin,
maps: None,
};
let random = self.sequences.get(&sequence, self.seed);
offers.extend(self.trades.offers_from_set(&ctx, set, random));
}
fn merchant(&mut self, spec: &str, origin: [f64; 3]) -> Merchant {
let mut offers = Vec::new();
if spec == "wt" {
for set in ["buying", "uncommon", "common"] {
self.add_set(
&mut offers,
&format!("minecraft:wandering_trader/{set}"),
"minecraft:plains",
origin,
);
}
return Merchant {
villager: false,
profession: "minecraft:wandering_trader".into(),
variant: "minecraft:plains".into(),
level: 1,
trader: Trader {
offers,
..Default::default()
},
};
}
let p: Vec<&str> = spec.split(':').collect();
let (profession, variant) = (format!("minecraft:{}", p[0]), format!("minecraft:{}", p[1]));
let max: i32 = p[2].parse().unwrap_or(1);
for level in 1..=max {
let set = Trades::set_for(&profession, level);
if self.trades.sets.contains_key(&set) {
self.add_set(&mut offers, &set, &variant, origin);
}
}
Merchant {
villager: true,
profession,
variant,
level: max,
trader: Trader {
offers,
..Default::default()
},
}
}
}
fn kv(fields: &[&str]) -> HashMap<String, String> {
let mut m: HashMap<String, String> = HashMap::new();
for f in &fields[2..] {
if let Some((k, v)) = f.split_once('=') {
m.entry(k.to_owned())
.and_modify(|e| {
e.push(' ');
e.push_str(v);
})
.or_insert_with(|| v.to_owned());
}
}
m
}
/// Uses, demand, gossip, xp and restock state, as the oracle's `prepare`.
fn prepare(m: &mut Merchant, gossips: &mut Gossips, kv: &HashMap<String, String>) {
for key in ["uses", "demand"] {
let Some(list) = kv.get(key) else { continue };
for s in list.split(' ') {
let mut f = s.split(':');
let i: usize = f.next().and_then(|v| v.parse().ok()).unwrap_or(0);
let n: i32 = f.next().and_then(|v| v.parse().ok()).unwrap_or(0);
let Some(o) = m.trader.offers.get_mut(i) else {
continue;
};
if key == "uses" {
o.uses += n;
} else {
o.demand = n;
}
}
}
if let Some(list) = kv.get("gossip") {
for s in list.split(' ') {
let (kind, n) = s.split_once(':').unwrap_or((s, "0"));
if let Some(kind) = GossipType::from_id(kind) {
gossips.add(PLAYER, kind, n.parse().unwrap_or(0));
}
}
}
if let Some(xp) = kv.get("xp") {
m.trader.xp = xp.parse().unwrap_or(0);
}
if let Some(v) = kv.get("lastrestock") {
m.trader.last_restock = v.parse().unwrap_or(0);
}
if let Some(v) = kv.get("restocks") {
m.trader.restocks_today = v.parse().unwrap_or(0);
}
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let usage = "usage: trading_fixture RESOURCES INPUT VANILLA_OUTPUT OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let input = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let enchantments = Enchantments::load(root);
let trades = Trades::load(root);
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let banners = horizon_client::banner::Banners::load(root);
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
.types
.into_iter()
.map(|(k, v)| (k, std::sync::Arc::new(v)))
.collect();
// The vanilla villagers the nbt cases read.
let snbt: HashMap<&str, &str> = vanilla
.lines()
.filter_map(|l| {
let (name, rest) = l.split_once(' ')?;
Some((name, rest.strip_prefix("snbt ")?))
})
.collect();
let mut world = World {
items: &items,
enchantments: &enchantments,
trades: &trades,
sequences: RandomSequences::default(),
seed: 0,
};
let mut out = String::new();
for line in input.lines().filter(|l| !l.trim().is_empty()) {
let fields: Vec<&str> = line.split(' ').collect();
let (kind, name) = (fields[0], fields[1]);
let kv = kv(&fields);
let seed: i64 = kv.get("seed").and_then(|s| s.parse().ok()).unwrap_or(0);
world.reset(seed);
let origin = [0.5, -60.0, 0.5];
match kind {
"gen" => {
let specs: Vec<String> = kv["v"].split(' ').map(str::to_owned).collect();
for (k, spec) in specs.iter().enumerate() {
let m = world.merchant(spec, origin);
writeln!(
out,
"{name} v{k} {}",
canon_str(&entity_nbt::offers_to_nbt(&m.trader.offers))
)?;
}
let mut line = format!("{name} draws");
for id in trades.sequences() {
write!(line, " {}", world.sequences.get(&id, seed).next_int())?;
}
writeln!(out, "{line}")?;
}
"price" => {
let mut m = world.merchant(&kv["v"], origin);
let mut gossips = Gossips::default();
prepare(&mut m, &mut gossips, &kv);
let hero = kv.get("hero").and_then(|h| h.parse().ok());
let rep = gossips.reputation(PLAYER);
merchant::update_special_prices(&mut m.trader.offers, rep, hero);
let mut line = format!("{name} rep {rep}");
for o in &m.trader.offers {
write!(line, " {}/{}", o.cost_a_count(&items), o.special_price_diff)?;
}
writeln!(out, "{line}")?;
}
"restock" => {
let mut m = world.merchant(&kv["v"], origin);
let mut gossips = Gossips::default();
prepare(&mut m, &mut gossips, &kv);
let steps = kv.get("steps").cloned().unwrap_or_default();
for (k, s) in steps.split(',').filter(|s| !s.is_empty()).enumerate() {
let f: Vec<&str> = s.split(':').collect();
let mut line = format!("{name} {k} {s}");
match f[0] {
"use" => {
let i: usize = f[1].parse().unwrap_or(0);
if let Some(o) = m.trader.offers.get_mut(i) {
o.uses += f[2].parse::<i32>().unwrap_or(0);
}
}
"check" => {
let time: i64 = f[1].parse().unwrap_or(0);
let day: i64 = f[2].parse().unwrap_or(0);
let should = m.trader.should_restock(time, day);
if should {
m.trader.restock(time);
}
write!(line, " {should}")?;
}
_ => {}
}
write!(
line,
" lr {} rt {} cd {}",
m.trader.last_restock, m.trader.restocks_today, m.trader.check_day
)?;
for o in &m.trader.offers {
write!(line, " {}/{}", o.uses, o.demand)?;
}
writeln!(out, "{line}")?;
}
}
"nbt" => {
let text = snbt.get(name).ok_or("missing vanilla snbt")?;
let (tag, _) = horizon_world::nbt::parse_snbt(text)?;
let id = if kv["v"] == "wt" {
"minecraft:wandering_trader"
} else {
"minecraft:villager"
};
let mut tag = tag;
if let Tag::Compound(c) = &mut tag {
c.insert("id".into(), Tag::String(id.into()));
}
let mob = entity_nbt::mob_from_nbt(&tag, &types, 1).ok_or("unreadable villager")?;
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None);
writeln!(out, "{name} snbt {text}")?;
let mut kept = horizon_world::nbt::Compound::new();
for key in [
"Offers",
"Xp",
"LastRestock",
"RestocksToday",
"VillagerData",
] {
if let Some(v) = back.get(key) {
kept.insert(key.into(), v.clone());
}
}
writeln!(out, "{name} canon {}", canon_str(&Tag::Compound(kept)))?;
}
"trade" => {
let env_trade = TradeEnv {
trades: &trades,
enchantments: &enchantments,
maps: None,
};
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
trade: Some(&env_trade),
};
trade(&mut out, name, &kv, &mut world, &env, origin)?;
}
_ => return Err(format!("unknown case {kind}").into()),
}
}
fs::write(args.get(4).ok_or(usage)?, out)?;
Ok(())
}
fn trade(
out: &mut String,
name: &str,
kv: &HashMap<String, String>,
world: &mut World,
env: &Env,
origin: [f64; 3],
) -> horizon_client::Result<()> {
let vseed: i64 = kv.get("vseed").and_then(|s| s.parse().ok()).unwrap_or(0);
let mut m = world.merchant(&kv["v"], origin);
let mut gossips = Gossips::default();
if m.villager {
prepare(&mut m, &mut gossips, kv);
}
let hero: Option<i32> = kv.get("hero").and_then(|h| h.parse().ok());
let mut menu = Menu::new(Kind::Merchant);
if let Some(inv) = kv.get("inv") {
for s in inv.split(',') {
let (slot, rest) = s.split_once(':').ok_or("inv")?;
let (item, count) = rest.rsplit_once('*').ok_or("inv")?;
let item = if item.contains(':') {
item.to_owned()
} else {
format!("minecraft:{item}")
};
menu.set_player_item(slot.parse()?, Some(ItemStack::new(&item, count.parse()?)));
}
}
menu.selected = kv.get("hand").and_then(|h| h.parse().ok()).unwrap_or(8);
// Villager.mobInteract / WanderingTrader.mobInteract.
let open = !m.trader.offers.is_empty();
let unhappy = if m.villager && !open { 40 } else { 0 };
let mut voice = LegacyRandom::new(vseed);
if unhappy > 0 {
// setUnhappy: makeSound(VILLAGER_NO) draws the voice pitch.
voice.next_float();
voice.next_float();
}
writeln!(out, "{name} open {open} unhappy {unhappy}")?;
let mut session = Session::new(m.trader.offers.clone(), m.villager);
session.xp = m.trader.xp;
session.level = m.level;
session.profession = m.profession.clone();
session.variant = m.variant.clone();
session.random = voice.clone();
session.origin = origin;
session.reputation = gossips.reputation(PLAYER);
session.hero = hero;
for id in Session::sequences_needed(world.trades, &m.profession, m.level) {
let r = world.sequences.lend(&id, world.seed);
session.sequences.insert(id, r);
}
session.update_special_prices();
let mut regen = false;
let mut open_menu = open;
if open_menu {
menu.merchant = Some(Box::new(session));
}
let steps = kv.get("steps").cloned().unwrap_or_default();
let mut session_after: Option<Box<Session>> = None;
for (k, s) in steps.split(',').filter(|s| !s.is_empty()).enumerate() {
let f: Vec<&str> = s.split(':').collect();
match f[0] {
"select" if open_menu => menu.merchant_select(env, f[1].parse().unwrap_or(0)),
"click" if open_menu => {
let input = Input::parse(&f[3].to_uppercase()).ok_or("input")?;
let _ = menu.click(env, f[1].parse()?, f[2].parse()?, input);
}
"tick" => {
let sess = menu.merchant.as_mut().or(session_after.as_mut());
if let Some(sess) = sess
&& sess.villager
&& sess.last_traded
{
sess.last_traded = false;
gossips.add(PLAYER, GossipType::Trading, 2);
sess.reputation = gossips.reputation(PLAYER);
if open_menu {
sess.update_special_prices();
menu.merchant_update(env);
}
}
}
"close" if open_menu => {
menu.merchant_close(env);
open_menu = false;
session_after = Some(menu.merchant.take().ok_or("session")?);
}
_ => {}
}
let mut line = format!("{name} {k} {s} menu {open_menu}");
if open_menu {
for i in 0..3 {
write!(line, " {}", stack(menu.slot(i)))?;
}
write!(
line,
" fxp {}",
menu.merchant.as_ref().map_or(0, |s| s.future_xp)
)?;
}
write!(line, " carried {}", stack(menu.carried.as_ref()))?;
line.push_str(" inv");
for i in 0..36 {
if let Some(st) = menu.player_item(i) {
write!(line, " {i}={}", stack(Some(st)))?;
}
}
let sess = menu
.merchant
.as_deref_mut()
.or(session_after.as_deref_mut());
let (xp, level, offers, orbs) = match sess {
Some(sess) => {
let mut orbs = Vec::new();
for e in sess.events.drain(..) {
match e {
Event::Orb(v) => orbs.push(v),
Event::LevelUp(_) => regen = true,
_ => {}
}
}
(sess.xp, sess.level, sess.offers.clone(), orbs)
}
None => (m.trader.xp, m.level, m.trader.offers.clone(), Vec::new()),
};
write!(line, " xp {xp}")?;
if m.villager {
write!(
line,
" lvl {level} rep {} regen {regen}",
gossips.reputation(PLAYER)
)?;
}
line.push_str(" offers");
for o in &offers {
write!(
line,
" {}/{}/{}",
o.uses,
o.special_price_diff,
o.cost_a_count(env.items)
)?;
}
line.push_str(" orbs");
for v in orbs {
write!(line, " {v}")?;
}
writeln!(out, "{line}")?;
}
let random = match menu.merchant.as_mut().or(session_after.as_mut()) {
Some(s) => s.random.next_int(),
None => voice.next_int(),
};
writeln!(out, "{name} next {random}")?;
Ok(())
}
@@ -0,0 +1,59 @@
//! WeatherEffectRenderer column instances and quads, without a GPU.
use horizon_client::weather;
use std::{fmt::Write, fs};
fn bits(value: f32) -> String {
if value.is_nan() {
"nan".into()
} else {
format!("{:x}", value.to_bits())
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().collect();
let mut output = String::new();
for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() {
let p: Vec<&str> = line.split_whitespace().collect();
let int = |i: usize| p[i].parse::<i32>().unwrap();
let kind = int(0);
let ticks: i64 = p[1].parse()?;
let (x, bottom, top, z, light) = (int(2), int(3), int(4), int(5), int(6));
let partial: f32 = p[7].parse()?;
let camera = [p[8].parse()?, p[9].parse()?, p[10].parse()?];
let radius = int(11);
let intensity: f32 = p[12].parse()?;
let mut random = weather::column_random(x, z);
let column = if kind == 0 {
weather::rain_column(&mut random, ticks, x, bottom, top, z, light, partial)
} else {
weather::snow_column(&mut random, ticks, x, bottom, top, z, light, partial)
};
write!(
output,
"{case} {} {} {} {} {} {} {}",
column.x,
column.z,
column.bottom_y,
column.top_y,
bits(column.u_offset),
bits(column.v_offset),
column.light_coords
)?;
let max_alpha = if kind == 0 { 1.0 } else { 0.8 };
for v in weather::column_quad(&column, camera, max_alpha, radius, intensity) {
write!(
output,
" |{},{},{},{},{},{:x},{}",
bits(v.pos[0]),
bits(v.pos[1]),
bits(v.pos[2]),
bits(v.uv[0]),
bits(v.uv[1]),
v.color,
v.light_coords
)?;
}
output.push('\n');
}
fs::write(&args[2], output)?;
Ok(())
}
+44 -2
View File
@@ -294,6 +294,8 @@ pub struct EntityView {
pub struck: Vec<EntityView>,
pub fishing_open_water: Option<bool>,
pub variant: Option<String>,
/// The data components the entity reports (`Entity.get`): id and value.
pub components: Vec<(String, String)>,
}
fn range_f(v: &Value, x: f64) -> bool {
match v {
@@ -310,7 +312,7 @@ fn range_i(v: &Value, x: i64) -> bool {
range_f(v, x as f64)
}
/// A registry id, `#tag`, or list matched against one id.
fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
match v {
Value::Null => true,
Value::String(s) => match s.strip_prefix('#') {
@@ -442,6 +444,26 @@ pub fn location(v: &Value, p: [f64; 3], w: &dyn World) -> bool {
}
true
}
/// `DataComponentExactPredicate.test` on an entity: every expected
/// component equals what the entity reports (absent ones never match).
fn components(expected: &Value, have: &[(String, String)]) -> bool {
let full = |id: &str| {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
};
expected.as_object().is_some_and(|o| {
o.iter().all(|(id, want)| {
let id = full(id);
want.as_str().is_some_and(|want| {
have.iter()
.any(|(k, v)| full(k) == id && full(v) == full(want))
})
})
})
}
/// `EntityPredicate` (26.x keyed sub-predicates).
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
if v.is_null() {
@@ -521,7 +543,8 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
"minecraft:type_specific/fishing_hook" => p["in_open_water"]
.as_bool()
.is_none_or(|want| e.fishing_open_water == Some(want)),
"minecraft:components" | "minecraft:predicates" => true,
"minecraft:components" => components(p, &e.components),
"minecraft:predicates" => true,
_ => false,
};
if !ok {
@@ -571,6 +594,7 @@ pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
w,
)
}
"minecraft:match_block" => ctx.block_pos.is_some_and(|b| block(v, b, w)),
"minecraft:block_state_property" => ctx.block_pos.is_some_and(|b| {
let mut pred = serde_json::Map::new();
pred.insert("blocks".into(), v["block"].clone());
@@ -929,6 +953,24 @@ mod tests {
}
}
#[test]
fn bucket_axolotl_block_advancements_require_the_placed_block() {
let ctx = Entities {
this: None,
origin: [0.0; 3],
tool: None,
block_pos: Some([0; 3]),
};
let stone = serde_json::json!({"type":"minecraft:match_block","blocks":"minecraft:stone"});
let wheat = serde_json::json!({"type":"minecraft:match_block","blocks":"minecraft:wheat"});
assert!(conditions(&stone, &ctx, &Flat));
assert!(!conditions(&wheat, &ctx, &Flat));
let absent = Entities {
block_pos: None,
..ctx
};
assert!(!conditions(&stone, &absent, &Flat));
}
#[test]
fn predicates_match_tags_ranges_and_locations() {
let w = Flat;
let stone = ItemView {
File diff suppressed because it is too large. Load diff
+46 -8
View File
@@ -9,7 +9,7 @@ use horizon_world::{
use serde_json::Value;
use std::{
collections::BTreeSet,
fs::File,
fs::{self, File},
io::{self, BufReader},
path::Path,
sync::Arc,
@@ -55,14 +55,19 @@ pub struct Assets {
pub properties: Properties,
pub material_states: [BlockStateId; 6],
pub sprite_size: u32,
pub sprite_minimum_size: u32,
pub pixels: Vec<u8>,
pub texture_layers: u32,
pub model_catalog: crate::models::StateModels,
/// Shared parsed resources; item-only validation errors surface at item preparation.
pub model_library: std::result::Result<Arc<horizon_assets::resolve::ModelLibrary>, String>,
pub model_placements: Vec<(horizon_core::position::BlockPos, BlockStateId)>,
pub model_gallery: Vec<Value>,
pub animations: Vec<crate::sprites::SpriteAnimation>,
pub sprite_layers: std::collections::BTreeMap<String, u32>,
pub translucent_layers: Vec<bool>,
/// Each layer's `MipmapGenerator` settings.
pub sprite_mips: Vec<crate::mipmap::Settings>,
/// Block names in `#minecraft:blocks_fluid_flow` (fluid flow vectors).
pub blocks_fluid_flow: BTreeSet<String>,
}
@@ -95,8 +100,7 @@ impl Assets {
if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) {
return Err(invalid("resources do not match Horizon's pinned data version").into());
}
let facts: Value =
serde_json::from_reader(BufReader::new(File::open(root.join("facts.json"))?))?;
let facts: Value = serde_json::from_slice(&fs::read(root.join("facts.json"))?)?;
let states = facts["states"]
.as_array()
.ok_or_else(|| invalid("missing registry state facts"))?;
@@ -180,16 +184,46 @@ impl Assets {
.collect::<Result<_>>()?,
));
let mut material_states = [BlockStateId(0); 6];
let mut sprites = crate::sprites::Sprites::default();
for (layer, name) in MATERIALS.iter().enumerate() {
let state = registry
material_states[layer] = registry
.default_state(&format!("minecraft:{name}"))
.ok_or_else(|| invalid("missing test-scene material"))?;
material_states[layer] = state;
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
let models = crate::models::prepare(root, registry, &mut sprites)?;
Self::with_models(
root,
registry,
properties,
material_states,
blocks_fluid_flow,
)
}
/// The same blocks with the models and sprites of another resource root
/// (resource packs changed): the registry and block facts come from the
/// game data, which resource packs do not touch.
pub fn reload(&self, root: &Path) -> Result<Self> {
Self::with_models(
root,
self.registry,
self.properties.clone(),
self.material_states,
self.blocks_fluid_flow.clone(),
)
}
fn with_models(
root: &Path,
registry: &'static BlockRegistry,
properties: Properties,
material_states: [BlockStateId; 6],
blocks_fluid_flow: BTreeSet<String>,
) -> Result<Self> {
let mut sprites = crate::sprites::Sprites::default();
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
let parsed = crate::model_library::ParsedModels::load(root);
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
let model_library = parsed.into_item_library();
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
for name in [
@@ -204,19 +238,23 @@ impl Assets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.finish();
Ok(Self {
registry,
properties,
material_states,
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
texture_layers: u32::try_from(sprites.layers.len())?,
pixels: sprites.pixels,
model_catalog: models.catalog,
model_library,
model_placements: models.placements,
model_gallery: models.states,
animations: sprites.animations,
sprite_layers: sprites.layers,
translucent_layers: sprites.translucent,
sprite_mips: sprites.mips,
blocks_fluid_flow,
})
}
+322 -59
View File
@@ -61,10 +61,78 @@ pub struct Heard {
pub struct MixerBackend {
mixer: Arc<Mutex<Mixer>>,
root: PathBuf,
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
cache: Arc<Mutex<SoundCache>>,
/// The decoder thread for static sounds, started with the first one.
decoder: Option<std::sync::mpsc::Sender<Decode>>,
next: ChannelId,
live: bool,
}
/// Decoded samples and their rate (`None`: the file did not load).
type Samples = Option<(Arc<Vec<f32>>, u32)>;
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
#[derive(Default)]
struct SoundCache {
entries: HashMap<String, (Samples, u64)>,
bytes: usize,
clock: u64,
}
const CACHE_BYTES: usize = 64 << 20;
impl SoundCache {
fn get(&mut self, key: &str) -> Option<Samples> {
self.clock += 1;
let entry = self.entries.get_mut(key)?;
entry.1 = self.clock;
Some(entry.0.clone())
}
fn insert(&mut self, key: String, samples: Samples) {
self.clock += 1;
self.bytes += Self::size(&samples);
if let Some((old, _)) = self.entries.insert(key, (samples, self.clock)) {
self.bytes -= Self::size(&old);
}
while self.bytes > CACHE_BYTES && self.entries.len() > 1 {
let Some(oldest) = self
.entries
.iter()
.min_by_key(|(_, (_, used))| *used)
.map(|(k, _)| k.clone())
else {
break;
};
if let Some((gone, _)) = self.entries.remove(&oldest) {
self.bytes -= Self::size(&gone);
}
}
}
fn size(samples: &Samples) -> usize {
samples
.as_ref()
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
}
}
/// A static sound to decode for a channel waiting on it.
struct Decode {
key: String,
file: PathBuf,
channel: ChannelId,
}
/// Give a channel waiting on its sound the decoded samples and start it, as
/// the buffer future attaching and playing the source. A source stopped or
/// released meanwhile stays so.
fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: bool) {
if let Some(samples) = samples
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
&& c.state == State::Initial
{
c.samples = Some(samples);
c.position = 0.0;
c.state = State::Playing;
c.looping |= looping;
}
}
pub struct Audio {
_stream: Option<cpal::Stream>,
pub engine: SoundEngine,
@@ -176,53 +244,59 @@ impl Backend for MixerBackend {
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
self.with(channel, |c| c.relative = relative);
}
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
/// worker), then attach and `alSourcePlay`. A sound that cannot load
/// leaves its source initial, as a failed buffer future does in vanilla.
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
/// `getStream` (decoded on a thread of its own), then attach and
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
/// as a failed buffer future does in vanilla.
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
if !self.live {
return;
}
let (namespace, path) = sound.path();
let file = self.root.join("assets").join(namespace).join(&path);
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
if let Some(samples) = samples
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.samples = Some(samples);
c.position = 0.0;
c.state = State::Playing;
}
};
if sound.stream {
// A streamed sound loops by restarting its stream.
let mixer = self.mixer.clone();
std::thread::spawn(move || {
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
if looping
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.looping = true;
}
attach(&mixer, samples);
attach(&mixer, channel, samples, looping);
});
} else {
let samples = self
.cache
.entry(format!("{namespace}:{path}"))
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
.clone();
attach(&self.mixer, samples);
return;
}
}
fn stop(&mut self, channel: ChannelId) {
self.with(channel, |c| {
if c.state != State::Initial {
c.state = State::Stopped;
}
let key = format!("{namespace}:{path}");
let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
if let Some(samples) = cached {
attach(&self.mixer, channel, samples, false);
return;
}
let decoder = self.decoder.get_or_insert_with(|| {
let (send, receive) = std::sync::mpsc::channel::<Decode>();
let (mixer, cache) = (self.mixer.clone(), self.cache.clone());
std::thread::Builder::new()
.name("sound decoder".into())
.spawn(move || {
for job in receive {
// Queued twice before the first decode finished.
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
let samples = cached.unwrap_or_else(|| {
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
if let Ok(mut c) = cache.lock() {
c.insert(job.key, samples.clone());
}
samples
});
attach(&mixer, job.channel, samples, false);
}
})
.expect("sound decoder thread");
send
});
let _ = decoder.send(Decode { key, file, channel });
}
/// `alSourceStop`: any state becomes stopped, an initial source too (a
/// sound still decoding then never starts).
fn stop(&mut self, channel: ChannelId) {
self.with(channel, |c| c.state = State::Stopped);
}
fn pause(&mut self, channel: ChannelId) {
self.with(channel, |c| {
@@ -327,6 +401,71 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
*s = s.clamp(-1.0, 1.0);
}
}
/// Why the game should not open an output, if it should not:
/// `HORIZON_NO_AUDIO`, or a virtual screen (`vscreen`), where agents and
/// harnesses run the game and must stay off the user's speakers.
/// `HORIZON_AUDIO=1` opens the output anyway (testing into a null sink).
fn output_disabled(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<String> {
if var("HORIZON_NO_AUDIO").is_some() {
return Some("audio off: HORIZON_NO_AUDIO is set".into());
}
if var("HORIZON_AUDIO").is_some_and(|v| v == "1") {
return None;
}
let display = var("WAYLAND_DISPLAY")?;
Path::new(&display)
.components()
.any(|c| c.as_os_str() == "vscreen")
.then(|| "audio off on a virtual screen (vscreen); HORIZON_AUDIO=1 opens it".into())
}
/// An output stream on `device` in its default configuration, whatever its
/// sample format: the mixer works in f32 and converts.
fn open_device(
device: &cpal::Device,
mixer: Arc<Mutex<Mixer>>,
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
let stream = match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, mixer),
F::F64 => build_stream::<f64>(device, &config, mixer),
F::I16 => build_stream::<i16>(device, &config, mixer),
F::I32 => build_stream::<i32>(device, &config, mixer),
F::I8 => build_stream::<i8>(device, &config, mixer),
F::U16 => build_stream::<u16>(device, &config, mixer),
F::U32 => build_stream::<u32>(device, &config, mixer),
F::U8 => build_stream::<u8>(device, &config, mixer),
other => Err(format!("unsupported sample format {other}").into()),
}?;
stream.play()?;
Ok((stream, config))
}
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
mixer: Arc<Mutex<Mixer>>,
) -> Result<cpal::Stream> {
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let mut scratch = Vec::new();
let stream = device.build_output_stream(
&config.config(),
move |data: &mut [T], _| {
scratch.resize(data.len(), 0.0f32);
if let Ok(mut m) = mixer.lock() {
mix(&mut m, &mut scratch, channels, rate);
} else {
scratch.fill(0.0);
}
for (out, s) in data.iter_mut().zip(&scratch) {
*out = T::from_sample(*s);
}
},
|e| log::warn!("Horizon: audio stream error: {e}"),
None,
)?;
Ok(stream)
}
impl Audio {
pub fn new(root: &Path) -> Self {
Self::with_output(root, true)
@@ -347,7 +486,7 @@ impl Audio {
Ok(v) => (Some(v), true),
Err(e) => {
if output {
eprintln!("Horizon: audio unavailable: {e}");
log::warn!("Horizon: no sound: {e}");
}
(None, false)
}
@@ -360,7 +499,8 @@ impl Audio {
backend: MixerBackend {
mixer,
root: root.to_owned(),
cache: HashMap::new(),
cache: Arc::default(),
decoder: None,
next: 0,
live,
},
@@ -374,32 +514,40 @@ impl Audio {
}
}
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
// Test runs (harnesses, agents) launch the game on a virtual screen;
// keep them off the user's speakers.
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
}
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or("no audio output device")?;
let config = device.default_output_config()?;
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let stream = device.build_output_stream(
&config.into(),
move |data: &mut [f32], _| {
if let Ok(mut m) = mixer.lock() {
mix(&mut m, data, channels, rate);
} else {
data.fill(0.0);
let mut errors = Vec::new();
// The default device first, then any other output a host lists:
// a default that cannot open (busy, or a stale ALSA default) should
// not leave the game silent when another device works.
let default = host.default_output_device();
let others = host.output_devices().into_iter().flatten();
let mut tried = Vec::new();
for device in default.into_iter().chain(others) {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if tried.contains(&name) {
continue;
}
tried.push(name.clone());
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok(stream);
}
},
|e| eprintln!("Horizon: audio stream error: {e}"),
None,
)?;
stream.play()?;
Ok(stream)
Err(e) => errors.push(format!("{name}: {e}")),
}
}
if errors.is_empty() {
return Err(format!("no audio output device on the {} host", host.id().name()).into());
}
Err(format!("no audio output device opened ({})", errors.join("; ")).into())
}
fn bound(&mut self) -> Bound<'_, MixerBackend> {
Bound {
@@ -452,6 +600,16 @@ impl Audio {
pub fn stop_all(&mut self) {
self.engine.stop_all(&mut self.backend);
}
/// `SoundManager.reload` (resource packs changed): stop every sound, then
/// read sound events and sound files from the new resource root.
pub fn reload(&mut self, root: &Path) {
self.stop_all();
self.engine.registry = SoundRegistry::load(root);
self.backend.root = root.to_owned();
let mut cache = self.backend.cache.lock().unwrap();
cache.entries.clear();
cache.bytes = 0;
}
/// Play a sound event at a world position, as `ClientLevel.playLocalSound`
/// (a `SimpleSoundInstance` seeded from the level random), or relative to
/// the listener without attenuation (`None`).
@@ -459,6 +617,23 @@ impl Audio {
let seed = self.random.next_long();
self.play_seeded(event, at, volume, pitch, seed);
}
/// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the
/// client (the entity's own random there, never the server's world random).
pub fn hurt_pitch(&mut self) -> f32 {
(self.random.next_float() - self.random.next_float()) * 0.2 + 1.0
}
/// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound.
pub fn play_button_click(&mut self) {
self.play_instance(
SoundInstance::for_ui(
"minecraft:ui.button.click",
1.0,
0.25,
SoundEngine::unseeded(),
),
false,
);
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
@@ -525,6 +700,21 @@ impl Audio {
self.release(id);
}
/// Every sound event id (for command completion).
/// The engine's table sizes and the mixer's channels (`status`).
pub fn census(&self) -> serde_json::Value {
let mut census = self.engine.census();
census["channels"] = self
.backend
.mixer
.lock()
.map_or(0, |m| m.channels.len())
.into();
if let Ok(cache) = self.backend.cache.lock() {
census["decoded"] = cache.entries.len().into();
census["decoded_mib"] = (cache.bytes >> 20).into();
}
census
}
pub fn event_names(&self) -> Vec<String> {
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
v.sort();
@@ -576,6 +766,38 @@ impl Sounds for Audio {
mod tests {
use super::*;
#[test]
fn button_click_uses_vanilla_ui_parameters_without_advancing_world_random() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
audio.engine.registry.add_file(
"minecraft",
br#"{"ui.button.click":{"sounds":["gui/button"]}}"#,
&|_, _| true,
);
// Exercise the mixer without opening an output device. The missing
// sample is harmless: this test checks the source the engine creates.
audio.engine.loaded = true;
audio.backend.live = true;
let mut random = audio.random.clone();
audio.play_button_click();
let instance = audio.engine.instance(1).expect("click reached the engine");
assert_eq!(instance.identifier, "minecraft:ui.button.click");
assert_eq!(instance.source, crate::sound_engine::UI);
assert_eq!(instance.volume, 0.25);
assert_eq!(instance.pitch, 1.0);
assert_eq!(instance.attenuation, Attenuation::None);
assert_eq!(instance.position, [0.0; 3]);
assert!(instance.relative);
assert!(!instance.looping);
assert_eq!(audio.random.next_long(), random.next_long());
audio.pause();
assert_eq!(
audio.backend.mixer.lock().unwrap().channels[&1].state,
State::Initial
);
audio.stop_all();
}
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
Channel {
samples: Some((Arc::new(samples), 48_000)),
@@ -590,6 +812,47 @@ mod tests {
}
}
#[test]
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
let mut cache = SoundCache::default();
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
for key in ["a", "b", "c", "d"] {
cache.insert(key.into(), sound());
}
assert!(cache.get("a").is_some());
cache.insert("e".into(), sound());
assert!(cache.bytes <= CACHE_BYTES);
assert!(
cache.get("b").is_none(),
"the least recently used goes first"
);
assert!(cache.get("a").is_some() && cache.get("e").is_some());
// A failed load is remembered without counting.
cache.insert("missing".into(), None);
assert_eq!(cache.get("missing"), Some(None));
}
#[test]
fn output_stays_off_on_a_virtual_screen_unless_asked() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |name: &str| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| std::ffi::OsString::from(*v))
}
};
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", "wayland-1")])).is_none());
assert!(output_disabled(env(&[])).is_none());
const VSCREEN: &str = "/run/user/1000/vscreen/main/wayland-1";
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN)])).is_some());
assert!(
output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN), ("HORIZON_AUDIO", "1")])).is_none()
);
assert!(
output_disabled(env(&[("HORIZON_NO_AUDIO", "1"), ("HORIZON_AUDIO", "1")])).is_some()
);
}
#[test]
fn linear_attenuation_reaches_zero_at_the_max_distance() {
assert_eq!(distance_gain(None, 100.0), 1.0);
+343
View File
@@ -0,0 +1,343 @@
//! The Microsoft account the launcher signed in, handed over in a session
//! file (docs/microsoft-account.md), and the Minecraft services call
//! online-mode servers need: authlib's MinecraftServicesSessionService.joinServer
//! through the services discovery document. The access token stays in this
//! process's memory: it is never logged, printed or written anywhere.
use std::{
path::Path,
sync::{Arc, Mutex, OnceLock},
time::{Duration, SystemTime},
};
/// MinecraftServicesEnvironment.PROD.
pub const DEFAULT_DISCOVERY: &str = "https://discovery.minecraftservices.com/minecraft/client";
/// A session file older than this is refused: it can't be replayed later.
const MAX_SESSION_FILE_AGE: Duration = Duration::from_secs(120);
/// A signed-in account (vanilla's User with type msa).
pub struct AuthSession {
pub name: String,
pub uuid: u128,
pub xuid: Option<String>,
access_token: String,
}
impl std::fmt::Debug for AuthSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthSession")
.field("name", &self.name)
.field("uuid", &horizon_protocol::crypto::uuid_string(self.uuid))
.field("access_token", &"<hidden>")
.finish()
}
}
/// A UUID with or without dashes.
pub fn parse_uuid(text: &str) -> Option<u128> {
let hex: String = text.chars().filter(|c| *c != '-').collect();
(hex.len() == 32)
.then(|| u128::from_str_radix(&hex, 16).ok())
.flatten()
}
impl AuthSession {
/// For tests and harnesses.
pub fn new(name: &str, uuid: u128, access_token: &str) -> Self {
Self {
name: name.to_owned(),
uuid,
xuid: None,
access_token: access_token.to_owned(),
}
}
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
pub fn parse(text: &str) -> Result<Self, String> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
let name = field("name").ok_or("the session file has no name")?;
if name.is_empty() || name.chars().count() > 16 {
return Err("the session file's name is not a valid player name".into());
}
let uuid = field("uuid")
.and_then(parse_uuid)
.ok_or("the session file has no valid uuid")?;
let access_token = field("access_token")
.filter(|t| !t.is_empty())
.ok_or("the session file has no access token")?;
if field("user_type").is_some_and(|t| t != "msa") {
return Err("the session file is not a Microsoft account session".into());
}
Ok(Self {
name: name.to_owned(),
uuid,
xuid: field("xuid").map(str::to_owned),
access_token: access_token.to_owned(),
})
}
/// Read the session file the launcher wrote and delete it at once. It
/// must be fresh and, on Unix, readable by its owner only.
pub fn load(path: &Path) -> Result<Self, String> {
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
let refuse = |why: &str| {
let _ = std::fs::remove_file(path);
Err(format!("{}: {why}", path.display()))
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o077 != 0 {
return refuse("other users can read it");
}
}
let age = meta
.modified()
.ok()
.and_then(|m| SystemTime::now().duration_since(m).ok())
.unwrap_or_default();
if age > MAX_SESSION_FILE_AGE {
return refuse("it is too old (start the game from the launcher)");
}
let text = std::fs::read_to_string(path);
let _ = std::fs::remove_file(path);
Self::parse(&text.map_err(|e| format!("{}: {e}", path.display()))?)
}
}
static CURRENT: OnceLock<Arc<AuthSession>> = OnceLock::new();
/// The account this game was started with (set once at start).
pub fn set_current(session: AuthSession) {
let _ = CURRENT.set(Arc::new(session));
}
pub fn current() -> Option<Arc<AuthSession>> {
CURRENT.get().cloned()
}
/// Why joining a server through the session service failed, like the
/// AuthenticationException subclasses authlib throws.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum JoinError {
/// No answer, or a 5xx (AuthenticationUnavailableException).
Unavailable,
/// 401 or ForbiddenOperationException (InvalidCredentialsException).
InvalidSession,
/// InsufficientPrivilegesException: multiplayer is off for the account.
InsufficientPrivileges,
/// multiplayer.access.banned or FORCED_USERNAME_CHANGE.
Banned,
/// Anything else, with the service's message.
Other(String),
}
impl JoinError {
/// MinecraftClientHttpException.toAuthenticationException.
pub fn from_response(status: u16, body: &str) -> Self {
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
match error {
"ForbiddenOperationException" => return Self::InvalidSession,
"multiplayer.access.banned" | "FORCED_USERNAME_CHANGE" => return Self::Banned,
"InsufficientPrivilegesException" => return Self::InsufficientPrivileges,
_ => {}
}
match status {
401 => Self::InvalidSession,
500.. => Self::Unavailable,
_ => Self::Other(
v.get("errorMessage")
.and_then(|m| m.as_str())
.map_or_else(|| format!("Status: {status}"), str::to_owned),
),
}
}
/// ClientHandshakePacketListenerImpl.authenticateServer's message: the
/// key of the reason inside "disconnect.loginFailedInfo", or its text.
pub fn reason(&self) -> Result<&'static str, &str> {
match self {
Self::Unavailable => Ok("disconnect.loginFailedInfo.serversUnavailable"),
Self::InvalidSession => Ok("disconnect.loginFailedInfo.invalidSession"),
Self::InsufficientPrivileges => Ok("disconnect.loginFailedInfo.insufficientPrivileges"),
Self::Banned => Ok("disconnect.loginFailedInfo.userBanned"),
Self::Other(message) => Err(message),
}
}
}
/// The services discovery document URL: `HORIZON_API_DISCOVERY` overrides
/// it like authlib's `-Dminecraft.api.discovery.host`.
pub fn discovery_url() -> String {
std::env::var("HORIZON_API_DISCOVERY")
.ok()
.filter(|u| !u.is_empty())
.unwrap_or_else(|| DEFAULT_DISCOVERY.to_owned())
}
fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(15))
.user_agent(concat!("Horizon/", env!("CARGO_PKG_VERSION")))
.build()
}
/// The discovery document, fetched once per game (a failure is retried next time).
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
if let Some(d) = CACHE.lock().unwrap().clone() {
return Ok(d);
}
let doc: serde_json::Value = agent()
.get(&discovery_url())
.call()
.ok()
.and_then(|r| r.into_string().ok())
.and_then(|t| serde_json::from_str(&t).ok())
.ok_or(JoinError::Unavailable)?;
let doc = Arc::new(doc);
*CACHE.lock().unwrap() = Some(doc.clone());
Ok(doc)
}
/// MinecraftServicesDiscoveryService.getUrl(service, endpoint).
pub fn endpoint(doc: &serde_json::Value, service: &str, name: &str) -> Option<String> {
doc.pointer(&format!("/discovery/{service}/endpoints/{name}/uri"))
.and_then(|u| u.as_str())
.map(str::to_owned)
}
/// The JoinMinecraftServerRequest body; `selectedProfile` is the undashed UUID.
pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
serde_json::json!({
"accessToken": session.access_token,
"selectedProfile": format!("{:032x}", session.uuid),
"serverId": server_hash,
})
.to_string()
}
/// MinecraftServicesSessionService.joinServer: tell the session service this
/// account is joining the server whose hash is `server_hash`.
pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "session", "join").ok_or(JoinError::Unavailable)?;
match agent()
.post(&url)
.set("Content-Type", "application/json; charset=utf-8")
.send_string(&join_request(session, server_hash))
{
Ok(_) => Ok(()),
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
)),
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
}
}
#[cfg(test)]
mod tests {
use super::*;
const UUID: u128 = 0x069a79f444e94726a5befca90e38aaf5;
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
let path = dir.join("session.json");
std::fs::write(&path, text).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
}
let _ = mode;
path
}
#[test]
fn session_files_are_read_once_and_must_be_private() {
let dir = std::env::temp_dir().join(format!("horizon-auth-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let text = r#"{"name":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","access_token":"mock-token","user_type":"msa"}"#;
let path = write(&dir, text, 0o600);
let s = AuthSession::load(&path).unwrap();
assert_eq!((s.name.as_str(), s.uuid), ("Notch", UUID));
assert!(!path.exists(), "deleted after reading");
assert!(!format!("{s:?}").contains("mock-token"));
#[cfg(unix)]
{
let path = write(&dir, text, 0o644);
assert!(
AuthSession::load(&path)
.unwrap_err()
.contains("other users")
);
assert!(!path.exists());
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn session_file_fields() {
assert!(
AuthSession::parse(r#"{"name":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5"}"#)
.is_err()
);
assert!(
AuthSession::parse(
r#"{"name":"","uuid":"069a79f444e94726a5befca90e38aaf5","access_token":"t"}"#
)
.is_err()
);
assert!(AuthSession::parse(r#"{"name":"A","uuid":"nope","access_token":"t"}"#).is_err());
assert!(AuthSession::parse(r#"{"name":"A","uuid":"069a79f444e94726a5befca90e38aaf5","access_token":"t","user_type":"legacy"}"#).is_err());
let s = AuthSession::parse(r#"{"name":"A","uuid":"069a79f444e94726a5befca90e38aaf5","access_token":"t","xuid":"25"}"#).unwrap();
assert_eq!(s.xuid.as_deref(), Some("25"));
}
#[test]
fn join_request_like_authlib() {
let s = AuthSession::new("Notch", UUID, "tok");
let v: serde_json::Value = serde_json::from_str(&join_request(&s, "-7c9d")).unwrap();
assert_eq!(
v,
serde_json::json!({"accessToken": "tok", "selectedProfile": "069a79f444e94726a5befca90e38aaf5", "serverId": "-7c9d"})
);
}
#[test]
fn errors_map_like_authlib() {
let e = |status, body| JoinError::from_response(status, body);
assert_eq!(
e(
403,
r#"{"error":"ForbiddenOperationException","errorMessage":"Invalid token"}"#
),
JoinError::InvalidSession
);
assert_eq!(e(401, ""), JoinError::InvalidSession);
assert_eq!(
e(403, r#"{"error":"InsufficientPrivilegesException"}"#),
JoinError::InsufficientPrivileges
);
assert_eq!(
e(403, r#"{"error":"multiplayer.access.banned"}"#),
JoinError::Banned
);
assert_eq!(e(503, "down"), JoinError::Unavailable);
assert_eq!(
e(429, r#"{"errorMessage":"Too many"}"#),
JoinError::Other("Too many".into())
);
assert_eq!(
JoinError::InvalidSession.reason(),
Ok("disconnect.loginFailedInfo.invalidSession")
);
}
#[test]
fn discovery_endpoints() {
let doc = serde_json::json!({"discovery": {"session": {"endpoints": {"join": {"uri": "https://s/join"}}}}});
assert_eq!(
endpoint(&doc, "session", "join").as_deref(),
Some("https://s/join")
);
assert_eq!(endpoint(&doc, "session", "verify"), None);
}
}
+438
View File
@@ -0,0 +1,438 @@
//! Beehives and bee nests (`BeehiveBlockEntity`): up to three bees stored as
//! entity tags, let out when their time inside is up (never at night or in
//! the rain, nor through a blocked front, unless the hive is emptied in an
//! emergency), honey for every bee coming home with nectar. The host (the game
//! or the mob AI probe) loads and adds the bees through [`HiveHost`].
use crate::mobs::{Mob, Species, bee};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::nbt::{Compound, Tag};
pub const MAX_OCCUPANTS: usize = 3;
/// `BeehiveBlockEntity.IGNORED_BEE_TAGS`: dropped from a bee going in, and
/// again from the tag it comes out with.
const IGNORED_BEE_TAGS: [&str; 25] = [
"Air",
"drop_chances",
"equipment",
"Brain",
"CanPickUpLoot",
"DeathTime",
"fall_distance",
"FallFlying",
"Fire",
"HurtTime",
"LeftHanded",
"Motion",
"NoGravity",
"OnGround",
"PortalCooldown",
"Pos",
"Rotation",
"sleeping_pos",
"CannotEnterHiveTicks",
"TicksSincePollination",
"CropsGrownSincePollination",
"hive_pos",
"Passengers",
"leash",
"UUID",
];
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
/// `BeehiveBlockEntity.BeeReleaseStatus`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Release {
HoneyDelivered,
BeeReleased,
Emergency,
}
/// What a hive needs from the level it is in.
pub trait HiveHost {
/// `level.getRandom()`.
fn random(&mut self) -> &mut LegacyRandom;
/// `EnvironmentAttributes.BEES_STAY_IN_HIVE` at a position.
fn bees_stay_in_hive(&self, at: [i32; 3]) -> bool;
/// A non-empty collision shape at a block.
fn blocked(&self, at: [i32; 3]) -> bool;
/// `BlockState.setValue(HONEY_LEVEL, level)` on the hive.
fn set_honey_level(&mut self, at: [i32; 3], level: i32);
/// `EntityType.loadEntityRecursive` (the tag has its `id` and a `Pos`):
/// a new entity, or `None` for anything but a `#beehive_inhabitors`.
fn load(&mut self, tag: &Tag) -> Option<Mob>;
/// `Level.addFreshEntity`.
fn add(&mut self, m: Mob) -> bool;
fn sound(&mut self, name: &'static str, at: [f64; 3]);
}
fn strip_ignored(c: &mut Compound) {
for key in IGNORED_BEE_TAGS {
c.remove(&key.into());
}
}
fn int(tag: Option<&Tag>) -> Option<i32> {
match tag {
Some(Tag::Int(v)) => Some(*v),
_ => None,
}
}
fn pos(tag: Option<&Tag>) -> Option<[i32; 3]> {
match tag {
Some(Tag::IntArray(p)) if p.len() == 3 => Some([p[0], p[1], p[2]]),
_ => None,
}
}
impl Occupant {
/// `Occupant.of`: the bee's saved tag without the ignored tags, staying
/// 2400 ticks with nectar, else 600.
pub fn of(mut tag: Tag) -> Self {
if let Tag::Compound(c) = &mut tag {
strip_ignored(c);
}
let nectar = matches!(tag.get("HasNectar"), Some(Tag::Byte(v)) if *v != 0);
Self {
entity_data: tag,
ticks_in_hive: 0,
min_ticks_in_hive: if nectar { 2400 } else { 600 },
}
}
/// `Occupant.create`: a worldgen nest's bee, a bare bee tag.
pub fn create(ticks_in_hive: i32) -> Self {
Self {
entity_data: Tag::compound([("id", Tag::String("minecraft:bee".into()))]),
ticks_in_hive,
min_ticks_in_hive: 600,
}
}
fn has_nectar(&self) -> bool {
matches!(self.entity_data.get("HasNectar"), Some(Tag::Byte(v)) if *v != 0)
}
fn from_nbt(tag: &Tag) -> Option<Self> {
let data = tag.get("entity_data")?;
if !matches!(data, Tag::Compound(c) if c.contains_key(&"id".into())) {
return None;
}
Some(Self {
entity_data: data.clone(),
ticks_in_hive: int(tag.get("ticks_in_hive"))?,
min_ticks_in_hive: int(tag.get("min_ticks_in_hive"))?,
})
}
fn to_nbt(&self) -> Tag {
Tag::compound([
("entity_data", self.entity_data.clone()),
("ticks_in_hive", Tag::Int(self.ticks_in_hive)),
("min_ticks_in_hive", Tag::Int(self.min_ticks_in_hive)),
])
}
}
impl Beehive {
pub fn full(&self) -> bool {
self.bees.len() == MAX_OCCUPANTS
}
/// `loadAdditional`: `bees` (all or none, as the list codec reads them)
/// and `flower_pos`.
pub fn from_nbt(tag: &Tag) -> Self {
let bees = match tag.get("bees") {
Some(Tag::List(list)) => list
.iter()
.map(Occupant::from_nbt)
.collect::<Option<Vec<_>>>()
.unwrap_or_default(),
_ => Vec::new(),
};
Self {
bees,
flower: pos(tag.get("flower_pos")),
}
}
/// `saveAdditional`.
pub fn to_nbt(&self) -> Vec<(&'static str, Tag)> {
let mut out = vec![(
"bees",
Tag::List(self.bees.iter().map(Occupant::to_nbt).collect()),
)];
if let Some(f) = self.flower {
out.push(("flower_pos", Tag::IntArray(f.to_vec())));
}
out
}
/// `addOccupant` (the bee's own tag, saved by the host): the hive keeps
/// the bee's flower, or now and then swaps its own for it. The host then
/// discards the bee.
pub fn add_occupant(
&mut self,
at: [i32; 3],
tag: Tag,
bee_flower: Option<[i32; 3]>,
host: &mut impl HiveHost,
) -> bool {
if self.bees.len() >= MAX_OCCUPANTS {
return false;
}
self.bees.push(Occupant::of(tag));
if let Some(f) = bee_flower
&& (self.flower.is_none() || host.random().next_boolean())
{
self.flower = Some(f);
}
let corner = at.map(f64::from);
host.sound("block.beehive.enter", corner);
true
}
/// `serverTick`: the bees whose time is up come out, then the hive
/// hums now and then while it holds any. `honey` is the hive state's
/// honey level as the tick starts (`None` without the property);
/// `facing` its front.
pub fn server_tick(
&mut self,
at: [i32; 3],
facing: [i32; 2],
honey: Option<i32>,
host: &mut impl HiveHost,
) {
let flower = self.flower;
let mut i = 0;
while i < self.bees.len() {
// BeeData.tick: ticksInHive++ > minTicksInHive.
let b = &mut self.bees[i];
let up = b.ticks_in_hive > b.min_ticks_in_hive;
b.ticks_in_hive += 1;
if up {
let status = if b.has_nectar() {
Release::HoneyDelivered
} else {
Release::BeeReleased
};
let occupant = b.clone();
if release(host, at, facing, honey, &occupant, status, flower).is_some() {
self.bees.remove(i);
continue;
}
}
i += 1;
}
if !self.bees.is_empty() && host.random().next_double() < 0.005 {
let hum = [
f64::from(at[0]) + 0.5,
f64::from(at[1]),
f64::from(at[2]) + 0.5,
];
host.sound("block.beehive.work", hum);
}
}
/// `releaseAllOccupants`: the ids of the bees let out.
pub fn release_all(
&mut self,
at: [i32; 3],
facing: [i32; 2],
honey: Option<i32>,
status: Release,
host: &mut impl HiveHost,
) -> Vec<u32> {
let flower = self.flower;
let mut out = Vec::new();
self.bees.retain(
|b| match release(host, at, facing, honey, b, status, flower) {
Some(id) => {
out.extend(id);
false
}
None => true,
},
);
out
}
}
/// `releaseOccupant`: `None` while the bee stays in, else the id of the bee
/// let out (`Some(None)` when the level refused it).
fn release(
host: &mut impl HiveHost,
at: [i32; 3],
facing: [i32; 2],
honey: Option<i32>,
occupant: &Occupant,
status: Release,
flower: Option<[i32; 3]>,
) -> Option<Option<u32>> {
let emergency = status == Release::Emergency;
if host.bees_stay_in_hive(at) && !emergency {
return None;
}
let blocked = host.blocked([at[0] + facing[0], at[1], at[2] + facing[1]]);
if blocked && !emergency {
return None;
}
// Occupant.createEntity: the tag without the ignored tags.
let mut tag = occupant.entity_data.clone();
if let Tag::Compound(c) = &mut tag {
strip_ignored(c);
let center = [at[0], at[1], at[2]].map(|v| Tag::Double((f64::from(v) + 0.5).to_bits()));
c.insert("Pos".into(), Tag::List(center.to_vec()));
}
let mut m = host.load(&tag)?;
m.no_gravity = true;
if m.species() == Species::Bee {
bee::set_hive(&mut m, Some(at));
// setBeeReleaseData: the time inside counts towards growing up and
// away from love.
let ticks = occupant.ticks_in_hive;
if !m.extra.age_locked {
let age = m.extra.age;
m.extra.age = if age < 0 {
(age + ticks).min(0)
} else if age > 0 {
(age - ticks).max(0)
} else {
age
};
}
m.extra.in_love = (m.extra.in_love - ticks).max(0);
if let Some(f) = flower
&& bee::get(&m).is_none_or(|b| b.flower.is_none())
&& host.random().next_float() < 0.9
{
bee::set_flower(&mut m, Some(f));
}
if status == Release::HoneyDelivered {
bee::drop_off_nectar(&mut m);
if let Some(level) = honey.filter(|&l| l < 5) {
let mut increase = if host.random().next_int_bound(100).unwrap_or(1) == 0 {
2
} else {
1
};
if level + increase > 5 {
increase -= 1;
}
host.set_honey_level(at, level + increase);
}
}
let delta = if blocked {
0.0
} else {
0.55 + f64::from(m.width() / 2.0)
};
let p = horizon_core::geometry::Vec3::new(
f64::from(at[0]) + 0.5 + delta * f64::from(facing[0]),
f64::from(at[1]) + 0.5 - f64::from(m.height() / 2.0),
f64::from(at[2]) + 0.5 + delta * f64::from(facing[1]),
);
m.position = p;
m.previous = p;
}
host.sound(
"block.beehive.exit",
[
f64::from(at[0]) + 0.5,
f64::from(at[1]) + 0.5,
f64::from(at[2]) + 0.5,
],
);
let id = m.id;
Some(host.add(m).then_some(id))
}
/// `CampfireBlock.isSmokeyPos`: a lit campfire up to five blocks below,
/// unless a block whose collision crosses the smoke's centre post comes
/// first (a lit campfire right under that block still counts).
pub fn smokey(w: &crate::mobs::MobWorld, at: [i32; 3]) -> bool {
let lit = |y: i32| {
w.has_tag("minecraft:campfires", w.block_name(at[0], y, at[2]))
&& w.prop(at[0], y, at[2], "lit") == Some("true")
};
for i in 1..=5 {
let y = at[1] - i;
if lit(y) {
return true;
}
// SHAPE_VIRTUAL_POST: Block.column(4, 0, 16), the middle quarter.
let (x0, z0) = (f64::from(at[0]), f64::from(at[2]));
let blocked = w.block_collision_boxes(at[0], y, at[2]).iter().any(|b| {
let (min, max) = (b.min_position(), b.max_position());
min.x < x0 + 0.625 && max.x > x0 + 0.375 && min.z < z0 + 0.625 && max.z > z0 + 0.375
});
if blocked {
return lit(y - 1);
}
}
false
}
/// `HorizontalDirectionalBlock.FACING` as an x/z step.
pub fn facing_step(facing: Option<&str>) -> [i32; 2] {
match facing {
Some("south") => [0, 1],
Some("west") => [-1, 0],
Some("east") => [1, 0],
_ => [0, -1],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nbt_round_trips_in_vanilla_order() {
let tag = Tag::compound([
(
"bees",
Tag::List(vec![
Occupant::create(37).to_nbt(),
Occupant::create(0).to_nbt(),
]),
),
("flower_pos", Tag::IntArray(vec![1, -60, 3])),
]);
let hive = Beehive::from_nbt(&tag);
assert_eq!(hive.bees.len(), 2);
assert_eq!(hive.bees[0].ticks_in_hive, 37);
assert_eq!(hive.flower, Some([1, -60, 3]));
assert_eq!(Tag::compound(hive.to_nbt()), tag);
}
#[test]
fn a_bad_bee_drops_the_whole_list() {
let tag = Tag::compound([(
"bees",
Tag::List(vec![
Occupant::create(1).to_nbt(),
Tag::compound([("ticks_in_hive", Tag::Int(3))]),
]),
)]);
assert!(Beehive::from_nbt(&tag).bees.is_empty());
}
#[test]
fn going_in_strips_the_ignored_tags() {
let tag = Tag::compound([
("id", Tag::String("minecraft:bee".into())),
("UUID", Tag::IntArray(vec![1, 2, 3, 4])),
("Health", Tag::Float(10.0_f32.to_bits())),
("HasNectar", Tag::Byte(1)),
("hive_pos", Tag::IntArray(vec![0, 0, 0])),
]);
let o = Occupant::of(tag);
assert_eq!(o.min_ticks_in_hive, 2400);
assert!(o.entity_data.get("UUID").is_none());
assert!(o.entity_data.get("hive_pos").is_none());
assert!(o.entity_data.get("Health").is_some());
}
}
+427
View File
@@ -0,0 +1,427 @@
//! `horizon-client --benchmark SCENARIO`: the run `tools/perf_bench.py` drives from outside, driven
//! from inside, so anyone can measure their machine with nothing but the game. A thread sends the
//! same control commands `horizon-ctl` would, through the game's own command queue, and writes a
//! report (docs/benchmark-report.md) to `benchmarks/` in the game folder.
//!
//! The scenes, the world (seed 1234, creative) and every step and figure match `tools/perf_bench.py`,
//! so reports from the two can be compared.
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Sender};
use std::thread;
use std::time::{Duration, Instant};
use horizon_bench::{App, Frames, Kind, Report};
use serde_json::Value;
use crate::control::Request;
pub const SEED: i64 = 1234;
/// The world the benchmark creates under `saves/`, replaced on every run.
pub const WORLD: &str = "horizon-benchmark";
/// Written into the world so a later run only ever replaces its own world.
const MARKER: &str = "horizon-benchmark.txt";
/// A straight flight over terrain: name, start, end, pitch (`tools/graphics_bench.py`'s paths).
type Scene = (&'static str, [f64; 3], [f64; 3], f64);
pub const SCENES: [Scene; 4] = [
(
"village",
[-960.0, 125.0, 80.0],
[-960.0, 125.0, 272.0],
35.0,
),
(
"forest",
[-1100.0, 120.0, -560.0],
[-1100.0, 120.0, -368.0],
25.0,
),
(
"ocean",
[-300.0, 85.0, -420.0],
[-300.0, 85.0, -228.0],
10.0,
),
(
"mountain",
[560.0, 260.0, 860.0],
[752.0, 260.0, 860.0],
30.0,
),
];
const FLIGHT_SECONDS: f64 = 12.0;
const SETTLE: Duration = Duration::from_secs(2);
const LOAD_LIMIT: Duration = Duration::from_secs(600);
/// `standard` flies every scene; a scene's name flies only that one.
pub fn scenes(scenario: &str) -> Option<Vec<Scene>> {
match scenario {
"standard" => Some(SCENES.to_vec()),
name => SCENES.iter().find(|s| s.0 == name).map(|s| vec![*s]),
}
}
/// What the game knows about the run before it starts.
pub struct Setup {
pub scenario: String,
pub started: Instant,
pub app: App,
pub simulation_distance: u32,
pub vsync: bool,
/// `None`: unlimited.
pub fps_cap: Option<u32>,
pub offscreen: bool,
pub game_dir: PathBuf,
}
/// Starts the driver; the game quits when it is done, and the report's path is printed.
pub fn start(requests: Sender<Request>, setup: Setup) {
thread::Builder::new()
.name("horizon-benchmark".into())
.spawn(move || {
let control = Control(requests);
match run(&control, setup) {
Ok(path) => println!("Horizon: benchmark report {}", path.display()),
Err(e) => eprintln!("Horizon: benchmark failed: {e}"),
}
let _ = control.call("quit");
})
.expect("spawn the benchmark thread");
}
struct Control(Sender<Request>);
impl Control {
fn call(&self, line: &str) -> Result<Value, String> {
let (reply, response) = mpsc::channel();
self.0
.send(Request {
line: line.to_owned(),
reply,
})
.map_err(|_| "the game has exited".to_owned())?;
// Flights and loads reply when they finish.
let text = response
.recv_timeout(LOAD_LIMIT)
.map_err(|_| format!("{line}: no reply"))?;
let value: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
match value.get("error").and_then(Value::as_str) {
Some(e) => Err(format!("{line}: {e}")),
None => Ok(value),
}
}
fn status(&self) -> Value {
self.call("status").unwrap_or(Value::Null)
}
}
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
let scenes =
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
let mut report = Report::new(Kind::Game, setup.app.clone());
let pid = std::process::id();
report.conditions.load_avg_1m = horizon_bench::load_average();
report.conditions.power = horizon_bench::power_source().map(str::to_owned);
let rss = RssSampler::start(pid);
let title = poll(control, LOAD_LIMIT, |s| {
s["screen"] == "title" && !s["renderer"].is_null()
})?;
report.metric("startup.title_ms", &[ms(setup.started.elapsed())]);
let renderer = &title["renderer"];
report.hardware.gpu = Some(horizon_bench::Gpu {
name: text(&renderer["gpu"]),
vendor: match text(&renderer["vendor"]) {
// Metal reports no vendor ID.
v if v.is_empty() && text(&renderer["gpu"]).starts_with("Apple") => "apple".into(),
v => v,
},
driver: text(&renderer["driver"]),
backend: text(&renderer["backend"]).to_lowercase(),
kind: text(&renderer["device_type"]),
});
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
replace_world(&setup.game_dir)?;
control.call(&format!("open {WORLD} {SEED} creative"))?;
std::fs::write(
setup.game_dir.join("saves").join(WORLD).join(MARKER),
"Created by horizon-client --benchmark; replaced by the next run.\n",
)
.map_err(|e| e.to_string())?;
let loaded = wait_loaded(control, |_| true)?;
report.metric("startup.world_ms", &[ms(loaded - setup.started)]);
let mut steady = Vec::new();
for (name, start, end, pitch) in scenes {
control.call("time 6000")?;
let meshed = control.status()["meshed_sections"].as_f64().unwrap_or(0.0);
let generated = control.status()["generated_chunks"].as_f64().unwrap_or(0.0);
let [x, y, z] = start;
control.call(&format!("tp {x} {y} {z}"))?;
let teleported = Instant::now();
// A zero-length flight holds the player at the start, flying.
control.call(&format!("fly {x} {y} {z} {x} {y} {z} 0.1 {pitch}"))?;
control.call(&format!("look {:.3} {pitch}", yaw(start, end)))?;
let near = |p: &Value| {
let p: Vec<f64> = p
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_f64)
.collect();
p.len() == 3 && (0..3).map(|i| (p[i] - start[i]).powi(2)).sum::<f64>() < 4.0
};
let loaded = wait_loaded(control, near)?;
let load = loaded - teleported;
report.metric(&format!("chunks.{name}.load_ms"), &[ms(load)]);
let status = control.status();
let meshes = status["meshed_sections"].as_f64().unwrap_or(0.0) - meshed;
let chunks = status["generated_chunks"].as_f64().unwrap_or(0.0) - generated;
report.metric(&format!("chunks.{name}.meshed_count"), &[meshes]);
report.metric(&format!("chunks.{name}.generated_count"), &[chunks]);
if load.as_secs_f64() > 0.0 {
report.metric(
&format!("chunks.{name}.generated_per_s"),
&[chunks / load.as_secs_f64()],
);
}
thread::sleep(SETTLE);
let [ex, ey, ez] = end;
let begin = Instant::now();
let flight = control.call(&format!(
"fly {x} {y} {z} {ex} {ey} {ez} {FLIGHT_SECONDS} {pitch} raw"
))?;
steady.push((begin, Instant::now()));
let series = |key: &str| -> Vec<f32> {
flight[key]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_f64)
.map(|v| v as f32)
.collect()
};
let frames = Frames::of(
name,
0,
&series("frame_ms"),
&series("cpu_ms"),
&series("gpu_ms"),
)
.ok_or("the flight drew no frames")?;
report.metric(&format!("fps.{name}.avg_fps"), &[frames.avg_fps]);
report.metric(&format!("fps.{name}.low_1pct_fps"), &[frames.low_1pct_fps]);
report.metric(
&format!("fps.{name}.low_01pct_fps"),
&[frames.low_01pct_fps],
);
report.frames.push(frames);
}
let status = control.status();
let renderer = &status["renderer"];
let c = &mut report.conditions;
c.scenario = Some(setup.scenario.clone());
c.seed = Some(SEED.to_string());
c.render_distance = status["render_distance"].as_u64().map(|d| d as u32);
c.simulation_distance = Some(setup.simulation_distance);
c.window = serde_json::from_value(renderer["size"].clone()).ok();
c.vsync = Some(setup.vsync);
c.fps_cap = setup.fps_cap;
c.offscreen = Some(setup.offscreen);
c.repeats = Some(1);
c.warmup_s = Some(SETTLE.as_secs_f64());
if let Some(bytes) = renderer["gpu_allocated_bytes"].as_f64() {
report.metric("memory.gpu_mb", &[bytes / f64::from(1 << 20)]);
}
let samples = rss.stop();
if let Some(peak) = samples.iter().map(|s| s.1).max() {
report.metric("memory.rss_peak_mb", &[peak as f64 / f64::from(1 << 20)]);
}
let mut during: Vec<u64> = samples
.iter()
.filter(|(t, _)| steady.iter().any(|(a, b)| a <= t && t <= b))
.map(|s| s.1)
.collect();
during.sort_unstable();
if let Some(&median) = during.get(during.len() / 2) {
report.metric(
"memory.rss_steady_mb",
&[median as f64 / f64::from(1 << 20)],
);
}
if let Ok(meta) = std::env::current_exe().and_then(std::fs::metadata) {
report.metric("game.binary_bytes", &[meta.len() as f64]);
}
crate::telemetry::keep_benchmark(&crate::logging::game_home(), &report);
report
.write_to(&setup.game_dir.join("benchmarks"))
.map_err(|e| e.to_string())
}
/// Removes the previous run's world, never one the benchmark didn't create.
fn replace_world(game_dir: &Path) -> Result<(), String> {
let world = game_dir.join("saves").join(WORLD);
if !world.exists() {
return Ok(());
}
if !world.join(MARKER).exists() {
return Err(format!(
"{} exists and was not created by the benchmark: rename it",
world.display()
));
}
std::fs::remove_dir_all(&world).map_err(|e| e.to_string())
}
fn poll(
control: &Control,
limit: Duration,
ready: impl Fn(&Value) -> bool,
) -> Result<Value, String> {
let deadline = Instant::now() + limit;
while Instant::now() < deadline {
let status = control.status();
if ready(&status) {
return Ok(status);
}
thread::sleep(Duration::from_millis(50));
}
Err(format!("not ready after {} s", limit.as_secs()))
}
/// Fewer section meshes than this in a second is the game remeshing blocks that change (random
/// ticks), not loading.
const QUIET_MESHES_PER_S: f64 = 100.0;
/// Waits until the player is spawned where `near` says, nothing is queued, the resident and
/// generated chunk counts have not changed for a second and the last sections are drawn (they wait
/// for their neighbours' light, so after the counts settle); returns when the last of these
/// happened (`tools/perf_bench.py`'s `wait_loaded`). With `status.streaming`, the sections are
/// drawn once no stage has held work for half a second: blocks changing after the load (fluids
/// settling, random ticks) only remesh in bursts. Without it, meshing has to go quiet.
fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Instant, String> {
let deadline = Instant::now() + LOAD_LIMIT;
let (mut last, mut changed, mut meshing) = (None, Instant::now(), Instant::now());
let mut previous: Option<(Instant, f64)> = None;
while Instant::now() < deadline {
let s = control.status();
let now = Instant::now();
let position = &s["player"]["position"];
if position.is_null()
|| !near(position)
|| s["pending_chunks"] != 0
|| s["screen"] == "title"
{
(last, changed, previous, meshing) = (None, now, None, now);
} else {
let counts = (s["chunks"].as_u64(), s["generated_chunks"].as_u64());
if last != Some(counts) {
(last, changed) = (Some(counts), now);
}
let meshed = s["meshed_sections"].as_f64().unwrap_or(0.0);
let quiet = if let Some(stages) = s["streaming"].as_object() {
// Protos outside the view distance wait for neighbours that are never generated.
if stages
.iter()
.any(|(k, v)| k != "protos" && v.as_u64().unwrap_or(0) > 0)
{
meshing = now;
}
Duration::from_millis(500)
} else {
if let Some((at, before)) = previous
&& meshed - before > QUIET_MESHES_PER_S * (now - at).as_secs_f64()
{
meshing = now;
}
Duration::from_secs(1)
};
previous = Some((now, meshed));
if now - changed >= Duration::from_secs(1) && now - meshing >= quiet {
return Ok(changed.max(meshing));
}
}
thread::sleep(Duration::from_millis(50));
}
Err(format!("not loaded after {} s", LOAD_LIMIT.as_secs()))
}
/// Vanilla's yaw facing from `start` to `end`.
fn yaw(start: [f64; 3], end: [f64; 3]) -> f64 {
(-(end[0] - start[0])).atan2(end[2] - start[2]).to_degrees()
}
fn ms(d: Duration) -> f64 {
d.as_secs_f64() * 1000.0
}
fn text(v: &Value) -> String {
v.as_str().unwrap_or_default().to_owned()
}
/// Samples the process's resident memory four times a second, as `tools/perf_bench.py` does.
struct RssSampler {
stop: Sender<()>,
thread: thread::JoinHandle<Vec<(Instant, u64)>>,
}
impl RssSampler {
fn start(pid: u32) -> Self {
let (stop, stopped) = mpsc::channel();
let thread = thread::spawn(move || {
let mut samples = Vec::new();
while let Err(mpsc::RecvTimeoutError::Timeout) =
stopped.recv_timeout(Duration::from_millis(250))
{
samples.extend(horizon_bench::rss_bytes(pid).map(|b| (Instant::now(), b)));
}
samples
});
Self { stop, thread }
}
fn stop(self) -> Vec<(Instant, u64)> {
let _ = self.stop.send(());
self.thread.join().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scenarios() {
assert_eq!(scenes("standard").unwrap().len(), 4);
assert_eq!(scenes("ocean").unwrap()[0].0, "ocean");
assert!(scenes("nether").is_none());
}
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
assert!((yaw([0.0; 3], [0.0, 0.0, 10.0])).abs() < 1e-9);
assert!((yaw([0.0; 3], [-10.0, 0.0, 0.0]) - 90.0).abs() < 1e-9);
assert!((yaw([0.0; 3], [10.0, 0.0, 0.0]) + 90.0).abs() < 1e-9);
}
#[test]
fn only_replaces_its_own_world() {
let dir =
std::env::temp_dir().join(format!("horizon-benchmark-test-{}", std::process::id()));
let world = dir.join("saves").join(WORLD);
std::fs::create_dir_all(&world).unwrap();
assert!(
replace_world(&dir).is_err(),
"a player's world of that name stays"
);
assert!(world.exists());
std::fs::write(world.join(MARKER), "").unwrap();
replace_world(&dir).unwrap();
assert!(!world.exists());
replace_world(&dir).unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
}
@@ -1,204 +0,0 @@
//! horizon-ctl: drive a running Horizon client through its control socket.
#![forbid(unsafe_code)]
use std::{
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
process::{Command, ExitCode, Stdio},
thread,
time::{Duration, Instant},
};
use horizon_client::control::UnixStream;
const USAGE: &str = "usage: horizon-ctl [--socket PATH] COMMAND [ARGS...]
Local commands:
launch [GAME ARGS...] start horizon-client with a control socket and wait for it
hold KEY SECONDS press KEY, wait, release it
wait-ready [SECONDS] wait until the player has spawned and no chunks are pending
- run commands from stdin, one per line (\"sleep S\" waits locally)
Game commands (run \"horizon-ctl help\" against a running game for the full list):
status, key, mouse, look, turn, tp, hotbar, give, render-distance, time,
screenshot PATH, stats, bench SECONDS, open, save, quit
Replies are JSON lines; the exit status is 1 when the reply has an \"error\".
The socket defaults to $HORIZON_CONTROL or $XDG_RUNTIME_DIR/horizon-control.sock.";
struct Client {
writer: UnixStream,
reader: BufReader<UnixStream>,
}
impl Client {
fn connect(path: &Path) -> Result<Self, String> {
let stream = UnixStream::connect(path).map_err(|e| {
format!(
"cannot connect to {} ({e}); is the game running with --control?",
path.display()
)
})?;
let reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?);
Ok(Self {
writer: stream,
reader,
})
}
fn send(&mut self, line: &str) -> Result<String, String> {
writeln!(self.writer, "{line}").map_err(|e| e.to_string())?;
let mut reply = String::new();
if self
.reader
.read_line(&mut reply)
.map_err(|e| e.to_string())?
== 0
{
return Err("the game closed the connection".into());
}
Ok(reply.trim_end().to_owned())
}
}
fn is_error(reply: &str) -> bool {
serde_json::from_str::<serde_json::Value>(reply).map_or(true, |v| v.get("error").is_some())
}
/// Screenshot paths are written by the game process: make them absolute here.
fn absolute_paths(words: &mut [String]) {
if words.first().map(String::as_str) == Some("screenshot")
&& let Some(path) = words.get_mut(1)
&& let Ok(cwd) = std::env::current_dir()
{
*path = cwd.join(&*path).display().to_string();
}
}
fn run_line(client: &mut Client, words: &mut [String]) -> Result<bool, String> {
match words.first().map(String::as_str) {
Some("sleep") => {
let seconds: f64 = words
.get(1)
.ok_or("sleep SECONDS")?
.parse()
.map_err(|_| "sleep SECONDS")?;
thread::sleep(Duration::from_secs_f64(seconds));
Ok(true)
}
Some("hold") => {
let (key, seconds) = match words {
[_, key, seconds] => (
key.clone(),
seconds.parse::<f64>().map_err(|_| "hold KEY SECONDS")?,
),
_ => return Err("hold KEY SECONDS".into()),
};
let down = client.send(&format!("key {key} down"))?;
if is_error(&down) {
println!("{down}");
return Ok(false);
}
thread::sleep(Duration::from_secs_f64(seconds));
let up = client.send(&format!("key {key} up"))?;
println!("{up}");
Ok(!is_error(&up))
}
Some("wait-ready") => {
let limit = words
.get(1)
.map_or(Ok(120.0), |s| s.parse::<f64>())
.map_err(|_| "wait-ready [SECONDS]")?;
let deadline = Instant::now() + Duration::from_secs_f64(limit);
loop {
let reply = client.send("status")?;
let status: serde_json::Value =
serde_json::from_str(&reply).map_err(|e| e.to_string())?;
if !status["player"].is_null() && status["pending_chunks"] == 0 {
println!("{reply}");
return Ok(true);
}
if Instant::now() > deadline {
println!(
"{{\"error\":\"not ready after {limit} seconds\",\"status\":{reply}}}"
);
return Ok(false);
}
thread::sleep(Duration::from_millis(250));
}
}
_ => {
absolute_paths(words);
let reply = client.send(&words.join(" "))?;
println!("{reply}");
Ok(!is_error(&reply))
}
}
}
fn launch(socket: &Path, args: &[String]) -> Result<bool, String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let game = exe.with_file_name("horizon-client");
let log = socket.with_extension("log");
let file = std::fs::File::create(&log).map_err(|e| format!("{}: {e}", log.display()))?;
let child = Command::new(&game)
.args(args)
.arg("--control-socket")
.arg(socket)
.stdin(Stdio::null())
.stdout(file.try_clone().map_err(|e| e.to_string())?)
.stderr(file)
.spawn()
.map_err(|e| format!("{}: {e}", game.display()))?;
let deadline = Instant::now() + Duration::from_secs(300);
while Instant::now() < deadline {
if UnixStream::connect(socket).is_ok() {
println!(
"{}",
serde_json::json!({ "ok": true, "pid": child.id(), "socket": socket, "log": log })
);
return Ok(true);
}
thread::sleep(Duration::from_millis(100));
}
println!(
"{}",
serde_json::json!({ "error": "control socket did not open within 300 seconds", "log": log })
);
Ok(false)
}
fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let mut socket = horizon_client::control::default_path();
if args.first().map(String::as_str) == Some("--socket") && args.len() >= 2 {
socket = PathBuf::from(args.remove(1));
args.remove(0);
}
let result = match args.first().map(String::as_str) {
None | Some("-h" | "--help") => {
println!("{USAGE}");
return ExitCode::SUCCESS;
}
Some("launch") => launch(&socket, &args[1..]),
Some("-") => Client::connect(&socket).and_then(|mut client| {
let mut all_ok = true;
for line in std::io::stdin().lock().lines() {
let line = line.map_err(|e| e.to_string())?;
let mut words: Vec<String> = line.split_whitespace().map(str::to_owned).collect();
if words.is_empty() || words[0].starts_with('#') {
continue;
}
all_ok &= run_line(&mut client, &mut words)?;
}
Ok(all_ok)
}),
Some(_) => Client::connect(&socket).and_then(|mut client| run_line(&mut client, &mut args)),
};
match result {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::from(1),
Err(e) => {
eprintln!("horizon-ctl: {e}");
ExitCode::from(2)
}
}
}
@@ -0,0 +1,647 @@
//! Every command horizon-ctl knows: its usage, help, examples and arguments.
//! The same table prints `help`, checks `COMMAND --help` and describes the
//! MCP tools, so the CLI and the MCP server always offer the same commands.
use serde_json::{Map, Value, json};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Ty {
Int,
Num,
Str,
Bool,
/// One of these words.
Enum(&'static [&'static str]),
/// The rest of the line, spaces included.
Rest,
}
#[derive(Clone, Copy)]
pub struct Param {
pub name: &'static str,
pub ty: Ty,
pub required: bool,
/// Sent as `--name value` (`--name` alone for booleans), not by position.
pub flag: bool,
pub help: &'static str,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Group {
Session,
Observe,
Move,
Act,
Screens,
World,
Scripting,
}
impl Group {
pub const ALL: [Group; 7] = [
Group::Session,
Group::Observe,
Group::Move,
Group::Act,
Group::Screens,
Group::World,
Group::Scripting,
];
pub fn title(self) -> &'static str {
match self {
Group::Session => "Session",
Group::Observe => "Observe (JSON state)",
Group::Move => "Move and look",
Group::Act => "Act on the world",
Group::Screens => "Screens, menus and chat",
Group::World => "World and cheats",
Group::Scripting => "Scripting and benchmarks",
}
}
}
pub struct Cmd {
pub name: &'static str,
/// What the game receives instead of `name` (aliases such as look-at-block).
pub wire: Option<&'static str>,
pub group: Group,
pub usage: &'static str,
pub summary: &'static str,
pub details: &'static str,
pub examples: &'static [&'static str],
pub params: &'static [Param],
/// Offered as an MCP tool.
pub mcp: bool,
/// Runs in horizon-ctl itself, not in the game.
pub local: bool,
}
const fn p(name: &'static str, ty: Ty, help: &'static str) -> Param {
Param {
name,
ty,
required: true,
flag: false,
help,
}
}
const fn opt(name: &'static str, ty: Ty, help: &'static str) -> Param {
Param {
name,
ty,
required: false,
flag: false,
help,
}
}
const fn flag(name: &'static str, ty: Ty, help: &'static str) -> Param {
Param {
name,
ty,
required: false,
flag: true,
help,
}
}
const XYZ: [Param; 3] = [
p("x", Ty::Num, "X coordinate"),
p("y", Ty::Num, "Y coordinate"),
p("z", Ty::Num, "Z coordinate"),
];
const BLOCK_XYZ: [Param; 3] = [
p("x", Ty::Int, "block X"),
p("y", Ty::Int, "block Y"),
p("z", Ty::Int, "block Z"),
];
const OVER: Param = flag(
"over",
Ty::Num,
"turn with mouse motion spread over this many seconds (eased), as a player would",
);
const FACE: Ty = Ty::Enum(&["down", "up", "north", "south", "west", "east"]);
macro_rules! cmd {
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
Cmd {
name: $name,
wire: cmd!(@wire $($wire)?),
group: Group::$group,
usage: $usage,
summary: $summary,
details: $details,
examples: &[$($ex),*],
params: $params,
mcp: $mcp,
local: $local,
}
};
(@wire) => { None };
(@wire $w:expr) => { Some($w) };
}
pub const COMMANDS: &[Cmd] = &[
// Session
cmd!("launch", Session, "launch [--world NAME] [--seed N] [--creative] [--version TAG] [--game PATH] [--wait] [GAME ARGS...]",
"start Horizon with the control socket and wait until it answers",
"Finds the game in this order: --game PATH; a horizon-client next to horizon-ctl (a build folder, a release folder or a launcher install); the newest version installed by the Horizon launcher (--version TAG picks one). Launcher installs run with their prepared resources and keep saves in the game home ($HORIZON_HOME, else ~/.local/share/horizon). --wait also waits until the player has spawned and chunks are loaded. Other arguments go to the game (horizon-client --help lists them). The game's output goes to a .log file beside the socket.",
["horizon-ctl launch --world Survival --seed 42 --wait", "horizon-ctl launch --world Flat --creative --render-distance 8"],
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)"),
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions")],
mcp: true, local: true),
cmd!("wait-for", Session, "wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]", "wait until the game reaches a state, then reply",
"screen takes the names state shows (playing, inventory, paused, dead, title...); widget an id from ui; loaded means a world is open, the player is in it and no chunk is pending; chat a new line containing TEXT; event a kind from events (death, respawn, item, screen...). chat and event count from --since SEQ (take last from events before acting), else from now. A timeout (default 30 s) is an error with kind timeout.",
["horizon-ctl wait-for screen inventory", "horizon-ctl wait-for loaded --timeout 120", "horizon-ctl wait-for chat joined the game", "horizon-ctl wait-for event death --timeout 600"],
&[flag("timeout", Ty::Num, "seconds (default 30)"), flag("since", Ty::Int, "chat and event: count from this event seq"), p("condition", Ty::Enum(&["screen", "widget", "loaded", "chat", "event"]), "what to wait for"), opt("value", Ty::Rest, "the screen name, widget id, chat text or event kind")], mcp: true, local: false),
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
cmd!("logs", Session, "logs [--lines N] [--crash]", "the end of the game's log, or its newest crash report",
"Reads the game home ($HORIZON_HOME, else ~/.local/share/horizon): logs/latest.log (the last N lines, default 100) or, with --crash, the newest file in crash-reports. Read-only; works whether the game runs or not.",
["horizon-ctl logs", "horizon-ctl logs --lines 300", "horizon-ctl logs --crash"],
&[flag("lines", Ty::Int, "how many lines of latest.log (default 100)"), flag("crash", Ty::Bool, "the newest crash report instead")],
mcp: true, local: true),
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
"", ["horizon-ctl status"], &[], mcp: false, local: false),
cmd!("open", Session, "open WORLD [SEED] [creative]", "open or create a world from the title screen",
"Opens saves/WORLD, creating it with SEED (and creative mode) when it doesn't exist.",
["horizon-ctl open Test 12345", "horizon-ctl open Build 0 creative"],
&[p("world", Ty::Str, "world folder"), opt("seed", Ty::Int, "seed for a new world"), opt("mode", Ty::Enum(&["creative"]), "creative for a creative world")], mcp: true, local: false),
cmd!("save", Session, "save", "save the world", "", ["horizon-ctl save"], &[], mcp: true, local: false),
cmd!("quit", Session, "quit", "save and exit the game", "", ["horizon-ctl quit"], &[], mcp: true, local: false),
cmd!("screenshot", Session, "screenshot PATH", "write the next rendered frame as a PNG",
"Relative paths are taken from horizon-ctl's directory. Over MCP the image is returned directly.",
["horizon-ctl screenshot view.png"], &[], mcp: true, local: false),
// Observe
cmd!("state", Observe, "state", "player, world, screen and crosshair target in one reply",
"position, block_pos, eye, yaw, pitch, velocity, on_ground, health, food, saturation, xp, effects, gamemode, dimension, time, weather, the screen or title menu, the held item, what the crosshair points at and whether an action is running.",
["horizon-ctl state"], &[], mcp: true, local: false),
cmd!("looking-at", Observe, "looking-at", "the block or entity under the crosshair",
"kind is block, entity or none; a block has pos, state, face, point and distance; an entity has id, type, health and distance.",
["horizon-ctl looking-at"], &[], mcp: true, local: false),
cmd!("entities", Observe, "entities [RADIUS] [TYPE]", "mobs, dropped items and experience orbs near the player, nearest first",
"RADIUS defaults to 32. TYPE filters by entity type (pig, minecraft:item, ...). Mobs have an id for look-at entity and attack.",
["horizon-ctl entities", "horizon-ctl entities 16 cow", "horizon-ctl entities 8 item"],
&[opt("radius", Ty::Num, "search radius in blocks (default 32)"), opt("type", Ty::Str, "entity type filter")], mcp: true, local: false),
cmd!("block", Observe, "block X Y Z", "the block state at a position", "",
["horizon-ctl block 10 64 -3"], &BLOCK_XYZ, mcp: true, local: false),
cmd!("blocks", Observe, "blocks X1 Y1 Z1 X2 Y2 Z2", "the non-air blocks of a box, with counts per block",
"At most 32768 blocks per request.", ["horizon-ctl blocks 0 60 0 15 70 15"],
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
cmd!("find-block", Observe, "find-block BLOCK [RADIUS]", "the nearest loaded blocks of a kind",
"Searches a cube of RADIUS (default 32, at most 64) around the player; returns the count and the 16 nearest.",
["horizon-ctl find-block oak_log", "horizon-ctl find-block diamond_ore 48"],
&[p("block", Ty::Str, "block id such as oak_log"), opt("radius", Ty::Int, "search radius (default 32)")], mcp: true, local: false),
cmd!("inventory", Observe, "inventory", "hotbar, main inventory, armor, offhand, carried stack and item counts",
"Slots carry the ids click-slot takes: 0-8 hotbar, 9-35 inventory, 500-503 armor (head to feet), 504 offhand. Stacks with components list them as SNBT.",
["horizon-ctl inventory"], &[], mcp: true, local: false),
cmd!("screen", Observe, "screen", "the open screen or title menu, its buttons and its slots",
"buttons are what click-button takes; slots are what click-slot takes (crafting grid 100-103 in the inventory, 100-108 at a crafting table, result 200, containers from 300, stations from 600). On the title screen, menu has the mode, worlds and the create-world fields.",
["horizon-ctl screen"], &[], mcp: true, local: false),
cmd!("messages", Observe, "messages [N]", "recent chat and system messages, the title and the action bar",
"N defaults to 20; chat is newest first.", ["horizon-ctl messages 5"], &[opt("n", Ty::Int, "how many chat lines (default 20)")], mcp: true, local: false),
cmd!("menu", Observe, "menu", "the open menu's slots and carried stack, with station details", "",
["horizon-ctl menu"], &[], mcp: false, local: false),
// Move
cmd!("move", Move, "move forward|back|left|right SECONDS [--sprint] [--sneak] [--jump]", "walk in a direction relative to the view for a time",
"Replies when done with the start and end positions.",
["horizon-ctl move forward 2", "horizon-ctl move forward 1.5 --sprint --jump"],
&[p("direction", Ty::Enum(&["forward", "back", "left", "right"]), "direction relative to the view"), p("seconds", Ty::Num, "how long to walk"),
flag("sprint", Ty::Bool, "sprint"), flag("sneak", Ty::Bool, "sneak"), flag("jump", Ty::Bool, "keep jumping")], mcp: true, local: false),
cmd!("goto", Move, "goto X Y Z [--near R] [--sprint] [--direct] [--timeout S]", "walk to a position, finding a path through loaded terrain",
"A* over standable blocks (steps up one block, drops up to three, avoids lava, swims when it must), then steering with jumps when stuck. Without a path it fails at once (--direct walks straight at the target anyway, falls included). --near R stops within R blocks (use 1.5-3 to reach a block you then break). Replies with arrived, position and distance, or an error when stuck or out of time (default 60 s).",
["horizon-ctl goto 120 70 -40", "horizon-ctl goto 8 64 3 --near 2 --sprint"],
&[XYZ[0], XYZ[1], XYZ[2], flag("near", Ty::Num, "arrive within this many blocks (default 0.6)"), flag("sprint", Ty::Bool, "sprint"), flag("direct", Ty::Bool, "walk straight when no path is found"), flag("timeout", Ty::Num, "give up after this many seconds (default 60)")], mcp: true, local: false),
cmd!("jump", Move, "jump", "jump once and reply when landed", "", ["horizon-ctl jump"], &[], mcp: true, local: false),
cmd!("look", Move, "look YAW PITCH", "set the view direction in degrees",
"Yaw 0 faces south (+Z), 90 west (-X), 180 north, -90 east; pitch 90 looks straight down.",
["horizon-ctl look 90 10"], &[p("yaw", Ty::Num, "yaw in degrees"), p("pitch", Ty::Num, "pitch in degrees (-90 up .. 90 down)")], mcp: true, local: false),
cmd!("turn", Move, "turn DYAW DPITCH [--over S]", "rotate the view by an amount",
"Without --over the view jumps. With --over S the turn is mouse motion fed over S seconds with ease-in-out, through the same path as a real mouse (sensitivity, inverted axes, cinematic camera), and the reply comes once it is done.",
["horizon-ctl turn 45 0", "horizon-ctl turn -90 10 --over 0.5"],
&[p("dyaw", Ty::Num, "yaw change in degrees"), p("dpitch", Ty::Num, "pitch change in degrees"), OVER], mcp: true, local: false),
cmd!("mouse-move", Move, "mouse-move DX DY", "move the mouse: relative motion that turns the view like a player's mouse",
"DX and DY are mouse units (pixels of relative motion) added to the frame's accumulated movement and turned by MouseHandler.turnPlayer: degrees = units x 0.15 x (sensitivity x 0.6 + 0.2)^3 x 8 (without the 8 while scoping), inverted axes and the cinematic camera included. Positive DX turns right, positive DY looks down. Works in the world with no screen open; replies with yaw and pitch after the frame that turned.",
["horizon-ctl mouse-move 100 0", "horizon-ctl mouse-move -25 40"],
&[p("dx", Ty::Num, "horizontal motion (positive turns right)"), p("dy", Ty::Num, "vertical motion (positive looks down)")], mcp: true, local: false),
cmd!("look-at", Move, "look-at X Y Z | look-at block X Y Z [FACE] | look-at entity ID [--over S]", "turn to face a point, a block (a visible face of it) or an entity",
"look-at block aims at a face the crosshair can reach and fails when none is visible. --over S turns there with mouse motion over S seconds (the shorter way round) instead of jumping.",
["horizon-ctl look-at 10.5 65 3.5", "horizon-ctl look-at block 10 64 3 up", "horizon-ctl look-at entity 42 --over 0.4"],
&[XYZ[0], XYZ[1], XYZ[2], OVER], mcp: true, local: false),
cmd!("look-at-block", Move, "look-at-block X Y Z [FACE] [--over S]", "aim the crosshair at a block (same as look-at block)", "",
["horizon-ctl look-at-block 10 64 3", "horizon-ctl look-at-block 10 64 3 up --over 0.3"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to aim at"), OVER], mcp: true, local: false, wire: "look-at block"),
cmd!("look-at-entity", Move, "look-at-entity ID [--over S]", "face an entity (same as look-at entity)", "",
["horizon-ctl look-at-entity 42"], &[p("id", Ty::Int, "entity id from entities"), OVER], mcp: true, local: false, wire: "look-at entity"),
cmd!("tp", Move, "tp X Y Z", "teleport the player", "", ["horizon-ctl tp 0 80 0"], &XYZ, mcp: true, local: false),
// Act
cmd!("break", Act, "break X Y Z [--timeout S]", "mine a block, holding the attack button until it breaks",
"Aims at a visible face (the block must be within reach: use goto --near 2 first), mines with the held item, then waits up to a second for the drops. Replies with block_before, block, ticks, drops and picked_up (inventory change).",
["horizon-ctl break 10 64 3"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("timeout", Ty::Num, "give up after this many seconds (default 30)")], mcp: true, local: false),
cmd!("place", Act, "place X Y Z [--item ITEM]", "place the held item (or ITEM from the hotbar) at an empty position",
"Clicks the face of a solid neighbour touching X Y Z. Replies with the block placed and the inventory change.",
["horizon-ctl place 10 64 4 --item oak_planks"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("item", Ty::Str, "hotbar item to select first")], mcp: true, local: false),
cmd!("interact", Act, "interact X Y Z [FACE]", "right-click a block: open a chest or crafting table, press a button, open a door",
"Replies with the screen afterwards and whether the block changed.", ["horizon-ctl interact 5 64 2"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to click")], mcp: true, local: false),
cmd!("use-item", Act, "use-item [--hold SECONDS]", "right-click with the held item: eat, drink, throw, draw a bow",
"Hold for 1.7 s to eat.", ["horizon-ctl use-item --hold 1.7"], &[flag("hold", Ty::Num, "hold the button this long")], mcp: true, local: false),
cmd!("attack", Act, "attack [ID]", "swing at what the crosshair targets (after facing entity ID)",
"Replies with the target's health before and after.", ["horizon-ctl attack", "horizon-ctl attack 42"],
&[opt("id", Ty::Int, "entity id to face first")], mcp: true, local: false),
cmd!("select", Act, "select SLOT|ITEM", "select a hotbar slot 1-9, or the hotbar slot holding ITEM", "",
["horizon-ctl select 3", "horizon-ctl select oak_planks"], &[p("slot", Ty::Str, "1-9 or an item id")], mcp: true, local: false),
cmd!("drop", Act, "drop [all]", "drop one of the held item, or the whole stack", "", ["horizon-ctl drop", "horizon-ctl drop all"],
&[opt("all", Ty::Bool, "drop the whole stack")], mcp: true, local: false),
cmd!("hold", Act, "hold KEY SECONDS", "press a key, wait, release it", "Keys as for key.", ["horizon-ctl hold w 2"],
&[p("key", Ty::Str, "key name"), p("seconds", Ty::Num, "how long")], mcp: false, local: true),
cmd!("key", Act, "key KEY down|up|tap|repeat", "press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
"down holds the key until key ... up, release or stop; wait does not release it. move, goto, jump, break and the other actions release the movement keys when they end.",
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap", "repeat"]), "what to do")], mcp: true, local: false),
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: false, local: false),
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: false, local: false),
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
// Screens
cmd!("open-inventory", Screens, "open-inventory", "open the inventory screen (crafting grid 100-103, result 200)", "",
["horizon-ctl open-inventory"], &[], mcp: true, local: false),
cmd!("click-slot", Screens, "click-slot SLOT [right] [shift]", "click a slot of the open inventory or menu",
"SLOT ids as listed by screen: 0-8 hotbar, 9-35 inventory, 100+ crafting grid, 200 result, 300+ container, 500-503 armor, 504 offhand, 600+ station. A left click picks up or puts down the carried stack; right puts down one item; shift moves the stack across.",
["horizon-ctl click-slot 36", "horizon-ctl click-slot 100 right", "horizon-ctl click-slot 200 shift"],
&[p("slot", Ty::Int, "slot id"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click")], mcp: true, local: false),
cmd!("close", Screens, "close", "close the open menu and return to the game", "", ["horizon-ctl close"], &[], mcp: true, local: false),
cmd!("ui", Screens, "ui", "every widget of the open screen: id, kind, label, bounds, value",
"Kinds: button, slider (value 0..1), field, row (worlds, servers, packs, languages), tab (selected), slot (slot-ID, with its stack). Ids come from labels (\"FOV: 70\" is fov, a repeat gets -2) or kind-N without one, and stay put when a value changes. Reflects the last drawn frame (frame counts them).",
["horizon-ctl ui"], &[], mcp: true, local: false),
cmd!("click", Screens, "click ID [right] [shift]", "click a widget of ui by its id, as the mouse would",
"A field takes focus (then type); slot-ID clicks a slot like click-slot.",
["horizon-ctl click done", "horizon-ctl click video-settings", "horizon-ctl click row-0", "horizon-ctl click slot-0 shift"],
&[p("id", Ty::Str, "widget id from ui"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click (slots)")], mcp: true, local: false),
cmd!("set", Screens, "set ID VALUE", "move a slider of ui to VALUE (0..1), as a click on its track",
"The option behind it rounds to its own steps; read the new label with ui.",
["horizon-ctl set fov 0.5", "horizon-ctl set render-distance 0"],
&[p("id", Ty::Str, "slider id from ui"), p("value", Ty::Num, "0..1 along the track")], mcp: true, local: false),
cmd!("options-get", Screens, "options-get [KEY...]", "the game's options (options.txt) by key, with their type and range or choices",
"Keys are vanilla's: fov, renderDistance, guiScale, gamma, maxFps, lang, resourcePacks, soundCategory_music, key_key.jump... Enums are given and set by name, key bindings by key name (key.keyboard.w).",
["horizon-ctl options-get", "horizon-ctl options-get fov renderDistance"], &[opt("keys", Ty::Rest, "option keys (default: all)")], mcp: true, local: false),
cmd!("options-set", Screens, "options-set KEY VALUE", "set one option, apply it at once and save options.txt, as the options screen does",
"A value the option refuses is an error saying what it accepts. Lists are JSON: options-set resourcePacks [\"vanilla\"].",
["horizon-ctl options-set fov 90", "horizon-ctl options-set renderClouds fast", "horizon-ctl options-set key_key.jump key.keyboard.j"],
&[p("key", Ty::Str, "option key from options-get"), p("value", Ty::Rest, "the new value")], mcp: true, local: false),
cmd!("click-button", Screens, "click-button LABEL", "click a button of the open screen or title menu by its label",
"Case-insensitive; a unique part of the label is enough. screen lists the buttons.",
["horizon-ctl click-button Singleplayer", "horizon-ctl click-button \"Create New World\"", "horizon-ctl click-button respawn"],
&[p("label", Ty::Rest, "button label")], mcp: true, local: false),
cmd!("click-at", Screens, "click-at X Y [right]", "click at GUI pixel X Y of the window (screen gives button rectangles)", "",
["horizon-ctl click-at 200 120"], &[p("x", Ty::Num, "GUI x"), p("y", Ty::Num, "GUI y"), opt("right", Ty::Bool, "right-click")], mcp: true, local: false),
cmd!("drag-at", Screens, "drag-at [right] X Y X Y...", "press at the first GUI point, move through the others, release at the last", "",
["horizon-ctl drag-at 130 100 148 100 166 100", "horizon-ctl drag-at right 130 100 148 100"], &[p("points", Ty::Rest, "right, then GUI x y pairs")], mcp: true, local: false),
cmd!("scroll-at", Screens, "scroll-at X Y STEPS", "turn the mouse wheel at GUI pixel X Y (positive scrolls up)", "",
["horizon-ctl scroll-at 130 100 -1"], &[p("x", Ty::Num, "GUI x"), p("y", Ty::Num, "GUI y"), p("steps", Ty::Num, "wheel steps")], mcp: true, local: false),
cmd!("type", Screens, "type [--field name|seed] [--replace] TEXT", "type into the focused text field (chat box, world name, anvil, sign...)",
"On the title's create-world screen, --field name|seed picks the field and --replace clears it first; type \\b deletes one character.",
["horizon-ctl type hello everyone", "horizon-ctl type --field name --replace Agent World", "horizon-ctl type --field seed 12345"], &[flag("field", Ty::Enum(&["name", "seed"]), "create-world field to type into"), flag("replace", Ty::Bool, "clear the field first"), p("text", Ty::Rest, "text to type")], mcp: true, local: false),
cmd!("chat", Screens, "chat TEXT", "send a chat line (or a /command) as if typed", "", ["horizon-ctl chat hello"],
&[p("text", Ty::Rest, "message")], mcp: true, local: false),
cmd!("command", Screens, "command TEXT", "run a command (the leading / is optional)", "",
["horizon-ctl command time set day", "horizon-ctl command /give @s oak_log 4"], &[p("command", Ty::Rest, "command line")], mcp: true, local: false),
cmd!("respawn", Screens, "respawn", "respawn from the death screen", "", ["horizon-ctl respawn"], &[], mcp: true, local: false),
cmd!("pause", Screens, "pause", "open the pause menu", "", ["horizon-ctl pause"], &[], mcp: true, local: false),
cmd!("resume", Screens, "resume", "leave the pause menu", "", ["horizon-ctl resume"], &[], mcp: true, local: false),
cmd!("chat-log", Screens, "chat-log [N]", "the newest N chat messages", "", ["horizon-ctl chat-log 5"], &[], mcp: false, local: false),
// World
cmd!("give", World, "give ITEM [COUNT]", "add items to the inventory", "", ["horizon-ctl give oak_log 16"],
&[p("item", Ty::Str, "item id"), opt("count", Ty::Int, "how many (default 1)")], mcp: true, local: false),
cmd!("time", World, "time TICKS", "set the time of day (0 sunrise, 6000 noon, 13000 night)", "", ["horizon-ctl time 6000"],
&[p("ticks", Ty::Int, "day time")], mcp: true, local: false),
cmd!("setblock", World, "setblock X Y Z BLOCK[props]", "set a block (with neighbour updates)", "", ["horizon-ctl setblock 0 64 0 stone"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], p("block", Ty::Str, "block state such as oak_stairs[facing=east]")], mcp: true, local: false),
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
&[p("ticks", Ty::Int, "game ticks")], mcp: true, local: false),
// Scripting
cmd!("script", Scripting, "script [FILE] [--keep-going]", "run commands from FILE (or stdin), one per line",
"Lines are split like a shell (quotes keep spaces); # starts a comment; sleep SECONDS waits. Stops at the first error unless --keep-going. horizon-ctl - is the older form: read stdin and keep going.",
["horizon-ctl script play.txt", "printf 'state\\ngoto 0 64 0\\n' | horizon-ctl script"], &[], mcp: false, local: true),
cmd!("sleep", Scripting, "sleep SECONDS", "wait (in scripts)", "", ["sleep 0.5"], &[], mcp: false, local: true),
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
["horizon-ctl mcp"], &[], mcp: false, local: true),
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[], mcp: false, local: false),
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[], mcp: false, local: false),
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
"With raw, the reply also lists every frame time in milliseconds (frame_ms).",
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
];
pub fn find(name: &str) -> Option<&'static Cmd> {
COMMANDS.iter().find(|c| c.name == name)
}
/// `horizon-ctl help`.
pub fn overview() -> String {
let mut out = String::from(
"horizon-ctl: play and script a running Horizon from the command line.\n\n\
usage: horizon-ctl [--socket PATH] [--timeout SECONDS] [--pretty] COMMAND [ARGS...]\n\
\x20 horizon-ctl COMMAND --help (or: horizon-ctl help COMMAND)\n",
);
for group in Group::ALL {
out.push_str(&format!("\n{}:\n", group.title()));
for c in COMMANDS.iter().filter(|c| c.group == group) {
out.push_str(&format!(" {:<16} {}\n", c.name, c.summary));
}
}
out.push_str(
"\nEvery reply is one JSON line. Errors are {\"error\": \"...\"}; the exit status is 0 on\n\
success, 1 when the game replied with an error, 2 for a usage error and 3 when the\n\
game cannot be reached or started. Actions (goto, break, place...) block until done.\n\
horizon-ctl help --game lists the running game's own commands, test hooks included.\n\
The socket is $HORIZON_CONTROL, else $XDG_RUNTIME_DIR/horizon-control.sock (on Windows\n\
%TEMP%\\horizon-control.json, a localhost TCP port with a token; any path ending in .json\n\
uses that transport).\n\n\
First steps:\n\
\x20 horizon-ctl launch --world Test --seed 1 --wait\n\
\x20 horizon-ctl state\n\
\x20 horizon-ctl find-block oak_log\n\
\x20 horizon-ctl goto X Y Z --near 2 && horizon-ctl break X Y Z\n\
\x20 horizon-ctl inventory\n",
);
out
}
/// `horizon-ctl COMMAND --help`.
pub fn detail(c: &Cmd) -> String {
let mut out = format!(
"usage: horizon-ctl {}\n\n{}.\n",
c.usage,
capitalize(c.summary)
);
if !c.details.is_empty() {
out.push('\n');
out.push_str(&wrap(c.details, 78));
}
if !c.examples.is_empty() {
out.push_str("\nExamples:\n");
for e in c.examples {
out.push_str(&format!(" {e}\n"));
}
}
out
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
chars
.next()
.map(|c| c.to_uppercase().chain(chars).collect())
.unwrap_or_default()
}
fn wrap(text: &str, width: usize) -> String {
let mut out = String::new();
let mut line = String::new();
for word in text.split_whitespace() {
if !line.is_empty() && line.len() + 1 + word.len() > width {
out.push_str(&line);
out.push('\n');
line.clear();
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() {
out.push_str(&line);
out.push('\n');
}
out
}
/// The MCP tool name of a command (`look-at-block` gives `look_at_block`).
pub fn tool_name(c: &Cmd) -> String {
c.name.replace('-', "_")
}
/// The JSON schema of a command's arguments.
pub fn schema(c: &Cmd) -> Value {
let mut props = Map::new();
let mut required = Vec::new();
for p in c.params {
let mut s = match p.ty {
Ty::Int => json!({ "type": "integer" }),
Ty::Num => json!({ "type": "number" }),
Ty::Str | Ty::Rest => json!({ "type": "string" }),
Ty::Bool => json!({ "type": "boolean" }),
Ty::Enum(values) => json!({ "type": "string", "enum": values }),
};
s["description"] = json!(p.help);
props.insert(p.name.to_owned(), s);
if p.required {
required.push(p.name);
}
}
json!({ "type": "object", "properties": props, "required": required })
}
/// The command line for an MCP call's arguments: positional values in order,
/// then `--flag value` options.
pub fn line_for(c: &Cmd, args: &Value) -> Result<Vec<String>, String> {
let mut words: Vec<String> = c
.wire
.unwrap_or(c.name)
.split(' ')
.map(str::to_owned)
.collect();
let mut flags = Vec::new();
let mut gap: Option<&str> = None;
let mut rest: Option<String> = None;
for p in c.params {
let value = args.get(p.name).filter(|v| !v.is_null());
let text = match value {
None => {
if p.required {
return Err(format!("{} is required", p.name));
}
if !p.flag {
gap.get_or_insert(p.name);
}
continue;
}
Some(Value::Bool(b)) => {
if p.ty != Ty::Bool {
return Err(format!("{} must be a {}", p.name, ty_name(p.ty)));
}
if *b {
if p.flag {
flags.push(format!("--{}", p.name));
} else {
words.push(p.name.to_owned());
}
}
continue;
}
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(other) => {
return Err(format!(
"{} must be a {}, not {other}",
p.name,
ty_name(p.ty)
));
}
};
match p.ty {
Ty::Int if text.parse::<i64>().is_err() => {
return Err(format!("{} must be an integer, not {text}", p.name));
}
Ty::Num if text.parse::<f64>().is_err() => {
return Err(format!("{} must be a number, not {text}", p.name));
}
Ty::Enum(values) if !values.contains(&text.as_str()) => {
return Err(format!("{} must be one of {values:?}, not {text}", p.name));
}
_ => {}
}
if p.flag {
flags.push(format!("--{}", p.name));
flags.push(text);
} else {
if let Some(missing) = gap {
return Err(format!("{} needs {missing} to be given too", p.name));
}
if p.ty == Ty::Rest {
rest = Some(text);
} else if text.contains(char::is_whitespace) || text.is_empty() {
return Err(format!("{} must be a single word", p.name));
} else {
words.push(text);
}
}
}
// Options go before free text, which runs to the end of the line.
words.extend(flags);
words.extend(rest);
Ok(words)
}
fn ty_name(ty: Ty) -> &'static str {
match ty {
Ty::Int => "integer",
Ty::Num => "number",
Ty::Bool => "boolean",
_ => "string",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_are_unique_and_documented() {
let mut seen = std::collections::HashSet::new();
for c in COMMANDS {
assert!(seen.insert(c.name), "duplicate command {}", c.name);
assert!(
c.usage.starts_with(c.name.split('-').next().unwrap()),
"{}",
c.name
);
assert!(
!c.summary.is_empty() && !c.examples.is_empty(),
"{}",
c.name
);
}
}
#[test]
fn mcp_arguments_become_command_lines() {
let goto = find("goto").unwrap();
let words = line_for(
goto,
&json!({ "x": 1.5, "y": 64, "z": -3, "near": 2, "sprint": true }),
)
.unwrap();
assert_eq!(
words,
["goto", "1.5", "64", "-3", "--near", "2", "--sprint"]
);
assert!(
line_for(goto, &json!({ "x": 1, "y": 2 }))
.unwrap_err()
.contains("z is required")
);
assert!(line_for(goto, &json!({ "x": "a", "y": 2, "z": 3 })).is_err());
let block = find("look-at-block").unwrap();
assert_eq!(
line_for(block, &json!({ "x": 1, "y": 2, "z": 3, "face": "up" })).unwrap(),
["look-at", "block", "1", "2", "3", "up"]
);
assert!(
line_for(
block,
&json!({ "x": 1, "y": 2, "z": 3, "face": "sideways" })
)
.is_err()
);
let turn = find("turn").unwrap();
assert_eq!(
line_for(turn, &json!({ "dyaw": -90, "dpitch": 10.5, "over": 0.5 })).unwrap(),
["turn", "-90", "10.5", "--over", "0.5"]
);
let mouse = find("mouse-move").unwrap();
assert_eq!(tool_name(mouse), "mouse_move");
assert_eq!(
line_for(mouse, &json!({ "dx": -25, "dy": 40 })).unwrap(),
["mouse-move", "-25", "40"]
);
assert_eq!(
line_for(block, &json!({ "x": 1, "y": 2, "z": 3, "over": 0.3 })).unwrap(),
["look-at", "block", "1", "2", "3", "--over", "0.3"]
);
let button = find("click-button").unwrap();
assert_eq!(
line_for(button, &json!({ "label": "Create New World" })).unwrap(),
["click-button", "Create New World"]
);
let slot = find("click-slot").unwrap();
assert_eq!(
line_for(slot, &json!({ "slot": 200, "shift": true })).unwrap(),
["click-slot", "200", "shift"]
);
let open = find("open").unwrap();
assert!(
line_for(open, &json!({ "world": "A", "mode": "creative" }))
.unwrap_err()
.contains("seed")
);
}
#[test]
fn schemas_list_required_parameters() {
let s = schema(find("break").unwrap());
assert_eq!(s["required"], json!(["x", "y", "z"]));
assert_eq!(s["properties"]["timeout"]["type"], "number");
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
}
#[test]
fn help_mentions_every_command() {
let text = overview();
for c in COMMANDS {
assert!(text.contains(c.name));
assert!(detail(c).contains("usage: horizon-ctl"));
}
}
}
@@ -0,0 +1,357 @@
//! `horizon-ctl launch`: find a Horizon to start and start it with a control
//! socket. Mirrors where the launcher puts things (launcher/src/backend.rs):
//! versions under `<launcher home>/versions/<tag>`, each with its prepared
//! resources in `.local/horizon-client/<minecraft version>` and run from the
//! game home, where saves and options.txt live.
use std::{
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
use horizon_client::logging::{data_dir, game_home};
use serde_json::{Value, json};
#[cfg(windows)]
const CLIENT: &str = "horizon-client.exe";
#[cfg(not(windows))]
const CLIENT: &str = "horizon-client";
pub fn launcher_home() -> PathBuf {
match std::env::var_os("HORIZON_LAUNCHER_HOME").filter(|h| !h.is_empty()) {
Some(h) => PathBuf::from(h),
None => data_dir().join("horizon-launcher"),
}
}
/// `v2026.10.13.1` gives [2026, 10, 13, 1]; a pre-release suffix is ignored.
pub fn version_parts(tag: &str) -> Vec<u64> {
tag.trim_start_matches('v')
.split('-')
.next()
.unwrap_or("")
.split('.')
.map(|p| p.parse().unwrap_or(0))
.collect()
}
/// Installed launcher versions, newest first.
pub fn installed(versions: &Path) -> Vec<String> {
let mut tags: Vec<String> = std::fs::read_dir(versions)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().join(".installed").exists() && e.path().join(CLIENT).exists())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
tags.sort_by_key(|t| std::cmp::Reverse(version_parts(t)));
tags
}
/// A folder's prepared resources, as the launcher prepares them.
pub fn prepared_resources(dir: &Path) -> Option<PathBuf> {
let version = std::fs::read_to_string(dir.join("research-lock.json"))
.ok()
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
.and_then(|v| v["minecraft"]["version"].as_str().map(str::to_owned))
.unwrap_or_else(|| "26.3".into());
let resources = dir.join(".local/horizon-client").join(version);
resources
.join("horizon-resources.json")
.exists()
.then_some(resources)
}
#[derive(Debug, PartialEq)]
pub struct Install {
pub game: PathBuf,
/// Passed as --resources unless the caller gave their own.
pub resources: Option<PathBuf>,
/// The working directory (the game home for prepared installs).
pub cwd: Option<PathBuf>,
pub source: &'static str,
pub version: Option<String>,
}
/// Which game to start: an explicit binary, the one beside horizon-ctl, or a
/// launcher install.
pub fn find(
game: Option<&Path>,
version: Option<&str>,
beside: Option<&Path>,
launcher: &Path,
) -> Result<Install, String> {
let prepared = |dir: &Path, source, version| {
let resources = prepared_resources(dir);
Install {
game: dir.join(CLIENT),
cwd: resources.as_ref().map(|_| game_home()),
resources,
source,
version,
}
};
if let Some(game) = game {
if !game.is_file() {
return Err(format!("{} is not a file", game.display()));
}
let dir = game.parent().unwrap_or(Path::new("."));
let mut install = prepared(dir, "--game", None);
install.game = game.to_owned();
return Ok(install);
}
let versions = launcher.join("versions");
if version.is_none()
&& let Some(dir) = beside
&& dir.join(CLIENT).is_file()
{
return Ok(prepared(dir, "beside horizon-ctl", None));
}
let tags = installed(&versions);
let tag = match version {
Some(v) => {
let want = if v.starts_with('v') { v.to_owned() } else { format!("v{v}") };
tags.iter()
.find(|t| **t == want || **t == v)
.cloned()
.ok_or_else(|| format!("version {v} is not installed in {} (installed: {tags:?})", versions.display()))?
}
None => tags.first().cloned().ok_or_else(|| {
format!(
"no Horizon found: no horizon-client beside horizon-ctl and no version installed by the launcher in {}. Install one with the launcher (horizon-launcher install latest) or pass --game PATH.",
versions.display()
)
})?,
};
let dir = versions.join(&tag);
let install = prepared(&dir, "launcher", Some(tag.clone()));
if install.resources.is_none() {
return Err(format!(
"{tag} is installed but not prepared; prepare it in the launcher first (horizon-launcher prepare {tag})"
));
}
Ok(install)
}
pub struct Options {
pub game: Option<PathBuf>,
pub version: Option<String>,
pub wait: bool,
pub game_args: Vec<String>,
}
pub fn parse(args: &[String]) -> Result<Options, String> {
let mut o = Options {
game: None,
version: None,
wait: false,
game_args: Vec::new(),
};
let mut i = 0;
while i < args.len() {
let value = |i: usize| {
args.get(i + 1)
.cloned()
.ok_or_else(|| format!("{} needs a value", args[i]))
};
match args[i].as_str() {
"--game" => {
o.game = Some(value(i)?.into());
i += 1;
}
"--version" => {
o.version = Some(value(i)?);
i += 1;
}
"--wait" | "--wait-ready" => o.wait = true,
other => o.game_args.push(other.to_owned()),
}
i += 1;
}
Ok(o)
}
/// Start the game and wait for its control endpoint; Ok(reply) or Err(reply).
pub fn launch(socket: &Path, args: &[String]) -> Result<Value, Value> {
let o = parse(args).map_err(|e| json!({ "error": e, "kind": "usage" }))?;
if horizon_client::control::connect(socket).is_ok() {
return Err(json!({
"error": format!("a game is already listening on {}; quit it first or pass another --socket", socket.display()),
"kind": "connection",
}));
}
let exe = std::env::current_exe().ok();
let beside = exe.as_deref().and_then(Path::parent);
let install = find(
o.game.as_deref(),
o.version.as_deref(),
beside,
&launcher_home(),
)
.map_err(|e| json!({ "error": e, "kind": "launch" }))?;
let log = socket.with_extension("log");
let file = std::fs::File::create(&log)
.map_err(|e| json!({ "error": format!("{}: {e}", log.display()), "kind": "launch" }))?;
let mut cmd = Command::new(&install.game);
let gave_resources = o.game_args.iter().any(|a| a == "--resources");
if let Some(resources) = install.resources.as_ref().filter(|_| !gave_resources) {
cmd.arg("--resources").arg(resources);
}
if let Some(cwd) = &install.cwd {
let _ = std::fs::create_dir_all(cwd);
cmd.current_dir(cwd);
}
let stderr = file
.try_clone()
.map_err(|e| json!({ "error": e.to_string(), "kind": "launch" }))?;
let mut child = cmd
.args(&o.game_args)
.arg("--control-socket")
.arg(socket)
.stdin(Stdio::null())
.stdout(file)
.stderr(stderr)
.spawn()
.map_err(
|e| json!({ "error": format!("{}: {e}", install.game.display()), "kind": "launch" }),
)?;
let deadline = Instant::now() + Duration::from_secs(300);
let info = json!({
"pid": child.id(),
"socket": socket,
"log": log,
"game": install.game,
"source": install.source,
"version": install.version,
"resources": install.resources,
"home": install.cwd,
});
while Instant::now() < deadline {
if horizon_client::control::connect(socket).is_ok() {
let mut reply = json!({ "ok": true });
reply
.as_object_mut()
.unwrap()
.extend(info.as_object().unwrap().clone());
return Ok(reply);
}
if let Ok(Some(status)) = child.try_wait() {
let tail = std::fs::read_to_string(&log).unwrap_or_default();
let tail: Vec<&str> = tail.lines().rev().take(15).collect();
return Err(json!({
"error": format!("the game exited ({status}) before opening the control socket"),
"kind": "launch",
"log_tail": tail.into_iter().rev().collect::<Vec<_>>(),
"log": log,
}));
}
thread::sleep(Duration::from_millis(100));
}
Err(
json!({ "error": "control socket did not open within 300 seconds", "kind": "launch", "log": log }),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("horizon-ctl-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn fake_version(versions: &Path, tag: &str, prepared: bool) -> PathBuf {
let dir = versions.join(tag);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(CLIENT), "").unwrap();
std::fs::write(dir.join(".installed"), "").unwrap();
std::fs::write(
dir.join("research-lock.json"),
r#"{"minecraft":{"version":"26.3"}}"#,
)
.unwrap();
if prepared {
let res = dir.join(".local/horizon-client/26.3");
std::fs::create_dir_all(&res).unwrap();
std::fs::write(res.join("horizon-resources.json"), "{}").unwrap();
}
dir
}
#[test]
fn versions_sort_newest_first() {
let home = temp("sort");
let versions = home.join("versions");
for tag in ["v2026.9.30", "v2026.10.13", "v2026.10.13.1", "v2026.10.2"] {
fake_version(&versions, tag, true);
}
assert_eq!(
installed(&versions),
["v2026.10.13.1", "v2026.10.13", "v2026.10.2", "v2026.9.30"]
);
let install = find(None, None, None, &home).unwrap();
assert_eq!(install.version.as_deref(), Some("v2026.10.13.1"));
assert!(
install
.resources
.unwrap()
.ends_with(".local/horizon-client/26.3")
);
assert_eq!(install.cwd, Some(game_home()));
let picked = find(None, Some("2026.10.2"), None, &home).unwrap();
assert_eq!(picked.version.as_deref(), Some("v2026.10.2"));
assert!(find(None, Some("v1.0"), None, &home).is_err());
let _ = std::fs::remove_dir_all(home);
}
#[test]
fn a_game_beside_ctl_wins_and_dev_builds_keep_the_directory() {
let home = temp("beside");
fake_version(&home.join("versions"), "v2026.10.13", true);
let build = temp("build");
std::fs::write(build.join(CLIENT), "").unwrap();
let install = find(None, None, Some(&build), &home).unwrap();
assert_eq!(install.source, "beside horizon-ctl");
assert_eq!((install.resources, install.cwd), (None, None));
// --version always means a launcher install.
assert_eq!(
find(None, Some("v2026.10.13"), Some(&build), &home)
.unwrap()
.source,
"launcher"
);
let _ = std::fs::remove_dir_all(home);
let _ = std::fs::remove_dir_all(build);
}
#[test]
fn unprepared_or_missing_installs_explain_themselves() {
let home = temp("unprepared");
assert!(
find(None, None, None, &home)
.unwrap_err()
.contains("no Horizon found")
);
fake_version(&home.join("versions"), "v2026.10.13", false);
assert!(
find(None, None, None, &home)
.unwrap_err()
.contains("not prepared")
);
let _ = std::fs::remove_dir_all(home);
}
#[test]
fn launch_options_pass_the_rest_to_the_game() {
let args: Vec<String> = ["--world", "A", "--version", "v1", "--wait", "--seed", "3"]
.map(String::from)
.to_vec();
let o = parse(&args).unwrap();
assert_eq!(o.version.as_deref(), Some("v1"));
assert!(o.wait);
assert_eq!(o.game_args, ["--world", "A", "--seed", "3"]);
assert!(parse(&["--game".to_owned()]).is_err());
}
}
@@ -0,0 +1,585 @@
//! horizon-ctl: play and script a running Horizon client through its control
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
#![forbid(unsafe_code)]
mod commands;
mod launch;
mod mcp;
use std::{
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
process::ExitCode,
thread,
time::{Duration, Instant},
};
use horizon_client::control::Stream;
use serde_json::{Value, json};
/// Exit statuses.
const GAME_ERROR: u8 = 1;
const USAGE: u8 = 2;
const UNREACHABLE: u8 = 3;
/// A failure, with the exit status it maps to.
#[derive(Debug)]
pub struct Failure {
pub reply: Value,
pub status: u8,
}
impl Failure {
fn usage(message: impl Into<String>) -> Self {
Self {
reply: json!({ "error": message.into(), "kind": "usage" }),
status: USAGE,
}
}
fn unreachable(message: impl Into<String>) -> Self {
Self {
reply: json!({ "error": message.into(), "kind": "connection" }),
status: UNREACHABLE,
}
}
}
pub struct Client {
writer: Stream,
reader: BufReader<Stream>,
}
impl Client {
fn connect(path: &Path, timeout: Option<Duration>) -> Result<Self, Failure> {
let stream = horizon_client::control::connect(path).map_err(|e| {
Failure::unreachable(format!(
"cannot connect to {} ({e}); is the game running with --control? Start one with: horizon-ctl launch --world NAME",
path.display()
))
})?;
stream
.set_read_timeout(timeout)
.map_err(|e| Failure::unreachable(e.to_string()))?;
let reader = BufReader::new(
stream
.try_clone()
.map_err(|e| Failure::unreachable(e.to_string()))?,
);
Ok(Self {
writer: stream,
reader,
})
}
/// Send one line and read its JSON reply.
fn send(&mut self, line: &str) -> Result<Value, Failure> {
writeln!(self.writer, "{line}").map_err(|e| Failure::unreachable(e.to_string()))?;
let mut reply = String::new();
let n = self.reader.read_line(&mut reply).map_err(|e| {
Failure::unreachable(
if matches!(
e.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) {
"timed out waiting for the game's reply (--timeout)".to_owned()
} else {
e.to_string()
},
)
})?;
if n == 0 {
return Err(Failure::unreachable("the game closed the connection"));
}
serde_json::from_str(reply.trim_end()).map_err(|_| Failure {
reply: json!({ "error": "the game sent a reply that is not JSON", "reply": reply.trim_end() }),
status: GAME_ERROR,
})
}
}
/// Global options and the connection, opened on first use.
pub struct Session {
pub socket: PathBuf,
pub timeout: Option<Duration>,
client: Option<Client>,
}
impl Session {
pub fn new(socket: PathBuf, timeout: Option<Duration>) -> Self {
Self {
socket,
timeout,
client: None,
}
}
fn client(&mut self) -> Result<&mut Client, Failure> {
if self.client.is_none() {
self.client = Some(Client::connect(&self.socket, self.timeout)?);
}
Ok(self.client.as_mut().unwrap())
}
/// Send a line to the game; a reply with "error" is a failure.
pub fn game(&mut self, line: &str) -> Result<Value, Failure> {
let result = self.client()?.send(line);
if result.as_ref().is_err_and(|f| f.status == UNREACHABLE) {
// Reconnect next time (the game may have restarted).
self.client = None;
}
let reply = result?;
if reply.get("error").is_some() {
return Err(Failure {
reply,
status: GAME_ERROR,
});
}
Ok(reply)
}
/// Run one command (CLI words); Ok(reply) or a failure.
pub fn run(&mut self, words: &[String]) -> Result<Value, Failure> {
let Some(name) = words.first().map(String::as_str) else {
return Err(Failure::usage("no command; try horizon-ctl help"));
};
if words.iter().skip(1).any(|w| w == "--help" || w == "-h") {
return Err(Failure::usage(
"use horizon-ctl COMMAND --help outside scripts",
));
}
let number = |i: usize, what: &str| -> Result<f64, Failure> {
words
.get(i)
.ok_or_else(|| Failure::usage(format!("missing {what}; usage: {}", usage(name))))?
.parse::<f64>()
.map_err(|_| {
Failure::usage(format!("{what} must be a number; usage: {}", usage(name)))
})
};
match name {
"sleep" => {
thread::sleep(Duration::from_secs_f64(number(1, "SECONDS")?.max(0.0)));
Ok(json!({ "ok": true }))
}
"launch" => {
let socket = self.socket.clone();
let wait = words.iter().any(|w| w == "--wait" || w == "--wait-ready");
let reply = launch::launch(&socket, &words[1..]).map_err(|reply| Failure {
status: if reply["kind"] == "usage" {
USAGE
} else {
UNREACHABLE
},
reply,
})?;
if !wait {
return Ok(reply);
}
let mut ready = self.wait_ready(300.0)?;
ready["launch"] = reply;
Ok(ready)
}
"hold" => {
let (key, seconds) = match words {
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
};
self.game(&format!("key {key} down"))?;
thread::sleep(Duration::from_secs_f64(seconds.max(0.0)));
self.game(&format!("key {key} up"))
}
"logs" => logs(&horizon_client::logging::game_home(), &words[1..]),
"wait-ready" => {
let limit = if words.len() > 1 {
number(1, "SECONDS")?
} else {
120.0
};
self.wait_ready(limit)
}
"help" if words.get(1).is_some_and(|w| w == "--game") => self.game("help"),
_ => {
let mut words = words.to_vec();
if let Some(cmd) = commands::find(name) {
if cmd.local {
return Err(Failure::usage(format!(
"{name} runs only as horizon-ctl {name}, not inside a script"
)));
}
if let Some(wire) = cmd.wire {
words.splice(0..1, wire.split(' ').map(str::to_owned));
}
}
absolute_paths(&mut words);
self.game(&words.join(" "))
}
}
}
fn wait_ready(&mut self, limit: f64) -> Result<Value, Failure> {
let deadline = Instant::now() + Duration::from_secs_f64(limit.max(0.0));
loop {
let status = self.game("status")?;
if !status["player"].is_null() && status["pending_chunks"] == 0 {
return Ok(status);
}
if Instant::now() > deadline {
return Err(Failure {
reply: json!({ "error": format!("not ready after {limit} seconds"), "status": status }),
status: GAME_ERROR,
});
}
thread::sleep(Duration::from_millis(250));
}
}
}
/// `logs [--lines N] [--crash]` for the game in `home`: the reply's `text` is the log's last
/// lines or the report.
fn logs(home: &Path, words: &[String]) -> Result<Value, Failure> {
use horizon_client::logging;
let mut lines = 100;
let mut crash = false;
let mut it = words.iter();
while let Some(word) = it.next() {
match word.as_str() {
"--crash" => crash = true,
"--lines" => {
lines = it
.next()
.and_then(|n| n.parse::<usize>().ok())
.filter(|&n| n > 0)
.ok_or_else(|| Failure::usage("--lines takes a number above 0"))?;
}
other => {
return Err(Failure::usage(format!(
"unknown argument {other}; usage: {}",
usage("logs")
)));
}
}
}
let missing = |what: String| Failure {
reply: json!({ "error": what }),
status: GAME_ERROR,
};
if crash {
let path = logging::newest_crash_report(home).ok_or_else(|| {
missing(format!(
"no crash report in {}",
logging::crash_reports_dir(home).display()
))
})?;
let text = std::fs::read_to_string(&path)
.map_err(|e| missing(format!("{}: {e}", path.display())))?;
return Ok(json!({ "path": path, "text": text }));
}
let path = logging::logs_dir(home).join("latest.log");
let tail =
logging::tail(&path, lines).map_err(|e| missing(format!("{}: {e}", path.display())))?;
Ok(json!({ "path": path, "text": tail.join("\n") + "\n" }))
}
fn usage(name: &str) -> String {
commands::find(name).map_or_else(|| name.to_owned(), |c| format!("horizon-ctl {}", c.usage))
}
/// Screenshot paths are written by the game process: make them absolute here.
fn absolute_paths(words: &mut [String]) {
if words.first().map(String::as_str) == Some("screenshot")
&& let Some(path) = words.get_mut(1)
&& let Ok(cwd) = std::env::current_dir()
{
*path = cwd.join(&*path).display().to_string();
}
}
/// Split a script line like a shell: quotes keep spaces, backslash escapes.
pub fn split_line(line: &str) -> Result<Vec<String>, String> {
let mut words = Vec::new();
let mut word = String::new();
let mut in_word = false;
let mut quote: Option<char> = None;
let mut chars = line.chars();
while let Some(c) = chars.next() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some('"'), '\\') => word.push(chars.next().unwrap_or('\\')),
(Some(_), c) => word.push(c),
(None, '"' | '\'') => {
quote = Some(c);
in_word = true;
}
(None, '\\') => {
word.push(chars.next().unwrap_or('\\'));
in_word = true;
}
(None, '#') if !in_word => break,
(None, c) if c.is_whitespace() => {
if in_word {
words.push(std::mem::take(&mut word));
in_word = false;
}
}
(None, c) => {
word.push(c);
in_word = true;
}
}
}
if quote.is_some() {
return Err("unterminated quote".into());
}
if in_word {
words.push(word);
}
Ok(words)
}
fn print(value: &Value, pretty: bool) {
let text = if pretty {
serde_json::to_string_pretty(value).unwrap_or_default()
} else {
value.to_string()
};
println!("{text}");
}
/// Run a script; the exit status of the first failure (or the last one with --keep-going).
fn script(session: &mut Session, reader: impl BufRead, keep_going: bool, pretty: bool) -> u8 {
let mut status = 0;
for (n, line) in reader.lines().enumerate() {
let Ok(line) = line else { break };
let words = match split_line(&line) {
Ok(w) if w.is_empty() => continue,
Ok(w) => w,
Err(e) => {
print(
&json!({ "error": e, "kind": "usage", "line": n + 1 }),
pretty,
);
status = USAGE;
if keep_going {
continue;
}
break;
}
};
match session.run(&words) {
Ok(reply) => print(&reply, pretty),
Err(mut f) => {
f.reply["line"] = json!(n + 1);
print(&f.reply, pretty);
status = f.status;
if !keep_going || f.status == UNREACHABLE {
break;
}
}
}
}
status
}
fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let mut socket = horizon_client::control::default_path();
let mut timeout = None;
let mut pretty = false;
// Global options before the command.
loop {
match args.first().map(String::as_str) {
Some("--socket") if args.len() >= 2 => {
socket = PathBuf::from(args.remove(1));
args.remove(0);
}
Some("--timeout") if args.len() >= 2 => {
let Ok(s) = args[1].parse::<f64>() else {
print(
&json!({ "error": "--timeout takes seconds", "kind": "usage" }),
false,
);
return ExitCode::from(USAGE);
};
timeout = Some(Duration::from_secs_f64(s.max(0.001)));
args.drain(0..2);
}
Some("--pretty") => {
pretty = true;
args.remove(0);
}
_ => break,
}
}
let mut session = Session::new(socket, timeout);
let name = args.first().cloned();
let status = match name.as_deref() {
None | Some("-h" | "--help") => {
print!("{}", commands::overview());
0
}
Some("help") if args.get(1).is_none_or(|a| a != "--game") => match args.get(1) {
None => {
print!("{}", commands::overview());
0
}
Some(c) => match commands::find(c) {
Some(cmd) => {
print!("{}", commands::detail(cmd));
0
}
None => {
print(
&json!({ "error": format!("unknown command {c}; horizon-ctl help lists them, horizon-ctl help --game lists the game's own"), "kind": "usage" }),
pretty,
);
USAGE
}
},
},
Some(c) if args.len() == 2 && matches!(args[1].as_str(), "--help" | "-h") => {
match commands::find(c) {
Some(cmd) => {
print!("{}", commands::detail(cmd));
0
}
None => {
print(
&json!({ "error": format!("no help for {c}; horizon-ctl help --game lists the game's own commands"), "kind": "usage" }),
pretty,
);
USAGE
}
}
}
Some("mcp") => match mcp::serve(&mut session) {
Ok(()) => 0,
Err(e) => {
eprintln!("horizon-ctl mcp: {e}");
UNREACHABLE
}
},
// The original form: stdin, keep going after errors.
Some("-") => script(&mut session, std::io::stdin().lock(), true, pretty),
Some("script") => {
let keep_going = args.iter().any(|a| a == "--keep-going");
match args.iter().skip(1).find(|a| !a.starts_with("--")) {
Some(file) if file != "-" => match std::fs::File::open(file) {
Ok(f) => script(&mut session, BufReader::new(f), keep_going, pretty),
Err(e) => {
print(
&json!({ "error": format!("{file}: {e}"), "kind": "usage" }),
pretty,
);
USAGE
}
},
_ => script(&mut session, std::io::stdin().lock(), keep_going, pretty),
}
}
// Text for people; scripts and MCP get the JSON reply.
Some("logs") => match session.run(&args) {
Ok(reply) => {
print!("{}", reply["text"].as_str().unwrap_or_default());
0
}
Err(f) => {
print(&f.reply, pretty);
f.status
}
},
Some(_) => match session.run(&args) {
Ok(reply) => {
print(&reply, pretty);
0
}
Err(f) => {
print(&f.reply, pretty);
f.status
}
},
};
ExitCode::from(status)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logs_reads_the_end_of_latest_log_and_the_newest_crash_report() {
let home = std::env::temp_dir().join(format!("horizon-ctl-logs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
let words = |s: &str| -> Vec<String> { s.split_whitespace().map(String::from).collect() };
let failure = logs(&home, &words("")).unwrap_err();
assert!(
failure.reply["error"]
.as_str()
.unwrap()
.contains("latest.log")
);
std::fs::create_dir_all(home.join("logs")).unwrap();
let text: String = (1..=150)
.map(|i| format!("[10:00:00] [Render thread/INFO]: line {i}\n"))
.collect();
std::fs::write(home.join("logs/latest.log"), text).unwrap();
let reply = logs(&home, &words("")).unwrap();
let tail = reply["text"].as_str().unwrap();
assert_eq!(tail.lines().count(), 100);
assert!(tail.starts_with("[10:00:00] [Render thread/INFO]: line 51\n"));
assert!(tail.ends_with("line 150\n"));
let reply = logs(&home, &words("--lines 2")).unwrap();
assert_eq!(
reply["text"],
"[10:00:00] [Render thread/INFO]: line 149\n[10:00:00] [Render thread/INFO]: line 150\n"
);
assert_eq!(logs(&home, &words("--lines 0")).unwrap_err().status, USAGE);
assert_eq!(logs(&home, &words("--tail")).unwrap_err().status, USAGE);
assert!(logs(&home, &words("--crash")).is_err());
std::fs::create_dir_all(home.join("crash-reports")).unwrap();
std::fs::write(
home.join("crash-reports/crash-2026-10-13_10.00.00-client.txt"),
"old",
)
.unwrap();
std::fs::write(
home.join("crash-reports/crash-2026-10-14_10.00.00-client.txt"),
"new",
)
.unwrap();
let reply = logs(&home, &words("--crash")).unwrap();
assert_eq!(reply["text"], "new");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn script_lines_split_like_a_shell() {
assert_eq!(
split_line("goto 1 2 3 --near 2").unwrap(),
["goto", "1", "2", "3", "--near", "2"]
);
assert_eq!(
split_line("click-button \"Create New World\"").unwrap(),
["click-button", "Create New World"]
);
assert_eq!(
split_line("type 'it''s' # comment").unwrap(),
["type", "its"]
);
assert_eq!(split_line("chat a\\ b").unwrap(), ["chat", "a b"]);
assert_eq!(split_line("chat issue#4").unwrap(), ["chat", "issue#4"]);
assert!(split_line(" # only a comment").unwrap().is_empty());
assert!(split_line("type \"open").is_err());
}
#[test]
fn unreachable_games_fail_with_status_3() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
let f = s.run(&["state".to_owned()]).unwrap_err();
assert_eq!(f.status, UNREACHABLE);
assert_eq!(f.reply["kind"], "connection");
let f = s.run(&["sleep".to_owned(), "x".to_owned()]).unwrap_err();
assert_eq!(f.status, USAGE);
}
#[test]
fn screenshot_paths_become_absolute() {
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
absolute_paths(&mut words);
assert!(Path::new(&words[1]).is_absolute());
}
}
@@ -0,0 +1,254 @@
//! `horizon-ctl mcp`: the Model Context Protocol over stdin and stdout
//! (JSON-RPC 2.0, one message per line, like the launcher's `mcp`), so AI
//! agents can play Horizon. Each tool is a horizon-ctl command from the
//! shared table, run through the same `Session` as the CLI.
use crate::{Session, commands};
use serde_json::{Value, json};
use std::io::{BufRead, Write};
const PROTOCOL: &str = "2025-06-18";
fn tools() -> Vec<Value> {
commands::COMMANDS
.iter()
.filter(|c| c.mcp)
.map(|c| {
let mut description = format!("{}. Usage: horizon-ctl {}", capital(c.summary), c.usage);
if !c.details.is_empty() {
description.push_str(". ");
description.push_str(c.details);
}
let mut schema = commands::schema(c);
if c.name == "screenshot" {
schema = json!({ "type": "object", "properties": {} });
description = "Capture the game window and return it as a PNG image.".into();
}
json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema })
})
.collect()
}
fn capital(s: &str) -> String {
let mut c = s.chars();
c.next()
.map(|f| f.to_uppercase().chain(c).collect())
.unwrap_or_default()
}
/// Standard base64 (RFC 4648) for image content.
pub fn base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
for i in 0..4 {
if i <= chunk.len() {
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
impl Session {
/// One tool call: (content blocks, is_error).
fn call(&mut self, name: &str, args: &Value) -> (Vec<Value>, bool) {
let text = |v: &Value| json!({ "type": "text", "text": serde_json::to_string_pretty(v).unwrap_or_default() });
let Some(cmd) = commands::COMMANDS
.iter()
.find(|c| c.mcp && commands::tool_name(c) == name)
else {
return (
vec![text(&json!({ "error": format!("unknown tool {name}") }))],
true,
);
};
if cmd.name == "screenshot" {
let path = std::env::temp_dir().join(format!("horizon-ctl-{}.png", std::process::id()));
let result = self
.run(&["screenshot".to_owned(), path.display().to_string()])
.and_then(|reply| {
std::fs::read(&path)
.map(|png| (reply, png))
.map_err(|e| crate::Failure {
reply: json!({ "error": format!("{}: {e}", path.display()) }),
status: 1,
})
});
let _ = std::fs::remove_file(&path);
return match result {
Ok((reply, png)) => (
vec![
json!({ "type": "image", "data": base64(&png), "mimeType": "image/png" }),
text(&reply),
],
false,
),
Err(f) => (vec![text(&f.reply)], true),
};
}
if cmd.name == "logs" {
let words = match commands::line_for(cmd, args) {
Ok(w) => w,
Err(e) => return (vec![text(&json!({ "error": e, "kind": "usage" }))], true),
};
return match self.run(&words) {
Ok(reply) => (
vec![
json!({ "type": "text", "text": format!("{}\n\n{}", reply["path"].as_str().unwrap_or_default(), reply["text"].as_str().unwrap_or_default()) }),
],
false,
),
Err(f) => (vec![text(&f.reply)], true),
};
}
let mut words = match commands::line_for(cmd, args) {
Ok(w) => w,
Err(e) => return (vec![text(&json!({ "error": e, "kind": "usage" }))], true),
};
if cmd.name == "launch" {
// An agent wants a world it can play once the call returns.
if args.get("wait").and_then(Value::as_bool) != Some(false)
&& !words.contains(&"--wait".to_owned())
{
words.push("--wait".into());
}
words.push("--no-pause".into());
}
match self.run(&words) {
Ok(reply) => (vec![text(&reply)], false),
Err(f) => (vec![text(&f.reply)], true),
}
}
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-ctl", "version": env!("CARGO_PKG_VERSION") },
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
})),
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({ "tools": tools() })),
"tools/call" => {
let name = params
.get("name")
.and_then(Value::as_str)
.ok_or((-32602, "name is required".to_string()))?;
if !tools().iter().any(|t| t["name"] == name) {
return Err((-32602, format!("Unknown tool {name}")));
}
let args = params.get("arguments").cloned().unwrap_or(json!({}));
let (content, error) = self.call(name, &args);
Ok(json!({ "content": content, "isError": error }))
}
_ => Err((-32601, format!("Method not found: {method}"))),
}
}
}
pub fn serve(session: &mut Session) -> Result<(), String> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Err(e) => Some(
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
),
Ok(msg) => {
let method = msg
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// Notifications (no id) get no reply.
msg.get("id").cloned().map(|id| match session.handle(method, &params) {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
})
}
};
if let Some(reply) = reply {
writeln!(stdout, "{reply}")
.and_then(|_| stdout.flush())
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_matches_rfc_4648() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foo"), "Zm9v");
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
}
#[test]
fn tools_have_schemas_and_unique_names() {
let list = tools();
let mut names = std::collections::HashSet::new();
for t in &list {
assert!(names.insert(t["name"].as_str().unwrap().to_owned()));
assert_eq!(t["inputSchema"]["type"], "object");
}
for want in [
"launch",
"state",
"goto",
"break_block",
"place",
"click_slot",
"click_button",
"screenshot",
"inventory",
"logs",
] {
let want = if want == "break_block" { "break" } else { want };
assert!(names.contains(want), "{want}");
}
}
#[test]
fn protocol_basics() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
let init = s
.handle("initialize", &json!({ "protocolVersion": "2025-03-26" }))
.unwrap();
assert_eq!(init["protocolVersion"], "2025-03-26");
assert!(s.handle("nope", &json!({})).is_err());
let r = s
.handle(
"tools/call",
&json!({ "name": "goto", "arguments": { "x": 1 } }),
)
.unwrap();
assert_eq!(r["isError"], true);
assert!(
r["content"][0]["text"]
.as_str()
.unwrap()
.contains("y is required")
);
// No game: a connection error, reported as a tool error.
let r = s
.handle("tools/call", &json!({ "name": "state", "arguments": {} }))
.unwrap();
assert_eq!(r["isError"], true);
}
}
@@ -0,0 +1,485 @@
//! Block entities carried by items, vanilla 26.3: Ctrl + Pick Block in creative
//! (`ServerGamePacketListenerImpl.addBlockDataToItem`) and placing such an item
//! (`BlockItem.updateCustomBlockEntityTag`, then
//! `BlockEntity.applyComponentsFromItemStack`). Both work on the block entity's
//! saved NBT (`block_entity_nbt::canonical`) and the item's component patch.
//!
//! What a type turns into item components (`removeComponentsFromTag`,
//! `collectImplicitComponents`) leaves its NBT; the rest goes to
//! `minecraft:block_entity_data`, tagged with the type's id.
use crate::block_entity_nbt::{self, canonical};
use horizon_world::nbt::{Compound, JavaString, Tag};
pub const BLOCK_ENTITY_DATA: &str = "minecraft:block_entity_data";
/// `BlockEntityTypes.OP_ONLY_CUSTOM_DATA`: the types whose data only a player
/// who can use game master blocks places (`BlockEntityType.onlyOpCanSetNbt`).
pub const OP_ONLY: [&str; 6] = [
"minecraft:command_block",
"minecraft:lectern",
"minecraft:sign",
"minecraft:hanging_sign",
"minecraft:mob_spawner",
"minecraft:trial_spawner",
];
/// The `minecraft:block_entity_data` of `stack`: its type id and its data.
pub fn data(stack: &crate::items::ItemStack) -> Option<(String, &Tag)> {
let data = stack.data()?.other(BLOCK_ENTITY_DATA)?;
Some((block_entity_nbt::id(data)?, data))
}
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
/// the item carries data of an op-only type.
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
}
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
/// spawners alike): the entity type a spawner item spawns.
pub fn spawner_entity(stack: &crate::items::ItemStack) -> Option<String> {
let (_, data) = data(stack)?;
match data.get("SpawnData")?.get("entity")?.get("id")? {
Tag::String(id) => Some(id.to_string_lossy()),
_ => None,
}
}
/// A part of a block entity's NBT that its item holds as a component.
#[derive(Clone, Copy)]
enum Part {
/// The NBT key and the component hold the same value.
Same(&'static str, &'static str),
/// `Items` as `minecraft:container`, in a container of this many slots.
Items(usize),
/// `LootTable` and `LootTableSeed` as `minecraft:container_loot`.
Loot,
/// `is_waxed` as `minecraft:waxed`.
Waxed,
/// A decorated pot's `item` as `minecraft:container`.
PotItem,
/// Left out of the item's data, with no component.
Drop(&'static str),
}
const NAME: Part = Part::Same("CustomName", "minecraft:custom_name");
const LOCK: Part = Part::Same("lock", "minecraft:lock");
const CONTAINER: &str = "minecraft:container";
/// Each type's `removeComponentsFromTag`, `collectImplicitComponents` and
/// `applyImplicitComponents`.
fn parts(id: &str) -> &'static [Part] {
match id {
"minecraft:chest"
| "minecraft:trapped_chest"
| "minecraft:barrel"
| "minecraft:shulker_box" => &[NAME, LOCK, Part::Items(27), Part::Loot],
"minecraft:dispenser" | "minecraft:dropper" | "minecraft:crafter" => {
&[NAME, LOCK, Part::Items(9), Part::Loot]
}
"minecraft:hopper" => &[NAME, LOCK, Part::Items(5), Part::Loot],
"minecraft:furnace" | "minecraft:smoker" | "minecraft:blast_furnace" => {
&[NAME, LOCK, Part::Items(3)]
}
"minecraft:brewing_stand" => &[NAME, LOCK, Part::Items(5)],
"minecraft:beacon" => &[NAME, LOCK],
"minecraft:enchanting_table" => &[NAME],
"minecraft:command_block" => &[NAME, Part::Drop("conditionMet"), Part::Drop("powered")],
"minecraft:campfire" => &[Part::Items(4)],
"minecraft:chiseled_bookshelf" => &[Part::Items(6)],
"minecraft:shelf" => &[Part::Items(3)],
"minecraft:beehive" => &[Part::Same("bees", "minecraft:bees")],
"minecraft:banner" => &[Part::Same("patterns", "minecraft:banner_patterns"), NAME],
"minecraft:decorated_pot" => &[
Part::Same("sherds", "minecraft:pot_decorations"),
Part::PotItem,
],
"minecraft:sign" | "minecraft:hanging_sign" => &[
Part::Same("front_text", "minecraft:sign_text_front"),
Part::Same("back_text", "minecraft:sign_text_back"),
Part::Waxed,
],
"minecraft:skull" => &[
Part::Same("profile", "minecraft:profile"),
Part::Same("note_block_sound", "minecraft:note_block_sound"),
Part::Same("custom_name", "minecraft:custom_name"),
],
_ => &[],
}
}
/// The component a part reads, and what it holds for `tag` (None: unset).
fn collect(part: Part, tag: &Tag) -> Option<(&'static str, Option<Tag>)> {
Some(match part {
Part::Same(key, component) => (
component,
tag.get(key).cloned().or_else(|| unset(component)),
),
Part::Items(_) => {
let slots = match tag.get("Items") {
Some(Tag::List(items)) => items.iter().filter_map(slot_entry).collect(),
_ => Vec::new(),
};
(CONTAINER, Some(Tag::List(slots)))
}
Part::Loot => {
let table = tag.get("LootTable").cloned();
let loot = table.map(|t| {
let mut entries = vec![("loot_table", t)];
if let Some(seed) = tag.get("LootTableSeed") {
entries.push(("seed", seed.clone()));
}
Tag::compound(entries)
});
("minecraft:container_loot", loot)
}
Part::Waxed => (
"minecraft:waxed",
matches!(tag.get("is_waxed"), Some(Tag::Byte(1)))
.then(|| Tag::Compound(Compound::new())),
),
Part::PotItem => {
let slots = match tag.get("item") {
Some(Tag::Compound(item)) => Some(container_slot(0, item.clone())),
_ => None,
};
(CONTAINER, Some(Tag::List(slots.into_iter().collect())))
}
Part::Drop(_) => return None,
})
}
/// What a type that always collects `component` gives when its NBT leaves the
/// value out (its empty value, which the NBT omits).
fn unset(component: &str) -> Option<Tag> {
Some(match component {
"minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
"minecraft:pot_decorations" => Tag::Compound(Compound::new()),
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
block_entity_nbt::sign_side(None)
}
_ => return None,
})
}
/// An `Items` entry as a `minecraft:container` slot.
fn slot_entry(entry: &Tag) -> Option<Tag> {
let Tag::Compound(entry) = entry else {
return None;
};
let mut item = entry.clone();
let slot = match item.remove(&JavaString::from("Slot"))? {
Tag::Byte(s) => i32::from(s),
_ => return None,
};
Some(container_slot(slot, item))
}
/// An `ItemContainerContents` slot: `ItemStackTemplate` leaves out a count of 1.
fn container_slot(slot: i32, mut item: Compound) -> Tag {
if item.get(&JavaString::from("count")) == Some(&Tag::Int(1)) {
item.remove(&JavaString::from("count"));
}
Tag::compound([("slot", Tag::Int(slot)), ("item", Tag::Compound(item))])
}
/// The value of `component` on the clone item of a type that collects it: a
/// picked item leaves it out of its patch, and a placed item without it in its
/// patch gives it to the block entity.
fn item_default(component: &str) -> Option<Tag> {
Some(match component {
CONTAINER | "minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
"minecraft:pot_decorations" => {
let brick = || Tag::compound([("id", Tag::String("minecraft:brick".into()))]);
Tag::compound([
("back", brick()),
("front", brick()),
("left", brick()),
("right", brick()),
])
}
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
block_entity_nbt::sign_side(None)
}
_ => return None,
})
}
const METADATA: [&str; 6] = ["id", "x", "y", "z", "keepPacked", "components"];
/// `saveCustomOnly`: the saved NBT without its id, position and components.
fn custom_only(saved: &Tag) -> Compound {
match saved {
Tag::Compound(c) => c
.iter()
.filter(|(k, _)| !METADATA.contains(&k.to_string_lossy().as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
_ => Compound::new(),
}
}
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
/// item of the block entity saved as `saved` (a full, canonical compound).
pub fn pick(saved: &Tag) -> Compound {
let id = block_entity_nbt::id(saved).unwrap_or_default();
let mut patch = Compound::new();
// BlockItem.setBlockEntityData after removeComponentsFromTag.
let mut data = custom_only(saved);
for part in parts(&id) {
let key = match *part {
Part::Same(key, _) | Part::Drop(key) => key,
Part::Items(_) => "Items",
Part::Loot => {
data.remove(&JavaString::from("LootTableSeed"));
"LootTable"
}
Part::Waxed => "is_waxed",
Part::PotItem => "item",
};
data.remove(&JavaString::from(key));
}
if !data.is_empty() {
data.insert("id".into(), Tag::String(id.as_str().into()));
patch.insert(BLOCK_ENTITY_DATA.into(), Tag::Compound(data));
}
// ItemStack.applyComponents(collectComponents()): the block entity's own
// components, then the implicit ones; a value equal to the item's own is
// no change.
let mut collected = match saved.get("components") {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for part in parts(&id) {
if let Some((component, value)) = collect(*part, saved) {
match value {
Some(v) => collected.insert(component.into(), v),
None => collected.remove(&JavaString::from(component)),
};
}
}
for (component, value) in collected {
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
patch.remove(&component);
} else {
patch.insert(component, value);
}
}
patch
}
/// The block entity saved as `saved` after an item with component patch
/// `components` is placed as it: `BlockItem.updateCustomBlockEntityTag` (the
/// item's data of the same type, op-only types only when `game_master`, the
/// player can use game master blocks), then `applyComponentsFromItemStack`.
pub fn place(saved: &Tag, components: &Compound, game_master: bool) -> Tag {
let Some(id) = block_entity_nbt::id(saved) else {
return saved.clone();
};
let Tag::Compound(mut tag) = saved.clone() else {
return saved.clone();
};
// TypedEntityData.loadInto: saveCustomOnly, merge, loadCustomOnly when changed.
if let Some(Tag::Compound(data)) = components.get(&JavaString::from(BLOCK_ENTITY_DATA))
&& data
.get(&JavaString::from("id"))
.is_some_and(|t| matches!(t, Tag::String(s) if s.to_string_lossy() == id))
&& (game_master || !OP_ONLY.contains(&id.as_str()))
{
let custom = custom_only(saved);
let mut merged = custom.clone();
let mut data = data.clone();
data.remove(&JavaString::from("id"));
crate::data_cmd::merge_compound(&mut merged, &data);
if merged != custom {
for key in METADATA {
if let Some(v) = tag.get(&JavaString::from(key)) {
merged.insert(key.into(), v.clone());
}
}
if let Some(Tag::Compound(loaded)) = canonical(&Tag::Compound(merged)) {
tag = loaded;
}
}
}
// applyImplicitComponents, then the rest of the patch as the block
// entity's own components.
let mut read = vec![BLOCK_ENTITY_DATA, "minecraft:block_state"];
// The item's components: its patch over its own defaults.
let get = |c: &str| match components.get(&JavaString::from(c)) {
Some(v) => Some(v.clone()),
None if components.contains_key(&JavaString::from(format!("!{c}").as_str())) => None,
None => item_default(c),
};
let mut set = |key: &str, value: Option<Tag>| match value {
Some(v) => tag.insert(key.into(), v),
None => tag.remove(&JavaString::from(key)),
};
for part in parts(&id) {
match *part {
Part::Same(key, component) => {
read.push(component);
set(key, get(component));
}
Part::Items(size) => {
read.push(CONTAINER);
let items = container_slots(get(CONTAINER).as_ref())
.filter(|(slot, _)| (*slot as usize) < size)
.map(|(slot, mut item)| {
item.insert("Slot".into(), Tag::Byte(slot as i8));
Tag::Compound(item)
})
.collect();
set("Items", Some(Tag::List(items)));
}
Part::Loot => {
read.push("minecraft:container_loot");
if let Some(loot) = get("minecraft:container_loot") {
set("LootTable", loot.get("loot_table").cloned());
set("LootTableSeed", loot.get("seed").cloned());
}
}
Part::Waxed => {
read.push("minecraft:waxed");
set(
"is_waxed",
Some(Tag::Byte(i8::from(get("minecraft:waxed").is_some()))),
);
}
Part::PotItem => {
read.push(CONTAINER);
let first = container_slots(get(CONTAINER).as_ref()).find(|(slot, _)| *slot == 0);
set("item", first.map(|(_, item)| Tag::Compound(item)));
}
Part::Drop(_) => {}
}
}
let own: Compound = components
.iter()
.filter(|(k, _)| {
let k = k.to_string_lossy();
!k.starts_with('!') && !read.contains(&k.as_str())
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
tag.insert("components".into(), Tag::Compound(own));
let tag = Tag::Compound(tag);
canonical(&tag).unwrap_or(tag)
}
/// The slots of a `minecraft:container` component.
fn container_slots(container: Option<&Tag>) -> impl Iterator<Item = (i32, Compound)> + '_ {
let list = match container {
Some(Tag::List(list)) => list.as_slice(),
_ => &[],
};
list.iter().filter_map(|entry| {
let slot = match entry.get("slot")? {
Tag::Int(s) => *s,
_ => return None,
};
match entry.get("item")? {
Tag::Compound(item) => Some((slot, item.clone())),
_ => None,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn snbt(text: &str) -> Tag {
horizon_world::nbt::parse_snbt(text).expect("snbt").0
}
fn saved(text: &str) -> Tag {
canonical(&snbt(text)).expect("known type")
}
fn canon(patch: &Compound) -> String {
crate::item_data::canon_tag(&Tag::Compound(patch.clone()))
}
#[test]
fn a_filled_named_chest_gives_its_items_and_name_as_components() {
let chest = saved(
"{id:\"minecraft:chest\",x:1,y:2,z:3,CustomName:\"Loot\",\
Items:[{Slot:4b,id:\"minecraft:stone\",count:5}]}",
);
let patch = pick(&chest);
assert_eq!(
canon(&patch),
crate::item_data::canon_tag(&snbt(
"{\"minecraft:container\":[{slot:4,item:{id:\"minecraft:stone\",count:5}}],\
\"minecraft:custom_name\":\"Loot\"}"
))
);
// Placing it back gives the same chest.
let empty = saved("{id:\"minecraft:chest\",x:1,y:2,z:3}");
assert_eq!(place(&empty, &patch, false), chest);
// An empty chest has nothing to carry.
assert!(pick(&empty).is_empty());
}
#[test]
fn a_command_block_keeps_its_command_in_block_entity_data() {
let block = saved(
"{id:\"minecraft:command_block\",x:0,y:0,z:0,Command:\"say hi\",auto:1b,powered:1b}",
);
let patch = pick(&block);
let data = patch
.get(&JavaString::from(BLOCK_ENTITY_DATA))
.expect("data");
assert_eq!(data.get("Command"), Some(&Tag::String("say hi".into())));
assert_eq!(data.get("powered"), None);
assert_eq!(
data.get("id"),
Some(&Tag::String("minecraft:command_block".into()))
);
// Only a player who can use game master blocks places it.
let fresh = saved("{id:\"minecraft:command_block\",x:0,y:0,z:0}");
assert_eq!(place(&fresh, &patch, false), fresh);
assert_eq!(
place(&fresh, &patch, true).get("Command"),
Some(&Tag::String("say hi".into()))
);
// Data of another type is ignored.
let chest = saved("{id:\"minecraft:chest\",x:0,y:0,z:0}");
assert_eq!(place(&chest, &patch, true), chest);
}
#[test]
fn item_data_names_its_spawn_and_op_only_types() {
let item = |text: &str| {
let components = snbt(&format!("{{\"minecraft:block_entity_data\":{text}}}"));
let (damage, extra) = crate::item_data::decode_components(Some(&components));
crate::items::ItemStack {
damage,
extra,
..crate::items::ItemStack::new("minecraft:spawner", 1)
}
};
let spawner =
item("{id:\"minecraft:mob_spawner\",SpawnData:{entity:{id:\"minecraft:zombie\"}}}");
assert_eq!(
spawner_entity(&spawner).as_deref(),
Some("minecraft:zombie")
);
assert!(op_warning(&spawner));
let chest = item("{id:\"minecraft:chest\",LootTable:\"x\"}");
assert_eq!(spawner_entity(&chest), None);
assert!(!op_warning(&chest));
}
#[test]
fn a_written_sign_gives_its_text_and_wax() {
let sign = saved(
"{id:\"minecraft:sign\",x:0,y:0,z:0,is_waxed:1b,\
front_text:{messages:[\"a\",\"b\",\"\",\"\"]}}",
);
let patch = pick(&sign);
let keys: Vec<String> = patch.keys().map(|k| k.to_string_lossy()).collect();
assert_eq!(keys, ["minecraft:sign_text_front", "minecraft:waxed"]);
let fresh = saved("{id:\"minecraft:sign\",x:0,y:0,z:0}");
assert_eq!(place(&fresh, &patch, false), sign);
}
}
@@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool {
matches!(tag, Tag::IntArray(v) if v.len() == 3)
}
/// The loaded `SignText` of one side, or the empty text.
fn sign_side(tag: Option<&Tag>) -> Tag {
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
let empty = || string("");
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
@@ -143,6 +143,7 @@ pub fn draw_part(
let color = pack_color(tint, 255, diffuse(normal, lights));
for (v, position) in poly.iter().zip(pos) {
out.push(Vertex {
normal: [0; 4],
pos: position.to_array(),
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
layer: 0,
@@ -637,6 +638,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
out.push(Vertex {
normal: [0; 4],
pos: call
.matrix
.transform_point3(Vec3::new(cx, cy, 0.0))
@@ -1587,6 +1589,7 @@ impl BeamPart {
(at(b, self.end), [s.u0, v1]),
] {
out.push(Vertex {
normal: [0; 4],
pos: p.to_array(),
uv,
layer: 6,
@@ -1656,6 +1659,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
for &d in faces {
for c in PORTAL_FACES[d as usize] {
out.push(Vertex {
normal: [0; 4],
pos: m.transform_point3(Vec3::from(c)).to_array(),
uv: [0.0; 2],
layer,
+58 -65
View File
@@ -1,9 +1,16 @@
//! Per-state gameplay facts exported from vanilla's own block-state accessors
//! (`gameplay-facts.json`), indexed by registry state ID.
mod decode;
use crate::Result;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::Value;
use std::{collections::HashMap, fs::File, io::BufReader, path::Path, sync::Arc};
use std::{
collections::HashMap,
fs::{self, File},
io::BufReader,
path::Path,
sync::Arc,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderShape {
@@ -54,6 +61,8 @@ pub struct StateFacts {
pub waterlogged: bool,
/// `hasOffsetFunction`: the model is jittered per column.
pub random_offset: bool,
/// Whether the outline, not just the model, follows BlockState.getOffset.
pub outline_offset: bool,
/// `BlockState.isSolid()` (legacy solidity; fluid surfaces read it).
pub legacy_solid: bool,
/// `isFaceSturdy(.., SupportType.FULL)` by Direction ordinal.
@@ -62,8 +71,8 @@ pub struct StateFacts {
pub classes: Arc<[Arc<str>]>,
/// `getShape` (the outline used for picking) as boxes in block space,
/// without the random offset; and its edges as LevelRenderer draws them.
pub outline: Arc<[[f32; 6]]>,
pub outline_edges: Arc<[[f32; 6]]>,
pub outline: Arc<[[f64; 6]]>,
pub outline_edges: Arc<[[f64; 6]]>,
/// `getMapColor(..).id` (0 = NONE).
pub map_color: u8,
}
@@ -78,11 +87,11 @@ pub const MAP_COLORS: [u32; 62] = [
14200723, 8365974,
];
/// The 12 edges of the unit cube (outline data predating the export).
fn cube_edges() -> Vec<[f32; 6]> {
fn cube_edges() -> Vec<[f64; 6]> {
let mut edges = Vec::new();
for a in 0..3 {
for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] {
let mut from = [0.0f32; 3];
let mut from = [0.0f64; 3];
let (b, c) = ((a + 1) % 3, (a + 2) % 3);
from[b] = u;
from[c] = v;
@@ -104,11 +113,13 @@ pub struct BlockFacts {
}
impl BlockFacts {
pub fn load(root: &Path, registry: &BlockRegistry) -> Result<Self> {
let raw: Value = serde_json::from_reader(BufReader::new(File::open(
root.join("gameplay-facts.json"),
)?))?;
let entries = raw["states"]
.as_array()
let bytes = fs::read(root.join("gameplay-facts.json"))?;
serde_json::from_slice::<decode::Validate>(&bytes)?;
let raw: decode::Gameplay<'_> = serde_json::from_slice(&bytes)?;
let entries = raw
.states
.0
.as_ref()
.ok_or("missing gameplay state facts")?;
if entries.len() != registry.len() {
return Err("gameplay facts do not match the block registry".into());
@@ -142,41 +153,22 @@ impl BlockFacts {
.collect();
let mut names: HashMap<String, Arc<str>> = HashMap::new();
let mut intern = |s: &str| -> Arc<str> {
names
.entry(s.to_owned())
.or_insert_with(|| Arc::from(s))
.clone()
};
let mut shapes: HashMap<String, Arc<[[f32; 6]]>> = HashMap::new();
let mut shape = |v: &Value, fallback: &dyn Fn() -> Vec<[f32; 6]>| -> Arc<[[f32; 6]]> {
let Some(list) = v.as_array() else {
return fallback().into();
};
shapes
.entry(v.to_string())
.or_insert_with(|| {
list.iter()
.filter_map(|b| {
let b = b.as_array()?;
let n =
|i: usize| b.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
Some([n(0), n(1), n(2), n(3), n(4), n(5)])
})
.collect()
})
.clone()
if let Some(name) = names.get(s) {
return name.clone();
}
let name: Arc<str> = Arc::from(s);
names.insert(s.to_owned(), name.clone());
name
};
let mut shapes = decode::Shapes::default();
let mut states = Vec::with_capacity(entries.len());
for (index, entry) in entries.iter().enumerate() {
let id = BlockStateId(index as u32);
if entry["id"].as_u64() != Some(index as u64) {
if entry.id != Some(index as u64) {
return Err("gameplay facts are not in registry order".into());
}
let state = registry.state(id).ok_or("missing registry state")?;
let num = |k: &str| entry[k].as_f64().unwrap_or(0.0);
let flag = |k: &str| entry[k].as_bool().unwrap_or(false);
let sound = &entry["sound"];
let sound_id = |k: &str| sound[k]["id"].as_str().unwrap_or("").to_owned();
let sound = &entry.sound;
let prop = |k: &str| state.properties.get(k).map(String::as_str);
let (fluid, fluid_level, fluid_falling) = match state.name.as_str() {
"minecraft:water" | "minecraft:lava" => {
@@ -215,10 +207,10 @@ impl BlockFacts {
}
};
let edges = move || if air { Vec::new() } else { cube_edges() };
let outline = shape(&entry["outline"], &cube);
let outline_edges = shape(&entry["outline_edges"], &edges);
let outline = shapes.intern(entry.outline, cube)?;
let outline_edges = shapes.intern(entry.outline_edges, edges)?;
// The export holds MapColor.col; ids are positions in MAP_COLORS.
let col = entry["map_color"].as_u64().unwrap_or(0) as u32;
let col = entry.map_color.unwrap_or(0) as u32;
let map_color = if col == 0 {
0
} else {
@@ -229,40 +221,41 @@ impl BlockFacts {
outline,
outline_edges,
name: intern(&state.name),
emission: num("emission") as u8,
dampening: num("dampening") as u8,
skylight_down: flag("skylight_down"),
shape_occlusion: flag("shape_occlusion"),
can_occlude: flag("can_occlude"),
solid_render: flag("solid_render"),
hardness: num("hardness") as f32,
requires_tool: flag("requires_tool"),
replaceable: flag("replaceable"),
random_ticks: flag("random_ticks"),
block_entity: flag("block_entity"),
render_shape: match entry["render_shape"].as_str() {
emission: entry.emission as u8,
dampening: entry.dampening as u8,
skylight_down: entry.skylight_down,
shape_occlusion: entry.shape_occlusion,
can_occlude: entry.can_occlude,
solid_render: entry.solid_render,
hardness: entry.hardness as f32,
requires_tool: entry.requires_tool,
replaceable: entry.replaceable,
random_ticks: entry.random_ticks,
block_entity: entry.block_entity,
render_shape: match entry.render_shape.as_deref() {
Some("INVISIBLE") => RenderShape::Invisible,
Some("ENTITYBLOCK_ANIMATED") => RenderShape::Animated,
_ => RenderShape::Model,
},
full_collision: flag("full_collision"),
friction: num("friction") as f32,
explosion_resistance: num("explosion_resistance") as f32,
item: intern(entry["item"].as_str().unwrap_or("minecraft:air")),
full_collision: entry.full_collision,
friction: entry.friction as f32,
explosion_resistance: entry.explosion_resistance as f32,
item: intern(entry.item.as_deref().unwrap_or("minecraft:air")),
sounds: Sounds {
volume: sound["volume"].as_f64().unwrap_or(1.0) as f32,
pitch: sound["pitch"].as_f64().unwrap_or(1.0) as f32,
break_sound: intern(&sound_id("break")),
step: intern(&sound_id("step")),
place: intern(&sound_id("place")),
hit: intern(&sound_id("hit")),
fall: intern(&sound_id("fall")),
volume: sound.volume as f32,
pitch: sound.pitch as f32,
break_sound: intern(sound.r#break.id.as_deref().unwrap_or("")),
step: intern(sound.step.id.as_deref().unwrap_or("")),
place: intern(sound.place.id.as_deref().unwrap_or("")),
hit: intern(sound.hit.id.as_deref().unwrap_or("")),
fall: intern(sound.fall.id.as_deref().unwrap_or("")),
},
fluid,
fluid_level,
fluid_falling,
waterlogged: prop("waterlogged") == Some("true"),
random_offset: flag("offset"),
random_offset: entry.offset,
outline_offset: entry.outline_offset.unwrap_or(entry.offset),
legacy_solid: behaviour_states[index][1].as_u64().unwrap_or(0) & 1 != 0,
sturdy_full: (behaviour_states[index][0].as_u64().unwrap_or(0) & 63) as u8,
classes: classes
+290
View File
@@ -0,0 +1,290 @@
//! Typed projection of gameplay facts. Scalar conversions deliberately retain Value's
//! permissive defaults; maps retain its last-key-wins behavior. Repeated geometry stays
//! borrowed until it is interned, rather than allocating a JSON tree for every state.
use serde::{
Deserialize, Deserializer,
de::{MapAccess, SeqAccess, Visitor},
};
use serde_json::{Value, value::RawValue};
use std::{
collections::{HashMap, hash_map::Entry as MapEntry},
fmt,
hash::{Hash, Hasher},
marker::PhantomData,
sync::Arc,
};
// RawValue skips numeric range and depth checks. A non-allocating validation pass
// keeps the original Value loader's JSON errors, even in overwritten/unused fields.
#[derive(Default)]
pub(super) struct Validate;
impl<'de> Container<'de> for Validate {}
impl<'de> Deserialize<'de> for Validate {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
container(d)
}
}
trait Container<'de>: Default {
fn map<A: MapAccess<'de>>(mut map: A) -> Result<Self, A::Error> {
while map.next_key::<serde::de::IgnoredAny>()?.is_some() {
map.next_value::<Validate>()?;
}
Ok(Self::default())
}
fn seq<A: SeqAccess<'de>>(mut seq: A) -> Result<Self, A::Error> {
while seq.next_element::<Validate>()?.is_some() {}
Ok(Self::default())
}
}
struct ContainerVisitor<T>(PhantomData<T>);
impl<'de, T: Container<'de>> Visitor<'de> for ContainerVisitor<T> {
type Value = T;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("JSON facts")
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<T, A::Error> {
T::map(map)
}
fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<T, A::Error> {
T::seq(seq)
}
fn visit_unit<E: serde::de::Error>(self) -> Result<T, E> {
Ok(T::default())
}
fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<T, E> {
Ok(T::default())
}
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<T, E> {
Ok(T::default())
}
fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<T, E> {
Ok(T::default())
}
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<T, E> {
Ok(T::default())
}
fn visit_str<E: serde::de::Error>(self, _: &str) -> Result<T, E> {
Ok(T::default())
}
}
fn container<'de, D: Deserializer<'de>, T: Container<'de>>(d: D) -> Result<T, D::Error> {
d.deserialize_any(ContainerVisitor(PhantomData))
}
macro_rules! read_field {
($map:ident, typed) => {
$map.next_value()?
};
($map:ident, value($convert:expr)) => {
($convert)($map.next_value::<Value>()?)
};
}
macro_rules! object {
($name:ident<$life:lifetime> { $($field:ident: $ty:ty = $default:expr => $kind:ident $(($convert:expr))?),* $(,)? }) => {
pub(super) struct $name<$life> {
$(pub $field: $ty,)*
marker: PhantomData<&$life ()>,
}
impl Default for $name<'_> {
fn default() -> Self { Self { $($field: $default,)* marker: PhantomData } }
}
impl<$life> Container<$life> for $name<$life> {
fn map<A: MapAccess<$life>>(mut map: A) -> Result<Self, A::Error> {
#[derive(Deserialize)]
#[allow(non_camel_case_types)]
#[serde(field_identifier)]
enum Field { $($field,)* #[serde(other)] Unknown }
let mut result = Self::default();
while let Some(field) = map.next_key::<Field>()? {
match field {
$(Field::$field => result.$field = read_field!(map, $kind $(($convert))?),)*
Field::Unknown => { map.next_value::<Validate>()?; }
}
}
Ok(result)
}
}
impl<$life> Deserialize<$life> for $name<$life> {
fn deserialize<D: Deserializer<$life>>(d: D) -> Result<Self, D::Error> { container(d) }
}
};
}
fn number(v: Value) -> f64 {
v.as_f64().unwrap_or(0.0)
}
fn flag(v: Value) -> bool {
v.as_bool().unwrap_or(false)
}
fn unsigned(v: Value) -> Option<u64> {
v.as_u64()
}
fn text(v: Value) -> Option<String> {
if let Value::String(s) = v {
Some(s)
} else {
None
}
}
#[derive(Default)]
pub(super) struct Entries<'a>(pub Option<Vec<Entry<'a>>>);
impl<'de> Container<'de> for Entries<'de> {
fn seq<A: SeqAccess<'de>>(mut seq: A) -> Result<Self, A::Error> {
let mut entries = Vec::new();
while let Some(entry) = seq.next_element()? {
entries.push(entry);
}
Ok(Self(Some(entries)))
}
}
impl<'de> Deserialize<'de> for Entries<'de> {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
container(d)
}
}
object!(Gameplay<'a> { states: Entries<'a> = Entries::default() => typed });
object!(SoundId<'a> { id: Option<String> = None => value(text) });
object!(Sound<'a> {
volume: f64 = 1.0 => value(|v: Value| v.as_f64().unwrap_or(1.0)),
pitch: f64 = 1.0 => value(|v: Value| v.as_f64().unwrap_or(1.0)),
r#break: SoundId<'a> = SoundId::default() => typed,
step: SoundId<'a> = SoundId::default() => typed,
place: SoundId<'a> = SoundId::default() => typed,
hit: SoundId<'a> = SoundId::default() => typed,
fall: SoundId<'a> = SoundId::default() => typed,
});
object!(Entry<'a> {
id: Option<u64> = None => value(unsigned),
emission: f64 = 0.0 => value(number),
dampening: f64 = 0.0 => value(number),
skylight_down: bool = false => value(flag),
shape_occlusion: bool = false => value(flag),
can_occlude: bool = false => value(flag),
solid_render: bool = false => value(flag),
hardness: f64 = 0.0 => value(number),
requires_tool: bool = false => value(flag),
replaceable: bool = false => value(flag),
random_ticks: bool = false => value(flag),
block_entity: bool = false => value(flag),
render_shape: Option<String> = None => value(text),
full_collision: bool = false => value(flag),
friction: f64 = 0.0 => value(number),
explosion_resistance: f64 = 0.0 => value(number),
item: Option<String> = None => value(text),
sound: Sound<'a> = Sound::default() => typed,
offset: bool = false => value(flag),
outline_offset: Option<bool> = None => value(|v: Value| v.as_bool()),
map_color: Option<u64> = None => value(unsigned),
outline: Option<&'a RawValue> = None => typed,
outline_edges: Option<&'a RawValue> = None => typed,
});
// Match Value::to_string identity without serializing: integer/float spelling is
// distinct, as is the sign of floating zero. Object keys are already sorted by Value.
struct ShapeKey(Value);
fn same(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(a), Value::Number(b)) => {
a == b && a.as_f64().map(f64::to_bits) == b.as_f64().map(f64::to_bits)
}
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| same(a, b))
}
(Value::Object(a), Value::Object(b)) => {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|((ak, av), (bk, bv))| ak == bk && same(av, bv))
}
_ => a == b,
}
}
impl PartialEq for ShapeKey {
fn eq(&self, other: &Self) -> bool {
same(&self.0, &other.0)
}
}
impl Eq for ShapeKey {}
fn shape_hash<H: Hasher>(v: &Value, h: &mut H) {
std::mem::discriminant(v).hash(h);
match v {
Value::Null => {}
Value::Bool(b) => b.hash(h),
Value::String(s) => s.hash(h),
Value::Number(n) => {
n.is_f64().hash(h);
n.hash(h);
n.as_f64().map(f64::to_bits).hash(h);
}
Value::Array(a) => {
a.len().hash(h);
for v in a {
shape_hash(v, h);
}
}
Value::Object(o) => {
o.len().hash(h);
for (k, v) in o {
k.hash(h);
shape_hash(v, h);
}
}
}
}
impl Hash for ShapeKey {
fn hash<H: Hasher>(&self, h: &mut H) {
shape_hash(&self.0, h);
}
}
#[derive(Default)]
pub(super) struct Shapes<'a> {
raw: HashMap<&'a str, usize>,
canonical: HashMap<ShapeKey, usize>,
values: Vec<Arc<[[f64; 6]]>>,
}
impl<'a> Shapes<'a> {
pub fn intern(
&mut self,
raw: Option<&'a RawValue>,
fallback: impl FnOnce() -> Vec<[f64; 6]>,
) -> crate::Result<Arc<[[f64; 6]]>> {
let Some(raw) = raw else {
return Ok(fallback().into());
};
if let Some(&id) = self.raw.get(raw.get()) {
return Ok(self.values[id].clone());
}
let value: Value = serde_json::from_str(raw.get())?;
if !value.is_array() {
return Ok(fallback().into());
}
let id = match self.canonical.entry(ShapeKey(value)) {
MapEntry::Occupied(entry) => *entry.get(),
MapEntry::Vacant(entry) => {
let shape = entry
.key()
.0
.as_array()
.unwrap()
.iter()
.filter_map(|b| {
let b = b.as_array()?;
Some(std::array::from_fn(|i| {
b.get(i).and_then(Value::as_f64).unwrap_or(0.0)
}))
})
.collect::<Arc<[[f64; 6]]>>();
let id = self.values.len();
self.values.push(shape);
entry.insert(id);
id
}
};
self.raw.insert(raw.get(), id);
Ok(self.values[id].clone())
}
}
#[cfg(test)]
#[path = "decode_tests.rs"]
mod tests;
Loaded 100 of 974 files, more files were not shown because too many files have changed in this diff. Show more