Compare commits

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

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

Fixes #1647

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #1591.

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

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

Fixes #1592.

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

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

Fixes #1272.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:30:32 +02:00
Ludovic Marie fe128948ba Merge pull request #1575 from RobertTGreat/fix/launcher-startup-windows
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 21:16:44 +02:00
Ludovic Marie 33c2512cbd Merge pull request #1576 from RobertTGreat/fix/launcher-mod-lists
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 21:14:20 +02:00
Ludovic Marie 2e35028930 Merge pull request #1574 from RobertTGreat/fix/launcher-window-layout
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 21:13:52 +02:00
Ludovic Marie d0b7c20371 Merge pull request #1577 from RVZO6/fix/server-text-debug-shortcuts
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 21:13:24 +02:00
Ludovic MarieandClaude Opus 5.5 afcb4062cb Merge main into pa1/remove-mod-surveys (keep the surveys deleted)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:07:46 +02:00
Ludovic Marie 7f995af677 Merge pull request #1570 from ludovic111/feat/server-packs-client 2026-10-24 21:02:56 +02:00
Ludovic Marie 61e044e67c Merge pull request #1569 from ludovic111/feat/server-packs-manager 2026-10-24 21:01:05 +02:00
Ludovic Marie 18b63d7acb Merge pull request #1581 from ludovic111/docs/mod-api-reference 2026-10-24 20:58:01 +02:00
Ludovic Marie e683cdf322 Merge pull request #1579 from ludovic111/fix/builtin-pack-icons 2026-10-24 20:56:28 +02:00
levzzzandClaude Opus 5.5 66235cc981 Let mobs ride boats, and make boats collide with and push entities
A moving boat now stops at other boats, minecarts, mobs and players
(AbstractBoat.canVehicleCollide), and on the server it pushes what it
touches and is pushed back (AbstractBoat.push, LivingEntity.pushEntities).
A boat no player steers picks up the mobs that touch it, two seats in a
boat and one in a chest boat, with the player always in front
(getPassengerAttachmentPoint, clampRotation). Mobs get out when the boat
breaks or stays under water, are saved under the boat's Passengers, and
are seated by the client, which is sent no passenger's position.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #1506.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-24 19:50:45 +03:00
Ludovic Marie ccb827282b Merge remote-tracking branch 'origin/main' into pa1/gate-1558
# Conflicts:
#	docs/vulkan.md
#	mods/horizon/src/main/java/dev/horizonlab/client/HorizonClient.java
#	mods/horizon/src/test/java/dev/horizonlab/client/DistantTerrainSettingsTest.java
#	mods/horizon/src/test/java/dev/horizonlab/metrics/ClientGraphicsIntegration.java
#	packs/START-HERE.txt
#	packs/mods.lock.json
#	tests/test_fixtures.py
#	tests/test_packs.py
#	tools/Compare-Benchmarks.html
#	tools/build_pack.py
#	tools/compare_benchmarks.py
#	tools/test_client_graphics.py
2026-10-24 18:47:57 +02:00
Ludovic Marie 56aa5f9549 Merge remote-tracking branch 'origin/main' into pa1/gate-1560
# Conflicts:
#	Cargo.toml
2026-10-24 18:47:16 +02:00
Ludovic Marie d65282fb0f Merge pull request #1566 from ludovic111/fix/baby-villager-type-texture 2026-10-24 18:34:08 +02:00
Ludovic MarieandClaude Opus 5.5 6a698c40f0 Launcher: start the game from a Horizon.app on macOS, so the Dock and Cmd+Tab show Horizon
Started as a bare executable, the game showed in the Dock and Cmd+Tab as
"horizon-client" with the generic executable icon: winit's window icon does
nothing on macOS. The launcher now keeps a small Horizon.app in each version
folder (Info.plist, the launcher's icon, hard links to the version's
horizon-client and horizon-ctl) and starts the game from it. Server setup
leaves the bundle out of its copy.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:55:06 +02:00
rdbtCVSandOpenAI Codex gpt-6.1-sol 06ec15f3a0 Retire the legacy Java optimization mod and performance packs
Preserve Java parity oracles and comparison benchmark instrumentation. Move their remaining dependency locks under tools/benchmark-deps and remove legacy product packaging, fixtures, and CI checks.

Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:50:55 +01:00
rdbtCVSandOpenAI Codex gpt-6.1-sol 2a9e0ab28d Remove unused voxel-kernels research experiment
Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:49:46 +01:00
Ludovic MarieandClaude Opus 5.5 7efdbdb39a docs: remove the surveys of other mods; mod code is never read
Removes docs/performance/java-perf-mods.md (a survey of eight Java
performance mods' techniques) and docs/research/java-mod-compat.md (notes
on how Fabric, NeoForge and Mixin work, with a list of mods to port).
Links to them now point at mods.md, which says in two lines why Java jars
don't run in Horizon.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:42:44 +02:00
242 changed files with 7319 additions and 8191 deletions

No files matched your search

+1
View File
@@ -3,3 +3,4 @@
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+1 -95
View File
@@ -430,7 +430,7 @@ jobs:
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_float_log_parity.py
portable-mod:
jfr-analysis:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
@@ -442,101 +442,7 @@ jobs:
with:
distribution: temurin
java-version: '25'
- name: Compile original mod and test metrics
run: python tools/build_mod.py
- name: Verify streaming JFR analysis against the JDK exporter
run: python tools/test_jfr.py
- name: Verify ZGC pause and allocation event interpretation
run: python tools/test_jfr.py --gc zgc
- name: Build pinned portable packs
run: python tools/build_pack.py
- name: Measure isolated worker pacing on this platform
run: python -c "from tools.build_mod import benchmark_workers; benchmark_workers()"
- name: Measure isolated debounce queue polling
run: python -c "from tools.build_mod import benchmark_debounce; benchmark_debounce()"
- name: Measure wakeable idle queue latency
run: python -c "from tools.build_mod import benchmark_idle; benchmark_idle()"
- name: Measure object allocations with compact headers off and on
run: python -c "from tools.build_mod import benchmark_headers; benchmark_headers()"
- name: Test pack integrity and comparisons
run: python -m unittest discover -s tests -v
- name: Start recommended OpenGL macOS clients and verify framebuffer readback
if: runner.os == 'macOS'
run: |
python tools/test_client_graphics.py --native-display
cp dist/client-graphics-smoke.json dist/client-graphics-macos-title.json
python tools/test_client_graphics.py --native-display --world
cp dist/client-graphics-smoke.json dist/client-graphics-macos-world.json
- name: Compile the private dispatch fixture
run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()"
- name: Compile the private integrated-server graphics fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('server-graphics', 'dev.horizonlab.metrics.ServerGraphicsIntegration', fixture_dependencies={})"
- name: Compile the private tick-manager state fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
- name: Compile the private terrain tick membership fixture
run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()"
- name: Compile the private culling whitelist fixture
run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()"
- name: Compile the private culling ray geometry fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('culling-rays', 'dev.horizonlab.dispatch.CullingRayIntegration', ['entityculling'], ['CullingRayAccess'])"
- name: Compile the private terrain position lookup fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-position', 'dev.horizonlab.dispatch.PositionIntegration')"
- name: Compile the private terrain quad reuse fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('quad-reuse', 'dev.horizonlab.dispatch.QuadReuseIntegration')"
- name: Compile the private object-size agent and terrain quad fixture
run: python -c "from tools.test_quad_sizes import build_agent; from tools.build_mod import build_integration_fixture; build_agent(); build_integration_fixture('quad-size', 'dev.horizonlab.dispatch.QuadSizeIntegration')"
- name: Compile the private terrain read shutdown fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')"
- name: Install headless graphics libraries for applied-hook checks
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0
- name: Verify integrated-server graphics report against the actual device
run: python tools/test_server_graphics.py ${{ runner.os == 'macOS' && '--native-display' || '' }}
- name: Exercise the applied dispatcher hooks and upstream rejection path
if: runner.os == 'Linux'
run: python tools/test_dispatch.py
- name: Verify applied tick membership transitions and allocation
if: runner.os == 'Linux'
run: python tools/test_tick_dedup.py
- name: Verify live culling whitelist registration and empty iteration
if: runner.os == 'Linux'
run: python tools/test_culling_empty.py
- name: Compare applied culling ray geometry and provider queries
if: runner.os == 'Linux'
run: python tools/test_culling_rays.py
- name: Verify actual full-data position lookup equivalence
if: runner.os == 'Linux'
run: python tools/test_terrain_position.py
- name: Verify actual terrain quad geometry and allocation
if: runner.os == 'Linux'
run: python tools/test_quad_reuse.py
- name: Measure actual builder and quad shallow sizes in both header modes
if: runner.os == 'Linux'
run: python tools/test_quad_sizes.py
- name: Verify interrupted DH reads and preserved shared futures
if: runner.os == 'Linux'
run: python tools/test_terrain_read.py
- name: Upload original mod and pack manifests only
uses: actions/upload-artifact@v4
with:
name: horizon-linux
path: |
dist/*.mrpack
dist/horizon-*.jar
dist/worker-pacing-benchmark.txt
dist/debounce-queue-benchmark.txt
dist/idle-queue-benchmark.txt
dist/object-header-benchmark.txt
dist/dispatch-integration.json
dist/tick-dedup-integration.json
dist/culling-empty-integration.json
dist/terrain-read-integration.json
dist/culling-rays-integration.json
dist/terrain-position-integration.json
dist/quad-reuse-integration.json
dist/quad-size-integration.json
dist/server-graphics-smoke.json
dist/client-graphics-smoke.json
dist/client-graphics-macos-title.json
dist/client-graphics-macos-world.json
retention-days: 3
+8 -3
View File
@@ -367,10 +367,15 @@ jobs:
TAG: ${{ needs.decide.outputs.tag }}
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# The release and the latest at once: there is one channel (docs/release-flow.md).
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
--notes-file notes/body.md --latest \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
+1 -1
View File
@@ -47,7 +47,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
+9
View File
@@ -40,6 +40,15 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
them (`git config core.hooksPath .githooks` runs it before every commit).
## Only original code
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
had been written from another project's source; they come back only as a clean-room rewrite from a written
description of the pack format.
## Before asking for review
Run the self-check and paste the block it prints into your pull request:
Generated
+44 -6
View File
@@ -155,7 +155,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1278,6 +1278,7 @@ dependencies = [
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"symphonia",
"ureq",
@@ -1314,7 +1315,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"socket2",
"socket2 0.5.10",
"spake2",
]
@@ -1377,6 +1378,7 @@ dependencies = [
"getrandom 0.3.4",
"horizon-core",
"horizon-world",
"ipconfig",
"serde_json",
"sha1",
"sha2",
@@ -1653,6 +1655,19 @@ dependencies = [
"generic-array",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2 0.6.5",
"widestring",
"windows-registry",
"windows-result 0.4.1",
"windows-sys 0.61.2",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -3137,6 +3152,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "spake2"
version = "0.4.0"
@@ -3577,10 +3602,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voxel-kernels"
version = "0.1.0"
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4297,6 +4318,12 @@ dependencies = [
"web-sys",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -4429,6 +4456,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result 0.4.1",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.1.2"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
resolver = "2"
[workspace.package]
-30
View File
@@ -1,35 +1,5 @@
# Third-party notices
## CullLeaves
The optional leaf and mangrove-root face-culling behavior is adapted from
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
Its MIT license is reproduced below.
```text
MIT License
Copyright (c) 2020 MidnightDust
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
@@ -131,8 +131,8 @@ average) and reached the ThinkPad over the tailnet.
It ran with Paper's defaults except `connection-throttle: -1` in `bukkit.yml`. Every bot connects from one
address, and the default throttle (4 s) refused all but the first. Paperclip patched the pinned server jar
from its cache. Same Java and heap.
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`packs/fabric.lock.json`) with Lithium
`mc26.3-0.26.2-fabric` (`packs/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`tools/benchmark-deps/fabric.lock.json`) with Lithium
`mc26.3-0.26.2-fabric` (`tools/benchmark-deps/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
Same Java and heap.
The Paper and Fabric jars are dev-only downloads into `.local/server-bench/jars` and are never committed or
+1 -8
View File
@@ -1,8 +1,7 @@
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
//! come from the program that rendered (the game knows its adapter, the launcher its window).
use std::process::Command;
use horizon_telemetry::system::command_output as output;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -173,12 +172,6 @@ pub fn cpu_seconds(pid: u32) -> Option<f64> {
Some(ticks / 100.0)
}
fn output(program: &str, args: &[&str]) -> Option<String> {
let out = Command::new(program).args(args).output().ok()?;
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
(out.status.success() && !text.is_empty()).then_some(text)
}
#[cfg(target_os = "macos")]
fn os_version() -> Option<String> {
output("sw_vers", &["-productVersion"])
+7 -1
View File
@@ -245,13 +245,19 @@ pub struct Report {
impl Report {
/// An empty report for this machine, created now.
pub fn new(kind: Kind, app: App) -> Report {
Self::with_hardware(kind, app, Hardware::detect())
}
/// Starts a report using supplied hardware, without running system probes.
/// Interactive apps can collect the remaining machine details after their first frame.
pub fn with_hardware(kind: Kind, app: App, hardware: Hardware) -> Report {
Report {
format: FORMAT.to_owned(),
version: VERSION,
kind,
created: utc_now(),
app,
hardware: Hardware::detect(),
hardware,
conditions: Conditions::default(),
metrics: BTreeMap::new(),
frames: Vec::new(),
+2
View File
@@ -27,6 +27,8 @@ bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
@@ -5,7 +5,7 @@
//!
//! usage: pack_screen_fixture RESOURCES CASES OUTPUT
use horizon_client::{
hud::HudAtlas,
hud::{HudAtlas, PACK_ICON_SIZE},
lang,
pack_screen::{Compat, PackInfo, PackScreen, Source},
packs::{self, Position},
@@ -13,7 +13,7 @@ use horizon_client::{
text::Text,
};
use serde_json::{Value, json};
use std::{fmt::Write as _, fs, path::PathBuf};
use std::{fmt::Write as _, fs, path::PathBuf, sync::Arc};
/// Styled runs: [text, rgb or null, bold, italic, underlined, strikethrough, obfuscated].
pub fn runs(t: &Text) -> Value {
@@ -105,7 +105,9 @@ pub fn main() -> horizon_client::Result<()> {
Some("unknown") => Compat::Unknown,
_ => Compat::Compatible,
},
icon: p["icon"].as_bool().unwrap_or(false),
icon: p["icon"].as_bool().unwrap_or(false).then(|| {
Arc::from(vec![0; (PACK_ICON_SIZE * PACK_ICON_SIZE * 4) as usize])
}),
required: p["required"].as_bool().unwrap_or(false),
position: if p["position"] == "top" {
Position::Top
@@ -136,7 +136,6 @@ fn scene(
blend_radius,
smooth_lighting,
cutout_leaves,
cull_leaves: false,
};
let section_y = int(&row["section_y"])?;
let started = Instant::now();
+24
View File
@@ -133,6 +133,30 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
border.collision_shape().map(Some).map_err(io::Error::other)
}
}
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
/// `SceneCollisions::resolve` that also stops at the entity boxes.
pub fn resolve(
&mut self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = self.blocks.context(bounds);
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context,
bounds,
movement,
max_step,
on_ground,
candidate_budget: 1_000_000,
},
)?)
}
}
impl SceneCollisions<'_, '_> {
/// Position contexts permit geometric movement probes without inventing a
/// player, inventory, effects or fluid-standing behavior; a lava walker
@@ -0,0 +1,75 @@
//! `ConfirmScreen`: a title, a message wrapped to `width - 50` (15 lines at
//! most) and two 150-wide buttons 4 apart and 16 below it, the whole column
//! centred; Escape answers no.
use crate::{
font::Font,
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
pub struct Confirm {
/// Each line and where it is drawn.
pub lines: Vec<(Text, f32, f32)>,
/// The yes and no buttons' corners.
pub buttons: [(f32, f32); 2],
}
impl Confirm {
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.map(|(line, _)| line)
.take(15)
.collect();
let tw = font.width_text(title);
let mw = message
.iter()
.map(|l| font.width_text(l))
.fold(0.0, f32::max);
let mh = 9.0 * message.len() as f32;
let col = tw.max(mw).max(304.0);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut lines = vec![(title.clone(), cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
Self {
lines,
buttons: [(bx, by), (bx + 154.0, by)],
}
}
/// The answer a click at `cursor` gives: true for yes, false for no.
pub fn answer_at(&self, cursor: (f32, f32)) -> Option<bool> {
let [yes, no] = self.buttons;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if on(yes) {
Some(true)
} else if on(no) {
Some(false)
} else {
None
}
}
/// The lines and the buttons (yes first), over whatever is behind.
pub fn draw(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
) {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
}
}
}
+33 -15
View File
@@ -13,6 +13,7 @@ mod block_entity_anims;
mod block_entity_ticks;
mod block_items;
mod blockctl;
mod boat_passengers;
mod boats;
mod books;
mod bossbars;
@@ -137,6 +138,7 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_packs;
mod server_travel;
mod shield;
mod sign_edit;
@@ -1126,6 +1128,8 @@ struct Game<'a> {
pending_entry: Option<PendingWorldEntry>,
world_loader: Option<world_loading::WorldLoader>,
pending_pack_reload: bool,
/// A server's resource packs (game/server_packs.rs).
server_packs: server_packs::ServerPackState,
world_renderer_ready: bool,
collision: &'a BlockCollisionRegistry<'a>,
motion: &'a MotionResources,
@@ -4410,7 +4414,7 @@ impl Game<'_> {
self.resolve_arrivals();
});
}
horizon_client::spans::time("tick_lan", || self.tick_lan());
horizon_client::spans::time("tick_lan", || self.tick_lan(false));
self.tick_friends();
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
@@ -4771,7 +4775,14 @@ impl Game<'_> {
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
let mut batch = self.hud_layers(size);
// The pack prompt replaces the connecting screen (setScreen), but
// in game it opens over the world.
let mut batch = if self.server_packs.prompt.is_some() && self.player.is_none() {
HudBatch::new(size)
} else {
self.hud_layers(size)
};
self.draw_pack_prompt(&mut batch);
// Gui.extractRenderState: the debug overlay comes after the screen
// and the toasts, so it is over everything, menus included.
self.draw_debug_overlay(&mut batch, size);
@@ -9080,21 +9091,11 @@ impl Game<'_> {
// Options.biomeBlendRadius, ambientOcclusion and cutoutLeaves:
// LevelExtractor.allChanged when they change.
let (s, st) = (&self.settings, &self.streaming);
let remesh = (
s.biome_blend,
s.smooth_lighting,
s.cutout_leaves,
s.horizon.cull_leaves,
) != (
st.biome_blend,
st.smooth_lighting,
st.cutout_leaves,
st.cull_leaves,
);
let remesh = (s.biome_blend, s.smooth_lighting, s.cutout_leaves)
!= (st.biome_blend, st.smooth_lighting, st.cutout_leaves);
self.streaming.biome_blend = self.settings.biome_blend;
self.streaming.smooth_lighting = self.settings.smooth_lighting;
self.streaming.cutout_leaves = self.settings.cutout_leaves;
self.streaming.cull_leaves = self.settings.horizon.cull_leaves;
if remesh {
self.all_sections_changed();
}
@@ -10995,6 +10996,13 @@ impl ApplicationHandler for Game<'_> {
self.advancements_scroll(scaled(raw_x) as f32, d);
}
}
WindowEvent::MouseInput { state, button, .. }
if self.server_packs.prompt.is_some() =>
{
if state == ElementState::Pressed && button == MouseButton::Left {
self.pack_prompt_click()?;
}
}
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
if self.options_waiting_for_key() && state == ElementState::Pressed {
@@ -11194,6 +11202,14 @@ impl ApplicationHandler for Game<'_> {
_ => {}
}
}
// ConfirmScreen.keyPressed: Escape answers no.
WindowEvent::KeyboardInput { event, .. } if self.server_packs.prompt.is_some() => {
if event.state == ElementState::Pressed
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
{
self.answer_pack_prompt(false)?;
}
}
WindowEvent::KeyboardInput { event, .. }
if self.screen == Screen::Chat && self.title.is_none() =>
{
@@ -11572,6 +11588,7 @@ impl ApplicationHandler for Game<'_> {
event_loop.exit();
return;
}
self.tick_server_packs();
self.poll_control(event_loop);
if self.pending_save.is_some() {
return;
@@ -11666,6 +11683,7 @@ impl<'a> Game<'a> {
pending_entry: None,
world_loader: None,
pending_pack_reload: false,
server_packs: Default::default(),
world_renderer_ready: false,
collision: res.collision(),
motion: res.motion(),
@@ -13254,7 +13272,7 @@ pub fn run() -> Result<()> {
return run_loading(options, early_settings, vanilla_root, None, started);
}
let mut pack_error = None;
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs)
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs, &[])
.unwrap_or_else(|e| {
log::warn!("Horizon: resource packs not applied: {e}");
pack_error = Some(e.to_string());
+15 -1
View File
@@ -9,6 +9,10 @@ use horizon_client::{
block_entity_anim::Ticker,
chunk::{MIN_Y, SECTIONS},
items::ItemStack,
mobs::{
Species,
render::{PROFESSIONS, VILLAGER_TYPES},
},
widgets,
};
use horizon_core::geometry::Vec3;
@@ -592,10 +596,20 @@ impl Game<'_> {
}
};
for m in self.mobs.iter().filter(|m| m.alive()) {
let mut v = json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time });
// VillagerData as this game knows it: on a client, what the
// server's synched data says.
if matches!(m.species(), Species::Villager | Species::ZombieVillager) {
v["villager"] = json!({
"type": VILLAGER_TYPES[m.extra.variant as usize % VILLAGER_TYPES.len()],
"profession": PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()],
"level": m.extra.variant3,
});
}
push(
[m.position.x, m.position.y, m.position.z],
m.kind.id.as_ref(),
json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time }),
v,
);
}
for item in &self.dropped {
@@ -0,0 +1,453 @@
//! Boats and the entities around them: what a boat runs into
//! (`AbstractBoat.canVehicleCollide`), the pushes between a boat and what
//! touches it (`AbstractBoat.push`, `LivingEntity.pushEntities`), the mobs
//! it picks up (the end of `AbstractBoat.tick`), where they sit
//! (`positionRider`, `clampRotation`) and how they get out.
//! Saving them under the boat's `Passengers` lives in `persist.rs`.
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
use horizon_client::{
camera::wrap_degrees,
mobs::{Mob, Species, Who, drops::is_animal, equine, riders},
scoreboard::pushable_by,
};
use horizon_core::geometry::{Aabb, Vec3};
/// `Entity.boardingCooldown` once a mob leaves its boat.
const BOARDING_COOLDOWN: i32 = 60;
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
/// feet are.
const PLAYER_RIDING_OFFSET: f64 = 0.6;
/// `Entity.isVehicle`: a player or a mob rides it.
fn carries(m: &Mob) -> bool {
m.rider.is_some() || m.extra.rider.is_some()
}
/// `isPushable`, as `pushableBy` and `canVehicleCollide` ask it:
/// `Mob::pushable` plus the shulkers it leaves out (they take no push, but
/// count), less a ridden horse (`AbstractHorse.isPushable`).
fn pushable(m: &Mob) -> bool {
!m.removed
&& (m.pushable() || m.species() == Species::Shulker && m.alive())
&& !(equine::is_equine(m.species()) && carries(m))
}
/// Whether `Entity.push` moves the mob: it is pushable and carries no one.
fn moved_by_push(m: &Mob) -> bool {
!m.removed && m.pushable() && !carries(m)
}
/// `Entity.push(Entity)`: the push `other` gets from `this` (which takes the
/// opposite one), or None when they stand on nearly the same spot.
pub(super) fn push_impulse(this: Vec3, other: Vec3) -> Option<Vec3> {
let (mut xa, mut za) = (other.x - this.x, other.z - this.z);
let mut dd = xa.abs().max(za.abs());
if dd < f64::from(0.01_f32) {
return None;
}
dd = dd.sqrt();
xa /= dd;
za /= dd;
let pow = (1.0 / dd).min(1.0);
let k = f64::from(0.05_f32);
Some(Vec3::new(xa * pow * k, 0.0, za * pow * k))
}
/// `AbstractBoat.push`: another boat is pushed while it is lower than this
/// boat's top, anything else while its feet are no higher than the boat's.
pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool {
if other_is_boat {
other.min_position().y < boat.max_position().y
} else {
other.min_position().y <= boat.min_position().y
}
}
impl Game<'_> {
/// The players in boat `id`: the window's player (or the server player
/// acting as it) and the server's other players.
pub(super) fn boat_players(&self, id: u32) -> usize {
let guests = self.lan.as_ref().map_or(0, |h| {
h.hub
.guests
.iter()
.filter(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(id))
.count()
});
usize::from(self.riding == Some(id)) + guests
}
/// The mobs in boat `id`, in their seat order after the players
/// (`Entity.addPassenger` puts a player first).
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
(0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
m.extra.mount == Some(id) && !m.removed && m.alive()
})
.collect()
}
/// `getPassengers().size()` of boat `id`.
pub(super) fn boat_passengers(&self, id: u32) -> usize {
self.boat_players(id) + self.boat_mobs(id).len()
}
/// `AbstractBoat.canAddPassenger`: a seat is free and the boat is not
/// under water.
pub(super) fn boat_has_room(&self, i: usize) -> bool {
let b = &self.boats[i];
self.boat_passengers(b.id) < b.max_passengers() && !b.under_water()
}
/// Where the player in boat `i` sits: in the middle alone, in front of a
/// mob.
pub(super) fn player_boat_seat(&self, i: usize) -> Vec3 {
let b = &self.boats[i];
let count = self.boat_players(b.id).max(1) + self.boat_mobs(b.id).len();
b.seat(0, count, false, PLAYER_RIDING_OFFSET, false)
}
/// `getRootVehicle` of mob `j`: the mob or boat at the bottom of what it
/// rides, None when it rides nothing.
fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
let mut root = None;
for _ in 0..8 {
let Some(v) = self.mobs[j].extra.mount else {
break;
};
root = Some(v);
match self.mobs.iter().position(|m| m.id == v) {
Some(k) => j = k,
None => break,
}
}
root
}
/// The players outside boat `id` that push and block it (living, not
/// spectators), with the window player's team.
fn players_outside(&self, id: u32) -> Vec<(Aabb, bool)> {
let mut out = Vec::new();
if let Some(p) = self.player.as_ref()
&& self.riding != Some(id)
&& !self.spectator()
&& !p.health.dead_or_dying()
{
out.push((p.bounds(), true));
}
if let Some(host) = self.lan.as_ref() {
for g in host.hub.guests.iter().filter(|g| {
Some(g.conn) != self.lan_acting
&& host.hub.here(g)
&& g.loaded
&& !g.dead
&& !g.spectator()
&& g.vehicle != Some(id)
}) {
out.push((guest_bounds(g), false));
}
}
out
}
/// `getEntityCollisions` of boat `id` within `area`, with
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
/// shulkers and pushable mobs and players, but not its own passengers.
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
let mut out: Vec<Aabb> = (0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
pushable(m)
&& m.bounds().intersects(area)
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
})
.map(|j| self.mobs[j].bounds())
.collect();
out.extend(
self.boats
.iter()
.filter(|b| b.id != id)
.map(Boat::bounds)
.chain(self.carts.iter().map(|c| c.bounds()))
.chain(self.players_outside(id).into_iter().map(|(bb, _)| bb))
.filter(|bb| bb.intersects(area)),
);
out
}
/// What boat `b` may run into this tick.
pub(super) fn boat_move_obstacles(&self, b: &Boat) -> Vec<Aabb> {
let reach = b.velocity.length() + 1.0;
self.boat_obstacles(b.id, b.bounds().inflate(Vec3::new(reach, reach, reach)))
}
/// `noEntityCollision(boat, box)` for a new boat (`BoatItem.use`).
pub(super) fn boat_entity_collision(&self, bb: Aabb) -> bool {
// EntityGetter.getEntityCollisions: the box grown by 1e-7.
let area = bb.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
!self.boat_obstacles(u32::MAX, area).is_empty()
}
/// The end of `AbstractBoat.tick` on the server for boat `i`: each
/// entity it touches gets in (a mob, while no player steers) or is
/// pushed apart from it, and `LivingEntity.pushEntities` of each mob and
/// player touching it pushes the two apart once more. Only the window's
/// own player is moved here: a server's players are pushed by their
/// clients, which tick the boats near them (`remote_pushes`).
pub(super) fn boat_contacts(&mut self, i: usize) {
let (id, bb, max) = {
let b = &self.boats[i];
(b.id, b.bounds(), b.max_passengers())
};
let area = bb.inflate(Vec3::new(
f64::from(0.2_f32),
f64::from(-0.01_f32),
f64::from(0.2_f32),
));
let mut count = self.boat_passengers(id);
// getControllingPassenger is a player: one rides it (players sit first).
let pick_up = self.boat_players(id) == 0;
// canAddPassenger, which startRiding asks after the checks below.
let room = !self.boats[i].under_water();
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
let teams = self.mob_teams();
let mobs: Vec<(usize, bool, bool)> = (0..self.mobs.len())
.filter_map(|j| {
let m = &self.mobs[j];
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
return None;
}
let mb = m.bounds();
let team = teams.of(Who::Mob(m.id));
let touched = mb.intersects(area) && pushable_by(None, team, true);
// Bats push nothing (Bat.pushEntities).
let pushes = m.species() != Species::Bat
&& mb.intersects(bb)
&& pushable_by(team, None, true);
(touched || pushes).then_some((j, touched, pushes))
})
.collect();
let player_team = teams.of(Who::Player);
let players: Vec<(Aabb, bool, bool, bool)> = self
.players_outside(id)
.into_iter()
.map(|(pb, own)| {
let team = if own { player_team } else { None };
(
pb,
own,
pb.intersects(area) && pushable_by(None, team, true),
pb.intersects(bb) && pushable_by(team, None, true),
)
})
.collect();
drop(teams);
for (j, touched, pushes) in mobs {
if touched {
let m = &self.mobs[j];
let boards = pick_up
&& count < max
&& m.extra.mount.is_none()
&& m.width() < super::boats::WIDTH as f32
&& !self
.res
.mob_data()
.entity_has("minecraft:cannot_be_pushed_onto_boats", &m.kind.id);
if boards {
// startRiding fails quietly (no push) while the mob's
// boarding cooldown runs (Entity.canRide) or the boat
// is under water.
if room && m.extra.boarding_cooldown <= 0 {
let m = &mut self.mobs[j];
m.extra.mount = Some(id);
m.extra.passenger = true;
m.ai.nav.stop();
let mob = m.id;
count += 1;
// Mob.startRiding drops its lead.
self.break_leash(mob, true, false);
}
continue;
}
self.boat_push_mob(i, j, count);
}
if pushes {
self.boat_push_mob(i, j, count);
}
}
for (pb, own, touched, pushes) in players {
let Some(v) = push_impulse(self.boats[i].position, pb.center())
.filter(|_| boat_pushes(bb, pb, false))
else {
continue;
};
for _ in 0..usize::from(touched) + usize::from(pushes) {
if count == 0 {
let b = &mut self.boats[i];
b.velocity = b.velocity.subtract(v);
}
if own && let Some(p) = self.player.as_mut() {
p.velocity = p.velocity.add_vector(v);
}
}
}
for k in 0..self.boats.len() {
let ob = self.boats[k].bounds();
if k == i || !ob.intersects(area) || !boat_pushes(bb, ob, true) {
continue;
}
let Some(v) = push_impulse(self.boats[i].position, self.boats[k].position) else {
continue;
};
if count == 0 {
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
}
if self.boat_passengers(self.boats[k].id) == 0 {
self.boats[k].velocity = self.boats[k].velocity.add_vector(v);
}
}
let guest_carts = self.guest_vehicles();
for c in 0..self.carts.len() {
let cb = self.carts[c].bounds();
if !cb.intersects(area) || !boat_pushes(bb, cb, false) {
continue;
}
let Some(v) = push_impulse(self.boats[i].position, self.carts[c].position) else {
continue;
};
if count == 0 {
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
}
let cid = self.carts[c].id;
let ridden = self.riding == Some(cid)
|| guest_carts.contains(&cid)
|| self.mobs.iter().any(|m| m.extra.mount == Some(cid));
if !ridden {
self.carts[c].velocity = self.carts[c].velocity.add_vector(v);
}
}
}
/// `AbstractBoat.push(mob)` from boat `i`, which has `count` passengers.
fn boat_push_mob(&mut self, i: usize, j: usize, count: usize) {
let (b, m) = (&self.boats[i], &self.mobs[j]);
if !boat_pushes(b.bounds(), m.bounds(), false) {
return;
}
let Some(v) = push_impulse(b.position, m.position) else {
return;
};
if count == 0 {
let b = &mut self.boats[i];
b.velocity = b.velocity.subtract(v);
}
let m = &mut self.mobs[j];
if moved_by_push(m) {
m.velocity = m.velocity.add_vector(v);
m.needs_sync = true;
}
}
/// `positionRider` and `clampRotation` for the mobs in boats: each sits
/// in its seat facing the way the boat does, an animal in a full boat
/// sideways, its head kept within its reach of its body.
pub(super) fn seat_boat_mobs(&mut self) {
for i in 0..self.boats.len() {
let id = self.boats[i].id;
let mobs = self.boat_mobs(id);
if mobs.is_empty() {
continue;
}
let players = self.boat_players(id);
let count = players + mobs.len();
let b = &self.boats[i];
let full = count == b.max_passengers();
let delta = b.delta_rotation;
let seats: Vec<_> = mobs
.iter()
.enumerate()
.map(|(n, &j)| {
let m = &self.mobs[j];
let animal = is_animal(m.species());
// Another player, on a server's client.
let offset = if &*m.kind.id == "minecraft:player" {
PLAYER_RIDING_OFFSET
} else {
riders::vehicle_attachment(m)
};
let index = players + n;
// calculatePassengerBodyYRot.
let turn = match (animal && full, m.id.is_multiple_of(2)) {
(false, _) => 0.0,
(true, true) => 90.0,
(true, false) => 270.0,
};
let turns = !self
.res
.mob_data()
.entity_has("minecraft:can_turn_in_boats", &m.kind.id);
(
j,
b.seat(index, count, animal, offset, false),
b.seat(index, count, animal, offset, true),
turns.then_some((b.yaw + turn, b.previous_yaw + turn)),
)
})
.collect();
for (j, at, previous, facing) in seats {
let m = &mut self.mobs[j];
m.position = at;
m.previous = previous;
m.velocity = Vec3::ZERO;
m.fall_distance = 0.0;
m.on_ground = false;
m.extra.passenger = true;
m.ai.nav.stop();
let Some((body, previous_body)) = facing else {
continue;
};
m.head_yaw += delta;
m.body_yaw = body;
m.previous_body_yaw = previous_body;
m.yaw = body;
m.previous_yaw = previous_body;
// Mob.clampHeadRotationToBody.
let limit = m.kind.max_head_y_rot;
let d = wrap_degrees(body - m.head_yaw);
m.head_yaw += d - d.clamp(-limit, limit);
}
}
}
/// `ejectPassengers` for the mobs in boat `i`: each gets out where
/// `getDismountLocationForPassenger` puts it (beside the boat when it
/// broke: `LivingEntity.dismountVehicle`) and can't board for a while.
pub(super) fn eject_boat_mobs(&mut self, i: usize, broken: bool) {
let b = &self.boats[i];
let (id, y) = (b.id, b.position.y);
for j in self.boat_mobs(id) {
let m = &self.mobs[j];
let at = if broken {
Vec3::new(m.position.x, m.position.y.max(y), m.position.z)
} else {
self.boat_dismount_target(i, f64::from(m.width()), m.yaw)
};
let m = &mut self.mobs[j];
m.extra.mount = None;
m.extra.passenger = false;
m.extra.boarding_cooldown = BOARDING_COOLDOWN;
m.position = at;
m.previous = at;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_is_capped_and_scaled() {
// One block apart on x: the full 0.05 along x.
let v = push_impulse(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)).unwrap();
assert!((v.x - f64::from(0.05_f32)).abs() < 1e-12 && v.z == 0.0);
// Almost on the same spot: no push.
assert!(push_impulse(Vec3::ZERO, Vec3::new(0.005, 0.0, 0.0)).is_none());
}
#[test]
fn boats_push_by_height() {
let boat = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(1.0, 64.5625, 1.0));
let at = |y: f64| Aabb::new(Vec3::new(0.0, y, 0.0), Vec3::new(1.0, y + 1.0, 1.0));
assert!(boat_pushes(boat, at(64.0), false));
assert!(!boat_pushes(boat, at(64.1), false));
assert!(boat_pushes(boat, at(64.5), true));
assert!(!boat_pushes(boat, at(64.5625), true));
}
}
+89 -16
View File
@@ -5,10 +5,10 @@
use super::{Game, Keys, entity_culling::at, view_vector};
use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
collisions::{EntityCollisions, SceneCollisions},
items::ItemStack,
mesher::Vertex,
mobs::{Part, anim::Poses},
mobs::{Part, anim::Poses, riders},
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -54,10 +54,8 @@ pub(super) struct Boat {
}
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
const WIDTH: f64 = 1.375;
pub(super) const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
pub(super) const SEAT: f64 = 0.1875 - 0.6;
impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
@@ -98,6 +96,54 @@ impl Boat {
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
)
}
/// `isUnderWater`; it stands in for `canAddPassenger`'s
/// `isEyeInFluid(WATER)` too.
pub fn under_water(&self) -> bool {
matches!(self.status, Status::UnderWater | Status::UnderFlowingWater)
}
/// `getMaxPassengers`: two, one beside a chest.
pub fn max_passengers(&self) -> usize {
if self.chest { 1 } else { 2 }
}
/// `rideHeight`: a third of a boat's height, 8/9 of a raft's.
fn ride_height(&self) -> f64 {
let height = HEIGHT as f32;
f64::from(if &*self.wood == "bamboo" {
height * 0.888_888_9
} else {
height / 3.0
})
}
/// Where the passenger at `index` of `count` stands, at the boat's current
/// (or, with `previous`, last) place: `getPassengerAttachmentPoint`
/// turned with the boat, less the passenger's `rider_offset`.
pub fn seat(
&self,
index: usize,
count: usize,
animal: bool,
rider_offset: f64,
previous: bool,
) -> Vec3 {
let mut z = if self.chest { 0.15f32 } else { 0.0 };
if count > 1 {
z = if index == 0 { 0.2 } else { -0.6 };
if animal {
z += 0.2;
}
}
let (at, yaw) = if previous {
(self.previous, self.previous_yaw)
} else {
(self.position, self.yaw)
};
riders::seat(
[0.0, self.ride_height(), f64::from(z)],
rider_offset,
at,
yaw,
)
}
/// `getRowingTime`.
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
if self.paddling[side] {
@@ -320,6 +366,7 @@ impl Game<'_> {
let mut vspeed = -0.04;
let mut buoyancy = 0.0;
let inv_friction: f32;
let obstacles = self.boat_move_obstacles(&b);
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
@@ -401,6 +448,11 @@ impl Game<'_> {
b.paddling = [right && !left || up, left && !right || up];
}
// Entity.move: collisions zero the blocked components.
let mut scene = EntityCollisions {
blocks: scene,
entities: &obstacles,
border: None,
};
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
b.position = Vec3::new(
b.position.x + moved.x,
@@ -477,6 +529,19 @@ impl Game<'_> {
i += 1;
}
}
// The rest of AbstractBoat.tick on the server, for every boat: the
// passengers of one under water too long get out, and it picks up or
// pushes what it touches.
for i in 0..self.boats.len() {
let p = self.boats[i].position;
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
continue;
}
if self.boats[i].out_of_control >= 60.0 {
self.eject_boat_mobs(i, false);
}
self.boat_contacts(i);
}
}
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
@@ -540,13 +605,14 @@ impl Game<'_> {
self.yaw += b.delta_rotation;
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
self.yaw += delta.clamp(-105.0, 105.0) - delta;
self.boats[i] = b;
let seat = self.player_boat_seat(i);
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = Vec3::new(b.position.x, b.position.y + SEAT, b.position.z);
p.position = seat;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
self.boats[i] = b;
if self.boat_lava_tick(i) {
self.riding = None;
}
@@ -556,9 +622,18 @@ impl Game<'_> {
/// floor there, else on top of the boat.
pub(super) fn dismount(&mut self, i: usize) {
self.riding = None;
let target = self.boat_dismount_target(i, 0.6, self.yaw);
if let Some(p) = self.player.as_mut() {
p.position = target;
p.velocity = Vec3::ZERO;
}
}
/// `getDismountLocationForPassenger` for a passenger `width` wide
/// facing `yaw`.
pub(super) fn boat_dismount_target(&self, i: usize, width: f64, yaw: f32) -> Vec3 {
let b = &self.boats[i];
let r = self.yaw.to_radians();
let distance = (WIDTH * std::f64::consts::SQRT_2 + 0.6 + 1.0e-5) / 2.0;
let r = yaw.to_radians();
let distance = (WIDTH * std::f64::consts::SQRT_2 + width + 1.0e-5) / 2.0;
let (s, c) = (-(r.sin()) as f64, r.cos() as f64);
let m = s.abs().max(c.abs());
let (x, z) = (
@@ -569,17 +644,13 @@ impl Game<'_> {
let (bx, by, bz) = (x.floor() as i32, top.floor() as i32, z.floor() as i32);
let below = self.res.facts().get(self.world.block(bx, by - 1, bz));
let here = self.res.facts().get(self.world.block(bx, by, bz));
let target = if below.fluid != Fluid::Water
if below.fluid != Fluid::Water
&& !here.full_collision
&& (below.full_collision || below.legacy_solid)
{
Vec3::new(x, f64::from(by), z)
} else {
Vec3::new(b.position.x, top, b.position.z)
};
if let Some(p) = self.player.as_mut() {
p.position = target;
p.velocity = Vec3::ZERO;
}
}
/// `BoatItem.use`: aim at a block or the water surface.
@@ -656,7 +727,7 @@ impl Game<'_> {
binding: self.collision,
subject: None,
};
if !scene.unobstructed(inner).unwrap_or(false) {
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
return false;
}
self.next_entity += 1;
@@ -711,7 +782,7 @@ impl Game<'_> {
return false;
}
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
let b = &self.boats[i];
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
self.world.block_entities.insert(
@@ -728,6 +799,7 @@ impl Game<'_> {
|| self.boats[i].out_of_control >= 60.0
|| self.riding.is_some()
|| self.ridden_by_guest(self.boats[i].id)
|| !self.boat_has_room(i)
{
return false;
}
@@ -758,6 +830,7 @@ impl Game<'_> {
b.damage += damage * 10.0;
let destroyed = creative || b.damage > 40.0;
if destroyed {
self.eject_boat_mobs(i, true);
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
if !creative && self.ui.rules.bool("entity_drops") {
+4 -1
View File
@@ -1783,8 +1783,11 @@ impl Game<'_> {
self.level_set(pos, state)
.map_err(|e| CommandError::new(Text::literal(e.to_string())))?;
}
// LevelChunk.setBlockState creates it when the block, not the state, is still
// there after onPlace: a powered hopper turns itself `enabled=false`.
let landed = self.world.block(pos[0], pos[1], pos[2]);
if old != new
&& self.world.block(pos[0], pos[1], pos[2]) == state
&& self.res.facts().get(landed).name == new
&& let Some(entity) = horizon_client::containers::BlockEntity::for_block(&new)
{
self.world.block_entities.insert(key, entity);
+9 -3
View File
@@ -82,8 +82,10 @@ impl Game<'_> {
}
return true;
}
// Screens with a text box take every key press first
// (EditBox.keyPressed); releases still reach KeyboardHandler.
// Vanilla types into a focused EditBox through charTyped, which no
// debug key can swallow. Here the character comes with the key press,
// so a screen with a focused text box takes every press first;
// releases still reach KeyboardHandler.
let typing = matches!(
self.screen,
Screen::Chat | Screen::Options | Screen::WorldOptions | Screen::CommandBlock
@@ -91,7 +93,11 @@ impl Game<'_> {
|| self.book.is_some()
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|| self.recipe_search_focused()
|| self.station_typing();
|| self.station_typing()
|| self
.title
.as_mut()
.is_some_and(|t| t.focused_field().is_some());
// Container screens use Escape, the inventory key and the hotbar and
// drop keys first; any other screen closes on Escape.
let screen_key = match self.screen {
@@ -608,6 +608,7 @@ impl Game<'_> {
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
log_ips: props.log_ips,
}),
resource_pack: props.resource_pack.clone(),
};
let config = HubConfig {
default_game_mode: game_mode,
@@ -3,6 +3,7 @@
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
//! Leaving with changes asks first (ConfirmScreen).
use horizon_client::{
confirm_screen::Confirm,
font::Font,
gamerules::{self, GameRules, RULES, Rule, Value},
hud::{HudAtlas, HudBatch},
@@ -66,9 +67,6 @@ fn parse(r: &Rule, s: &str) -> Option<i32> {
.flatten()
.filter(|v| (r.min..=r.max).contains(v))
}
fn widest(font: &Font, lines: &[Text]) -> f32 {
lines.iter().map(|l| font.width_text(l)).fold(0.0, f32::max)
}
fn lines(font: &Font, t: &Text, width: f32) -> Vec<Text> {
font.split_lines(t, width)
.into_iter()
@@ -95,38 +93,11 @@ fn yellow() -> u32 {
named_color("yellow").unwrap_or(0xFFFF55)
}
/// ConfirmScreen: the title, the message (at most `width - 50` wide), and
/// Yes / No 16 below, 4 apart, all centred.
struct Confirm {
lines: Vec<(Text, f32, f32)>,
buttons: [(f32, f32); 2],
}
impl Confirm {
fn new(font: &Font, (w, h): (f32, f32)) -> Self {
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
let message: Vec<Text> = lines(font, &message, w - 50.0)
.into_iter()
.take(15)
.collect();
let (tw, mw) = (font.width_text(&title), widest(font, &message));
let mh = 9.0 * message.len() as f32;
let col = tw.max(mw).max(304.0);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut out = vec![(title, cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
out.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
Self {
lines: out,
buttons: [(bx, by), (bx + 154.0, by)],
}
}
/// ConfirmScreen "Game Rule Changes".
fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
Confirm::new(font, size, &title, &message)
}
impl RulesScreen {
@@ -301,13 +272,10 @@ impl RulesScreen {
let (w, h) = size;
let on = |x: f32, y: f32, bw: f32, bh: f32| ui::hovered(cursor, x, y, bw, bh);
if self.confirm {
let c = Confirm::new(&atlas.font, size);
let [(yx, yy), (nx, ny)] = c.buttons;
if on(yx, yy, 150.0, 20.0) {
return Some(Exit::Discard);
}
if on(nx, ny, 150.0, 20.0) {
self.confirm = false;
match confirm(&atlas.font, size).answer_at(cursor) {
Some(true) => return Some(Exit::Discard),
Some(false) => self.confirm = false,
None => {}
}
return None;
}
@@ -367,7 +335,7 @@ impl RulesScreen {
let (w, h) = size;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if self.confirm {
return Confirm::new(&atlas.font, size).buttons.into_iter().any(on);
return confirm(&atlas.font, size).answer_at(cursor).is_some();
}
let [done, cancel] = footer(w, h);
if (on(done) && !self.invalid()) || on(cancel) {
@@ -384,15 +352,11 @@ impl RulesScreen {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
if self.confirm {
let c = Confirm::new(&atlas.font, (w, h));
for (line, x, y) in &c.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), key) in c.buttons.into_iter().zip(["gui.yes", "gui.no"]) {
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
let label = Text::translate(key, &[]);
ui::button(batch, atlas, bx, by, 150.0, 20.0, &label, true, hover);
}
let labels = [
Text::translate("gui.yes", &[]),
Text::translate("gui.no", &[]),
];
confirm(&atlas.font, (w, h)).draw(batch, atlas, cursor, [&labels[0], &labels[1]]);
return;
}
let rows = self.rows();
+8 -3
View File
@@ -834,8 +834,11 @@ impl Game<'_> {
self.lan_object_states(host_entity, hub, out);
}
/// Run the hub for this tick and apply what the guests did.
pub(super) fn tick_lan(&mut self) {
/// Run the hub for this tick and apply what the guests did. While the
/// single player game is `paused` only the connections run
/// (`IntegratedServer.tickPaused`): `ServerGamePacketListenerImpl.tick`
/// skips `tickPlayer`, so no player's air, hunger or damage moves on.
pub(super) fn tick_lan(&mut self, paused: bool) {
let Some(mut host) = self.lan.take() else {
return;
};
@@ -1000,7 +1003,9 @@ impl Game<'_> {
host.states = states;
}
self.lan_flush_effects(&mut host);
self.tick_guests(&mut host);
if !paused {
self.tick_guests(&mut host);
}
self.lan_living_flags(&mut host);
self.tick_server_travel(&mut host);
self.lan_swings(&mut host);
+14 -1
View File
@@ -1277,10 +1277,24 @@ impl Game<'_> {
id: i32,
location: Option<[f64; 3]>,
) -> Option<([f64; 3], [f64; 3])> {
let eye = {
let g = &host.hub.guests[gi];
[g.position[0], g.position[1] + g.eye_height(), g.position[2]]
};
let object = || {
let (lo, hi, origin) = self.object_target(id)?;
let middle = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
let Some(l) = location else {
// A boat's mobs sit over its middle: aim low at its near
// side, so the ray meets the boat before them (the server
// attacks the entity it is sent).
let carries = u32::try_from(id).is_ok_and(|v| {
self.boats.iter().any(|b| b.id == v) && !self.boat_mobs(v).is_empty()
});
if carries {
let near = |k: usize| eye[k].clamp(lo[k] + 0.01, hi[k] - 0.01);
return Some([near(0), lo[1] + 0.05, near(2)]);
}
return Some(middle);
};
let point = [0, 1, 2].map(|k| origin[k] + l[k]);
@@ -1311,7 +1325,6 @@ impl Game<'_> {
}
};
let g = &mut host.hub.guests[gi];
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let d = [centre[0] - eye[0], centre[1] - eye[1], centre[2] - eye[2]];
let horizontal = (d[0] * d[0] + d[2] * d[2]).sqrt();
g.yaw = (-d[0].atan2(d[2])).to_degrees() as f32;
+10 -9
View File
@@ -9,7 +9,7 @@
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, boats::SEAT, lan::LanHost};
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
use horizon_client::collisions::SceneCollisions;
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
@@ -120,8 +120,8 @@ impl Game<'_> {
game.dismount(i);
return None;
}
let p = game.boats[i].position;
return Some([p.x, p.y + SEAT, p.z]);
let p = game.player_boat_seat(i);
return Some([p.x, p.y, p.z]);
}
// The minecart moves with the rider's input, and lets it out when
// it sneaks or the cart breaks. An animal takes the rider's input
@@ -161,19 +161,19 @@ impl Game<'_> {
let at = self
.boats
.iter()
.find(|b| b.id == vehicle)
.map(|b| b.position)
.position(|b| b.id == vehicle)
.map(|i| (self.boats[i].position, self.player_boat_seat(i)))
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == vehicle)
.map(|c| c.position)
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
});
if let Some(p) = at {
if let Some((p, seat)) = at {
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([p.x, p.y, p.z]);
g.previous_position = g.position;
g.position = [p.x, p.y + SEAT, p.z];
g.position = [seat.x, seat.y, seat.z];
g.on_ground = false;
return;
}
@@ -289,10 +289,11 @@ impl Game<'_> {
b.previous_yaw = b.yaw;
b.yaw = p.yaw;
b.on_ground = p.on_ground;
let seat = self.player_boat_seat(i);
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([target.x, target.y, target.z]);
g.previous_position = g.position;
g.position = [target.x, target.y + SEAT, target.z];
g.position = [seat.x, seat.y, seat.z];
}
/// `handlePaddleBoat`: the paddles of the boat the player drives.
+1 -1
View File
@@ -296,7 +296,7 @@ impl Game<'_> {
self.update_dedicated_streaming();
self.update_world();
result = if paused {
self.tick_lan();
self.tick_lan(true);
Ok(())
} else if k == 0 {
self.tick()
+1 -1
View File
@@ -95,7 +95,7 @@ fn display_offset(kind: CartKind) -> f64 {
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
}
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
const SEAT: f64 = 0.1875 - 0.6;
pub(super) const SEAT: f64 = 0.1875 - 0.6;
/// The placeholder container key under which an open cart's items live.
pub(super) const CART_KEY_Y: i32 = -100_000;
+30 -17
View File
@@ -4,7 +4,7 @@ use super::Game;
use horizon_client::{
hud::HudAtlas,
lang,
pack_screen::{Compat, PackInfo, Source as PackSource},
pack_screen::{self, Compat, PackInfo, Source as PackSource},
packs::{self, Pack, Position},
text::{Style, Text},
};
@@ -34,10 +34,16 @@ pub fn open_folder(dir: &Path) {
log::warn!("Horizon: could not open {}: {e}", dir.display());
}
}
/// The resource root to load from, given the vanilla root and the selection.
pub fn startup_root(vanilla: &Path, selected: &[String]) -> std::io::Result<PathBuf> {
/// The resource root to load from, given the vanilla root, the selection and
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
pub fn startup_root(
vanilla: &Path,
selected: &[String],
server: &[Pack],
) -> std::io::Result<PathBuf> {
let all = packs::discover(vanilla, &packs_dir());
let enabled = packs::enabled(&all, selected);
let mut enabled = packs::enabled(&all, selected);
enabled.extend_from_slice(server);
packs::resource_root(vanilla, &enabled, &cache_dir())
}
fn info(p: &Pack) -> PackInfo {
@@ -53,14 +59,15 @@ fn info(p: &Pack) -> PackInfo {
PackSource::Default
},
compat: p.compatibility,
icon: p.icon,
icon: p.icon_png().as_deref().and_then(pack_screen::load_icon),
required: false,
position: Position::Top,
fixed: false,
}
}
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`).
fn vanilla_info() -> PackInfo {
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`), with the
/// client JAR's `pack.png` that the prepared resources hold.
fn vanilla_info(vanilla: &Path) -> PackInfo {
PackInfo {
id: "vanilla".into(),
title: Text::translate("resourcePack.vanilla.name", &[]),
@@ -68,7 +75,10 @@ fn vanilla_info() -> PackInfo {
description_style: Style::default(),
source: PackSource::BuiltIn,
compat: Compat::Compatible,
icon: true,
icon: std::fs::read(vanilla.join("pack.png"))
.ok()
.as_deref()
.and_then(pack_screen::load_icon),
required: true,
position: Position::Bottom,
fixed: false,
@@ -80,7 +90,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
.iter()
.map(info)
.collect();
infos.push(vanilla_info());
infos.push(vanilla_info(vanilla));
infos
}
impl Game<'_> {
@@ -113,14 +123,7 @@ impl Game<'_> {
self.pending_pack_reload = true;
return;
}
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let result = startup_root(&vanilla, &self.settings.resource_packs)
.map_err(Into::into)
.and_then(|root| self.reload_resources(&root));
if let Err(e) = result {
if let Err(e) = self.reload_selected_packs() {
log::warn!("Horizon: resource reload failed: {e}");
if self.screen != super::Screen::Options {
self.open_options_at(
@@ -131,6 +134,16 @@ impl Game<'_> {
self.ui.options.resource_error = Some(e.to_string());
}
}
/// Reload from the selected packs and the server's.
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let server = &self.server_packs.active;
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
self.reload_resources(&root)
}
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
+26 -5
View File
@@ -38,9 +38,16 @@ impl Game<'_> {
m.extra.rider = Some(rider);
}
}
/// Riders sit on their vehicles (`Entity.positionRider`), and let go when
/// the vehicle dies or is gone.
/// Riders sit on their vehicles (`Entity.positionRider`), mobs or boats,
/// and let go when the vehicle dies or is gone.
pub(super) fn sync_riders(&mut self) {
// Entity.baseTick.
for m in &mut self.mobs {
if m.extra.boarding_cooldown > 0 {
m.extra.boarding_cooldown -= 1;
}
}
let boats: std::collections::HashSet<u32> = self.boats.iter().map(|b| b.id).collect();
let index: HashMap<u32, usize> = self
.mobs
.iter()
@@ -54,9 +61,10 @@ impl Game<'_> {
};
let valid = self.mobs[i].alive()
&& !self.mobs[i].removed
&& index
.get(&v)
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed);
&& (boats.contains(&v)
|| index
.get(&v)
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed));
if valid {
riding.insert(v, self.mobs[i].id);
} else {
@@ -68,6 +76,19 @@ impl Game<'_> {
for m in &mut self.mobs {
m.extra.rider = riding.get(&m.id).copied();
}
self.position_riders();
}
/// `Entity.positionRider` for every mob passenger: in its boat, then on
/// its mob. A server's client places them itself too, as the server sends
/// no passenger's position.
pub(super) fn position_riders(&mut self) {
self.seat_boat_mobs();
let index: HashMap<u32, usize> = self
.mobs
.iter()
.enumerate()
.map(|(i, m)| (m.id, i))
.collect();
// Riders of riders settle over a few passes.
for _ in 0..3 {
for i in 0..self.mobs.len() {
+88 -54
View File
@@ -243,6 +243,8 @@ impl Game<'_> {
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
{
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
let mut tag = tag;
let riders = self.take_mob_passengers(&mut tag);
self.next_entity += 1;
let id = self.next_entity;
let (wood, chest) =
@@ -263,6 +265,9 @@ impl Game<'_> {
b.velocity = velocity;
self.remember(id, uuid, tag);
self.boats.push(b);
for rider in riders {
self.load_mob(rider, Some(id));
}
}
}
k if super::minecarts::CartKind::from_id(k).is_some() => {
@@ -341,25 +346,10 @@ impl Game<'_> {
// Structure entities placed with finalizeEntities.
let mut tag = tag;
let mut finalize = false;
let mut riders = Vec::new();
let mut unknown = Vec::new();
if let Tag::Compound(c) = &mut tag {
finalize = c.remove(&"horizon:finalize".into()).is_some();
if let Some(Tag::List(list)) = c.remove(&"Passengers".into()) {
for p in list {
let known =
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
if known {
riders.push(p);
} else {
unknown.push(p);
}
}
}
if !unknown.is_empty() {
c.insert("Passengers".into(), Tag::List(unknown));
}
}
let riders = self.take_mob_passengers(&mut tag);
let mut m = entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), self.next_entity)?;
m.extra.mount = mount;
m.extra.passenger = mount.is_some();
@@ -401,6 +391,49 @@ impl Game<'_> {
}
Some(id)
}
/// The mobs under an entity's `Passengers`, taken out of it; the
/// passengers Horizon doesn't simulate stay.
fn take_mob_passengers(&self, tag: &mut Tag) -> Vec<Tag> {
let mut riders = Vec::new();
if let Tag::Compound(c) = tag
&& let Some(Tag::List(list)) = c.remove(&"Passengers".into())
{
let mut unknown = Vec::new();
for p in list {
let known =
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
if known {
riders.push(p);
} else {
unknown.push(p);
}
}
if !unknown.is_empty() {
c.insert("Passengers".into(), Tag::List(unknown));
}
}
riders
}
/// The mobs riding entity `id` under `tag`'s `Passengers`, after the
/// passengers it kept from loading.
fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
let riders: Vec<usize> = (0..self.mobs.len())
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
.collect();
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
if let Tag::Compound(c) = tag {
let mut list = match c.get(&"Passengers".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
list.extend(riders);
if list.is_empty() {
c.remove(&"Passengers".into());
} else {
c.insert("Passengers".into(), Tag::List(list));
}
}
}
/// An entity of a type Horizon does not simulate, kept as its compound.
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
let position = entity_nbt::position(&tag)?;
@@ -476,22 +509,7 @@ impl Game<'_> {
let uuid = self.uuid_for(id);
let mut tag = entity_nbt::mob_to_nbt(&self.mobs[i], uuid, self.persist.raw.get(&id));
self.write_leash(&mut tag, i);
let riders: Vec<usize> = (0..self.mobs.len())
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
.collect();
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
if let Tag::Compound(c) = &mut tag {
let mut list = match c.get(&"Passengers".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
list.extend(riders);
if list.is_empty() {
c.remove(&"Passengers".into());
} else {
c.insert("Passengers".into(), Tag::List(list));
}
}
self.put_mob_passengers(&mut tag, id);
tag
}
/// Forget an entity that left the world (its UUID and loaded compound).
@@ -569,10 +587,15 @@ impl Game<'_> {
let mut out: HashMap<(i32, i32), Vec<Tag>> =
keys.iter().map(|k| (*k, Vec::new())).collect();
let player_uuid = self.level.player_uuid;
// A rider is saved inside its root vehicle, in the root's chunk.
// A rider is saved inside its root vehicle, in the root's chunk; a
// boat's passengers inside the boat.
let mobs: Vec<usize> = (0..self.mobs.len())
.filter(|&i| {
self.mob_root(i) == i
&& self.mobs[i]
.extra
.mount
.is_none_or(|v| self.boats.iter().all(|b| b.id != v))
&& keys.contains(&chunk_of(self.mobs[i].position))
&& self.mobs[i].alive()
})
@@ -650,25 +673,29 @@ impl Game<'_> {
.push(with_raw(tag, raw.as_ref()));
}
}
for b in &self.boats {
if keys.contains(&chunk_of(b.position)) {
let uuid = self
.persist
.uuids
.get(&b.id)
.copied()
.unwrap_or_else(random_uuid);
let tag = entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
uuid,
self.persist.raw.get(&b.id),
b.chest.then_some(&b.items[..]),
);
out.entry(chunk_of(b.position)).or_default().push(tag);
}
let boats: Vec<usize> = (0..self.boats.len())
.filter(|&i| keys.contains(&chunk_of(self.boats[i].position)))
.collect();
for i in boats {
let b = &self.boats[i];
let (id, at) = (b.id, b.position);
let uuid = self
.persist
.uuids
.get(&id)
.copied()
.unwrap_or_else(random_uuid);
let mut tag = entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
uuid,
self.persist.raw.get(&id),
b.chest.then_some(&b.items[..]),
);
self.put_mob_passengers(&mut tag, id);
out.entry(chunk_of(at)).or_default().push(tag);
}
if let Some(d) = &self.dragon
&& keys.contains(&chunk_of(d.position))
@@ -833,9 +860,16 @@ impl Game<'_> {
}
keep
});
// Riders leave with their root vehicle.
// Riders leave with their root vehicle, a boat's with the boat.
let leaving: HashSet<u32> = (0..self.mobs.len())
.filter(|&i| gone(self.mobs[self.mob_root(i)].position))
.filter(|&i| {
let r = self.mob_root(i);
match self.mobs[r].extra.mount {
Some(v) if removed.contains(&v) => true,
Some(v) if self.boats.iter().any(|b| b.id == v) => false,
_ => gone(self.mobs[r].position),
}
})
.map(|i| self.mobs[i].id)
.collect();
self.mobs.retain(|m| {
+60 -3
View File
@@ -305,6 +305,16 @@ impl Game<'_> {
// A signed-in account always plays under its own name.
let auth = horizon_client::auth::current();
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
let user = auth.as_ref().map_or_else(
|| {
(
name.to_owned(),
horizon_protocol::crypto::offline_uuid(name),
)
},
|a| (a.name.clone(), a.uuid),
);
self.server_packs_connect(local.is_none().then_some(address), user);
let session = match local {
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
None => {
@@ -453,6 +463,7 @@ impl Game<'_> {
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
self.server_packs_disconnect();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -827,6 +838,8 @@ impl Game<'_> {
}
}
Event::Reconfigured(registries) => self.set_registries(registries),
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
@@ -3136,6 +3149,9 @@ impl Game<'_> {
| u8::from(keys.sprint) << 6;
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
// The server sends no passenger's position: they sit where their
// vehicles are now.
self.position_riders();
let Some(player) = self.player.as_mut() else {
return Ok(());
};
@@ -3246,9 +3262,10 @@ impl Game<'_> {
player.flying_speed = a.flying_speed;
}
/// The local player's share of every touching entity's
/// `LivingEntity.pushEntities`: on a client `EntitySelector.pushableBy`
/// lets entities push only the local player, so the server never sends
/// these pushes. `doPush(player)` is `player.push(entity)`.
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
/// `EntitySelector.pushableBy` lets entities push only the local player,
/// so the server never sends these pushes. `doPush(player)` is
/// `player.push(entity)`.
fn remote_pushes(&self) -> Vec3 {
let (Some(p), Some(r)) = (self.player.as_ref(), self.remote.as_ref()) else {
return Vec3::ZERO;
@@ -3307,11 +3324,51 @@ impl Game<'_> {
za *= f64::from(0.05_f32);
push = Vec3::new(push.x - xa, push.y, push.z - za);
}
// AbstractBoat.push(player) from the boat's own tick (its box grown
// by 0.2 sideways) and from the player's pushEntities (its box).
let player_team = teams.of(Who::Player);
let area = Vec3::new(f64::from(0.2_f32), f64::from(-0.01_f32), f64::from(0.2_f32));
for b in self.boats.iter().filter(|b| self.riding != Some(b.id)) {
let bb = b.bounds();
let times = usize::from(
bounds.intersects(bb.inflate(area))
&& horizon_client::scoreboard::pushable_by(None, player_team, true),
) + usize::from(
bounds.intersects(bb)
&& horizon_client::scoreboard::pushable_by(player_team, None, true),
);
if times > 0
&& super::boat_passengers::boat_pushes(bb, bounds, false)
&& let Some(v) = super::boat_passengers::push_impulse(b.position, p.position)
{
push = push.add_vector(v.scale(times as f64));
}
}
push
}
/// ClientLevel.tickEntities; a frozen level only ticks the players
/// (`TickRateManager.isEntityFrozen`).
fn tick_remote_entities(&mut self, running: bool) {
// EntityFluidInteraction.update for the entities this window only displays: the
// server never sends the fluid contact, and the renderers read it (cod, salmon and
// tropical fish lie on their side without `in_water`, #1591).
// A frozen level ticks only the players, as below.
let fluids: Vec<_> = {
let w = self.mob_world_base(&[]);
let players = self.remote.as_ref().map(|r| &r.level_players);
self.mobs
.iter()
.map(|m| {
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
.then(|| w.fluids(m.bounds(), m.eye_y()))
})
.collect()
};
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
if let Some(fluids) = fluids {
m.fluids = fluids;
}
}
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -3,12 +3,15 @@
//! `onSyncedDataUpdated`): what a client draws from it rather than from its
//! own simulation, which a client of a server doesn't run.
use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{Mob, Species, tame};
use horizon_protocol::entity_data::Value;
use horizon_protocol::registry::Builtin;
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
/// `builtin` names the ids of built-in registries.
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
let species = m.species();
let baby = super::lan::baby_index(&m.kind.id);
for (index, _, value) in values {
@@ -42,16 +45,45 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
m.extra.size = (*size).clamp(1, 127);
}
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after
// Zombie's three): what VillagerProfessionLayer draws.
(Species::Villager, 19, Value::Ids(data))
| (Species::ZombieVillager, 20, Value::Ids(data)) => villager_data(m, data, builtin),
_ => {}
}
}
}
/// VillagerData as the network sends it: the type and the profession by
/// their built-in registry ids, then the level.
fn villager_data(m: &mut Mob, data: &[i32], builtin: &Builtin) {
let &[kind, profession, level] = data else {
return;
};
let index = |registry: &str, id: i32, names: &[&str]| {
let name = builtin
.get(registry)?
.name(id)?
.strip_prefix("minecraft:")?;
names.iter().position(|n| *n == name).map(|i| i as u8)
};
if let Some(t) = index("minecraft:villager_type", kind, &VILLAGER_TYPES) {
m.extra.variant = t;
}
if let Some(p) = index("minecraft:villager_profession", profession, &PROFESSIONS) {
m.extra.variant2 = p;
}
m.extra.variant3 = level.clamp(0, i32::from(u8::MAX)) as u8;
}
impl Game<'_> {
/// SetEntityData for a mob of the server's.
pub(super) fn remote_mob_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
let Some(r) = &self.remote else {
return;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
apply(m, values);
apply(m, values, &r.builtin);
}
}
}
@@ -72,11 +104,23 @@ mod tests {
/// A mob of the prepared resources' type `kind`, as a client spawns it
/// (None without the resources).
fn mob(kind: &str) -> Option<Mob> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
fn root() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
.join(horizon_core::VANILLA_VERSION)
}
/// The prepared resources' built-in registries (empty without them).
fn builtin() -> Builtin {
Builtin::load(&root().join("registries.json")).unwrap_or_default()
}
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
super::apply(m, values, &builtin());
}
fn mob(kind: &str) -> Option<Mob> {
let types = horizon_client::mobs::MobTypes::load(&root()).ok()?;
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
client_defaults(&mut m);
@@ -133,4 +177,45 @@ mod tests {
apply(&mut m, &[(0, s::BYTE, Value::Byte(0))]);
assert_eq!(m.fire_ticks, 0);
}
#[test]
fn a_villager_takes_its_type_profession_and_level() {
let Some(mut m) = mob("minecraft:villager") else {
return;
};
let registries = builtin();
let id = |registry: &str, name: &str| registries.get(registry)?.id(name);
let (Some(desert), Some(farmer), Some(librarian)) = (
id("minecraft:villager_type", "minecraft:desert"),
id("minecraft:villager_profession", "minecraft:farmer"),
id("minecraft:villager_profession", "minecraft:librarian"),
) else {
return;
};
let name = |m: &Mob| {
(
VILLAGER_TYPES[m.extra.variant as usize],
PROFESSIONS[m.extra.variant2 as usize],
m.extra.variant3,
)
};
apply(
&mut m,
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 2]))],
);
assert_eq!(name(&m), ("desert", "farmer", 2));
// A job taken later: only the profession and the level change.
apply(
&mut m,
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, librarian, 5]))],
);
assert_eq!(name(&m), ("desert", "librarian", 5));
// A zombie villager's data comes after Zombie's three entries.
let mut z = mob("minecraft:zombie_villager").unwrap();
apply(
&mut z,
&[(20, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 3]))],
);
assert_eq!(name(&z), ("desert", "farmer", 3));
}
}
@@ -88,8 +88,10 @@ impl Game<'_> {
for m in &mut self.mobs {
if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) {
m.extra.mount = None;
m.extra.passenger = false;
} else if passengers.contains(&(m.id as i32)) {
m.extra.mount = Some(vehicle_id);
m.extra.passenger = true;
}
}
if passengers.contains(&me) {
@@ -129,17 +131,20 @@ impl Game<'_> {
self.yaw += b.delta_rotation;
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
self.yaw += delta.clamp(-105.0, 105.0) - delta;
let seat = b.position;
self.boats[i] = b;
Some(seat)
// In front of a mob that shares the boat.
Some(self.player_boat_seat(i))
} else {
// A minecart: the server moves it, the rider sits in it.
self.carts.iter().find(|c| c.id == id).map(|c| c.position)
self.carts.iter().find(|c| c.id == id).map(|c| {
c.position
.add_vector(Vec3::new(0.0, super::minecarts::SEAT, 0.0))
})
};
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
if let Some(v) = seat {
p.position = Vec3::new(v.x, v.y + super::boats::SEAT, v.z);
p.position = v;
}
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
@@ -0,0 +1,316 @@
//! A server's resource packs bound to the game (`ClientCommonPacketListenerImpl.
//! handleResourcePackPush` and `handleResourcePackPop`, its `PackConfirmScreen`,
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
//! server list's setting, downloads into `downloads/`, and a resource reload
//! with the server's packs on top of the player's own.
use super::Game;
use horizon_client::{
Result,
confirm_screen::Confirm,
hud::{HudAtlas, HudBatch},
multiplayer, packs,
scoreboard::{comp_text, tag_to_json},
server_packs::{Prompt, ServerPacks, action, download, parse_sha1, valid_url},
text::{Text, named_color},
ui,
};
use horizon_protocol::{packets::configuration::ResourcePackPush, servers};
use std::path::PathBuf;
/// `ClientCommonPacketListenerImpl.PackConfirmScreen`: the packs it asks
/// about, whether the last one is required, and the server's message.
pub(super) struct PackPrompt {
requests: Vec<(u128, String, String)>,
required: bool,
prompt: Option<Text>,
}
impl PackPrompt {
fn texts(&self) -> (Text, Text, [Text; 2]) {
let t = |key: &str| Text::translate(key, &[]);
let (title, line2) = if self.required {
let yellow = named_color("yellow").unwrap_or(0xFFFF55);
(
t("multiplayer.requiredTexturePrompt.line1"),
t("multiplayer.requiredTexturePrompt.line2")
.with_color(yellow)
.bold(),
)
} else {
(
t("multiplayer.texturePrompt.line1"),
t("multiplayer.texturePrompt.line2"),
)
};
// preparePackPrompt.
let message = match &self.prompt {
Some(prompt) => Text::translate(
"multiplayer.texturePrompt.serverPrompt",
&[line2, prompt.clone()],
),
None => line2,
};
let buttons = if self.required {
[t("gui.proceed"), t("menu.disconnect")]
} else {
[t("gui.yes"), t("gui.no")]
};
(title, message, buttons)
}
fn layout(&self, atlas: &HudAtlas, size: (f32, f32)) -> Confirm {
let (title, message, _) = self.texts();
Confirm::new(&atlas.font, size, &title, &message)
}
}
/// The game's `DownloadedPackSource`.
#[derive(Default)]
pub(super) struct ServerPackState {
packs: ServerPacks,
/// `downloads/` in the game folder, opened at the first download.
queue: Option<download::Queue>,
pub(super) prompt: Option<PackPrompt>,
/// The server list entry the setting is saved to (none: a direct join).
address: Option<String>,
/// The account the downloads are made for (the download headers).
user: (String, u128),
/// The packs loaded on top of the player's own, bottom first.
pub(super) active: Vec<packs::Pack>,
}
fn saves() -> PathBuf {
std::env::current_dir().unwrap_or_default().join("saves")
}
impl Game<'_> {
/// `ConnectScreen`: the server's setting decides what its packs do
/// (`configureForServerControl`); single player's own server takes them
/// all, quietly (`configureForLocalWorld`).
pub(super) fn server_packs_connect(&mut self, address: Option<&str>, user: (String, u128)) {
let state = &mut self.server_packs;
state.user = user;
state.prompt = None;
let Some(address) = address else {
state.address = None;
state.packs.local_world();
return;
};
let file = multiplayer::servers_file(&saves());
let entry = servers::load(&file).into_iter().find(|s| s.ip == address);
let setting = entry.as_ref().and_then(|e| e.accept_textures);
state.address = entry.map(|e| e.ip);
state.packs.connect(Prompt::for_server(setting));
}
/// `Minecraft.clearDownloadedResourcePacks`: every server pack goes at once.
pub(super) fn server_packs_disconnect(&mut self) {
self.server_packs.prompt = None;
self.server_packs.packs.disconnect();
self.tick_server_packs();
}
/// `handleResourcePackPush`.
pub(super) fn handle_pack_push(&mut self, p: ResourcePackPush) {
if !valid_url(&p.url) {
self.send_pack_status(p.id, action::INVALID_URL);
return;
}
let setting = self.server_setting();
let prompt = Prompt::for_server(setting);
if prompt != Prompt::Pending && (!p.required || prompt != Prompt::Declined) {
self.server_packs
.packs
.push(p.id, &p.url, parse_sha1(&p.hash));
return;
}
// addOrUpdatePackPrompt: one prompt for every pack pushed before it is answered.
let text = p.prompt.as_ref().map(|t| comp_text(&tag_to_json(t)));
let state = &mut self.server_packs;
let open = state.prompt.get_or_insert(PackPrompt {
requests: Vec::new(),
required: p.required,
prompt: None,
});
open.requests.push((p.id, p.url, p.hash));
open.required = p.required;
open.prompt = text;
self.capture(false);
}
/// `handleResourcePackPop`: one pack, or all of them.
pub(super) fn handle_pack_pop(&mut self, id: Option<u128>) {
match id {
Some(id) => self.server_packs.packs.pop(id),
None => self.server_packs.packs.pop_all(),
}
}
/// The joined server's entry in the server list: its `acceptTextures`.
fn server_setting(&self) -> Option<bool> {
let address = self.server_packs.address.as_deref()?;
servers::load(&multiplayer::servers_file(&saves()))
.into_iter()
.find(|s| s.ip == address)?
.accept_textures
}
fn send_pack_status(&self, id: u128, status: i32) {
if let Some(r) = &self.remote {
r.session.send_pack_status(id, status);
}
}
/// The prompt's answer (`PackConfirmScreen`'s callback): the server's
/// setting follows it, declining a required pack disconnects, and every
/// pack it asked about is pushed.
pub(super) fn answer_pack_prompt(&mut self, yes: bool) -> Result<()> {
let Some(prompt) = self.server_packs.prompt.take() else {
return Ok(());
};
if self.player.is_some() && self.screen == super::Screen::Playing {
self.capture(true);
}
let setting = if yes {
self.server_packs.packs.allow();
Some(true)
} else {
self.server_packs.packs.reject();
if prompt.required {
let reason = Text::translate("multiplayer.requiredTexturePrompt.disconnect", &[]);
return self.leave_server(Some(reason.plain()));
}
Some(false)
};
for (id, url, hash) in prompt.requests {
self.server_packs.packs.push(id, &url, parse_sha1(&hash));
}
// ServerList.saveSingleServer.
if let Some(address) = &self.server_packs.address {
let file = multiplayer::servers_file(&saves());
let mut list = servers::load(&file);
for entry in list.iter_mut().filter(|s| &s.ip == address) {
entry.accept_textures = setting;
}
if let Err(e) = servers::save(&file, &list) {
log::warn!("Horizon: could not save {}: {e}", file.display());
}
}
Ok(())
}
/// Runs what the packs need (`createUpdateScheduler`): the downloads
/// that finished, the ones to start, a resource reload when the active
/// packs change, and the statuses for the server.
pub(super) fn tick_server_packs(&mut self) {
// A reload waits for the world's resources (`reload_packs`).
if self.rest_loader.is_some() || self.world_loader.is_some() {
return;
}
let finished = self
.server_packs
.queue
.as_ref()
.map(download::Queue::finished)
.unwrap_or_default();
for batch in finished {
self.server_packs.packs.downloaded(batch);
}
while self.server_packs.packs.wants_tick() {
let tick = self.server_packs.packs.tick();
if !tick.download.is_empty() {
self.start_pack_downloads(tick.download);
}
if let Some(mut load) = tick.reload {
while let Some(recovery) = {
let ok = self.reload_server_packs(&load);
self.server_packs.packs.reloaded(ok)
} {
load = recovery;
}
}
}
for (id, status) in self.server_packs.packs.take_replies() {
self.send_pack_status(id, status);
}
}
fn start_pack_downloads(&mut self, requests: Vec<horizon_client::server_packs::Request>) {
let state = &mut self.server_packs;
if state.queue.is_none() {
let dir = std::env::current_dir()
.unwrap_or_default()
.join("downloads");
match download::Queue::open(dir) {
Ok(queue) => state.queue = Some(queue),
Err(e) => {
log::error!("Horizon: failed to open the download queue: {e}");
let failed = requests.iter().map(|r| r.id).collect();
state.packs.downloaded(horizon_client::server_packs::Batch {
downloaded: Vec::new(),
failed,
});
return;
}
}
}
let headers = download::headers(&state.user.0, state.user.1);
if let Some(queue) = &state.queue {
queue.download(requests, headers);
}
}
/// `DownloadedPackSource.startReload`: the packs on top of the player's
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
/// readable `pack.mcmeta` or the reload failed.
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
let packs: Option<Vec<_>> = load
.iter()
.map(|(id, path)| packs::server_pack(*id, path))
.collect();
let Some(packs) = packs else {
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
return false;
};
self.server_packs.active = packs;
match self.reload_selected_packs() {
Ok(()) => true,
Err(e) => {
log::warn!("Horizon: server resource pack reload failed: {e}");
false
}
}
}
pub(super) fn draw_pack_prompt(&self, batch: &mut HudBatch) {
let Some(prompt) = &self.server_packs.prompt else {
return;
};
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
if self.player.is_none() {
// In place of the connecting screen, on its background.
batch.fill(
atlas,
0.0,
0.0,
w,
h,
horizon_client::hud::rgba(20, 20, 20, 255),
);
}
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
let (_, _, [yes, no]) = prompt.texts();
prompt
.layout(atlas, (w, h))
.draw(batch, atlas, self.cursor, [&yes, &no]);
}
/// A left click on the prompt: its answer when it hit a button.
pub(super) fn pack_prompt_click(&mut self) -> Result<()> {
let Some(prompt) = &self.server_packs.prompt else {
return Ok(());
};
let answer = prompt
.layout(&self.gui.atlas, self.gui_size())
.answer_at(self.cursor);
match answer {
Some(yes) => {
self.audio.play_button_click();
self.answer_pack_prompt(yes)
}
None => Ok(()),
}
}
}
@@ -219,12 +219,11 @@ pub(super) fn spawn_loader(
.name("resource-loader".into())
.spawn(move || {
let mut pack_error = None;
let root = packs::startup_root(&vanilla_root, &settings.resource_packs).unwrap_or_else(
|error| {
let root = packs::startup_root(&vanilla_root, &settings.resource_packs, &[])
.unwrap_or_else(|error| {
pack_error = Some(error.to_string());
vanilla_root.clone()
},
);
});
let root = mods::content_root(&root, &settings.mods.enabled, &dev_mods);
let result = load_resources(
root,
@@ -245,7 +245,6 @@ pub(super) fn make_streaming(
streaming.biome_blend = settings.biome_blend;
streaming.smooth_lighting = settings.smooth_lighting;
streaming.cutout_leaves = settings.cutout_leaves;
streaming.cull_leaves = settings.horizon.cull_leaves;
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
streaming.sky_light = dim != Dim::Nether;
streaming.nether_shading = dim == Dim::Nether;
+2 -8
View File
@@ -227,8 +227,6 @@ pub struct Graphics {
pub far_detail: FarDetail,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Hide shared leaf and mangrove-root faces, reducing foliage density.
pub cull_leaves: bool,
/// Skip drawing entities hidden behind opaque terrain.
pub entity_culling: bool,
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
@@ -262,7 +260,6 @@ impl Default for Graphics {
lod_distance: 0,
far_detail: FarDetail::Full,
cave_culling: true,
cull_leaves: false,
entity_culling: false,
chunk_updates: 0,
upload_budget_kib: 0,
@@ -308,7 +305,6 @@ impl Graphics {
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
"farDetail" => g.far_detail = FarDetail::parse(value),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"cullLeaves" => g.cull_leaves = flag(g.cull_leaves),
"entityCulling" => g.entity_culling = flag(g.entity_culling),
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
@@ -343,12 +339,11 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
bool_text(self.cull_leaves),
bool_text(self.entity_culling),
self.chunk_updates,
self.upload_budget_kib,
@@ -448,7 +443,6 @@ mod tests {
lod_distance: 256,
far_detail: FarDetail::Reduced,
cave_culling: false,
cull_leaves: true,
entity_culling: true,
chunk_updates: 12,
upload_budget_kib: 4096,
@@ -475,7 +469,7 @@ mod tests {
Graphics::default()
);
assert_eq!(
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:maybe\nentityCulling:maybe\n"),
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
Graphics::default()
);
}
-51
View File
@@ -24,7 +24,6 @@ pub enum H {
LodDistance,
FarDetail,
CaveCulling,
CullLeaves,
EntityCulling,
ChunkUpdates,
UploadBudget,
@@ -138,11 +137,6 @@ pub fn rows(level: Level) -> Vec<Row> {
Saves,
"Skips terrain hidden behind closed sections. Off costs a lot.",
),
h(
H::CullLeaves,
None,
"Hides shared leaf and mangrove root faces; foliage can look less dense.",
),
h(
H::EntityCulling,
None,
@@ -343,7 +337,6 @@ impl H {
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::CullLeaves => labelled("Cull Leaves", on_off(g.cull_leaves)),
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
@@ -418,7 +411,6 @@ impl H {
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
}
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::CullLeaves => g.cull_leaves = !g.cull_leaves,
H::EntityCulling => g.entity_culling = !g.entity_culling,
H::Sky => g.sky = !g.sky,
H::Fog => g.fog = !g.fog,
@@ -455,7 +447,6 @@ impl Item {
let g = &s.horizon;
Some(match self {
Item::Horizon(H::CaveCulling) => g.cave_culling,
Item::Horizon(H::CullLeaves) => g.cull_leaves,
Item::Horizon(H::EntityCulling) => g.entity_culling,
Item::Horizon(H::Sky) => g.sky,
Item::Horizon(H::Fog) => g.fog,
@@ -1003,48 +994,6 @@ mod tests {
assert_eq!(s.horizon.upload_budget_kib, 0);
}
#[test]
fn cull_leaves_is_opt_in_and_toggles_from_advanced_graphics() {
let mut s = Settings::default();
let item = Item::Horizon(H::CullLeaves);
assert_eq!(item.switch(&s), Some(false));
assert_eq!(item.label(&s).plain(), "Cull Leaves: Off");
for enabled in [false, true] {
s.horizon.cull_leaves = enabled;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.cull_leaves, enabled);
}
}
let mut ui = GraphicsUi::default();
let size = (480.0, 4000.0);
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
let switch = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
let point = (switch.x + 1.0, switch.y + 1.0);
for enabled in [false, true] {
s.horizon.preset = Preset::Balanced;
assert_eq!(
ui.click(
Level::Advanced,
&mut s,
point,
size,
[854, 480],
false,
false
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(item.switch(&s), Some(enabled));
assert_eq!(s.horizon.preset, Preset::Custom);
}
assert_eq!(item.label(&s).plain(), "Cull Leaves: On");
}
#[test]
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
let mut s = Settings::default();
+16 -1
View File
@@ -81,6 +81,12 @@ pub const MINIMAP_SPRITE: &str = "horizon/minimap";
pub const SKIN_SLOTS: usize = 32;
/// Reused by the visible rows of the classic world selector.
pub const WORLD_ICON_SLOTS: usize = 32;
/// Atlas slots for the pack icons on the pack screen, one per icon drawn
/// (`PackSelectionScreen.loadPackIcon` registers a texture per pack): two lists
/// of 36-pixel rows at GUI scale 2 on a 4K screen still fit.
pub const PACK_ICON_SLOTS: usize = 64;
/// A pack icon slot's side: vanilla's own icons are 128x128.
pub const PACK_ICON_SIZE: u32 = 128;
/// Atlas slots for armour trim textures made at run time (`trims`), each the
/// size of the largest trim texture.
pub const TRIM_SLOTS: usize = 32;
@@ -252,7 +258,7 @@ impl HudAtlas {
)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world icons and the on-screen keyboard's text.
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
@@ -284,6 +290,15 @@ impl HudAtlas {
vec![0; 64 * 64 * 4],
));
}
let side = PACK_ICON_SIZE as usize;
for i in 0..PACK_ICON_SLOTS {
images.push((
format!("horizon/pack-icon{i}"),
PACK_ICON_SIZE,
PACK_ICON_SIZE,
vec![0; side * side * 4],
));
}
// The on-screen keyboard's text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
+209 -6
View File
@@ -119,6 +119,88 @@ fn component_value(tag: &horizon_world::nbt::Tag) -> crate::server::json::Json {
}
}
/// The inverse of [`component_value`]: a JSON component (a server.properties
/// prompt) as ComponentSerialization.CODEC writes it with NbtOps. An array is
/// its first component with the rest appended (`createFromList`); a list of
/// components mixing plain strings and objects has the strings as `{text}`.
pub fn component_tag(v: &crate::server::json::Json) -> horizon_world::nbt::Tag {
use crate::server::json::Json;
use horizon_world::nbt::{JavaString, Tag};
fn list(items: &[Json]) -> Tag {
same_kind(items.iter().map(component_tag).collect())
}
fn same_kind(mut tags: Vec<Tag>) -> Tag {
if tags.iter().any(|t| matches!(t, Tag::Compound(_))) {
for t in &mut tags {
if let Tag::String(s) = t {
*t = Tag::compound([("text", Tag::String(s.clone()))]);
}
}
}
Tag::List(tags)
}
fn value(v: &Json) -> Tag {
match v {
Json::Bool(b) => Tag::Byte(i8::from(*b)),
Json::Number(n) => n.parse().map_or_else(
|_| Tag::Double(n.parse::<f64>().unwrap_or(0.0).to_bits()),
Tag::Int,
),
Json::String(s) => Tag::String(JavaString::from(s.as_str())),
Json::Array(items) => list(items),
Json::Object(members) => Tag::compound(
members
.iter()
.map(|(k, v)| (k.as_str(), value(v)))
.collect::<Vec<_>>(),
),
Json::Null => Tag::String(JavaString::from("")),
}
}
match v {
Json::Array(items) if !items.is_empty() => {
let mut first = component_tag(&items[0]);
if let Tag::String(s) = &first {
first = Tag::compound([("text", Tag::String(s.clone()))]);
}
if let Tag::Compound(map) = &mut first {
let mut extra = match map.remove(&JavaString::from("extra")) {
Some(Tag::List(e)) => e,
_ => Vec::new(),
};
extra.extend(items[1..].iter().map(component_tag));
map.insert(JavaString::from("extra"), same_kind(extra));
}
first
}
Json::Object(members) => {
if let [(key, Json::String(text))] = members.as_slice()
&& key == "text"
{
return Tag::String(JavaString::from(text.as_str()));
}
Tag::compound(
members
.iter()
.map(|(k, v)| {
let tag = match k.as_str() {
"extra" | "with" => match v {
Json::Array(items) => list(items),
other => value(other),
},
_ => value(v),
};
(k.as_str(), tag)
})
.collect::<Vec<_>>(),
)
}
Json::String(s) | Json::Number(s) => Tag::String(JavaString::from(s.as_str())),
Json::Bool(b) => Tag::String(JavaString::from(b.to_string().as_str())),
_ => Tag::String(JavaString::from("")),
}
}
/// StringUtil.isValidPlayerName.
pub fn valid_name(name: &str) -> bool {
(1..=16).contains(&name.len()) && name.chars().all(|c| c as u32 > 32 && (c as u32) < 127)
@@ -745,6 +827,14 @@ fn login_and_configure(
send(c, cfg::Clientbound::RegistryData(packet))?;
}
send(c, cfg::Clientbound::UpdateTags(sync.tags.clone()))?;
if let Some(pack) = &shared.config.resource_pack
&& let Some(rejected) = offer_pack(c, pack, &mut info)?
{
let (reason, text) = pack_rejected(shared, name, rejected);
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
lost(shared, &format!("{name} ({})", uuid_string(uuid)), &text);
return Ok(None);
}
send(c, cfg::Clientbound::FinishConfiguration(Empty))?;
loop {
let (id, payload) = c.recv().map_err(e)?;
@@ -763,6 +853,79 @@ fn login_and_configure(
}))
}
/// ServerboundResourcePackPacket.Action.DECLINED.
const PACK_DECLINED: i32 = 1;
/// ServerCommonPacketListenerImpl.handleResourcePackResponse: a player who
/// declined the required pack is disconnected. The reason and its text.
fn pack_rejected(shared: &Shared, name: &str, pack: u128) -> (horizon_world::nbt::Tag, String) {
let reason = translatable("multiplayer.requiredTexturePrompt.disconnect", &[]);
let Some(log) = &shared.config.log else {
return (reason, String::new());
};
let line = format!(
"Disconnecting {name} due to resource pack {} rejection",
uuid_string(pack)
);
(log.write)("Server thread", "INFO", &line);
let text = horizon_protocol::text::plain(&reason, &log.lang);
(reason, text)
}
/// ServerResourcePackConfigurationTask: offer the server's pack, then wait
/// for the client's final answer (`Action.isTerminal`: anything but
/// ACCEPTED or DOWNLOADED). The player may take a while to answer the
/// prompt, so the connection is kept alive meanwhile, as
/// ServerConfigurationPacketListenerImpl.tick does. The id of a required
/// pack the client declined, which disconnects it.
fn offer_pack(
c: &mut Connection,
pack: &crate::server::properties::ResourcePackInfo,
info: &mut ClientInformation,
) -> Result<Option<u128>, String> {
let e = |e: horizon_protocol::Error| e.to_string();
let push = cfg::ResourcePackPush {
id: pack.id,
url: pack.url.clone(),
hash: pack.hash.clone(),
required: pack.required,
prompt: pack.prompt.as_ref().map(component_tag),
};
c.send(&cfg::Clientbound::ResourcePackPush(push).encode())
.map_err(e)?;
std::thread::scope(|scope| {
let (stop, stopped) = channel::<()>();
let writer = &mut c.writer;
scope.spawn(move || {
let start = Instant::now();
while stopped.recv_timeout(KEEP_ALIVE_EVERY) == Err(RecvTimeoutError::Timeout) {
let id = i64::try_from(start.elapsed().as_millis()).unwrap_or(0);
let keep_alive = cfg::Clientbound::KeepAlive(cfg::KeepAlive { id });
if writer.send(&keep_alive.encode()).is_err() {
break;
}
}
});
let answer = loop {
let (id, payload) = match c.reader.recv() {
Ok(packet) => packet,
Err(err) => break Err(err.to_string()),
};
match cfg::Serverbound::decode(id, &payload) {
Ok(cfg::Serverbound::ResourcePack(r)) if !matches!(r.action, 3 | 4) => {
let rejected = r.action == PACK_DECLINED && pack.required;
break Ok(rejected.then_some(r.id));
}
Ok(cfg::Serverbound::ClientInformation(i)) => *info = i,
Ok(_) => {}
Err(err) => break Err(err.to_string()),
}
};
drop(stop);
answer
})
}
fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::SocketAddr) {
let conn_id = shared.next_id();
let events = shared.events.clone();
@@ -784,6 +947,7 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
})
.ok()
};
let name = parts.name.clone();
let joined = Joined {
conn: conn_id,
uuid: parts.uuid,
@@ -795,8 +959,20 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
latency,
address,
};
let required_pack = shared
.config
.resource_pack
.as_ref()
.is_some_and(|p| p.required);
let reason = if events.send(NetEvent::Joined(Box::new(joined))).is_ok() {
read_loop(&mut reader, conn_id, &events, &alive)
match read_loop(&mut reader, conn_id, &events, &alive, required_pack) {
Ok(reason) => reason,
Err(rejected) => {
let (reason, text) = pack_rejected(shared, &name, rejected);
let _ = tx.send(Out::Disconnect(reason));
text
}
}
} else {
"server closed".to_owned()
};
@@ -808,12 +984,16 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
shared.release(parts.uuid);
}
/// Forward the player's packets until the connection ends: why it did, or
/// the id of a required resource pack the player declined (with
/// `required_pack`), which disconnects them.
fn read_loop(
reader: &mut ConnReader,
conn: u32,
events: &Sender<NetEvent>,
alive: &AtomicI64,
) -> String {
required_pack: bool,
) -> Result<String, u128> {
loop {
let (id, payload) = match reader.recv() {
Ok(p) => p,
@@ -824,19 +1004,24 @@ fn read_loop(
|| e.0.contains("reset by peer")
|| e.0.contains("aborted") =>
{
return "Disconnected".to_owned();
return Ok("Disconnected".to_owned());
}
Err(e) => return e.to_string(),
Err(e) => return Ok(e.to_string()),
};
match play::Serverbound::decode(id, &payload) {
Ok(play::Serverbound::KeepAlive(k)) => alive.store(k.id, Ordering::Relaxed),
Ok(play::Serverbound::ResourcePack(r))
if r.action == PACK_DECLINED && required_pack =>
{
return Err(r.id);
}
Ok(p) => {
if events.send(NetEvent::Packet(conn, Box::new(p))).is_err() {
return "server closed".to_owned();
return Ok("server closed".to_owned());
}
}
// A malformed packet disconnects like vanilla's decoder exception.
Err(e) => return format!("Invalid packet {id}: {e}"),
Err(e) => return Ok(format!("Invalid packet {id}: {e}")),
}
}
}
@@ -988,6 +1173,24 @@ fn write_loop(
mod tests {
use super::*;
#[test]
fn prompts_become_the_components_vanilla_sends() {
let tag = |json: &str| {
let tag = component_tag(&crate::server::json::parse(json, true).unwrap());
component_json(&tag)
};
assert_eq!(tag(r#"{"text":"Hi"}"#), r#""Hi""#);
assert_eq!(tag(r#""Hi""#), r#""Hi""#);
assert_eq!(
tag(r#"["Hi ",{"text":"there","bold":true}]"#),
r#"{"text":"Hi ","extra":[{"text":"there","bold":true}]}"#
);
assert_eq!(
tag(r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"#),
r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"#
);
}
fn unpack(out: Out) -> Vec<Vec<u8>> {
match out {
Out::Packet(body) => vec![body],
+4
View File
@@ -139,6 +139,9 @@ pub struct NetConfig {
/// Online mode: players are authenticated (none on a LAN world).
pub online: Option<Arc<OnlineMode>>,
pub log: Option<NetLog>,
/// server.properties `resource-pack`: offered to every player during
/// configuration (a dedicated server's; MinecraftServer.getServerResourcePack).
pub resource_pack: Option<crate::server::properties::ResourcePackInfo>,
}
impl NetConfig {
/// MinecraftServer.enforceSecureProfile.
@@ -164,6 +167,7 @@ impl NetConfig {
gate: None,
online: None,
log: None,
resource_pack: None,
}
}
}
+2
View File
@@ -41,6 +41,7 @@ pub mod command;
pub mod command_block;
pub mod command_storage;
pub mod component;
pub mod confirm_screen;
pub mod container_screen;
pub mod containers;
pub mod control;
@@ -157,6 +158,7 @@ pub mod screen_effects;
pub mod screen_layout;
pub mod sculk;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod settings;
pub mod shadows;
pub mod signs;
+3 -12
View File
@@ -230,8 +230,6 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
result
}
#[cfg(test)]
mod leaf_culling_tests;
#[cfg(test)]
mod particle_tests;
#[cfg(test)]
mod visibility_tests;
@@ -277,7 +275,6 @@ struct Info {
overlay: bool,
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
leaves: bool,
mangrove_roots: bool,
blocks_flow: bool,
ice: bool,
sturdy: u8,
@@ -440,7 +437,6 @@ impl MeshTables {
legacy_solid: s.legacy_solid,
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
leaves: s.has_class("LeavesBlock"),
mangrove_roots: s.has_class("MangroveRootsBlock"),
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
ice: s.has_class("IceBlock"),
sturdy: s.sturdy_full,
@@ -692,8 +688,6 @@ pub struct Mesher<'a> {
pub smooth_lighting: bool,
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
pub cutout_leaves: bool,
/// Cull shared leaf and mangrove-root faces, including transparent leaves.
pub cull_leaves: bool,
}
impl Mesher<'_> {
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
@@ -773,12 +767,9 @@ impl Mesher<'_> {
let [dx, dy, dz] = d.normal();
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
let other = &info[neighbor as usize];
// Adapted from CullLeaves (MIT); see THIRD_PARTY_NOTICES.md.
let shared_foliage = (state.leaves
&& other.leaves
&& (!self.cutout_leaves || self.cull_leaves))
|| (self.cull_leaves && state.mangrove_roots && other.mangrove_roots);
visible[d as usize] = !shared_foliage
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
visible[d as usize] = !opaque_leaves
&& self
.visibility
.should_render_face(
@@ -1,126 +0,0 @@
use super::*;
use horizon_assets::model::Direction as ModelDirection;
#[test]
fn leaf_culling_removes_only_internal_leaf_and_root_faces() {
let root =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("horizon-resources.json").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let biomes = Biomes::load(&root).unwrap();
let state = |name| assets.registry.default_state(name).unwrap().0;
let oak = state("minecraft:oak_leaves");
let birch = state("minecraft:birch_leaves");
let roots = state("minecraft:mangrove_roots");
let stone = state("minecraft:stone");
let mesh = |blocks: &[([i32; 3], u32)], cull_leaves, cutout_leaves| {
let mut center = Chunk::empty(0, 0);
let mut east = Chunk::empty(1, 0);
for &([x, y, z], state) in blocks {
let chunk = if x < 16 { &mut center } else { &mut east };
chunk.set(x, y, z, state);
}
let mut region = [[None; 3]; 3];
region[1][1] = Some(&center);
region[1][2] = Some(&east);
Mesher {
nether_shading: false,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility: visibility.clone(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves,
cull_leaves,
}
.tesselate(&region, (0 - MIN_Y) / 16)
.solid
};
// Native leaves can contain unculled interior geometry as well as their
// outer faces. Select the touching cull group from the actual baked model,
// preserving every other quad and its full vertex attributes.
let without_face = |at: [i32; 3], id, direction| {
let vertices = mesh(&[(at, id)], false, true);
let models = render
.model_catalog
.collect(BlockStateId(id), BlockPos::new(at[0], at[1], at[2]))
.unwrap();
let faces: Vec<_> = models.iter().flat_map(|(_, model)| &model.faces).collect();
assert_eq!(vertices.len(), faces.len() * 4);
let retained: Vec<_> = vertices
.as_chunks::<4>()
.0
.iter()
.zip(faces)
.filter(|(_, face)| face.quad.cull != Some(direction))
.flat_map(|(quad, _)| quad.iter().copied())
.collect();
assert!(retained.len() < vertices.len(), "fixture has a cull group");
retained
};
// Same-species leaves, mixed leaves and roots hide only their touching
// model groups. Disabling the option restores the isolated models.
for (a, b) in [(oak, oak), (oak, birch), (roots, roots)] {
let blocks = [([7, 7, 7], a), ([8, 7, 7], b)];
let off = mesh(&blocks, false, true);
let on = mesh(&blocks, true, true);
assert_eq!(
off.len(),
mesh(&[blocks[0]], false, true).len() + mesh(&[blocks[1]], false, true).len()
);
let mut retained = without_face(blocks[0].0, a, ModelDirection::East);
retained.extend(without_face(blocks[1].0, b, ModelDirection::West));
assert_eq!(
bytemuck::cast_slice::<_, u8>(&on),
bytemuck::cast_slice::<_, u8>(&retained)
);
}
// Roots do not join leaves into a single culling group. Ordinary opaque
// neighbors also keep their existing occlusion behavior.
for (a, b) in [(oak, roots), (roots, oak), (oak, stone)] {
let blocks = [([7, 7, 7], a), ([8, 7, 7], b)];
let off = mesh(&blocks, false, true);
let on = mesh(&blocks, true, true);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&on),
bytemuck::cast_slice::<_, u8>(&off)
);
}
// Opaque leaves already hide internal leaf faces independently of this
// option, and a leaf beside air retains its complete baked model.
let pair = [([7, 7, 7], oak), ([8, 7, 7], birch)];
assert_eq!(
mesh(&pair, false, false).len(),
mesh(&pair, true, false).len()
);
let isolated = [([7, 7, 7], oak)];
assert_eq!(
bytemuck::cast_slice::<_, u8>(&mesh(&isolated, false, true)),
bytemuck::cast_slice::<_, u8>(&mesh(&isolated, true, true))
);
// The padding read must also remove the center chunk's internal face
// against a different leaf species in the neighboring chunk.
let boundary = [([15, 7, 7], oak), ([16, 7, 7], birch)];
assert_eq!(
mesh(&boundary, false, true).len(),
mesh(&[boundary[0]], false, true).len()
);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&mesh(&boundary, true, true)),
bytemuck::cast_slice::<_, u8>(&without_face(boundary[0].0, oak, ModelDirection::East))
);
}
@@ -87,7 +87,6 @@ fn info(solid: bool) -> Info {
legacy_solid: false,
overlay: false,
leaves: false,
mangrove_roots: false,
blocks_flow: false,
ice: false,
sturdy: 0,
+5 -1
View File
@@ -736,9 +736,13 @@ pub struct Extra {
/// The sniffer's and the ghastling's brain.
pub animal_brain: Option<Box<animal_brain::Brain>>,
pub axolotl: axolotl::Animation,
/// The mob this one rides, and the mob riding it (`getVehicle`, `getFirstPassenger`).
/// The mob or boat this one rides, and the mob riding it (`getVehicle`,
/// `getFirstPassenger`).
pub mount: Option<u32>,
pub rider: Option<u32>,
/// `Entity.boardingCooldown`: ticks after leaving a boat before a boat
/// can pick this mob up again.
pub boarding_cooldown: i32,
/// A chicken carrying a zombie (`IsChickenJockey`).
pub jockey: bool,
/// A skeleton horse waiting to spring its trap, and how long it has waited.
+2 -1
View File
@@ -96,7 +96,8 @@ fn xp_reward(m: &Mob) -> i32 {
_ => 0,
}
}
fn is_animal(s: Species) -> bool {
/// The mobs that extend `Animal`.
pub fn is_animal(s: Species) -> bool {
use Species as S;
matches!(
s,
+45 -4
View File
@@ -636,11 +636,12 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
} else {
"entity/zombie_villager"
};
let baby = if m.is_baby() { "baby/" } else { "" };
// VillagerProfessionLayer: a baby's type texture is in `baby/`.
let group = if m.is_baby() { "baby" } else { "type" };
push(
&mut v,
&model,
&format!("{folder}/type/{baby}{t}"),
&format!("{folder}/{group}/{t}"),
white,
false,
);
@@ -1098,9 +1099,11 @@ fn render_visible(
let mut poses = Poses::new(&part);
anim::setup(m, &s, &mut poses, &data.animations);
if matches!(m.species(), Species::Villager | Species::ZombieVillager)
&& let Some((folder, variant)) = layer.texture.split_once("/type/")
&& let Some((folder, variant)) = layer
.texture
.split_once("/type/")
.or_else(|| layer.texture.split_once("/baby/"))
{
let variant = variant.strip_prefix("baby/").unwrap_or(variant);
let profession = PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()];
data.villager_hats
.read()
@@ -1275,6 +1278,44 @@ pub fn held_item_transform(
mod tests {
use super::*;
/// VillagerProfessionLayer's textures (type, or baby for a baby;
/// profession; level) of every villager and zombie villager are in the
/// prepared resources: a missing one draws nothing.
#[test]
fn villager_layers_name_textures_that_exist() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
return;
}
let types = super::super::MobTypes::load(&root).unwrap();
for kind in ["minecraft:villager", "minecraft:zombie_villager"] {
let kind = std::sync::Arc::new(types.types[kind].clone());
for baby in [false, true] {
for t in 0..VILLAGER_TYPES.len() {
for p in 0..PROFESSIONS.len() {
let mut m =
Mob::new(1, kind.clone(), horizon_core::geometry::Vec3::ZERO, 0.0);
m.extra.age = if baby { -24000 } else { 0 };
(m.extra.variant, m.extra.variant2, m.extra.variant3) =
(t as u8, p as u8, 5);
let layers = layers(&m);
let group = if baby { "/baby/" } else { "/type/" };
assert!(
layers.iter().any(|l| l.texture.contains(group)),
"{kind:?} baby={baby}: no {group} layer"
);
for layer in layers {
let png = root
.join(format!("assets/minecraft/textures/{}.png", layer.texture));
assert!(png.is_file(), "missing {}", png.display());
}
}
}
}
}
}
#[test]
fn sulfur_cube_opaque_core_is_submitted_before_its_translucent_shell() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+94 -2
View File
@@ -67,6 +67,9 @@ pub enum Screen {
name: String,
address: String,
focus: u8,
/// Server Resource Packs (`acceptTextures`): Enabled, Disabled, or
/// none for Prompt.
packs: Option<bool>,
},
Direct {
address: String,
@@ -224,6 +227,19 @@ fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option
}
}
/// The Server Resource Packs button's label: `CommonComponents.optionNameValue`.
fn pack_setting_label(packs: Option<bool>) -> String {
let (key, fallback) = match packs {
Some(true) => ("manageServer.resourcePack.enabled", "Enabled"),
Some(false) => ("manageServer.resourcePack.disabled", "Disabled"),
None => ("manageServer.resourcePack.prompt", "Prompt"),
};
let name = crate::lang::get_or("manageServer.resourcePack", "Server Resource Packs");
crate::lang::get_or("options.generic_value", "%s: %s")
.replacen("%s", &name, 1)
.replacen("%s", &crate::lang::get_or(key, fallback), 1)
}
/// JoinMultiplayerScreen's `HeaderAndFooterLayout(33, 60)`.
const HEADER: f32 = 33.0;
const FOOTER: f32 = 60.0;
@@ -461,7 +477,16 @@ impl Screen {
),
]
}
Self::Edit { address, .. } => vec![
Self::Edit { address, packs, .. } => vec![
// ManageServerScreen's CycleButton, above Done.
b(
w / 2.0 - 100.0,
h / 4.0 + 72.0,
200.0,
&pack_setting_label(*packs),
true,
60,
),
b(
w / 2.0 - 100.0,
h / 4.0 + 96.0 + 18.0,
@@ -847,6 +872,7 @@ impl Screen {
name,
address,
focus,
..
} => {
centered(
batch,
@@ -996,6 +1022,7 @@ impl Screen {
name: "Minecraft Server".into(),
address: String::new(),
focus: 1,
packs: None,
}
}
(
@@ -1011,6 +1038,7 @@ impl Screen {
name: s.name,
address: s.ip,
focus: 0,
packs: s.accept_textures,
};
}
}
@@ -1125,21 +1153,32 @@ impl Screen {
*selected = Some(index);
}
}
// CycleButton: Enabled, Disabled, Prompt, and round again.
(Self::Edit { packs, .. }, Some(60)) => {
*packs = match *packs {
Some(true) => Some(false),
Some(false) => None,
None => Some(true),
}
}
(
Self::Edit {
index,
name,
address,
packs,
..
},
Some(50),
) => {
let mut list = servers::load(&servers_file(saves));
let entry = ServerEntry::new(name.trim(), address.trim());
let mut entry = ServerEntry::new(name.trim(), address.trim());
entry.accept_textures = *packs;
match index.and_then(|i| list.get_mut(i)) {
Some(e) => {
e.name = entry.name;
e.ip = entry.ip;
e.accept_textures = entry.accept_textures;
}
None => list.push(entry),
}
@@ -1637,4 +1676,57 @@ mod tests {
assert_eq!(names(), ["Keep"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn edit_cycles_and_saves_the_server_resource_pack_setting() {
let dir = std::env::temp_dir().join(format!("horizon-mp-packs-{}", std::process::id()));
let saves = dir.join("saves");
std::fs::create_dir_all(&saves).unwrap();
let file = servers_file(&saves);
let mut entry = ServerEntry::new("Packs", "127.0.0.1:4");
entry.accept_textures = Some(false);
servers::save(&file, &[entry]).unwrap();
let size = (427.0, 240.0);
let mut screen = Screen::list(&saves);
if let Screen::List { selected, .. } = &mut screen {
*selected = Some(0);
}
screen.click(Some(43), 0.0, 0.0, size, &saves);
let cycle = |screen: &Screen| {
screen
.buttons(size.0, size.1)
.into_iter()
.find(|b| b.5 == 60)
.map(|b| (b.0, b.1, b.2, b.3))
};
// ManageServerScreen: 200 wide at height / 4 + 72, the saved value first.
assert_eq!(
cycle(&screen),
Some((
113.5,
132.0,
200.0,
"Server Resource Packs: Disabled".into()
))
);
// Disabled, Prompt, Enabled: Cancel keeps what was saved.
screen.click(Some(60), 0.0, 0.0, size, &saves);
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Prompt");
screen.click(Some(51), 0.0, 0.0, size, &saves);
assert_eq!(servers::load(&file)[0].accept_textures, Some(false));
// Done saves it.
if let Screen::List { selected, .. } = &mut screen {
*selected = Some(0);
}
screen.click(Some(43), 0.0, 0.0, size, &saves);
screen.click(Some(60), 0.0, 0.0, size, &saves);
screen.click(Some(60), 0.0, 0.0, size, &saves);
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Enabled");
screen.click(Some(50), 0.0, 0.0, size, &saves);
assert_eq!(servers::load(&file)[0].accept_textures, Some(true));
// A new server asks.
screen.click(Some(42), 0.0, 0.0, size, &saves);
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Prompt");
let _ = std::fs::remove_dir_all(&dir);
}
}
+66 -18
View File
@@ -20,6 +20,7 @@ use horizon_protocol::{
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc::{Receiver, Sender, channel},
},
thread::JoinHandle,
@@ -43,6 +44,10 @@ pub enum Event {
DecodeError(String),
/// The server sent us back to configuration and finished it again.
Reconfigured(Arc<Dynamic>),
/// ClientboundResourcePackPushPacket, in configuration or in play.
PackPush(cfg::ResourcePackPush),
/// ClientboundResourcePackPopPacket: one pack, or every pack.
PackPop(Option<u128>),
/// The connection ended. `from_server`: the server gave the reason (or
/// the connection was refused for good), rather than the connection
/// failing (auto-reconnect retries only failures and shutdowns).
@@ -55,6 +60,9 @@ pub enum Event {
pub struct Session {
events: Receiver<Event>,
writer: Arc<Mutex<Option<ConnWriter>>>,
/// The connection is in configuration (else in play). Changed only
/// with `writer` locked, as the packet that switches is sent.
configuring: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
pub address: String,
pub name: String,
@@ -92,8 +100,10 @@ impl Session {
) -> Self {
let (send, events) = channel();
let writer = Arc::new(Mutex::new(None));
let configuring = Arc::new(AtomicBool::new(true));
let counters = Arc::new(Mutex::new(None));
let (thread_writer, thread_counters) = (writer.clone(), counters.clone());
let thread_configuring = configuring.clone();
let (address_owned, name_owned) = (address.to_owned(), name.to_owned());
let thread = std::thread::Builder::new()
.name("horizon-net".into())
@@ -107,6 +117,7 @@ impl Session {
&lang,
&send,
&thread_writer,
&thread_configuring,
&thread_counters,
);
let from_server = result.is_ok();
@@ -124,6 +135,7 @@ impl Session {
Self {
events,
writer,
configuring,
thread,
address: address.to_owned(),
name: name.to_owned(),
@@ -153,6 +165,21 @@ impl Session {
w.shutdown();
}
}
/// ServerboundResourcePackPacket, in whichever phase the connection is.
pub fn send_pack_status(&self, id: u128, action: i32) {
let status = cfg::ResourcePackResponse { id, action };
let mut writer = self.writer.lock().unwrap();
let body = if self.configuring.load(Ordering::Relaxed) {
cfg::Serverbound::ResourcePack(status).encode()
} else {
play::Serverbound::ResourcePack(status).encode()
};
if let Some(w) = writer.as_mut()
&& w.send(&body).is_err()
{
w.shutdown();
}
}
pub fn disconnect(&mut self) {
if let Some(w) = self.writer.lock().unwrap().take() {
w.shutdown();
@@ -253,6 +280,7 @@ fn run(
lang: &Language,
send: &Sender<Event>,
shared_writer: &Mutex<Option<ConnWriter>>,
configuring: &AtomicBool,
shared_counters: &Mutex<Option<Arc<Counters>>>,
) -> Result<String, String> {
let (host, port, mut c) = if let Some(pipe) = local {
@@ -268,8 +296,13 @@ fn run(
target
}
};
let c =
Connection::connect(&host, port, Duration::from_secs(10)).map_err(|e| e.to_string())?;
let c = if address.starts_with(FRIEND) {
// The friends relay supplies a concrete endpoint, not a Minecraft hostname.
Connection::connect(&host, port, Duration::from_secs(10))
} else {
Connection::connect_server(&host, port, Duration::from_secs(10))
}
.map_err(|e| e.to_string())?;
(host, port, c)
};
*shared_counters.lock().unwrap() = Some(c.reader.counters.clone());
@@ -377,18 +410,19 @@ fn run(
.map_err(e)?;
let (mut reader, writer) = c.split();
*shared_writer.lock().unwrap() = Some(writer);
let registries = configure(&mut reader, shared_writer, lang, send)?;
let registries = configure(&mut reader, shared_writer, configuring, lang, send)?;
let _ = send.send(Event::Joined {
registries: Arc::new(registries),
uuid: profile.uuid,
name: profile.name,
});
receive_play(&mut reader, shared_writer, lang, send)
receive_play(&mut reader, shared_writer, configuring, lang, send)
}
fn receive_play(
reader: &mut ConnReader,
shared_writer: &Mutex<Option<ConnWriter>>,
configuring: &AtomicBool,
lang: &Language,
send: &Sender<Event>,
) -> Result<String, String> {
@@ -416,15 +450,17 @@ fn receive_play(
play::Clientbound::KeepAlive(k) => reply(play::Serverbound::KeepAlive(k)),
play::Clientbound::BundleDelimiter(_) => {}
play::Clientbound::ResourcePackPush(p) => {
// Declined: Horizon has no server resource packs.
reply(play::Serverbound::ResourcePack(cfg::ResourcePackResponse {
id: p.id,
action: 1,
}));
let _ = send.send(Event::PackPush(p));
}
play::Clientbound::ResourcePackPop(p) => {
let _ = send.send(Event::PackPop(p.id));
}
play::Clientbound::StartConfiguration(_) => {
reply(play::Serverbound::ConfigurationAcknowledged(Empty));
let registries = configure(reader, shared_writer, lang, send)?;
if let Some(w) = shared_writer.lock().unwrap().as_mut() {
let _ = w.send(&play::Serverbound::ConfigurationAcknowledged(Empty).encode());
configuring.store(true, Ordering::Relaxed);
}
let registries = configure(reader, shared_writer, configuring, lang, send)?;
let _ = send.send(Event::Reconfigured(Arc::new(registries)));
}
play::Clientbound::Disconnect(d) => return Ok(plain(&d.reason, lang)),
@@ -445,6 +481,7 @@ fn receive_play(
fn configure(
reader: &mut ConnReader,
writer: &Mutex<Option<ConnWriter>>,
configuring: &AtomicBool,
lang: &Language,
send: &Sender<Event>,
) -> Result<Dynamic, String> {
@@ -471,11 +508,12 @@ fn configure(
cfg::Clientbound::CodeOfConduct(_) => {
reply(cfg::Serverbound::AcceptCodeOfConduct(Empty))?
}
// The game answers: it may prompt the player first.
cfg::Clientbound::ResourcePackPush(p) => {
reply(cfg::Serverbound::ResourcePack(cfg::ResourcePackResponse {
id: p.id,
action: 1,
}))?
let _ = send.send(Event::PackPush(p));
}
cfg::Clientbound::ResourcePackPop(p) => {
let _ = send.send(Event::PackPop(p.id));
}
cfg::Clientbound::CookieRequest(q) => {
reply(cfg::Serverbound::CookieResponse(cfg::CookieResponse {
@@ -485,7 +523,11 @@ fn configure(
}
cfg::Clientbound::Disconnect(d) => return Err(plain(&d.reason, lang)),
cfg::Clientbound::FinishConfiguration(_) => {
reply(cfg::Serverbound::FinishConfiguration(Empty))?;
let mut w = writer.lock().unwrap();
let w = w.as_mut().ok_or("Disconnected")?;
w.send(&cfg::Serverbound::FinishConfiguration(Empty).encode())
.map_err(|e| e.to_string())?;
configuring.store(false, Ordering::Relaxed);
return Ok(registries);
}
_ => {}
@@ -559,7 +601,7 @@ pub fn load_language(resources: &std::path::Path) -> Language {
pub fn ping(address: &str, timeout: Duration) -> Result<(serde_json::Value, u128), String> {
use horizon_protocol::packets::status;
let (host, port) = parse_address(address).ok_or("Invalid address")?;
let mut c = Connection::connect(&host, port, timeout).map_err(|e| e.to_string())?;
let mut c = Connection::connect_server(&host, port, timeout).map_err(|e| e.to_string())?;
c.reader
.set_read_timeout(Some(timeout))
.map_err(|e| e.to_string())?;
@@ -602,7 +644,13 @@ mod tests {
let (send, events) = channel();
let thread_writer = writer.clone();
let thread = std::thread::spawn(move || {
receive_play(&mut reader, &thread_writer, &Language::new(), &send)
receive_play(
&mut reader,
&thread_writer,
&AtomicBool::new(false),
&Language::new(),
&send,
)
});
let mut server = Connection::local(server);
let teleport = play::Clientbound::PlayerPosition(play::PlayerPosition {
+3 -1
View File
@@ -2079,7 +2079,9 @@ impl OptionsUi {
let blink = self.pack_focus.elapsed().as_millis() as u64;
let m = crate::screen_draw::Measure(&atlas.font);
let mouse = (cursor.0.floor() as i32, cursor.1.floor() as i32);
let draws = self.packs(atlas, (w, h)).draw(&m, mouse, blink);
let packs = self.packs(atlas, (w, h));
let mut draws = packs.draw(&m, mouse, blink);
packs.bind_icons(&mut draws, atlas, &mut batch.patches);
crate::screen_draw::paint(batch, atlas, &draws);
return;
}
+163 -3
View File
@@ -6,10 +6,13 @@
//! [`PackScreen::draw`] gives the screen's GUI calls. The game shows it;
//! tools/test_pack_screen_parity.py compares it with vanilla.
use crate::{
font::Patch,
hud::{HudAtlas, PACK_ICON_SIZE, PACK_ICON_SLOTS},
packs::{Position, Selectable, SelectionModel},
screen_draw::{self as d, Draw, Measure, TextField},
text::{Style, Text},
};
use std::{collections::HashMap, sync::Arc};
/// `PackCompatibility`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -50,8 +53,8 @@ pub struct PackInfo {
pub description_style: Style,
pub source: Source,
pub compat: Compat,
/// Whether the pack has a `pack.png`.
pub icon: bool,
/// The pack's `pack.png` ([`load_icon`]); none draws `unknown_pack`.
pub icon: Option<Arc<[u8]>>,
pub required: bool,
pub position: Position,
pub fixed: bool,
@@ -90,6 +93,27 @@ impl PackInfo {
(t, Style::color(d::GRAY))
}
}
/// A `pack.png` as the pack screen draws it: RGBA, stretched to a
/// `PACK_ICON_SIZE` square by nearest texels, as vanilla's icon texture is
/// sampled into its 32x32 square. None when it does not decode, where
/// `loadPackIcon` falls back to `unknown_pack`.
pub fn load_icon(png: &[u8]) -> Option<Arc<[u8]>> {
let (w, h, rgba) = crate::hud::decode_bytes(png).ok()?;
if w == 0 || h == 0 {
return None;
}
let size = u64::from(PACK_ICON_SIZE);
let mut out = Vec::with_capacity((size * size * 4) as usize);
for y in 0..size {
let sy = ((2 * y + 1) * u64::from(h) / (2 * size)) as usize;
for x in 0..size {
let sx = ((2 * x + 1) * u64::from(w) / (2 * size)) as usize;
let at = (sy * w as usize + sx) * 4;
out.extend_from_slice(&rgba[at..at + 4]);
}
}
Some(out.into())
}
/// `PackSelectionScreen.loadPackIcon`'s texture for a pack with an icon.
pub fn icon_texture(id: &str) -> String {
let sanitized: String = id
@@ -306,6 +330,8 @@ pub struct PackScreen {
confirm: Option<Confirm>,
/// The selection changed since the screen opened.
pub changed: bool,
/// The packs' icons by their [`icon_texture`].
icons: HashMap<String, Arc<[u8]>>,
}
fn tr(key: &str) -> Text {
@@ -329,11 +355,16 @@ impl PackScreen {
italic: Some(true),
..Style::default()
}));
let icons = packs
.iter()
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
.collect();
let mut s = Self {
width,
height,
in_world,
packs,
icons,
model,
search,
focus: Focus::None,
@@ -760,6 +791,35 @@ impl PackScreen {
pub fn commit(&self) -> Vec<String> {
self.model.commit()
}
/// Points the icon blits of `draws` at the atlas's pack icon slots and
/// writes the icons into them, one slot per icon drawn (vanilla registers a
/// texture per pack). Icons past the last slot keep `unknown_pack`.
pub fn bind_icons(&self, draws: &mut [Draw], atlas: &HudAtlas, patches: &mut Vec<Patch>) {
let mut slots = 0..PACK_ICON_SLOTS;
for draw in draws {
let Draw::Blit { texture, .. } = draw else {
continue;
};
let Some(icon) = self.icons.get(texture.as_str()) else {
continue;
};
let Some(slot) = slots.next() else {
return;
};
let sprite = format!("horizon/pack-icon{slot}");
let Some(rect) = atlas.sprites.get(&sprite) else {
return;
};
patches.push(Patch {
x: (rect.u0 * atlas.width as f32).round() as u32,
y: (rect.v0 * atlas.height as f32).round() as u32,
width: PACK_ICON_SIZE,
height: PACK_ICON_SIZE,
rgba: icon.to_vec(),
});
*texture = sprite;
}
}
/// The shown screen's calls (`Screen.extractRenderState`); `blink_ms`
/// is the time since the search box took the focus.
@@ -891,7 +951,7 @@ impl PackScreen {
-8978432,
);
}
let icon = if p.icon {
let icon = if p.icon.is_some() {
icon_texture(id)
} else {
"textures/misc/unknown_pack.png".into()
@@ -979,6 +1039,106 @@ impl PackScreen {
}
}
#[cfg(test)]
mod icon_tests {
use super::*;
fn png(w: u32, h: u32, rgba: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(rgba)
.unwrap();
out
}
fn pack(id: &str, icon: Option<Arc<[u8]>>) -> PackInfo {
PackInfo {
id: id.into(),
title: Text::literal(id),
description: Text::literal(""),
description_style: Style::default(),
source: Source::BuiltIn,
compat: Compat::Compatible,
icon,
required: false,
position: Position::Top,
fixed: false,
}
}
/// Any size stretches over the whole square, nearest texels, as the
/// icon texture is sampled into its 32x32 blit.
#[test]
fn icons_stretch_to_the_slot_by_nearest_texels() {
let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 128]);
let icon = load_icon(&png(2, 1, &[red, blue].concat())).unwrap();
let side = PACK_ICON_SIZE as usize;
assert_eq!(icon.len(), side * side * 4);
let at = |x: usize, y: usize| &icon[(y * side + x) * 4..][..4];
assert_eq!((at(0, 0), at(side / 2 - 1, side - 1)), (&red[..], &red[..]));
assert_eq!(
(at(side / 2, 0), at(side - 1, side - 1)),
(&blue[..], &blue[..])
);
assert!(load_icon(b"not a png").is_none());
}
/// Programmer Art and High Contrast draw their own `pack.png`; a pack
/// without one keeps `unknown_pack`.
#[test]
fn drawn_icons_take_atlas_slots() {
let atlas =
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
.unwrap();
let m = Measure(&atlas.font);
let dirt = load_icon(&png(1, 1, &[134, 96, 67, 255])).unwrap();
let contrast = load_icon(&png(1, 1, &[0, 0, 0, 255])).unwrap();
let packs = vec![
pack("programmer_art", Some(dirt.clone())),
pack("high_contrast", Some(contrast.clone())),
pack("file/Iconless", None),
];
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
let mut draws = screen.draw(&m, (0, 0), 0);
let mut patches = Vec::new();
screen.bind_icons(&mut draws, &atlas, &mut patches);
let icons: Vec<&str> = draws
.iter()
.filter_map(|d| match d {
Draw::Blit { texture, .. } if texture.contains("pack") => Some(texture.as_str()),
_ => None,
})
.collect();
// The list shows them sorted by id.
assert_eq!(
icons,
[
"textures/misc/unknown_pack.png",
"horizon/pack-icon0",
"horizon/pack-icon1"
]
);
assert_eq!(patches.len(), 2);
for (i, (patch, icon)) in patches.iter().zip([&contrast, &dirt]).enumerate() {
let rect = atlas.sprites[&format!("horizon/pack-icon{i}")];
assert_eq!(
(patch.x, patch.y, patch.width, patch.height),
(
(rect.u0 * atlas.width as f32).round() as u32,
(rect.v0 * atlas.height as f32).round() as u32,
PACK_ICON_SIZE,
PACK_ICON_SIZE
)
);
assert_eq!(&patch.rgba[..], &icon[..]);
}
}
}
#[cfg(test)]
mod click_sound_tests {
use super::*;
+59 -7
View File
@@ -34,8 +34,6 @@ pub struct Pack {
pub description_style: Style,
pub compatible: bool,
pub compatibility: crate::pack_screen::Compat,
/// The pack has a `pack.png`.
pub icon: bool,
/// Overlay directories active for this game version, in `pack.mcmeta` order.
pub overlays: Vec<String>,
/// `filter.block` entries (namespace and path patterns) hiding resources
@@ -260,6 +258,15 @@ fn filter_of(meta: &serde_json::Value) -> Vec<Filter> {
})
.collect()
}
impl Pack {
/// The pack's `pack.png` (`PackResources.getRootResource`), for its icon.
pub fn icon_png(&self) -> Option<Vec<u8>> {
match &self.source {
Source::Dir(d) => fs::read(d.join("pack.png")).ok(),
Source::Zip(z) => Zip::open(z).ok()?.read("pack.png").ok(),
}
}
}
fn read_meta(source: &Source) -> Option<serde_json::Value> {
let bytes = match source {
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
@@ -270,10 +277,6 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
let meta = read_meta(&source)?;
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
let icon = match &source {
Source::Dir(d) => d.join("pack.png").is_file(),
Source::Zip(z) => Zip::open(z).is_ok_and(|z| z.read("pack.png").is_ok()),
};
Some(Pack {
id,
title,
@@ -282,7 +285,6 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
description_style,
compatible: compatible(&meta),
compatibility: compatibility(&meta),
icon,
overlays: overlays_of(&meta),
filter: filter_of(&meta),
source,
@@ -629,6 +631,17 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
};
serde_json::to_vec(&out).ok()
}
/// `DownloadedPackSource.loadRequestedPacks`: a server's downloaded pack,
/// "World Specific Resources"; None when its `pack.mcmeta` can't be read. Its
/// id names the file, which is named after its hash.
pub fn server_pack(id: u128, path: &Path) -> Option<Pack> {
let file = path.file_name()?.to_string_lossy();
pack(
format!("server/{id:032x}/{file}"),
Text::translate("resourcePack.server.name", &[]),
Source::Zip(path.to_path_buf()),
)
}
/// The packs to apply for an options.txt `resourcePacks` list (bottom first).
pub fn enabled(all: &[Pack], ids: &[String]) -> Vec<Pack> {
ids.iter()
@@ -983,6 +996,45 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn built_in_and_folder_packs_give_their_pack_png() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-icons-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
let built_in = base.join("vanilla/assets/minecraft/resourcepacks");
fs::create_dir_all(&built_in).unwrap();
let mut zip =
zip::ZipWriter::new(fs::File::create(built_in.join("programmer_art.zip")).unwrap());
for (name, data) in [("pack.mcmeta", &meta[..]), ("pack.png", b"dirt")] {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap();
for (name, icon) in [("Iconless", None), ("Mine", Some(&b"mine"[..]))] {
let dir = base.join("packs").join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("pack.mcmeta"), meta).unwrap();
if let Some(icon) = icon {
fs::write(dir.join("pack.png"), icon).unwrap();
}
}
let packs = discover(&base.join("vanilla"), &base.join("packs"));
let icons: Vec<_> = packs
.iter()
.map(|p| (p.id.as_str(), p.icon_png()))
.collect();
assert_eq!(
icons,
[
("programmer_art", Some(b"dirt".to_vec())),
("file/Iconless", None),
("file/Mine", Some(b"mine".to_vec())),
]
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn overlays_and_filters() {
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
let meta = v(r#"{"overlays":{"entries":[
+618
View File
@@ -0,0 +1,618 @@
//! Server resource packs (`ServerPackManager`, `DownloadedPackSource`): the
//! packs a server pushes, the player's answer to its prompt, their downloads,
//! their activation on top of the player's own packs, and the status each one
//! reports back to the server. The game drives it: it shows the prompt,
//! starts the downloads [`ServerPacks::tick`] asks for and reloads the
//! resources with [`ServerPacks::active`] when it asks for a reload.
pub mod download;
use std::path::PathBuf;
/// `ServerboundResourcePackPacket.Action`, by its protocol number.
pub mod action {
pub const SUCCESSFULLY_LOADED: i32 = 0;
pub const DECLINED: i32 = 1;
pub const FAILED_DOWNLOAD: i32 = 2;
pub const ACCEPTED: i32 = 3;
pub const DOWNLOADED: i32 = 4;
pub const INVALID_URL: i32 = 5;
pub const FAILED_RELOAD: i32 = 6;
pub const DISCARDED: i32 = 7;
}
/// `ServerPackManager.PackPromptStatus`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Prompt {
Pending,
Allowed,
Declined,
}
impl Prompt {
/// `ConnectScreen.convertPackStatus`: a server list entry's setting
/// (servers.dat `acceptTextures`; none is Prompt).
pub fn for_server(accept_textures: Option<bool>) -> Self {
match accept_textures {
Some(true) => Self::Allowed,
Some(false) => Self::Declined,
None => Self::Pending,
}
}
}
/// `ClientCommonPacketListenerImpl.parseResourcePackUrl`: only http and https.
pub fn valid_url(url: &str) -> bool {
url.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
}
/// `DownloadedPackSource.tryParseSha1Hash`: 40 hex digits, else no check.
pub fn parse_sha1(hash: &str) -> Option<String> {
(hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
.then(|| hash.to_ascii_lowercase())
}
/// One pack to download (`DownloadQueue.DownloadRequest`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Request {
pub id: u128,
pub url: String,
pub sha1: Option<String>,
}
/// `DownloadQueue.BatchResult`: the file each downloaded pack is in, and the
/// packs that failed.
#[derive(Debug, Default)]
pub struct Batch {
pub downloaded: Vec<(u128, PathBuf)>,
pub failed: Vec<u128>,
}
/// What the game has to do after a [`ServerPacks::tick`].
#[derive(Debug, Default, PartialEq)]
pub struct Tick {
/// Download these, then hand the result to [`ServerPacks::downloaded`].
pub download: Vec<Request>,
/// Reload the resources with these packs on top, bottom first
/// (`PackReloadConfig.scheduleReload`), then call [`ServerPacks::reloaded`].
pub reload: Option<Vec<(u128, PathBuf)>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Download {
Requested,
Pending,
Done,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Activation {
Inactive,
Pending,
Active,
}
/// `ServerPackManager.RemovalReason`, with the status it reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Removal {
DownloadFailed,
ActivationFailed,
Declined,
Discarded,
ServerRemoved,
ServerReplaced,
}
impl Removal {
fn reply(self) -> Option<i32> {
match self {
Self::DownloadFailed => Some(action::FAILED_DOWNLOAD),
Self::ActivationFailed => Some(action::FAILED_RELOAD),
Self::Declined => Some(action::DECLINED),
Self::Discarded => Some(action::DISCARDED),
Self::ServerRemoved | Self::ServerReplaced => None,
}
}
}
/// `ServerPackManager.ServerPackData`.
#[derive(Debug)]
struct Pack {
id: u128,
url: String,
sha1: Option<String>,
path: Option<PathBuf>,
removal: Option<Removal>,
download: Download,
activation: Activation,
accepted: bool,
}
impl Pack {
fn remove(&mut self, reason: Removal) {
self.removal.get_or_insert(reason);
}
fn removed(&self) -> bool {
self.removal.is_some()
}
}
/// The reload in progress: the packs it loads and unloads (the callbacks
/// `triggerReloadIfNeeded` gives `scheduleReload`).
#[derive(Debug)]
struct Reload {
load: Vec<u128>,
unload: Vec<u128>,
/// It reloads what was active before a failed one (`isRecovery`).
recovery: bool,
}
/// `ServerPackManager` with the feedback `DownloadedPackSource` gives it.
#[derive(Debug)]
pub struct ServerPacks {
packs: Vec<Pack>,
prompt: Prompt,
reload: Option<Reload>,
/// `registerForUpdate`: something changed, [`Self::tick`] again.
update: bool,
/// Report to the server (`configureForServerControl`); otherwise the
/// statuses are only logged, as for a local world.
reporting: bool,
replies: Vec<(u128, i32)>,
}
impl Default for ServerPacks {
fn default() -> Self {
Self {
packs: Vec::new(),
prompt: Prompt::Pending,
reload: None,
update: false,
reporting: false,
replies: Vec::new(),
}
}
}
impl ServerPacks {
fn report(&mut self, id: u128, status: i32) {
log::debug!("Horizon: server pack {id:032x} changed status to {status}");
if self.reporting {
self.replies.push((id, status));
}
}
/// The statuses to send the server, oldest first.
pub fn take_replies(&mut self) -> Vec<(u128, i32)> {
std::mem::take(&mut self.replies)
}
/// Whether [`Self::tick`] has something to do.
pub fn wants_tick(&self) -> bool {
self.update
}
/// `DownloadedPackSource.configureForServerControl`.
pub fn connect(&mut self, prompt: Prompt) {
self.reporting = true;
match prompt {
Prompt::Allowed => self.allow(),
Prompt::Declined => self.reject(),
Prompt::Pending => self.prompt = Prompt::Pending,
}
}
/// `DownloadedPackSource.configureForLocalWorld`: single player's own
/// server; its packs are taken, and nothing is reported.
pub fn local_world(&mut self) {
self.reporting = false;
self.allow();
}
/// `DownloadedPackSource.cleanupAfterDisconnect`: every pack goes, and
/// the next server starts from its own setting.
pub fn disconnect(&mut self) {
self.pop_all();
self.reporting = false;
self.replies.clear();
self.prompt = Prompt::Pending;
}
pub fn push(&mut self, id: u128, url: &str, sha1: Option<String>) {
if self.prompt == Prompt::Declined {
self.report(id, action::DECLINED);
return;
}
for pack in self.packs.iter_mut().filter(|p| p.id == id) {
pack.remove(Removal::ServerReplaced);
}
self.packs.push(Pack {
id,
url: url.to_owned(),
sha1,
path: None,
removal: None,
download: Download::Requested,
activation: Activation::Inactive,
accepted: false,
});
if self.prompt == Prompt::Allowed {
self.accept(self.packs.len() - 1);
}
self.update = true;
}
fn accept(&mut self, i: usize) {
self.packs[i].accepted = true;
let id = self.packs[i].id;
self.report(id, action::ACCEPTED);
}
pub fn pop(&mut self, id: u128) {
if let Some(pack) = self.packs.iter_mut().find(|p| !p.removed() && p.id == id) {
pack.remove(Removal::ServerRemoved);
self.update = true;
}
}
pub fn pop_all(&mut self) {
for pack in &mut self.packs {
pack.remove(Removal::ServerRemoved);
}
self.update = true;
}
/// `allowServerPacks`: the prompt said yes (or the server is Enabled).
pub fn allow(&mut self) {
self.prompt = Prompt::Allowed;
for i in 0..self.packs.len() {
if !self.packs[i].accepted && !self.packs[i].removed() {
self.accept(i);
}
}
self.update = true;
}
/// `rejectServerPacks`: the prompt said no (or the server is Disabled).
pub fn reject(&mut self) {
self.prompt = Prompt::Declined;
for pack in self.packs.iter_mut().filter(|p| !p.accepted) {
pack.remove(Removal::Declined);
}
self.update = true;
}
/// `ServerPackManager.tick`: start the downloads the accepted packs
/// need; once none is left, reload if the active packs should change;
/// then forget the packs that are gone, reporting why.
pub fn tick(&mut self) -> Tick {
self.update = false;
let mut out = Tick::default();
let mut downloading = false;
for pack in self.packs.iter_mut().filter(|p| !p.removed() && p.accepted) {
downloading |= pack.download != Download::Done;
if pack.download == Download::Requested {
pack.download = Download::Pending;
out.download.push(Request {
id: pack.id,
url: pack.url.clone(),
sha1: pack.sha1.clone(),
});
}
}
if !downloading {
out.reload = self.reload_if_needed();
}
let mut gone = Vec::new();
self.packs.retain(|p| {
let keep = p.activation != Activation::Inactive || p.removal.is_none();
if !keep && let Some(reply) = p.removal.and_then(Removal::reply) {
gone.push((p.id, reply));
}
keep
});
for (id, reply) in gone {
self.report(id, reply);
}
out
}
fn reload_if_needed(&mut self) -> Option<Vec<(u128, PathBuf)>> {
if self.reload.is_some()
|| self
.packs
.iter()
.any(|p| p.activation == Activation::Pending)
{
return None;
}
let mut changed = false;
let (mut load, mut unload) = (Vec::new(), Vec::new());
for pack in &self.packs {
let wanted = pack.accepted && pack.download == Download::Done && !pack.removed();
match pack.activation {
Activation::Inactive if wanted => {
load.push(pack.id);
changed = true;
}
Activation::Active if wanted => load.push(pack.id),
Activation::Active => {
unload.push(pack.id);
changed = true;
}
_ => {}
}
}
if !changed {
return None;
}
for pack in &mut self.packs {
let loading = load.contains(&pack.id) && pack.activation != Activation::Active;
if loading || unload.contains(&pack.id) {
pack.activation = Activation::Pending;
}
}
let paths = self.paths(&load);
self.reload = Some(Reload {
load,
unload,
recovery: false,
});
Some(paths)
}
/// The packs to load (`PackReloadConfig.Callbacks.packsToLoad`), bottom first.
fn paths(&self, ids: &[u128]) -> Vec<(u128, PathBuf)> {
ids.iter()
.filter_map(|id| {
let pack = self
.packs
.iter()
.find(|p| p.id == *id && p.path.is_some())?;
Some((*id, pack.path.clone()?))
})
.collect()
}
/// `ServerPackManager.onDownload`.
pub fn downloaded(&mut self, batch: Batch) {
if !batch.failed.is_empty() {
for pack in self
.packs
.iter_mut()
.filter(|p| p.activation != Activation::Active)
{
pack.remove(if batch.failed.contains(&pack.id) {
Removal::DownloadFailed
} else {
Removal::Discarded
});
}
}
let mut done = Vec::new();
for (id, path) in batch.downloaded {
if let Some(pack) = self
.packs
.iter_mut()
.find(|p| p.id == id && p.download == Download::Pending)
{
pack.download = Download::Done;
pack.path = Some(path);
if !pack.removed() {
done.push(id);
}
}
}
for id in done {
self.report(id, action::DOWNLOADED);
}
self.update = true;
}
/// The reload [`Tick::reload`] asked for finished (`Callbacks.onSuccess`),
/// or its packs could not be loaded (`onFailure`). After a first failure
/// the packs that were active are loaded again: the packs to reload with
/// are returned, and this is called again with that reload's outcome.
pub fn reloaded(&mut self, ok: bool) -> Option<Vec<(u128, PathBuf)>> {
let mut reload = self.reload.take()?;
self.update = true;
if ok {
let mut applied = Vec::new();
for pack in &mut self.packs {
if reload.load.contains(&pack.id) {
pack.activation = Activation::Active;
if pack.removal.is_none() {
applied.push(pack.id);
}
} else if reload.unload.contains(&pack.id) {
pack.activation = Activation::Inactive;
}
}
for id in applied {
self.report(id, action::SUCCESSFULLY_LOADED);
}
return None;
}
if reload.recovery {
// onFailure(true): nothing of the server's is loaded.
for pack in &mut self.packs {
if pack.activation == Activation::Pending {
pack.activation = Activation::Inactive;
}
}
return None;
}
reload.load.clear();
for pack in &mut self.packs {
match pack.activation {
Activation::Inactive => pack.remove(Removal::Discarded),
Activation::Pending => {
pack.activation = Activation::Inactive;
pack.remove(Removal::ActivationFailed);
}
Activation::Active => reload.load.push(pack.id),
}
}
reload.recovery = true;
let paths = self.paths(&reload.load);
self.reload = Some(reload);
Some(paths)
}
}
#[cfg(test)]
mod tests {
use super::*;
use action::*;
fn ok(id: u128) -> Batch {
Batch {
downloaded: vec![(id, PathBuf::from(format!("/p/{id}")))],
failed: vec![],
}
}
/// Runs the updates as `createUpdateScheduler` does, downloading and
/// reloading with `download` and `reload` as the game would.
fn settle(
sp: &mut ServerPacks,
download: &mut dyn FnMut(&[Request]) -> Batch,
reload: &mut dyn FnMut(&[(u128, PathBuf)]) -> bool,
) {
let mut rounds = 0;
while sp.wants_tick() {
rounds += 1;
assert!(rounds < 50);
let t = sp.tick();
if !t.download.is_empty() {
let batch = download(&t.download);
sp.downloaded(batch);
}
if let Some(mut packs) = t.reload {
while let Some(back) = sp.reloaded(reload(&packs)) {
packs = back;
}
}
}
}
#[test]
fn an_enabled_server_s_pack_is_accepted_downloaded_and_loaded() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
let mut loaded = Vec::new();
settle(&mut sp, &mut |r| ok(r[0].id), &mut |p| {
loaded = p.to_vec();
true
});
assert_eq!(
sp.take_replies(),
[(1, ACCEPTED), (1, DOWNLOADED), (1, SUCCESSFULLY_LOADED)]
);
assert_eq!(loaded, [(1, PathBuf::from("/p/1"))]);
}
#[test]
fn the_prompt_holds_packs_until_answered() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Pending);
sp.push(1, "http://a/p.zip", None);
settle(&mut sp, &mut |_| unreachable!(), &mut |_| unreachable!());
assert!(sp.take_replies().is_empty());
sp.reject();
settle(&mut sp, &mut |_| unreachable!(), &mut |_| unreachable!());
assert_eq!(sp.take_replies(), [(1, DECLINED)]);
// Declined: later pushes are declined at once.
sp.push(2, "http://a/q.zip", None);
assert_eq!(sp.take_replies(), [(2, DECLINED)]);
}
#[test]
fn a_failed_download_discards_the_rest_of_the_batch() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
sp.push(2, "http://a/dead.zip", None);
settle(
&mut sp,
&mut |_| Batch {
downloaded: vec![(1, PathBuf::from("/p/1"))],
failed: vec![2],
},
&mut |_| unreachable!(),
);
assert_eq!(
sp.take_replies(),
[
(1, ACCEPTED),
(2, ACCEPTED),
(1, DISCARDED),
(2, FAILED_DOWNLOAD)
]
);
}
#[test]
fn a_pack_that_does_not_load_fails_its_reload() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
let mut reloads = Vec::new();
settle(&mut sp, &mut |r| ok(r[0].id), &mut |p| {
reloads.push(p.to_vec());
reloads.len() > 1
});
assert_eq!(
sp.take_replies(),
[(1, ACCEPTED), (1, DOWNLOADED), (1, FAILED_RELOAD)]
);
// The recovery reload has none of the server's packs.
assert_eq!(reloads[1], []);
}
#[test]
fn popping_and_disconnecting_unload_the_packs() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
sp.push(2, "http://a/q.zip", None);
let last = std::cell::RefCell::new(Vec::new());
let mut reload = |p: &[(u128, PathBuf)]| {
*last.borrow_mut() = p.to_vec();
true
};
settle(
&mut sp,
&mut |r| Batch {
downloaded: r
.iter()
.map(|q| (q.id, PathBuf::from(format!("/p/{}", q.id))))
.collect(),
failed: vec![],
},
&mut reload,
);
sp.take_replies();
sp.pop(1);
settle(&mut sp, &mut |_| unreachable!(), &mut reload);
// Callbacks.onSuccess reports every pack the reload loaded, again.
assert_eq!(sp.take_replies(), [(2, SUCCESSFULLY_LOADED)]);
assert_eq!(*last.borrow(), [(2, PathBuf::from("/p/2"))]);
sp.disconnect();
settle(&mut sp, &mut |_| unreachable!(), &mut reload);
assert!(last.borrow().is_empty());
assert!(sp.packs.is_empty());
}
#[test]
fn a_pushed_id_replaces_its_pack() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/old.zip", None);
sp.push(1, "http://a/new.zip", None);
let mut urls = Vec::new();
settle(
&mut sp,
&mut |r| {
urls.extend(r.iter().map(|q| q.url.clone()));
ok(1)
},
&mut |_| true,
);
assert_eq!(urls, ["http://a/new.zip"]);
}
#[test]
fn urls_and_hashes_are_read_as_vanilla_reads_them() {
assert!(valid_url("https://example.com/p.zip"));
assert!(valid_url("HTTP://example.com/p.zip"));
assert!(!valid_url("ftp://example.com/p.zip"));
assert!(!valid_url("example.com/p.zip"));
let sha1 = "0123456789ABCDEF0123456789abcdef01234567";
assert_eq!(parse_sha1(sha1), Some(sha1.to_ascii_lowercase()));
assert_eq!(parse_sha1("abc"), None);
}
}
@@ -0,0 +1,482 @@
//! Server pack downloads into the game directory's `downloads/` cache, as
//! vanilla keeps it (`DownloadQueue`, `HttpUtil.downloadFile`,
//! `DownloadCacheCleaner`): `downloads/<pack id>/<SHA-1 of the file>`, one
//! batch at a time on a thread of its own, every download logged in
//! `downloads/log.json`, and the cache cut to its 20 newest files on start.
use super::{Batch, Request};
use sha1::{Digest, Sha1};
use std::{
fs,
io::{self, Read, Write},
path::{Path, PathBuf},
sync::mpsc::{Receiver, Sender, channel},
time::SystemTime,
};
/// `DownloadedPackSource`'s MAX_PACK_SIZE_BYTES (250 MiB).
pub const MAX_SIZE: u64 = 262_144_000;
/// `DownloadQueue.MAX_KEPT_PACKS`.
const MAX_KEPT: usize = 20;
/// `createDownloadHeaders`: who is downloading, with which game.
pub fn headers(name: &str, uuid: u128) -> Vec<(&'static str, String)> {
use horizon_core::version::{RESOURCE_PACK_FORMAT, VANILLA_VERSION, VERSION_ID};
vec![
("X-Minecraft-Username", name.to_owned()),
("X-Minecraft-UUID", format!("{uuid:032x}")),
("X-Minecraft-Version", VANILLA_VERSION.to_owned()),
("X-Minecraft-Version-ID", VERSION_ID.to_owned()),
(
"X-Minecraft-Pack-Format",
format!("{}.{}", RESOURCE_PACK_FORMAT.0, RESOURCE_PACK_FORMAT.1),
),
("User-Agent", format!("Minecraft Java/{VANILLA_VERSION}")),
]
}
type Job = (Vec<Request>, Vec<(&'static str, String)>);
/// `DownloadQueue`: batches download in order, on one thread.
pub struct Queue {
jobs: Sender<Job>,
done: Receiver<Batch>,
}
impl Queue {
pub fn open(dir: PathBuf) -> io::Result<Self> {
fs::create_dir_all(&dir)?;
vacuum(&dir, MAX_KEPT);
let (jobs, todo) = channel::<Job>();
let (finished, done) = channel();
std::thread::Builder::new()
.name("download-queue".into())
.spawn(move || {
for (requests, headers) in todo {
if finished.send(run(&dir, &requests, &headers)).is_err() {
break;
}
}
})?;
Ok(Self { jobs, done })
}
pub fn download(&self, requests: Vec<Request>, headers: Vec<(&'static str, String)>) {
let _ = self.jobs.send((requests, headers));
}
/// The batches finished since the last call.
pub fn finished(&self) -> Vec<Batch> {
self.done.try_iter().collect()
}
}
/// `DownloadQueue.runDownload`: each pack into `<dir>/<id>/`, logged.
fn run(dir: &Path, requests: &[Request], headers: &[(&'static str, String)]) -> Batch {
let mut batch = Batch::default();
for request in requests {
let id = horizon_protocol::crypto::uuid_string(request.id);
let result = download_file(&dir.join(&id), request, headers, MAX_SIZE);
// DownloadQueue.LogEntry, its fields in the codec's order.
let mut entry = vec![
("id", serde_json::Value::from(id)),
("url", request.url.as_str().into()),
(
"time",
chrono::Utc::now()
.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)
.into(),
),
];
if let Some(sha1) = &request.sha1 {
entry.push(("hash", sha1.as_str().into()));
}
entry.push(match &result {
Ok(path) => match fs::metadata(path) {
Ok(meta) => {
let name = path.strip_prefix(dir).unwrap_or(path);
let name = name.to_string_lossy().replace('\\', "/");
(
"file",
serde_json::json!({"name": name, "size": meta.len()}),
)
}
Err(_) => ("error", "no_access".into()),
},
Err(e) => {
log::error!("Horizon: failed to download {}: {e}", request.url);
("error", "download_failed".into())
}
});
if let Err(e) = append_log(&dir.join("log.json"), &entry) {
log::error!("Horizon: failed to log download of {}: {e}", request.url);
}
match result {
Ok(path) => batch.downloaded.push((request.id, path)),
Err(_) => batch.failed.push(request.id),
}
}
batch
}
/// `JsonEventLog.write`: one JSON object a line, as Gson writes it (with
/// its HTML escaping).
fn append_log(path: &Path, entry: &[(&str, serde_json::Value)]) -> io::Result<()> {
let fields: Vec<String> = entry
.iter()
.map(|(k, v)| format!("{}:{v}", serde_json::Value::from(*k)))
.collect();
let mut line = String::new();
for c in format!("{{{}}}", fields.join(",")).chars() {
match c {
'<' | '>' | '&' | '=' | '\'' => line.push_str(&format!("\\u{:04x}", c as u32)),
c => line.push(c),
}
}
line.push('\n');
fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?
.write_all(line.as_bytes())
}
fn sha1_of(path: &Path) -> io::Result<String> {
let mut hasher = Sha1::new();
io::copy(&mut fs::File::open(path)?, &mut hasher)?;
Ok(hex(&hasher.finalize()))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// `checkExistingFile`: the file is there with that hash.
fn matches(path: &Path, sha1: &str) -> bool {
path.exists() && sha1_of(path).is_ok_and(|actual| actual == sha1)
}
/// `updateModificationTime`: used now, so the cleaner keeps it longest.
fn touch(path: &Path) {
let _ = fs::File::options()
.append(true)
.open(path)
.and_then(|f| f.set_modified(SystemTime::now()));
}
/// `HttpUtil.downloadFile`: into `<dir>/<SHA-1>`. With a hash the cached file
/// is used when it matches, and a download that does not is an error;
/// without one the file is named after the hash of what came.
pub fn download_file(
dir: &Path,
request: &Request,
headers: &[(&'static str, String)],
max_size: u64,
) -> Result<PathBuf, String> {
let target = request.sha1.as_ref().map(|sha1| dir.join(sha1));
if let (Some(target), Some(sha1)) = (&target, &request.sha1) {
if matches(target, sha1) {
log::info!("Horizon: returning cached file since actual hash matches requested");
touch(target);
return Ok(target.clone());
}
if target.exists() {
log::warn!(
"Horizon: existing file {} not found or had mismatched hash",
target.display()
);
}
fs::remove_file(target)
.or_else(|e| {
if e.kind() == io::ErrorKind::NotFound {
Ok(())
} else {
Err(e)
}
})
.map_err(|e| format!("Failed to remove existing file {}: {e}", target.display()))?;
}
let mut call = ureq::AgentBuilder::new().build().get(&request.url);
for (name, value) in headers {
call = call.set(name, value);
}
let response = call.call().map_err(|e| e.to_string())?;
let size = response
.header("Content-Length")
.and_then(|v| v.trim().parse::<u64>().ok());
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
if let Some(size) = size
&& size > max_size
{
return Err(format!(
"Filesize is bigger than maximum allowed (file is {size}, limit is {max_size})"
));
}
let mut input = response.into_reader();
match (&target, &request.sha1) {
(Some(target), Some(sha1)) => {
let actual = save_hashed(&mut input, target, max_size).map_err(|e| e.to_string())?;
if &actual != sha1 {
return Err(format!(
"Hash of downloaded file ({actual}) did not match requested ({sha1})"
));
}
Ok(target.clone())
}
_ => {
let tmp = dir.join(format!("download{:016x}.tmp", rand_suffix()));
let result = save_hashed(&mut input, &tmp, max_size).and_then(|actual| {
let path = dir.join(&actual);
if matches(&path, &actual) {
touch(&path);
} else {
fs::rename(&tmp, &path)?;
}
Ok(path)
});
let _ = fs::remove_file(&tmp);
result.map_err(|e| e.to_string())
}
}
}
fn rand_suffix() -> u64 {
use std::hash::{BuildHasher, RandomState};
RandomState::new().hash_one(std::time::Instant::now())
}
/// `downloadAndHash`: write what comes, at most `max_size` bytes.
fn save_hashed(input: &mut dyn Read, path: &Path, max_size: u64) -> io::Result<String> {
let mut out = fs::File::create(path)?;
let mut hasher = Sha1::new();
let mut buffer = [0u8; 8196];
let mut read_so_far = 0u64;
loop {
let n = input.read(&mut buffer)?;
if n == 0 {
break;
}
read_so_far += n as u64;
if read_so_far > max_size {
return Err(io::Error::other(format!(
"Filesize was bigger than maximum allowed (got >= {read_so_far}, limit was {max_size})"
)));
}
out.write_all(&buffer[..n])?;
hasher.update(&buffer[..n]);
}
Ok(hex(&hasher.finalize()))
}
/// `DownloadCacheCleaner.vacuumCacheDir`: keep the `max_files` files most
/// worth keeping (in the pack folders: the newest, and one per pack before a
/// second of any), then drop the folders left empty.
pub fn vacuum(dir: &Path, max_files: usize) {
let mut files = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(entries) = fs::read_dir(&d) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
let Ok(meta) = e.metadata() else { continue };
if meta.is_dir() {
stack.push(path);
} else if meta.is_file() && path.parent() != Some(dir) {
let time = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
files.push((path, time));
}
}
}
let Some(remove) = files.len().checked_sub(max_files).filter(|n| *n > 0) else {
return;
};
// Newest first, then each file's rank in its folder (0 for a folder's
// newest), reversed, and stably sorted by rank, highest first.
files.sort_by_key(|f| std::cmp::Reverse(f.1));
let mut counts = std::collections::HashMap::<PathBuf, usize>::new();
let mut ranked: Vec<(PathBuf, usize)> = files
.into_iter()
.map(|(path, _)| {
let count = counts
.entry(path.parent().unwrap_or(dir).to_path_buf())
.or_default();
let rank = *count;
*count += 1;
(path, rank)
})
.collect();
ranked.reverse();
ranked.sort_by_key(|f| std::cmp::Reverse(f.1));
let mut emptied = std::collections::HashSet::new();
for (path, rank) in ranked.into_iter().take(remove) {
match fs::remove_file(&path) {
Ok(()) if rank == 0 => {
emptied.insert(path.parent().unwrap_or(dir).to_path_buf());
}
Ok(()) => {}
Err(e) => log::warn!(
"Horizon: failed to delete cache file {}: {e}",
path.display()
),
}
}
emptied.remove(dir);
for d in emptied {
let _ = fs::remove_dir(d);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
/// A one-shot HTTP server answering `status` with `body`; the request it got.
fn serve(status: &str, body: Vec<u8>) -> (String, std::thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/pack.zip", listener.local_addr().unwrap());
let status = status.to_owned();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 1024];
while !request.ends_with(b"\r\n\r\n") {
let n = stream.read(&mut buf).unwrap();
request.extend_from_slice(&buf[..n]);
}
let head = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(head.as_bytes()).unwrap();
stream.write_all(&body).unwrap();
String::from_utf8_lossy(&request).into_owned()
});
(url, handle)
}
fn sha1(bytes: &[u8]) -> String {
hex(&Sha1::digest(bytes))
}
fn scratch() -> PathBuf {
let dir = std::env::temp_dir().join(format!("horizon-packs-{:016x}", rand_suffix()));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_pack_lands_under_its_hash_and_is_reused_from_the_cache() {
let dir = scratch();
let body = b"PK pack".to_vec();
let (url, server) = serve("200 OK", body.clone());
let request = Request {
id: 7,
url,
sha1: Some(sha1(&body)),
};
let path = download_file(&dir, &request, &headers("Alex", 0xab), MAX_SIZE).unwrap();
assert_eq!(path, dir.join(sha1(&body)));
assert_eq!(fs::read(&path).unwrap(), body);
let sent = server.join().unwrap();
assert!(sent.contains("X-Minecraft-Username: Alex"), "{sent}");
assert!(sent.contains("X-Minecraft-Pack-Format: 97.1"), "{sent}");
// Cached: no server this time.
let cached = Request {
url: "http://127.0.0.1:9/gone.zip".into(),
..request
};
assert_eq!(download_file(&dir, &cached, &[], MAX_SIZE).unwrap(), path);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_wrong_hash_a_dead_url_and_a_large_pack_fail() {
let dir = scratch();
let (url, server) = serve("200 OK", b"other bytes".to_vec());
let wrong = Request {
id: 1,
url,
sha1: Some("0".repeat(40)),
};
let err = download_file(&dir, &wrong, &[], MAX_SIZE).unwrap_err();
assert!(err.contains("did not match requested"), "{err}");
server.join().unwrap();
let dead = Request {
id: 2,
url: "http://127.0.0.1:9/pack.zip".into(),
sha1: None,
};
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE).is_err());
assert!(!dir.join("dead").exists());
let (url, server) = serve("200 OK", vec![0; 100]);
let large = Request {
id: 3,
url,
sha1: None,
};
let err = download_file(&dir, &large, &[], 10).unwrap_err();
assert!(err.contains("bigger than maximum"), "{err}");
let _ = server.join();
let (url, server) = serve("404 Not Found", Vec::new());
let missing = Request {
id: 4,
url,
sha1: None,
};
assert!(download_file(&dir, &missing, &[], MAX_SIZE).is_err());
server.join().unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn the_queue_logs_each_download() {
let dir = scratch();
let (url, server) = serve("200 OK", b"zip".to_vec());
let queue = Queue::open(dir.clone()).unwrap();
let id = 0x5b2c_7a4e_1f3d_4c8b_9a6e_2d4f_8b1c_3e5a;
queue.download(
vec![Request {
id,
url,
sha1: None,
}],
Vec::new(),
);
let batch = loop {
if let Some(b) = queue.finished().pop() {
break b;
}
std::thread::sleep(std::time::Duration::from_millis(10));
};
server.join().unwrap();
let file = dir
.join("5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a")
.join(sha1(b"zip"));
assert_eq!(batch.downloaded, [(id, file)]);
let log = fs::read_to_string(dir.join("log.json")).unwrap();
let entry: serde_json::Value = serde_json::from_str(log.trim()).unwrap();
assert_eq!(entry["id"], "5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a");
assert_eq!(entry["file"]["size"], 3);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn the_cleaner_keeps_the_newest_file_of_each_pack_longest() {
let dir = scratch();
let at = |secs| SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
for (pack, file, time) in [
("a", "1", 10),
("a", "2", 20),
("a", "3", 30),
("b", "1", 5),
] {
fs::create_dir_all(dir.join(pack)).unwrap();
let path = dir.join(pack).join(file);
fs::write(&path, b"x").unwrap();
fs::File::options()
.append(true)
.open(&path)
.unwrap()
.set_modified(at(time))
.unwrap();
}
fs::write(dir.join("log.json"), b"").unwrap();
vacuum(&dir, 2);
assert!(dir.join("a/3").exists() && dir.join("b/1").exists());
assert!(!dir.join("a/1").exists() && !dir.join("a/2").exists());
assert!(dir.join("log.json").exists());
fs::remove_dir_all(dir).unwrap();
}
}
-5
View File
@@ -153,7 +153,6 @@ struct MeshSettings {
blend_radius: i32,
smooth_lighting: bool,
cutout_leaves: bool,
cull_leaves: bool,
}
enum Job {
Generate(i32, i32),
@@ -258,7 +257,6 @@ pub struct Streaming {
/// The `ao` and `cutoutLeaves` options.
pub smooth_lighting: bool,
pub cutout_leaves: bool,
pub cull_leaves: bool,
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
light_tables: Arc<LightTables>,
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
@@ -500,7 +498,6 @@ impl Streaming {
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
cull_leaves: settings.cull_leaves,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
@@ -556,7 +553,6 @@ impl Streaming {
biome_blend: 2,
smooth_lighting: true,
cutout_leaves: true,
cull_leaves: false,
light_tables,
light_log: Vec::new(),
light_edit_sequence: 0,
@@ -1177,7 +1173,6 @@ impl Streaming {
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
cutout_leaves: self.cutout_leaves,
cull_leaves: self.cull_leaves,
},
),
);
@@ -215,7 +215,6 @@ fn relit_columns_keep_every_mesh_current() {
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
cull_leaves: false,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
+122 -4
View File
@@ -148,10 +148,11 @@ impl PerlinNoise {
y_scale: f64,
amplitude: f32,
) -> Result<(), DensityError> {
self.add_volume(buffer, volume, xz_scale, y_scale, amplitude, None)
self.add_volume::<true>(buffer, volume, xz_scale, y_scale, amplitude, None)
}
fn add_volume(
/// `BATCH` = false evaluates one Y at a time: the reference the batched loop is tested against.
fn add_volume<const BATCH: bool>(
&self,
buffer: &mut [f32],
volume: DensityVolume,
@@ -207,7 +208,10 @@ impl PerlinNoise {
let mut previous_y = i32::MIN;
let mut xz_dots = [0.0_f32; 8];
let mut y_gradients = [0.0_f32; 8];
for (sample, out) in ys.iter().zip(&mut buffer[index..index + size_y]) {
let column = &mut buffer[index..index + size_y];
let mut y = 0;
while y < size_y {
let sample = &ys[y];
if previous_y != sample.cell {
let gradients = self.gradients_at(p, q, sample.cell, cell_z);
for i in 0..8 {
@@ -217,11 +221,31 @@ impl PerlinNoise {
}
previous_y = sample.cell;
}
// Four Ys in one cell share the corners: side by side, LLVM computes them
// as vectors. Cell boundaries and the column's tail go one at a time.
if BATCH
&& y + 4 <= size_y
&& ys[y + 1..y + 4].iter().all(|s| s.cell == sample.cell)
{
add_four(
&mut column[y..y + 4],
&ys[y..y + 4],
xz_dots,
y_gradients,
fade_x,
fade_z,
amplitude,
);
y += 4;
continue;
}
let out = &mut column[y];
let dy = sample.offset;
let values = std::array::from_fn(|i| {
xz_dots[i] + y_gradients[i] * (dy - ((i >> 1) & 1) as f32)
});
*out += amplitude * lerp3([fade_x, sample.fade, fade_z], values);
y += 1;
}
index += size_y;
}
@@ -293,7 +317,7 @@ impl SmearedPerlinNoise {
y_scale: f64,
amplitude: f32,
) -> Result<(), DensityError> {
self.base.add_volume(
self.base.add_volume::<true>(
buffer,
volume,
xz_scale,
@@ -312,3 +336,97 @@ fn compute_fudge_y(input_y: f64, relative_y: f64, scale: f64) -> f64 {
};
f64::from(floor_i32(limited_y / scale + f64::from(1e-7_f32))) * scale
}
/// Adds four Y samples of one cell, laid out lane by lane so LLVM vectorizes them (no intrinsics,
/// no target feature). Each lane does the one-at-a-time loop's operations in the same order.
#[inline]
fn add_four(
out: &mut [f32],
ys: &[YSample],
dots: [f32; 8],
gradients: [f32; 8],
fade_x: f32,
fade_z: f32,
amplitude: f32,
) {
let dy: [f32; 4] = std::array::from_fn(|lane| ys[lane].offset);
let fy: [f32; 4] = std::array::from_fn(|lane| ys[lane].fade);
let values: [[f32; 4]; 8] = std::array::from_fn(|i| {
std::array::from_fn(|lane| dots[i] + gradients[i] * (dy[lane] - ((i >> 1) & 1) as f32))
});
for lane in 0..4 {
out[lane] += amplitude * lerp3([fade_x, fy[lane], fade_z], values.map(|v| v[lane]));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::random::LegacyRandom;
#[test]
fn batched_volume_is_bit_identical() {
let scales = [
0.0,
-0.0,
0.001,
-0.001,
0.125,
-0.5,
1.0,
4.0,
1e100,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NAN,
];
let amplitudes = [
0.0,
-0.0,
1.0,
-1.0,
f32::INFINITY,
f32::NEG_INFINITY,
f32::NAN,
f32::from_bits(1),
];
for seed in [0, -1, 1234, i64::MIN] {
let noise = PerlinNoise::new(&mut LegacyRandom::new(seed));
for height in [1, 2, 3, 4, 5, 7, 8, 9, 31, 64, 65, 97] {
for (case, &scale) in scales.iter().enumerate() {
let minimum = [
[-17, -64, 49],
[i32::MAX, i32::MIN, -16777216],
[16777216, 33554431, -33554432],
][case % 3];
let volume = DensityVolume::new(
[2, height, 2],
minimum,
[4, if case % 3 == 0 { i32::MAX } else { 1 }, 16],
)
.unwrap();
for fudge in [None, Some(0.125), Some(-0.25), Some(0.0), Some(f64::NAN)] {
let initial = [0.0, -0.0, 1.5, f32::from_bits(1)];
let mut a: Vec<_> =
(0..volume.size() + 3).map(|i| initial[i % 4]).collect();
let mut b = a.clone();
let amplitude = amplitudes[case % amplitudes.len()];
noise
.add_volume::<false>(&mut a, volume, scale, scale, amplitude, fudge)
.unwrap();
noise
.add_volume::<true>(&mut b, volume, scale, scale, amplitude, fudge)
.unwrap();
for (i, (a, b)) in a.iter().zip(&b).enumerate() {
assert_eq!(
a.to_bits(),
b.to_bits(),
"seed={seed} height={height} scale={scale} fudge={fudge:?} index={i}"
);
}
}
}
}
}
}
}
+12
View File
@@ -14,19 +14,24 @@ interface log {
warn,
error,
}
/// One line in the game's log as `[mod <id>] message`, at that level.
write: func(level: level, message: string);
}
/// Capability `game-read`: the local player and the world clock.
interface game {
record player {
/// Feet position, in blocks.
x: f64,
y: f64,
z: f64,
/// Degrees, as F3 shows them.
yaw: f32,
/// Degrees: -90 looks straight up, 90 straight down.
pitch: f32,
/// Hit points as vanilla counts them: 20 is full health without effects.
health: f32,
/// 0 to 20, as the hunger bar.
food: u32,
/// `minecraft:overworld`, `minecraft:the_nether`, ...
dimension: string,
@@ -39,6 +44,7 @@ interface game {
}
/// None on the title screen.
get-player: func() -> option<player>;
/// None on the title screen.
get-clock: func() -> option<clock>;
}
@@ -58,11 +64,14 @@ interface hud {
bottom-right,
}
record line {
/// Plain text, up to 256 characters.
text: string,
/// 0xRRGGBB.
color: u32,
}
/// Replaces the mod's panel: up to 16 lines in that corner of the screen.
set-panel: func(corner: corner, lines: list<line>);
/// Removes the mod's panel.
clear: func();
}
@@ -92,10 +101,13 @@ interface events {
}
/// Once, after loading. An error keeps the mod off and is shown.
init: func() -> result<registration, string>;
/// Each game tick while in a world, with the game's tick counter.
tick: func(tick: u64);
/// Each rendered frame, with the seconds since the last one.
frame: func(seconds: f32);
/// `/name args`: ok(some(text)) is shown as feedback, err(text) in red.
command: func(name: string, args: string) -> result<option<string>, string>;
/// Each chat line the player receives, as plain text.
chat: func(text: string);
}
+5
View File
@@ -30,5 +30,10 @@ pack = ["dep:wit-component", "dep:wit-parser"]
name = "sandbox"
required-features = ["runtime", "pack"]
# docs/api/mods-reference.md from the WIT contract (HORIZON_WRITE_DOCS=1 rewrites it).
[[test]]
name = "api_reference"
required-features = ["pack"]
[lints]
workspace = true
+304
View File
@@ -0,0 +1,304 @@
//! docs/api/mods-reference.md, generated from the WIT contract and the manifest's capabilities, and a test that
//! fails when it is out of date. Update it with:
//! `HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference`.
use horizon_mods::manifest::{APIS, Capability};
use std::{fmt::Write, path::Path};
use wit_parser::{Docs, Function, Handle, Resolve, Type, TypeDefKind, WorldItem};
const WIT: &str = "crates/horizon-mod-sdk/wit/horizon.wit";
const DOC: &str = "docs/api/mods-reference.md";
fn root() -> &'static Path {
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."))
}
/// A doc comment on one line, for a table cell.
fn cell(docs: &Docs) -> String {
let text = docs.contents.as_deref().unwrap_or("").trim();
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace('|', "\\|")
}
/// A type as WIT spells it.
fn ty(resolve: &Resolve, t: &Type) -> String {
let id = match t {
Type::Bool => return "bool".into(),
Type::U8 => return "u8".into(),
Type::U16 => return "u16".into(),
Type::U32 => return "u32".into(),
Type::U64 => return "u64".into(),
Type::S8 => return "s8".into(),
Type::S16 => return "s16".into(),
Type::S32 => return "s32".into(),
Type::S64 => return "s64".into(),
Type::F32 => return "f32".into(),
Type::F64 => return "f64".into(),
Type::Char => return "char".into(),
Type::String => return "string".into(),
Type::ErrorContext => return "error-context".into(),
Type::Id(id) => *id,
};
match &resolve.types[id].name {
Some(name) => name.clone(),
None => definition(resolve, &resolve.types[id].kind),
}
}
/// What an anonymous type, or the right-hand side of a `type` alias, spells.
fn definition(resolve: &Resolve, kind: &TypeDefKind) -> String {
let of = |t: &Option<Type>| t.as_ref().map_or("_".into(), |t| ty(resolve, t));
match kind {
TypeDefKind::Option(t) => format!("option<{}>", ty(resolve, t)),
TypeDefKind::List(t) => format!("list<{}>", ty(resolve, t)),
TypeDefKind::FixedSizeList(t, n) => format!("list<{}, {n}>", ty(resolve, t)),
TypeDefKind::Result(r) => match (&r.ok, &r.err) {
(None, None) => "result".into(),
(ok, None) => format!("result<{}>", of(ok)),
(ok, err) => format!("result<{}, {}>", of(ok), of(err)),
},
TypeDefKind::Tuple(t) => {
let items: Vec<_> = t.types.iter().map(|t| ty(resolve, t)).collect();
format!("tuple<{}>", items.join(", "))
}
TypeDefKind::Future(t) => t
.as_ref()
.map_or("future".into(), |t| format!("future<{}>", ty(resolve, t))),
TypeDefKind::Stream(t) => t
.as_ref()
.map_or("stream".into(), |t| format!("stream<{}>", ty(resolve, t))),
TypeDefKind::Handle(Handle::Own(t)) => ty(resolve, &Type::Id(*t)),
TypeDefKind::Handle(Handle::Borrow(t)) => format!("borrow<{}>", ty(resolve, &Type::Id(*t))),
TypeDefKind::Type(t) => ty(resolve, t),
other => panic!(
"{WIT}: an anonymous {} the reference can't spell",
other.as_str()
),
}
}
fn signature(resolve: &Resolve, f: &Function) -> String {
let params: Vec<_> = f
.params
.iter()
.map(|(name, t)| format!("{name}: {}", ty(resolve, t)))
.collect();
let result = f
.result
.as_ref()
.map_or(String::new(), |t| format!(" -> {}", ty(resolve, t)));
format!("func({}){result}", params.join(", "))
}
/// Who may use an interface: the capability that grants it, or what it is.
fn access(interface: &str, exported: bool) -> String {
if exported {
return "exported by the mod: the game calls it".into();
}
if interface == "log" {
return "always granted".into();
}
match Capability::ALL.iter().find(|c| c.interface() == interface) {
Some(c) => format!("capability `{c}`"),
None => "not granted by any capability".into(),
}
}
fn markdown() -> String {
let mut resolve = Resolve::new();
let package = resolve
.push_file(root().join(WIT))
.expect("the WIT contract parses");
let package = &resolve.packages[package];
let name = &package.name;
let mut out = String::new();
let w = &mut out;
writeln!(w, "<!-- Generated from {WIT} and the manifest's capabilities by crates/horizon-mods/tests/api_reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference -->").unwrap();
writeln!(w, "# Mod API reference\n").unwrap();
writeln!(
w,
"The contract between Horizon and its mods: the WIT package `{name}`, every interface, type, function and hook in it, \
and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: \
[stability.md](stability.md). Manifest `api` lines this game serves: {}.\n",
APIS.map(|a| format!("`{a}`")).join(", ")
)
.unwrap();
writeln!(w, "## Capabilities\n").unwrap();
writeln!(
w,
"| In `capabilities` | Grants | The player reads \"May …\" |\n| --- | --- | --- |"
)
.unwrap();
for c in Capability::ALL {
let grants = if package.interfaces.contains_key(c.interface()) {
format!("[`{0}`](#{0})", c.interface())
} else {
"data files (`assets/`, `data/`), no interface".into()
};
writeln!(w, "| `{c}` | {grants} | {} |", c.describe()).unwrap();
}
let mut exported = Vec::new();
for (world_name, &world) in &package.worlds {
let world = &resolve.worlds[world];
let prefix = format!("{}:{}/", name.namespace, name.name);
let names = |keys: Vec<&wit_parser::WorldKey>| {
keys.into_iter()
.map(|key| {
let full = resolve.name_world_key(key);
let short = full.trim_start_matches(&prefix);
format!("`{}`", short.split('@').next().unwrap_or(short))
})
.collect::<Vec<_>>()
.join(", ")
};
writeln!(w, "\n## World `{world_name}`\n").unwrap();
if world.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&world.docs)).unwrap();
}
writeln!(
w,
"- Imports (what the game offers): {}",
names(world.imports.keys().collect())
)
.unwrap();
writeln!(
w,
"- Exports (what the mod provides): {}",
names(world.exports.keys().collect())
)
.unwrap();
for item in world.exports.values() {
if let WorldItem::Interface { id, .. } = item {
exported.push(*id);
}
}
}
for (interface_name, &id) in &package.interfaces {
let interface = &resolve.interfaces[id];
writeln!(w, "\n## `{interface_name}`\n").unwrap();
writeln!(
w,
"Access: {}.\n",
access(interface_name, exported.contains(&id))
)
.unwrap();
if interface.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&interface.docs)).unwrap();
}
for (type_name, &type_id) in &interface.types {
let def = &resolve.types[type_id];
writeln!(w, "### {} `{type_name}`\n", def.kind.as_str()).unwrap();
if def.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&def.docs)).unwrap();
}
match &def.kind {
TypeDefKind::Record(r) => {
writeln!(w, "| Field | Type | |\n| --- | --- | --- |").unwrap();
for f in &r.fields {
writeln!(
w,
"| `{}` | `{}` | {} |",
f.name,
ty(&resolve, &f.ty),
cell(&f.docs)
)
.unwrap();
}
}
TypeDefKind::Enum(e) => {
writeln!(w, "| Case | |\n| --- | --- |").unwrap();
for c in &e.cases {
writeln!(w, "| `{}` | {} |", c.name, cell(&c.docs)).unwrap();
}
}
TypeDefKind::Variant(v) => {
writeln!(w, "| Case | Payload | |\n| --- | --- | --- |").unwrap();
for c in &v.cases {
let payload =
c.ty.as_ref()
.map_or(String::new(), |t| format!("`{}`", ty(&resolve, t)));
writeln!(w, "| `{}` | {payload} | {} |", c.name, cell(&c.docs)).unwrap();
}
}
TypeDefKind::Flags(f) => {
writeln!(w, "| Flag | |\n| --- | --- |").unwrap();
for flag in &f.flags {
writeln!(w, "| `{}` | {} |", flag.name, cell(&flag.docs)).unwrap();
}
}
TypeDefKind::Resource => {
writeln!(w, "A resource: the game owns it, the mod holds a handle.").unwrap()
}
kind => writeln!(w, "`type {type_name} = {}`", definition(&resolve, kind)).unwrap(),
}
writeln!(w).unwrap();
}
if !interface.functions.is_empty() {
writeln!(
w,
"| Function | Signature | What it does |\n| --- | --- | --- |"
)
.unwrap();
for (fn_name, f) in &interface.functions {
writeln!(
w,
"| `{fn_name}` | `{}` | {} |",
signature(&resolve, f),
cell(&f.docs)
)
.unwrap();
}
}
}
out
}
#[test]
fn the_mod_api_reference_is_up_to_date() {
let path = root().join(DOC);
let want = markdown();
if std::env::var_os("HORIZON_WRITE_DOCS").is_some() {
std::fs::write(&path, &want).unwrap();
}
let have = std::fs::read_to_string(&path)
.unwrap_or_default()
.replace("\r\n", "\n");
assert!(
have == want,
"{DOC} is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference"
);
}
#[test]
fn every_function_is_documented() {
let mut resolve = Resolve::new();
resolve.push_file(root().join(WIT)).unwrap();
for (_, interface) in resolve.interfaces.iter() {
for (name, f) in &interface.functions {
assert!(
f.docs.contents.is_some(),
"{WIT}: `{name}` needs a doc comment: the reference shows it to mod authors"
);
}
}
}
#[test]
fn every_capability_with_an_interface_is_in_the_contract() {
let mut resolve = Resolve::new();
let package = resolve.push_file(root().join(WIT)).unwrap();
let interfaces = &resolve.packages[package].interfaces;
for c in Capability::ALL {
if c != Capability::Registries {
assert!(
interfaces.contains_key(c.interface()),
"capability {c} grants `{}`, missing from {WIT}",
c.interface()
);
}
}
}
+19 -4
View File
@@ -1,6 +1,7 @@
//! The JDK that compiles and runs the vanilla exporters. They call Mojang's own code, so they
//! stay Java; everything around them is here.
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
@@ -13,6 +14,20 @@ pub struct Jdk {
const TIMEOUT: Duration = Duration::from_secs(3600);
/// Exporters and JDK probes write to pipes or log files: on Windows they open no terminal.
fn command(program: impl AsRef<OsStr>) -> Command {
let command = Command::new(program);
#[cfg(windows)]
let command = {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut command = command;
command.creation_flags(CREATE_NO_WINDOW);
command
};
command
}
fn executable(dir: &Path, name: &str) -> PathBuf {
dir.join(if cfg!(windows) {
format!("{name}.exe")
@@ -29,7 +44,7 @@ fn on_path(name: &str) -> Option<PathBuf> {
/// The major version `javac -version` reports ("javac 25.0.1" → 25; "javac 1.8.0" → 8).
fn javac_major(javac: &Path) -> Result<u32, String> {
let output = Command::new(javac)
let output = command(javac)
.arg("-version")
.stdin(Stdio::null())
.output()
@@ -183,7 +198,7 @@ pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
candidates.push((javac, java, "PATH"));
}
if cfg!(target_os = "macos") {
if let Ok(out) = Command::new("/usr/libexec/java_home")
if let Ok(out) = command("/usr/libexec/java_home")
.args(["-v", &major.to_string()])
.stderr(Stdio::null())
.output()
@@ -260,7 +275,7 @@ impl Jdk {
/// `javac --release <major> -proc:none` with Python's `subprocess.run(..., check=True,
/// stdout=log, stderr=STDOUT, timeout=3600)`.
pub fn javac(&self, major: i64, args: &[String], log: &fs::File) -> Result<(), String> {
let mut command = Command::new(&self.javac);
let mut command = command(&self.javac);
command
.args(["--release", &major.to_string(), "-proc:none"])
.args(args);
@@ -268,7 +283,7 @@ impl Jdk {
}
pub fn java(&self, args: &[String], cwd: Option<&Path>, log: &fs::File) -> Result<(), String> {
let mut command = Command::new(&self.java);
let mut command = command(&self.java);
command.args(args);
if let Some(dir) = cwd {
command.current_dir(dir);
+5 -3
View File
@@ -17,7 +17,7 @@ pub mod pyjson;
mod sounds;
mod survival;
pub use sounds::{fonts, packs, panorama, sounds, BUILT_IN_PACKS};
pub use sounds::{fonts, languages, packs, panorama, sounds, BUILT_IN_PACKS};
use pyjson::Value;
use sha2::Digest;
@@ -676,13 +676,15 @@ fn steps(
}
// The playable client needs every client asset (textures, models, fonts, GUI)
// and every data-pack resource (worldgen, recipes, loot tables, tags).
// and every data-pack resource (worldgen, recipes, loot tables, tags), and
// the vanilla pack's icon (`pack.png`) for the pack screen.
(run.log)("Copying the client's assets and data");
let mut count = 0usize;
let mut client = open_zip(&run.client)?;
let version = pyjson::loads(&zip_text(&mut client, "version.json")?)?;
for name in zip_names(&client) {
if !(name.starts_with("assets/") || name.starts_with("data/")) || name.ends_with('/') {
let wanted = name.starts_with("assets/") || name.starts_with("data/") || name == "pack.png";
if !wanted || name.ends_with('/') {
continue;
}
let relative = member_path(&name, "Invalid resource path")?;
+12 -3
View File
@@ -20,6 +20,7 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [-
horizon-prepare panorama [--minecraft-dir PATH] [--root PATH]
horizon-prepare fonts [--minecraft-dir PATH] [--root PATH]
horizon-prepare packs [--minecraft-dir PATH] [--root PATH]
horizon-prepare languages [--minecraft-dir PATH] [--root PATH]
--prepare Import the pinned game files from your own copy first
--server-jar PATH The pinned server jar you downloaded yourself
@@ -36,7 +37,8 @@ The resources go to <root>/.local/horizon-client/<version>; pass that to horizon
horizon-prepare panorama repairs the classic menu background in existing resources without Java.
horizon-prepare fonts adds the GNU Unifont glyphs (characters the bitmap fonts lack) the same way.
horizon-prepare packs adds the built-in Programmer Art and High Contrast resource packs the same way.
Full preparation also imports all three after extracting the client JAR's placeholders.
horizon-prepare languages adds the bundled languages and the language list the JAR lacks the same way.
Full preparation also imports all four after extracting the client JAR's placeholders.
horizon-prepare sounds copies the pinned version's sounds (about 380 MB) from the official
launcher's asset store into the same folder, checking every file. It is optional: without it the
@@ -68,8 +70,12 @@ fn main() -> ExitCode {
};
let mut root = None;
let mut args = std::env::args_os().skip(1).peekable();
let assets =
args.next_if(|a| matches!(a.to_str(), Some("sounds" | "panorama" | "fonts" | "packs")));
let assets = args.next_if(|a| {
matches!(
a.to_str(),
Some("sounds" | "panorama" | "fonts" | "packs" | "languages")
)
});
while let Some(arg) = args.next() {
let text = arg.to_string_lossy().into_owned();
let (flag, inline) = match text.split_once('=') {
@@ -138,6 +144,9 @@ fn main() -> ExitCode {
Some("packs") => {
horizon_prepare::packs(&options.root, options.minecraft_dir.as_deref(), &log)
}
Some("languages") => {
horizon_prepare::languages(&options.root, options.minecraft_dir.as_deref(), &log)
}
_ => horizon_prepare::prepare(&options, &log),
};
match result {
+67 -2
View File
@@ -112,6 +112,20 @@ pub fn fonts(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf,
copy_assets(root, minecraft, log, is_font, "Font", &UNIFONT)
}
/// Every bundled language and the language list, both of which only the asset index holds
/// (the client JAR carries `en_us` alone): `pack.mcmeta`'s `language` section is what the
/// language screen offers, `minecraft/lang/<code>.json` the strings each one draws (#1272).
/// Also usable on already-prepared resources without Java.
pub fn languages(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
copy_assets(root, minecraft, log, is_language, "Language", &LANGUAGES)
}
const LANGUAGES: [&str; 1] = ["pack.mcmeta"];
fn is_language(name: &str) -> bool {
name == "pack.mcmeta" || name.starts_with("minecraft/lang/")
}
/// Full preparation can run from cached JARs without an official launcher install.
pub(crate) fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
if let Err(error) = panorama(root, minecraft, log) {
@@ -129,6 +143,11 @@ pub(crate) fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
"Programmer Art and High Contrast not imported: {error}; run horizon-prepare packs later"
));
}
if let Err(error) = languages(root, minecraft, log) {
log(&format!(
"Languages not imported: {error}; run horizon-prepare languages later"
));
}
}
const PANORAMA: [&str; 7] = [
@@ -278,6 +297,15 @@ mod tests {
}
fn with_panorama(name: &str, panorama: &[&'static str]) -> Fixture {
Self::with_extras(name, panorama, &[])
}
/// The same store with extra index entries, for the files the index alone holds.
fn with_extras(
name: &str,
panorama: &[&'static str],
extras: &[(&'static str, &'static [u8])],
) -> Fixture {
let dir = std::env::temp_dir().join(format!(
"horizon-prepare-sounds-{name}-{}",
std::process::id()
@@ -302,6 +330,9 @@ mod tests {
// Distinct object bytes let the test catch swapped faces too.
objects.push((name, name.as_bytes().to_vec()));
}
for &(name, data) in extras {
objects.push((name, data.to_vec()));
}
let mut index = String::from("{\"objects\": {");
for (i, (name, data)) in objects.iter().enumerate() {
let hash = sha1_hex(data);
@@ -391,6 +422,32 @@ mod tests {
f.run().unwrap();
}
#[test]
fn languages_come_from_the_asset_store() {
// The client JAR carries `en_us` alone, so every other language and the list in
// `pack.mcmeta` come from the index (#1272).
let f = Fixture::with_extras(
"languages",
&[],
&[("pack.mcmeta", b"{\"language\": {\"fr_fr\": {}}}")],
);
let lines = std::cell::RefCell::new(Vec::new());
let target = languages(&f.root, Some(&f.minecraft), &|l| {
lines.borrow_mut().push(l.to_string())
})
.unwrap();
assert!(fs::read(target.join("assets/pack.mcmeta"))
.unwrap()
.starts_with(b"{\"language\""));
assert_eq!(
fs::read(target.join("assets/minecraft/lang/fr_fr.json")).unwrap(),
b"{}"
);
// Each entry point copies its own set: the sounds stay where they belong.
assert!(!target.join("assets/minecraft/sounds.json").exists());
assert_eq!(*lines.borrow(), ["Language assets ready: 2 files, 0.0 MB"]);
}
/// #1076: the launcher holds an index Mojang republished under the same id, so its SHA-1
/// is not the one in the version JSON. A known one is used quietly, an unknown one with a
/// line in the log; the sounds come through either way.
@@ -539,7 +596,10 @@ mod tests {
lines[1..],
[
"Font assets ready: 2 files, 0.0 MB",
"Built-in pack assets ready: 2 files, 0.0 MB"
"Built-in pack assets ready: 2 files, 0.0 MB",
// This store has no `pack.mcmeta`, so the languages report it.
"Languages not imported: pack.mcmeta is missing from the asset index; \
run horizon-prepare languages later"
]
),
_ => {
@@ -549,9 +609,14 @@ mod tests {
&& lines[2].ends_with("; run horizon-prepare packs later"),
"{lines:?}"
);
assert!(
lines[3].starts_with("Languages not imported: ")
&& lines[3].ends_with("; run horizon-prepare languages later"),
"{lines:?}"
);
}
}
assert_eq!(lines.len(), 3);
assert_eq!(lines.len(), 4);
for name in PANORAMA {
assert_eq!(fs::read(target.join(name)).unwrap(), b"jar placeholder");
}
+4
View File
@@ -17,6 +17,10 @@ sha1 = "0.10"
# Secure chat: SHA256withRSA message signatures.
sha2 = "0.10"
# The name servers of Windows' network adapters, for Minecraft SRV redirects.
[target.'cfg(windows)'.dependencies]
ipconfig = "0.3.4"
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
[[example]]
name = "protocol_probe"
+13
View File
@@ -191,6 +191,19 @@ impl Connection {
.to_socket_addrs()
.map_err(|e| Error::new(format!("Unknown host {host}: {e}")))?
.collect();
Self::connect_addresses(host, addrs, timeout)
}
/// Minecraft's SRV redirect applies to the socket destination only. Callers
/// keep the player's original host and port in the protocol handshake.
pub fn connect_server(host: &str, port: u16, timeout: Duration) -> Result<Self> {
let addrs = crate::resolver::resolve(host, port)?;
Self::connect_addresses(host, addrs, timeout)
}
fn connect_addresses(
host: &str,
addrs: Vec<std::net::SocketAddr>,
timeout: Duration,
) -> Result<Self> {
let mut last = Error::new(format!("Unknown host {host}"));
for addr in addrs {
match TcpStream::connect_timeout(&addr, timeout) {
+1
View File
@@ -12,6 +12,7 @@ pub mod ids;
pub mod item;
pub mod packets;
pub mod registry;
mod resolver;
pub mod rsa;
pub mod secure_chat;
pub mod servers;
+3 -2
View File
@@ -13,8 +13,8 @@ use crate::{
packets::{
Empty,
configuration::{
ClientInformation, CustomPayload, Disconnect, KeepAlive, Ping, ResourcePackPush,
ResourcePackResponse, UpdateTags,
ClientInformation, CustomPayload, Disconnect, KeepAlive, Ping, ResourcePackPop,
ResourcePackPush, ResourcePackResponse, UpdateTags,
},
login::Property,
},
@@ -1640,6 +1640,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
RemoveEntities(RemoveEntities) = REMOVE_ENTITIES,
RemoveMobEffect(RemoveMobEffect) = REMOVE_MOB_EFFECT,
ResetScore(ResetScore) = RESET_SCORE,
ResourcePackPop(ResourcePackPop) = RESOURCE_PACK_POP,
ResourcePackPush(ResourcePackPush) = RESOURCE_PACK_PUSH,
Respawn(Respawn) = RESPAWN,
RotateHead(RotateHead) = ROTATE_HEAD,
+145
View File
@@ -0,0 +1,145 @@
//! Minecraft server DNS redirects (`ServerRedirectHandler` / `ServerNameResolver`).
//! Port 25565 asks for `_minecraft._tcp.<host>`'s SRV record; no record or a
//! failed query leaves the original address in use. The protocol handshake
//! still uses that original address.
use crate::{Error, Result};
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
mod dns;
pub(crate) fn resolve(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
resolve_with(host, port, srv_redirect, |host, port| {
(host, port)
.to_socket_addrs()
.map(|addrs| addrs.collect())
.map_err(|e| Error::new(format!("Unknown host {host}: {e}")))
})
}
fn resolve_with(
host: &str,
port: u16,
srv: impl FnOnce(&str) -> Option<(String, u16)>,
addresses: impl FnOnce(&str, u16) -> Result<Vec<SocketAddr>>,
) -> Result<Vec<SocketAddr>> {
// Vanilla also asks for `_minecraft._tcp.127.0.0.1`, which never has a
// record: IP literals and localhost skip the query.
if port == 25565
&& host.parse::<IpAddr>().is_err()
&& host != "localhost"
&& let Some((target, target_port)) =
srv(&format!("_minecraft._tcp.{}.", host.trim_end_matches('.')))
{
// No second SRV lookup at the target, and no fallback to the original
// host when the target is unknown.
return addresses(&target, target_port);
}
addresses(host, port)
}
fn srv_redirect(name: &str) -> Option<(String, u16)> {
dns::query_srv(name, &dns::system_name_servers(), dns::SERVER_TIMEOUT)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redirects_default_port_even_when_the_original_host_has_no_addresses() {
let result = resolve_with(
"play.example.org",
25565,
|name| {
assert_eq!(name, "_minecraft._tcp.play.example.org.");
Some(("backend.example.org.".into(), 25570))
},
|host, port| {
assert_eq!((host, port), ("backend.example.org.", 25570));
Ok(vec!["127.0.0.1:25570".parse().unwrap()])
},
)
.unwrap();
assert_eq!(
result,
vec!["127.0.0.1:25570".parse::<SocketAddr>().unwrap()]
);
}
#[test]
fn no_record_or_lookup_failure_keeps_the_original_address() {
resolve_with(
"play.example.org",
25565,
|_| None,
|host, port| {
assert_eq!((host, port), ("play.example.org", 25565));
Ok(vec![])
},
)
.unwrap();
}
#[test]
fn explicit_nondefault_ports_and_ip_literals_do_not_query_srv() {
for (host, port) in [
("play.example.org", 25570),
("127.0.0.1", 25565),
("::1", 25565),
("localhost", 25565),
] {
resolve_with(
host,
port,
|_| panic!("unexpected SRV query"),
|actual, actual_port| {
assert_eq!((actual, actual_port), (host, port));
Ok(vec![])
},
)
.unwrap();
}
}
#[test]
fn fully_qualified_host_does_not_get_a_second_trailing_dot() {
resolve_with(
"play.example.org.",
25565,
|name| {
assert_eq!(name, "_minecraft._tcp.play.example.org.");
None
},
|_, _| Ok(vec![]),
)
.unwrap();
}
#[test]
fn unknown_redirect_target_does_not_fall_back_to_the_original() {
let result = resolve_with(
"play.example.org",
25565,
|_| Some(("missing.example.org.".into(), 25570)),
|host, _| {
assert_eq!(host, "missing.example.org.");
Err(Error::new("unknown redirected host"))
},
);
assert_eq!(result.unwrap_err().to_string(), "unknown redirected host");
}
#[test]
fn literal_address_can_connect_without_dns() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let connection = crate::connection::Connection::connect_server(
"127.0.0.1",
address.port(),
std::time::Duration::from_secs(1),
)
.unwrap();
let (accepted, _) = listener.accept().unwrap();
drop((connection, accepted));
}
}
+339
View File
@@ -0,0 +1,339 @@
//! The SRV question as JNDI's DNS client asks it: the system's name servers in
//! turn, one UDP try of 1 s (`com.sun.jndi.dns.timeout.retries` = 1), TCP when
//! the reply is truncated, and the answer's first SRV record. By hand, so the
//! game needs no async runtime or resolver library for one question.
use std::{
io::{Read, Write},
net::{SocketAddr, TcpStream, UdpSocket},
time::Duration,
};
const TYPE_SRV: u16 = 33;
/// JNDI's `com.sun.jndi.dns.timeout.initial`, per name server.
pub(super) const SERVER_TIMEOUT: Duration = Duration::from_secs(1);
/// JNDI reads them from resolv.conf on Unix and from the network adapters on
/// Windows.
#[cfg(unix)]
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
std::fs::read_to_string("/etc/resolv.conf")
.map_or_else(|_| Vec::new(), |conf| name_servers(&conf))
}
#[cfg(windows)]
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
let mut servers = Vec::new();
for adapter in ipconfig::get_adapters().unwrap_or_default() {
if adapter.oper_status() != ipconfig::OperStatus::IfOperStatusUp {
continue;
}
for &ip in adapter.dns_servers() {
let server = SocketAddr::new(ip, 53);
if !servers.contains(&server) {
servers.push(server);
}
}
}
servers
}
#[cfg(not(any(unix, windows)))]
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
Vec::new()
}
/// `nameserver` lines of resolv.conf, in order.
#[cfg(any(unix, test))]
fn name_servers(conf: &str) -> Vec<SocketAddr> {
conf.lines()
.filter_map(|line| line.strip_prefix("nameserver"))
.filter_map(|rest| rest.split_whitespace().next())
.filter_map(|ip| ip.split('%').next()?.parse::<std::net::IpAddr>().ok())
.map(|ip| SocketAddr::new(ip, 53))
.collect()
}
enum Reply {
/// The server answered: the first SRV record, if any.
Answer(Option<(String, u16)>),
Truncated,
/// No reply, a server failure or a malformed reply: ask the next server.
Unusable,
}
pub(super) fn query_srv(
name: &str,
servers: &[SocketAddr],
timeout: Duration,
) -> Option<(String, u16)> {
let mut id = [0; 2];
getrandom::fill(&mut id).ok()?;
let id = u16::from_be_bytes(id);
let query = encode_query(id, name)?;
for &server in servers {
let reply = match ask_udp(server, &query, timeout).map(|reply| parse_reply(id, &reply)) {
Some(Reply::Truncated) => ask_tcp(server, &query, timeout)
.map_or(Reply::Unusable, |reply| parse_reply(id, &reply)),
reply => reply.unwrap_or(Reply::Unusable),
};
if let Reply::Answer(srv) = reply {
return srv;
}
}
None
}
fn ask_udp(server: SocketAddr, query: &[u8], timeout: Duration) -> Option<Vec<u8>> {
let local: SocketAddr = if server.is_ipv4() {
([0, 0, 0, 0], 0).into()
} else {
([0u16; 8], 0).into()
};
let socket = UdpSocket::bind(local).ok()?;
socket.set_read_timeout(Some(timeout)).ok()?;
socket.connect(server).ok()?;
socket.send(query).ok()?;
let mut buf = vec![0; 512];
let len = socket.recv(&mut buf).ok()?;
buf.truncate(len);
Some(buf)
}
fn ask_tcp(server: SocketAddr, query: &[u8], timeout: Duration) -> Option<Vec<u8>> {
let mut stream = TcpStream::connect_timeout(&server, timeout).ok()?;
stream.set_read_timeout(Some(timeout)).ok()?;
let mut framed = (query.len() as u16).to_be_bytes().to_vec();
framed.extend_from_slice(query);
stream.write_all(&framed).ok()?;
let mut len = [0; 2];
stream.read_exact(&mut len).ok()?;
let mut buf = vec![0; u16::from_be_bytes(len) as usize];
stream.read_exact(&mut buf).ok()?;
Some(buf)
}
/// A recursive SRV question (RFC 1035 §4.1) for an absolute name.
fn encode_query(id: u16, name: &str) -> Option<Vec<u8>> {
let mut msg = Vec::with_capacity(18 + name.len());
msg.extend_from_slice(&id.to_be_bytes());
msg.extend_from_slice(&[0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]); // RD; one question
for label in name.trim_end_matches('.').split('.') {
if label.is_empty() || label.len() > 63 {
return None;
}
msg.push(label.len() as u8);
msg.extend_from_slice(label.as_bytes());
}
msg.push(0);
if msg.len() - 12 > 255 {
return None;
}
msg.extend_from_slice(&TYPE_SRV.to_be_bytes());
msg.extend_from_slice(&1u16.to_be_bytes()); // IN
Some(msg)
}
fn parse_reply(id: u16, msg: &[u8]) -> Reply {
parse_answer(id, msg).unwrap_or(Reply::Unusable)
}
fn parse_answer(id: u16, msg: &[u8]) -> Option<Reply> {
let u16_at = |at: usize| Some(u16::from_be_bytes([*msg.get(at)?, *msg.get(at + 1)?]));
let flags = u16_at(2)?;
if u16_at(0)? != id || flags & 0x8000 == 0 {
return None;
}
if flags & 0x0200 != 0 {
return Some(Reply::Truncated);
}
match flags & 0xf {
0 => {}
3 => return Some(Reply::Answer(None)), // NXDOMAIN
_ => return None,
}
let mut at = 12;
for _ in 0..u16_at(4)? {
at = read_name(msg, at)?.1 + 4;
}
for _ in 0..u16_at(6)? {
at = read_name(msg, at)?.1;
let (kind, len) = (u16_at(at)?, u16_at(at + 8)? as usize);
let data = at + 10;
msg.get(data..data + len)?;
if kind == TYPE_SRV && len >= 7 {
let (target, _) = read_name(msg, data + 6)?;
return Some(Reply::Answer(Some((target, u16_at(data + 4)?))));
}
at = data + len;
}
Some(Reply::Answer(None))
}
/// The name at `at`, absolute (`a.b.`), and where the record continues.
fn read_name(msg: &[u8], mut at: usize) -> Option<(String, usize)> {
let mut name = String::new();
let mut end = None;
// Pointers must go backwards; the bound ends a loop of labels and pointers.
for _ in 0..128 {
let len = *msg.get(at)? as usize;
match len {
0 => {
if name.is_empty() {
name.push('.');
}
return Some((name, end.unwrap_or(at + 1)));
}
0xc0.. => {
let target = (len & 0x3f) << 8 | *msg.get(at + 1)? as usize;
if target >= at {
return None;
}
end.get_or_insert(at + 2);
at = target;
}
1..=63 => {
let label = msg.get(at + 1..at + 1 + len)?;
name.push_str(std::str::from_utf8(label).ok()?);
name.push('.');
at += 1 + len;
}
_ => return None,
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const NAME: &str = "_minecraft._tcp.play.example.org.";
/// The reply a server gives to `query`: its question, then a CNAME and the
/// SRV records, each owner name compressed to the question's name.
fn reply(query: &[u8], rcode: u8, records: &[(u16, &str)]) -> Vec<u8> {
let mut msg = query.to_vec();
msg[2] |= 0x80;
msg[3] = 0x80 | rcode;
let answers = records.len() as u16 + u16::from(!records.is_empty());
msg[6..8].copy_from_slice(&answers.to_be_bytes());
if !records.is_empty() {
// CNAME first: the parser must step over records of other types.
msg.extend_from_slice(&[0xc0, 12, 0, 5, 0, 1, 0, 0, 0, 60, 0, 2, 0xc0, 12]);
}
for &(port, target) in records {
let mut rdata = vec![0, 1, 0, 1];
rdata.extend_from_slice(&port.to_be_bytes());
for label in target.trim_end_matches('.').split('.') {
rdata.push(label.len() as u8);
rdata.extend_from_slice(label.as_bytes());
}
rdata.push(0);
msg.extend_from_slice(&[0xc0, 12, 0, 33, 0, 1, 0, 0, 0, 60]);
msg.extend_from_slice(&(rdata.len() as u16).to_be_bytes());
msg.extend_from_slice(&rdata);
}
msg
}
fn answer(msg: &[u8]) -> Option<Option<(String, u16)>> {
match parse_reply(7, msg) {
Reply::Answer(srv) => Some(srv),
_ => None,
}
}
#[test]
fn query_is_a_recursive_srv_question() {
let query = encode_query(0x1234, NAME).unwrap();
let mut expected = vec![0x12, 0x34, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0];
expected.extend_from_slice(b"\x0a_minecraft\x04_tcp\x04play\x07example\x03org\x00");
expected.extend_from_slice(&[0, 33, 0, 1]);
assert_eq!(query, expected);
assert!(encode_query(1, "a..b.").is_none());
assert!(encode_query(1, &format!("{}.org.", "a".repeat(64))).is_none());
}
#[test]
fn first_srv_record_of_the_answer_wins() {
let query = encode_query(7, NAME).unwrap();
let msg = reply(
&query,
0,
&[(25570, "a.example.org."), (25571, "b.example.org.")],
);
assert_eq!(answer(&msg), Some(Some(("a.example.org.".into(), 25570))));
}
#[test]
fn empty_answer_and_nxdomain_mean_no_redirect() {
let query = encode_query(7, NAME).unwrap();
assert_eq!(answer(&reply(&query, 0, &[])), Some(None));
assert_eq!(answer(&reply(&query, 3, &[])), Some(None));
}
#[test]
fn server_failures_and_bad_replies_ask_the_next_server() {
let query = encode_query(7, NAME).unwrap();
let good = reply(&query, 0, &[(25570, "a.example.org.")]);
assert!(matches!(
parse_reply(7, &reply(&query, 2, &[])),
Reply::Unusable
));
assert!(matches!(parse_reply(8, &good), Reply::Unusable)); // another id
assert!(matches!(parse_reply(7, &query), Reply::Unusable)); // not a response
for cut in 0..good.len() {
assert!(matches!(parse_reply(7, &good[..cut]), Reply::Unusable));
}
let mut forward = good.clone();
let at = query.len(); // the first answer's owner pointer
assert_eq!(forward[at..at + 2], [0xc0, 12]);
forward[at + 1] = 0xff; // a pointer forwards
assert!(matches!(parse_reply(7, &forward), Reply::Unusable));
let mut truncated = good;
truncated[2] |= 0x02;
assert!(matches!(parse_reply(7, &truncated), Reply::Truncated));
}
#[test]
fn resolv_conf_name_servers_in_order() {
let conf = "# generated\nsearch lan\nnameserver 192.168.1.1\nnameserver fe80::1%en0\n\
nameserver ::1 \nnameserver bogus\noptions ndots:1\n";
let servers: Vec<String> = name_servers(conf).iter().map(|s| s.to_string()).collect();
assert_eq!(servers, ["192.168.1.1:53", "[fe80::1]:53", "[::1]:53"]);
}
/// A local name server: a silent one first, then one that answers truncated
/// over UDP and in full over TCP, like a real server with a big answer.
#[test]
fn asks_each_server_in_turn_and_retries_truncated_replies_over_tcp() {
let silent = UdpSocket::bind("127.0.0.1:0").unwrap();
let (udp, tcp) = loop {
let udp = UdpSocket::bind("127.0.0.1:0").unwrap();
if let Ok(tcp) = std::net::TcpListener::bind(udp.local_addr().unwrap()) {
break (udp, tcp);
}
};
let servers = [silent.local_addr().unwrap(), udp.local_addr().unwrap()];
let server = std::thread::spawn(move || {
let mut buf = [0; 512];
let (len, from) = udp.recv_from(&mut buf).unwrap();
let mut truncated = reply(&buf[..len], 0, &[]);
truncated[2] |= 0x02;
udp.send_to(&truncated, from).unwrap();
let (mut stream, _) = tcp.accept().unwrap();
let mut len = [0; 2];
stream.read_exact(&mut len).unwrap();
let mut query = vec![0; u16::from_be_bytes(len) as usize];
stream.read_exact(&mut query).unwrap();
let full = reply(&query, 0, &[(25570, "backend.example.org.")]);
stream
.write_all(&(full.len() as u16).to_be_bytes())
.unwrap();
stream.write_all(&full).unwrap();
});
let srv = query_srv(NAME, &servers, Duration::from_millis(200));
server.join().unwrap();
assert_eq!(srv, Some(("backend.example.org.".into(), 25570)));
}
}
+4 -2
View File
@@ -68,7 +68,8 @@ pub fn program_in(
/// system can't load the program, as on a Linux without the ALSA and udev libraries the full game links
/// (the server build doesn't). The reason, with what to install.
pub fn check_starts(program: &Path) -> Result<(), String> {
let output = Command::new(program)
let mut command = crate::process::command(program);
let output = command
.arg("--help")
.stdin(Stdio::null())
.stdout(Stdio::null())
@@ -176,7 +177,8 @@ pub fn spawn(program: &Path, resources: &Path, dir: &Path) -> std::io::Result<Ch
#[cfg(windows)]
std::os::windows::process::CommandExt::creation_flags(
&mut command,
windows_sys::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP,
windows_sys::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP
| windows_sys::Win32::System::Threading::CREATE_NO_WINDOW,
);
command.spawn()
}
+1
View File
@@ -10,6 +10,7 @@ mod http;
mod init;
mod lists;
mod panel;
mod process;
mod service;
mod settings;
mod signals;
+20
View File
@@ -0,0 +1,20 @@
//! The programs the server runs. Started by the launcher, the server has no console, and each
//! console program it started would open a terminal window on Windows: those open none. Run from
//! a terminal, they share it, so what they print still reaches it.
use std::{ffi::OsStr, process::Command};
pub fn command(program: impl AsRef<OsStr>) -> Command {
let command = Command::new(program);
#[cfg(windows)]
let command = {
use std::os::windows::process::CommandExt;
use windows_sys::Win32::System::{Console::GetConsoleWindow, Threading::CREATE_NO_WINDOW};
let mut command = command;
// SAFETY: no arguments; it returns this process's console window or null.
if unsafe { GetConsoleWindow() }.is_null() {
command.creation_flags(CREATE_NO_WINDOW);
}
command
};
command
}
+2 -2
View File
@@ -178,7 +178,7 @@ fn systemd_path(service: &str, system: bool) -> Result<PathBuf, String> {
}
fn run(program: &str, args: &[&str]) -> Result<(), String> {
let status = Command::new(program)
let status = crate::process::command(program)
.args(args)
.status()
.map_err(|e| format!("{program}: {e}"))?;
@@ -191,7 +191,7 @@ fn run(program: &str, args: &[&str]) -> Result<(), String> {
/// A step that may fail harmlessly (stopping what isn't running): its output would only alarm.
fn quiet(program: &str, args: &[&str]) {
let _ = Command::new(program)
let _ = crate::process::command(program)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
+3 -4
View File
@@ -10,7 +10,6 @@ use serde_json::{Value, json};
use std::{
io::Read,
path::{Path, PathBuf},
process::Command,
time::{Duration, Instant},
};
@@ -206,7 +205,7 @@ fn point(link: &Path, target: &Path) -> std::io::Result<()> {
if link.exists() || link.symlink_metadata().is_ok() {
std::fs::remove_dir(link)?;
}
let status = Command::new("cmd")
let status = crate::process::command("cmd")
.arg("/C")
.arg("mklink")
.arg("/J")
@@ -474,7 +473,7 @@ pub fn unpack(install: &Install, archive: &Path, tag: &str) -> Result<PathBuf, S
// tar is on every Linux and macOS, and on Windows 10 and later. The files are the
// user's, not the archive's owners: as root in a user namespace (an unprivileged LXC
// or rootless container) taking those fails, and as plain root they'd be strangers'.
let status = Command::new("tar")
let status = crate::process::command("tar")
.arg("--no-same-owner")
.arg("-xzf")
.arg(archive)
@@ -1058,7 +1057,7 @@ mod tests {
std::fs::write(inner.join("horizon-server"), "v2").unwrap();
std::fs::write(inner.join("horizon-engine"), "engine").unwrap();
let archive = source.join("horizon-server-linux-x86_64.tar.gz");
let status = Command::new("tar")
let status = std::process::Command::new("tar")
.arg("-czf")
.arg(&archive)
.arg("-C")
+13 -5
View File
@@ -9,11 +9,19 @@ use std::sync::OnceLock;
use crate::payload::System;
fn command_output(program: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(program)
.args(args)
.output()
.ok()?;
/// What `program args` prints, trimmed, when it succeeds and prints something. On Windows the
/// program opens no console window: a GUI launcher or a game started without one would flash a
/// terminal for every probe.
pub fn command_output(program: &str, args: &[&str]) -> Option<String> {
let mut command = std::process::Command::new(program);
command.args(args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
let out = command.output().ok()?;
let text = String::from_utf8_lossy(&out.stdout).trim().to_owned();
(out.status.success() && !text.is_empty()).then_some(text)
}
-17
View File
@@ -318,23 +318,6 @@ impl ChunkData {
)?))
}
pub fn load_block_section<'r>(
&self,
block: BlockPos,
registry: &'r BlockRegistry,
) -> io::Result<Option<BlockSection<'r>>> {
let Some(index) = self.section_index(block)? else {
return Ok(None);
};
let Tag::List(sections) = self.root.tag.get("sections").unwrap() else {
unreachable!()
};
sections[index]
.get("block_states")
.map(|tag| BlockSection::from_nbt(tag, registry))
.transpose()
}
/// Storage-only mutation. The caller must arrange simulation-derived updates
/// before treating this as a gameplay block placement.
pub fn set_block_data(&mut self, block: BlockPos, state: BlockState) -> io::Result<BlockState> {
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "voxel-kernels"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "voxel-bench"
path = "src/main.rs"
[lints]
workspace = true
-136
View File
@@ -1,136 +0,0 @@
//! Original occupancy kernels for synthetic 16³ voxel sections.
//! A set bit means an opaque full cube; all neighbors outside the section are air.
//! This is not a Minecraft renderer or a model of partial/transparent blocks.
pub const SIDE: usize = 16;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Section {
rows: [u16; SIDE * SIDE],
}
impl Default for Section {
fn default() -> Self {
Self {
rows: [0; SIDE * SIDE],
}
}
}
impl Section {
pub fn from_fn(mut occupied: impl FnMut(usize, usize, usize) -> bool) -> Self {
let mut section = Self::default();
for z in 0..SIDE {
for y in 0..SIDE {
for x in 0..SIDE {
if occupied(x, y, z) {
section.rows[z * SIDE + y] |= 1 << x;
}
}
}
}
section
}
pub fn occupied(&self, x: usize, y: usize, z: usize) -> bool {
assert!(x < SIDE && y < SIDE && z < SIDE);
self.rows[z * SIDE + y] & (1 << x) != 0
}
/// Simple per-voxel reference. Count each opaque cube's exposed unit faces.
pub fn exposed_faces_scalar(&self) -> u32 {
let mut faces = 0;
for z in 0..SIDE {
for y in 0..SIDE {
for x in 0..SIDE {
if !self.occupied(x, y, z) {
continue;
}
faces += u32::from(x == 0 || !self.occupied(x - 1, y, z));
faces += u32::from(x + 1 == SIDE || !self.occupied(x + 1, y, z));
faces += u32::from(y == 0 || !self.occupied(x, y - 1, z));
faces += u32::from(y + 1 == SIDE || !self.occupied(x, y + 1, z));
faces += u32::from(z == 0 || !self.occupied(x, y, z - 1));
faces += u32::from(z + 1 == SIDE || !self.occupied(x, y, z + 1));
}
}
}
faces
}
/// Evaluate sixteen voxels at once with row masks and population counts.
pub fn exposed_faces_bitset(&self) -> u32 {
let mut faces = 0;
for z in 0..SIDE {
for y in 0..SIDE {
let i = z * SIDE + y;
let row = self.rows[i];
let below = if y == 0 { 0 } else { self.rows[i - 1] };
let above = if y + 1 == SIDE { 0 } else { self.rows[i + 1] };
let back = if z == 0 { 0 } else { self.rows[i - SIDE] };
let front = if z + 1 == SIDE {
0
} else {
self.rows[i + SIDE]
};
faces += (row & !(row << 1)).count_ones();
faces += (row & !(row >> 1)).count_ones();
faces += (row & !below).count_ones();
faces += (row & !above).count_ones();
faces += (row & !back).count_ones();
faces += (row & !front).count_ones();
}
}
faces
}
}
#[cfg(test)]
mod tests {
use super::*;
fn check(section: &Section, expected: u32) {
assert_eq!(section.exposed_faces_scalar(), expected);
assert_eq!(section.exposed_faces_bitset(), expected);
}
#[test]
fn known_geometry() {
check(&Section::default(), 0);
check(&Section::from_fn(|_, _, _| true), 6 * 16 * 16);
check(&Section::from_fn(|x, y, z| x == 3 && y == 4 && z == 5), 6);
check(&Section::from_fn(|x, y, z| x < 2 && y == 4 && z == 5), 10);
check(
&Section::from_fn(|x, y, z| (x + y + z) % 2 == 0),
4096 / 2 * 6,
);
}
#[test]
fn every_single_voxel_including_boundaries() {
for i in 0..SIDE.pow(3) {
let mut section = Section::default();
section.rows[i / SIDE] = 1 << (i % SIDE);
check(&section, 6);
}
}
#[test]
fn random_occupancy_matches_reference_at_multiple_densities() {
let mut state = 0x1234_5678_9abc_def0_u64;
for threshold in [0, 1, 16, 64, 128, 192, 254, 255, 256] {
for _ in 0..32 {
let section = Section::from_fn(|_, _, _| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state & 255) < threshold
});
assert_eq!(
section.exposed_faces_scalar(),
section.exposed_faces_bitset()
);
}
}
}
}
-92
View File
@@ -1,92 +0,0 @@
use std::{hint::black_box, time::Instant};
use voxel_kernels::Section;
fn sample(corpus: &[Section], kernel: fn(&Section) -> u32, repeats: usize) -> f64 {
let start = Instant::now();
let mut checksum = 0_u64;
for _ in 0..repeats {
for section in corpus {
checksum = checksum.wrapping_add(u64::from(kernel(black_box(section))));
}
}
black_box(checksum);
start.elapsed().as_nanos() as f64 / (corpus.len() * repeats) as f64
}
fn median(samples: &mut [f64]) -> f64 {
samples.sort_by(f64::total_cmp);
samples[samples.len() / 2]
}
fn main() {
let repeats: usize = match std::env::args().nth(1) {
None => 100,
Some(value) => match value.parse() {
Ok(n) if n > 0 => n,
_ => {
eprintln!("usage: voxel-bench [positive repeat count]");
std::process::exit(2);
}
},
};
let mut rng = 0x7065_7273_6f6e_616c_u64;
let random = |rng: &mut u64| {
*rng ^= *rng << 13;
*rng ^= *rng >> 7;
*rng ^= *rng << 17;
*rng
};
println!(
"workload,sections,samples,repeats,scalar_ns_per_section,bitset_ns_per_section,speedup"
);
for workload in [
"empty",
"solid",
"flat",
"checkerboard",
"random_10",
"random_50",
"random_90",
] {
let corpus: Vec<_> = (0..32)
.map(|i| {
Section::from_fn(|x, y, z| match workload {
"empty" => false,
"solid" => true,
"flat" => y < 1 + i % 15,
"checkerboard" => (x + y + z + i) % 2 == 0,
"random_10" => random(&mut rng) % 100 < 10,
"random_50" => random(&mut rng) % 100 < 50,
"random_90" => random(&mut rng) % 100 < 90,
_ => unreachable!(),
})
})
.collect();
for section in &corpus {
assert_eq!(
section.exposed_faces_scalar(),
section.exposed_faces_bitset()
);
}
for kernel in [Section::exposed_faces_scalar, Section::exposed_faces_bitset] {
sample(&corpus, kernel, 10);
}
let mut scalar = Vec::new();
let mut bitset = Vec::new();
for trial in 0..21 {
if trial % 2 == 0 {
scalar.push(sample(&corpus, Section::exposed_faces_scalar, repeats));
bitset.push(sample(&corpus, Section::exposed_faces_bitset, repeats));
} else {
bitset.push(sample(&corpus, Section::exposed_faces_bitset, repeats));
scalar.push(sample(&corpus, Section::exposed_faces_scalar, repeats));
}
}
let scalar = median(&mut scalar);
let bitset = median(&mut bitset);
println!(
"{workload},32,21,{repeats},{scalar:.2},{bitset:.2},{:.2}",
scalar / bitset
);
}
}
+3 -3
View File
@@ -13,7 +13,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
- [Graphics](graphics.md): the Simple and Advanced graphics settings, the graphics benchmark and what it measured.
- [Benchmark](benchmark.md): Horizon against vanilla 26.3 and vanilla with performance mods, on the same machine, world, path and settings: what is measured, how it is kept fair, how to reproduce it.
- [Extras](extras.md): optional zoom, minimap, FPS and keystroke overlays and auto-reconnect, all off by default.
- [Mods](mods.md): Horizon's own mod system (Rust, sandboxed WebAssembly), how to make and install mods, and [what carries over from Java mods](research/java-mod-compat.md).
- [Mods](mods.md): Horizon's own mod system (Rust, sandboxed WebAssembly), how to make and install mods.
- [Modpacks](modpacks.md): porting whole packs (ATM10 and smaller): import, inventory, carry-over, an agent per mod with a harness against the Java original, coverage, and how "10x" is measured.
- [Launcher](../launcher/README.md): installs, updates and starts builds; its CLI and MCP server; releasing it.
- [Versioning](versioning.md): how releases are numbered from 0.2 (`v0.2.x`, `[email protected]`), how installs from before 0.2 get there, and the steps for the day 0.2 ships.
@@ -45,6 +45,6 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
- [Collision geometry](collision-geometry.md): shapes, collision, movement and fluids.
- [Face visibility](face-visibility.md): which model faces the mesher emits.
## The earlier Java track (no longer developed)
## Java comparison profiling
- [Java performance packs](java-packs.md), with [Vulkan on your Mac](vulkan.md), [collector comparisons](collectors.md), [profiling](profiling.md), [process memory](process-memory.md) and [tick state](tick-state.md). The Fabric server starter is retired: [host a server](hosting.md) with horizon-server.
- [JFR analysis for Java comparisons](profiling.md).
+141
View File
@@ -0,0 +1,141 @@
<!-- Generated from crates/horizon-mod-sdk/wit/horizon.wit and the manifest's capabilities by crates/horizon-mods/tests/api_reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference -->
# Mod API reference
The contract between Horizon and its mods: the WIT package `horizon:[email protected]`, every interface, type, function and hook in it, and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: [stability.md](stability.md). Manifest `api` lines this game serves: `0.1`, `0.2`.
## Capabilities
| In `capabilities` | Grants | The player reads "May …" |
| --- | --- | --- |
| `game-read` | [`game`](#game) | see your position, health and the time |
| `chat` | [`chat`](#chat) | show you chat messages |
| `hud` | [`hud`](#hud) | draw text on your screen |
| `storage` | [`storage`](#storage) | save its own settings to disk |
| `registries` | data files (`assets/`, `data/`), no interface | add blocks and items to your worlds |
## World `horizon-mod`
- Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage`
- Exports (what the mod provides): `events`
## `log`
Access: always granted.
The mod's log, in the game's log under the mod's id. Always granted.
### enum `level`
| Case | |
| --- | --- |
| `debug` | |
| `info` | |
| `warn` | |
| `error` | |
| Function | Signature | What it does |
| --- | --- | --- |
| `write` | `func(level: level, message: string)` | One line in the game's log as `[mod <id>] message`, at that level. |
## `game`
Access: capability `game-read`.
Capability `game-read`: the local player and the world clock.
### record `player`
| Field | Type | |
| --- | --- | --- |
| `x` | `f64` | Feet position, in blocks. |
| `y` | `f64` | |
| `z` | `f64` | |
| `yaw` | `f32` | Degrees, as F3 shows them. |
| `pitch` | `f32` | Degrees: -90 looks straight up, 90 straight down. |
| `health` | `f32` | Hit points as vanilla counts them: 20 is full health without effects. |
| `food` | `u32` | 0 to 20, as the hunger bar. |
| `dimension` | `string` | `minecraft:overworld`, `minecraft:the_nether`, ... |
### record `clock`
| Field | Type | |
| --- | --- | --- |
| `game-time` | `u64` | Ticks since the world was created. |
| `day-time` | `u64` | 24000 ticks per day; 0 is 6:00 on the first day. |
| Function | Signature | What it does |
| --- | --- | --- |
| `get-player` | `func() -> option<player>` | None on the title screen. |
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
## `chat`
Access: capability `chat`.
Capability `chat`: messages only the local player sees.
| Function | Signature | What it does |
| --- | --- | --- |
| `show` | `func(text: string)` | Shown in chat after the mod's name, like a system message. |
## `hud`
Access: capability `hud`.
Capability `hud`: one panel of text lines per mod, drawn by the game every frame until the mod changes or clears it (no call per frame).
### enum `corner`
| Case | |
| --- | --- |
| `top-left` | |
| `top-right` | |
| `bottom-left` | |
| `bottom-right` | |
### record `line`
| Field | Type | |
| --- | --- | --- |
| `text` | `string` | Plain text, up to 256 characters. |
| `color` | `u32` | 0xRRGGBB. |
| Function | Signature | What it does |
| --- | --- | --- |
| `set-panel` | `func(corner: corner, lines: list<line>)` | Replaces the mod's panel: up to 16 lines in that corner of the screen. |
| `clear` | `func()` | Removes the mod's panel. |
## `storage`
Access: capability `storage`.
Capability `storage`: a per-mod folder of small key/value files, quota'd (docs/mods.md). Keys are `[a-z0-9_.-]{1,64}`, one path segment.
| Function | Signature | What it does |
| --- | --- | --- |
| `get` | `func(key: string) -> option<list<u8>>` | None if `key` has never been `set`, or the key is not well-formed. |
| `set` | `func(key: string, value: list<u8>) -> result<_, string>` | Errs on a malformed key or over quota (bytes for the whole mod). |
| `delete` | `func(key: string)` | No error if `key` was already absent. |
## `events`
Access: exported by the mod: the game calls it.
What the game calls. Only the hooks `init` asks for are ever called.
### record `registration`
| Field | Type | |
| --- | --- | --- |
| `commands` | `list<string>` | Chat commands, without the slash. A name vanilla already uses is refused (vanilla's command runs). |
| `tick` | `bool` | Call `tick` every game tick (20 per second while playing). |
| `frame` | `bool` | Call `frame` every rendered frame. Budget: 0.1 ms. |
| `chat` | `bool` | Call `chat` for each chat line the player receives. |
| Function | Signature | What it does |
| --- | --- | --- |
| `init` | `func() -> result<registration, string>` | Once, after loading. An error keeps the mod off and is shown. |
| `tick` | `func(tick: u64)` | Each game tick while in a world, with the game's tick counter. |
| `frame` | `func(seconds: f32)` | Each rendered frame, with the seconds since the last one. |
| `command` | `func(name: string, args: string) -> result<option<string>, string>` | `/name args`: ok(some(text)) is shown as feedback, err(text) in red. |
| `chat` | `func(text: string)` | Each chat line the player receives, as plain text. |
+2 -2
View File
@@ -27,7 +27,7 @@ file selects ~103.
| 10 | Launcher (124) | `launcher/src/` (`main.rs`, `backend.rs`, `cli.rs`, `mcp.rs`, `servers_page.rs`) | `cd launcher && cargo test`; `python3 tools/launcher_mcp_e2e.py` |
| 11 | Mob types and species (117) | `L/mobs.rs`, `L/mobs/species.rs`, `L/mobs/data.rs`, `L/mobs/living.rs`, `L/mobs/drops.rs`, `L/mobs/gear.rs` | mob_attributes, entity_attribute, entity_death_loot, mob_ai |
| 12 | Guests' actions on a server (98) | `G/lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `G/lan_{attributes,pvp,chat,crafting,stations,trading,mount_menu,waypoints}.rs` | server_player_combat, server_player_damage, server_player_targets, lan_e2e (all) |
| 13 | Modpack porting, mod content (97) | `crates/horizon-port-pack/`, `L/mod_content.rs`, `tools/port_*.py`, `packs/` | `cargo test -p horizon-port-pack`; `python3 tools/mod_port_harness.py` |
| 13 | Modpack porting, mod content (97) | `crates/horizon-port-pack/`, `L/mod_content.rs`, `tools/port_*.py` | `cargo test -p horizon-port-pack`; `python3 tools/mod_port_harness.py` |
| 14 | Mob AI (96) | `L/mobs/ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `control.rs` | mob_ai (probe `examples/mob_ai_probe.rs`; known failure on main: drowned_*, #290) |
| 15 | Items and item use (96) | `L/items.rs`, `L/item_data.rs`, `G/hand.rs`, `G/consume.rs`, `G/potions.rs`, `L/effects.rs`; game.rs `fn use_hand`, `fn use_item` | use_item_on, item_use, item_use_lifecycle, consumable, consume_effect, status_effect |
| 16 | Options and settings screens (93) | `L/options_ui.rs`, `G/options.rs`, `L/settings.rs`, `L/options_file.rs`, `L/graphics_ui.rs`, `L/graphics.rs` | options, toggle_keys |
@@ -41,7 +41,7 @@ file selects ~103.
| 24 | Container menus and inventory (61) | `L/menu.rs`, `L/container_screen.rs`, `L/containers.rs`, `L/creative.rs`; game.rs `fn menu_ops`, `fn click_inventory` | menu_click, container_screen, creative, lan_e2e containers clicks |
| 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `L/enchant.rs`, `L/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing |
| 26 | Crafting and the recipe book (59) | `L/crafting.rs`, `L/recipe_book.rs`, `L/recipe_book_screen.rs`, `G/recipe_book.rs`, `G/machines.rs`, `G/machines/crafter.rs` | recipe, recipe_book, recipe_book_view, place_recipe, ghost_recipe, crafter |
| 27 | Vehicles and mounts (54) | `G/boats.rs`, `G/minecarts.rs`, `G/mounts.rs`, `G/mount_menu.rs`, `G/llamas.rs`, `G/passengers.rs` | server_entities, lan_e2e boats minecarts riding mounts |
| 27 | Vehicles and mounts (54) | `G/boats.rs`, `G/boat_passengers.rs`, `G/minecarts.rs`, `G/mounts.rs`, `G/mount_menu.rs`, `G/llamas.rs`, `G/passengers.rs` | server_entities, lan_e2e boats minecarts riding mounts |
| 28 | Villagers, trading, raids (50) | `L/mobs/villager*.rs`, `L/merchant.rs`, `G/trading.rs`, `G/villagers.rs`, `L/raid.rs`, `G/raids.rs`, `L/gossip.rs` | trading, raid, mob_ai |
| 29 | Title, worlds, startup (49) | `L/title.rs` + `L/title/`, `G/startup_menu.rs`, `L/title_edition.rs` | world_options; `cargo test -p horizon-client --lib title` |
| 30 | Dedicated server, horizon-server (48) | `G/dedicated.rs`, `G/dedicated/status.rs`, `crates/horizon-server/`, `crates/horizon-server-files/` | dedicated_server, server_files, user_cache, online_mode, server_ops_feedback |
+1 -1
View File
@@ -21,7 +21,7 @@ One game is started per window size (1280x720 and 1920x1080 by default) and rend
- **Warm-up excluded.** Each scene's flight starts only after its chunks are loaded and drawn and 2 seconds have passed; the load itself is reported separately. JIT warm-up of the JVM during the first scene is part of vanilla's real behaviour and is not removed.
- **The same statistics code.** Horizon returns its raw frame times (`horizon-ctl fly ... raw`) and the vanilla mod writes its own; `stats()` in the driver computes every figure for both.
- **Vanilla as players run it.** Your own 26.3 client jar and libraries (as the official launcher installs them for your OS), the official launcher's default JVM arguments (`-Xmx2G` and its G1 options), Java 25. The game's assets index is not used, so neither game plays sound (Horizon runs with `HORIZON_NO_AUDIO=1`, vanilla with OpenAL's null device); textures, models and shaders come from the client jar as usual.
- **Performance mods.** `--game vanilla-mods` adds the Fabric builds for 26.3 of Sodium, Lithium, FerriteCore and ImmediatelyFast, the versions pinned with their hashes in `packs/mods.lock.json` (downloaded from Modrinth, never committed), plus Fabric API if one of them needs it.
- **Performance mods.** `--game vanilla-mods` adds the Fabric builds for 26.3 of Sodium, Lithium, FerriteCore and ImmediatelyFast, the versions pinned with their hashes in `tools/benchmark-deps/mods.lock.json` (downloaded from Modrinth, never committed), plus Fabric API if one of them needs it.
## Known caveats
-48
View File
@@ -1,48 +0,0 @@
# Compare Java collectors
Use the balanced pack first with the launcher's existing JVM policy. Java 25
HotSpot supports explicit G1 (`-XX:+UseG1GC`) and ZGC (`-XX:+UseZGC`) selections;
the [Java command reference](https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html)
documents both. A collector choice can change CPU, heap headroom, collection
behavior and frame pacing. Lower pauses alone do not establish lower RAM or
higher FPS. The packs do not automatically select a collector.
For a Mac comparison, duplicate one instance. In Edit > Settings > Java, enable
the Java Arguments override checkbox. Preserve existing non-collector arguments,
but use exactly one collector selector in each instance. Choose G1 in one and
ZGC in the other. Use the same native Java 25 ARM64, maximum/minimum heap, compact
header setting, world copy, resolution, shaders, distances and warmed camera route.
Alternate at least three 120-second runs rather than comparing unrelated captures.
Remove the explicit selector to restore the launcher's policy, then restart.
The viewer shows the actual collector names captured by the game. Check them:
setting a launcher's text field does not establish which flags reached the VM.
Prism's per-instance Java Arguments and Memory settings have separate override
checkboxes. A global collector selection may need removal or replacement when
creating an explicit per-instance experiment.
Record FPS, slowest-1% rate, frame spikes, heap and process RSS together. RSS does
not represent all shared GPU memory on Apple Silicon. `gc_time_ms` sums collector
MXBean counters; concurrent cycles and overlapping phases can contribute, so it
is not a measured pause total. Use JFR root pause events for pause analysis.
The streaming reader is checked against the JDK's event exporter with both
default-GC and ZGC fixtures in CI on Windows, Linux and ARM macOS.
The developer demo launcher accepts `--gc default`, `--gc g1` or `--gc zgc`.
For example:
```sh
python3 tools/runtime.py demo --instance .local/runtime/collector-test --heap 6G --gc zgc --compact-headers
python3 tools/test_jfr.py --gc zgc
```
Prepare demo mods/worlds before comparing.
Options affect only the launched process; they do not change global Java settings.
The collector and compact-header experiments can be tested independently.
A local 3 GiB, compact-header ZGC client completed a 120-second game recording.
That recording also encountered a distant-terrain rejection storm. Its workload
and cache history differed from G1 captures, so the larger memory footprint and
different frame rate cannot be attributed to the collector alone. The dispatcher
experiment addresses the identified retry loop separately. No M2 collector
performance winner is established.
+2
View File
@@ -165,6 +165,8 @@ hardcore = false
# Any other server.properties key, spelled as vanilla spells it:
[properties]
# spawn-protection = 16
# resource-pack = "https://example.com/pack.zip" # offered to players as they join; with
# resource-pack-sha1, require-resource-pack and resource-pack-prompt, as on vanilla
[engine]
path = "" # the game program; empty: the one beside horizon-server
+41
View File
@@ -0,0 +1,41 @@
# How Horizon is built
Who writes Horizon, how changes are checked, and why it is safe to run. You can check every claim on this page in the repository, and anything not done yet is marked as not done.
This page is also on the website: [playhorizon.dev/docs/how-horizon-is-built](https://playhorizon.dev/docs/how-horizon-is-built). The longer version, with what is still missing, is [Security](security.md).
## Who builds it
Horizon is Ludovic Marie's project: he decides what it does and what ships. **Most of the code is written by AI agents**: Claude sessions that Ludovic runs, and contributors' own AI tools. Some contributors write their code by hand.
Agents say what they are. A pull request opened by one of Ludovic's agents starts with "Opened by Ludovic's AI agent (Claude), not by Ludovic himself.", its comments start with "🤖 Agent:", and its commits name the agent in a `Co-Authored-By` line.
## How changes are checked
Not every line is read by a person. Instead, every change is checked against the real game, automatically:
- **Harnesses.** A harness is a test that gives the same inputs to Horizon and to the real Minecraft 26.3, then compares the results. There are 326 of them (25 October 2026), from world generation to mobs, redstone and commands. The [status table](https://playhorizon.dev/#parity) shows what they cover. Passing them all does not prove that nothing else differs.
- **Checks on every pull request.** `verify` runs on GitHub's machines for every pull request and every change to main: formatting, lints with warnings treated as errors, the tests, and the check that no Minecraft files are committed. Main takes no change without it. `parity` runs the harnesses a change can affect, on a machine that holds a copy of Minecraft. For a pull request from a fork, it runs once a maintainer has read the change.
- **Outside contributions are checked again.** Before a contributor's pull request merges, Ludovic's agents compare what it claims with vanilla's own code, review it line by line, rewrite the weak parts, and test it in single player, on a LAN and on a dedicated server where those apply.
- **Releases are tested first.** A release is published only after its smoke test passes (the game starts and draws a world) and while no issue is labelled `release-blocker`.
## Why it's safe to run
- **Open source.** All of the code is public on [GitHub](https://github.com/ludovic111/horizon), under the MIT licence.
- **Built from the public code.** Releases are built by GitHub Actions, the build service of GitHub, from the public source. Linux and Windows are built on GitHub's machines, and macOS on Ludovic's Mac mini, which runs GitHub Actions jobs. Every build log is [public](https://github.com/ludovic111/horizon/actions/workflows/release.yml).
- **Signed checksums.** Every release has a `SHA256SUMS` file, a fingerprint of each download, and `SHA256SUMS.sig`, a signature of that list made with Horizon's release key, which only release builds from main can use. The launcher and `horizon-server update` check both before they install anything, and refuse a file that does not match. The very first server install with `install.sh` checks the fingerprint but not the signature.
- **Not yet signed by Apple or Microsoft.** The macOS app is not notarized by Apple, because it is not signed with a paid Apple developer account, so macOS asks you to confirm the first time ([Install](https://playhorizon.dev/docs/install#download-the-launcher) shows how). The Windows programs are not code-signed either, so Windows may warn you too.
- **Mods run in a sandbox.** Horizon mods are WebAssembly programs with no access to your files, the network or other programs. A mod lists what it needs, you see that list before you turn it on, and it can do nothing else. See [Mods](mods.md).
- **Nothing collected unless you say yes.** Data collection is off until you turn it on, with four separate switches. The one exception is an anonymous "I'm playing" ping for the launcher's player count, which holds nothing about you. See [Opt-in data collection](telemetry.md).
## What it doesn't contain
- **No Minecraft files.** Horizon contains no Minecraft code, assets or data, and never downloads them. The launcher builds the game files on your computer from your own copy of Minecraft: Java Edition 26.3. A check that runs on every pull request (`tools/check_public_tree.py`) keeps game files and decompiled code out of the repository and its history.
- **No code from other mods.** Since 24 October 2026, Horizon's code may only be written from Minecraft 26.3 itself and public documentation, never from another mod, loader or renderer ([the rule](../CONTRIBUTING.md#only-original-code)). That day shader packs were removed ([#1561](https://github.com/ludovic111/horizon/pull/1561)), after a developer of Iris showed that our shader pack code had been written from Iris's source. They are being written again from a description of the pack format only.
## Check it yourself
- **Read the code and its checks.** [The source](https://github.com/ludovic111/horizon), [the checks on every pull request](https://github.com/ludovic111/horizon/actions/workflows/ci.yml) and [the release builds](https://github.com/ludovic111/horizon/actions/workflows/release.yml).
- **Compare the fingerprints.** Put a release's files and its `SHA256SUMS` in one folder and run `sha256sum -c SHA256SUMS --ignore-missing` (on macOS `shasum -a 256 -c SHA256SUMS --ignore-missing`, on Windows `Get-FileHash <file>` in PowerShell). Each file should say `OK`.
- **Check the signature.** Download a whole release into a folder, then from a clone of the repository run `cargo run -p horizon-release-sign -- verify --tag <tag> <folder>`. The public key is in `crates/horizon-release-sign/release-keys.txt`.
- **Build it yourself.** The README explains how to [build from source](../README.md#build-from-source).
+1 -1
View File
@@ -6,7 +6,7 @@ packs, see how well each one runs before installing it, and install it with its
## What it offers
Horizon doesn't run Java code ([research/java-mod-compat.md](research/java-mod-compat.md)). It ports what a Java mod
Horizon doesn't run Java code ([mods.md](mods.md)). It ports what a Java mod
carries as data (blocks, items, recipes, loot tables, tags, worldgen, textures, names) with `horizon-port-pack`, and
says before install which of the mod's features that leaves out. Horizon `.hmod` mods aren't on Modrinth or CurseForge
yet (Modrinth has no `horizon` loader).
+12 -12
View File
@@ -7,9 +7,8 @@ pipeline, not a list of hand ports. It takes a pack as players download it and p
Horizon mods. The pipeline is AI agents in dependency order, each port checked against the
original mod running in a dev-only Java instance.
This page is the design. It builds on [mods.md](mods.md) (the mod API, WASM in wasmtime) and
[research/java-mod-compat.md](research/java-mod-compat.md) (why Java jars and Mixins cannot
run in Horizon, so we port instead). The ATM10 inventory behind the numbers below is
This page is the design. It builds on [mods.md](mods.md) (the mod API, WASM in wasmtime, and
why Java jars cannot run in Horizon, so we port instead). The ATM10 inventory behind the numbers below is
[modpacks/atm10-mods.csv](modpacks/atm10-mods.csv).
## In one picture
@@ -141,12 +140,13 @@ derivative work. These rules are part of the pipeline, not advice to users:
has given written permission, recorded in the index entry. The tooling refuses to package
anything else. No-derivatives and non-commercial licences (14 in ATM10) are never published.
For them, even a local port gets a warning that the licence may not allow it.
4. **Mod code never enters this repository.** Mod jars, decompiled sources, Java oracle builds
4. **Mod code never enters this repository, and nobody reads it.** Mod jars, Java oracle builds
with mod code, and ports of mods we have no permission to publish all live outside the
repo: in the user's pipeline work directory, or for our own development in
`~/code/horizon-parity/modports/` (dev-only, never pushed). Ports are written from
observed behaviour and the harness, with decompiled code as a local reference only. The
repository holds the pipeline, the API, metadata (ids, names, versions, licences, counts)
`~/code/horizon-parity/modports/` (dev-only, never pushed). A mod's code is never
decompiled, read, quoted or paraphrased, by people or agents. Ports are written only from
the mod's observed behaviour (the oracle and the harness), its own data files and its
public documentation. The repository holds the pipeline, the API, metadata (ids, names, versions, licences, counts)
and test fixtures we write ourselves.
5. **Contacting authors or publishing any port is Ludovic's call.** Agents ask first.
@@ -177,7 +177,7 @@ frame, chat and command hooks. Server mods do not run yet. What a content pack n
| Entities: types, attributes, AI goals and brains, spawning | — | deep (87) | 0.5 | `entity` interface, AI in the mod with vanilla goals reusable |
| Custom renderers and animated models (block-entity, entity, item; GeckoLib's format) | — | deep | 0.5 | data models (Bedrock geometry + animations), plus a budgeted draw-list interface for the rest |
| Dimensions: dimension types, levels, chunk generators | — | about 10 (Twilight Forest, Aether, Ad Astra, Undergarden, ...) | 0.5 | JSON part with worldgen; custom generators as mod code |
| New hooks into vanilla behaviour (what Mixins do) | — | deep | as asked | each becomes a named hook, added for everyone ([java-mod-compat.md](research/java-mod-compat.md#3-a-porting-guide-outline)) |
| New hooks into vanilla behaviour (what Mixins do) | — | deep | as asked | each becomes a named hook, added for everyone |
| Client interfaces: `keys`, `target`, `inventory-read`, `ui`, `render`, `world-read` | planned | client tier (38) | 0.2–0.4 | as in [mods.md](mods.md#planned-interfaces) |
Two rules from mods.md stay: **the sandbox** (no files, sockets or processes; memory cap; fuel
@@ -711,16 +711,16 @@ For tiers logic, machines and deep, in dependency order, as many agents at once
budget allows:
- **Input:** the inventory record, the carried-over data, the oracle's exports and traces, the
harness, the Horizon mod API docs, the ports of its dependencies, and the mod's decompiled
code as a local reference (dev-only, in the work directory).
harness, the Horizon mod API docs, the ports of its dependencies, and the mod's public
documentation. Never its code: the agent doesn't decompile, read or quote it.
- **Output:** a Rust crate in `mods/<id>/port/` built with `cargo horizon-mod build`, and its
`.hmod`.
- **Loop:** write → build → run the harness → read the differences → fix, until the harness
passes or the agent reports what blocks it. Usually that is an API gap, which becomes an
issue against the mod API ("needs a scheduled-tick hook"), the way version ports turn
differences into work items.
- **Rules:** the agent writes the port from observed behaviour and the harness, not by
transliterating decompiled Java; ports follow the mod's licence ([Licences](#licences)).
- **Rules:** the agent writes the port from observed behaviour, the harness and the mod's
own data files only; ports follow the mod's licence ([Licences](#licences)).
Players without an AI agent get the carry-over tiers. A port the licence lets us publish can
be downloaded instead of regenerated.
+4 -3
View File
@@ -3,8 +3,9 @@
Horizon gets its own mod system, written in Rust and run in a sandbox, plus
two ways to bring over what Java players use: native Horizon ports of popular
mods and the data-driven parts of packs and mods. Fabric and NeoForge jars do
**not** run as they are: [research/java-mod-compat.md](research/java-mod-compat.md)
explains why and what we do instead.
**not** run as they are: their code calls and patches Minecraft's Java classes, and
Horizon has none. What carries over is the data and assets inside a jar
([modpacks.md](modpacks.md)).
Mods are **off and absent by default**. A fresh install has no `horizon-mods`
folder, loads no runtime and plays exactly like vanilla 26.3.
@@ -102,7 +103,7 @@ name (the mod reads it as `args`; API 0.1 has no way to declare argument types).
### Planned interfaces
Each is a new capability, added in a 0.x minor version so older mods keep
loading. Ordered by what the first native ports need ([research](research/java-mod-compat.md#port-first)):
loading. Ordered by what the first native ports need:
| Interface | For | Notes |
|---|---|---|
-130
View File
@@ -1,130 +0,0 @@
# Java performance mods: which of their techniques apply to Horizon
A survey of the techniques used by eight Java Edition performance mods. Asked for in #991, from famescare on Discord. For each technique it gives the vanilla cost it targets, whether Horizon already avoids that cost, and whether it is worth measuring here.
**Rules.** Techniques only, taken from each mod's public description, documentation and changelogs (linked below). No mod code was read or copied. Several of these mods are LGPL-3.0, and Horizon's code stays its own. Anything Horizon adopts is reimplemented against vanilla 26.3's behaviour. It keeps parity, so the harnesses stay unchanged, and it needs before/after numbers like any other perf PR (`docs/benchmark.md`, `docs/profiling.md`).
**Why many don't apply.** A large share of these techniques work around costs of the JVM, OpenGL or GLFW rather than costs of the game's rules. Examples are object headers and duplicate objects, boxing, lambdas and Streams, garbage collection, JNI upcalls, immediate-mode GL draws and GL error checks. Horizon is Rust on wgpu and winit, so it never pays those costs. The techniques that do carry over remove work the game's rules don't need: sleeping block entities, skipped searches, and cheaper geometry tests.
Verdicts:
- **has**: Horizon already avoids the cost, by its design or with a merged change.
- **n/a**: the cost comes from the JVM, OpenGL or GLFW, and Horizon doesn't have it.
- **later**: missing, and worth measuring. These are listed in "Worth measuring" below and in the LATER list.
- **no**: missing, but it would change vanilla's behaviour or look, or the gain here is too small to pay for itself.
## Lithium (server and shared game logic)
Source: [Lithium's mixin configuration](https://github.com/CaffeineMC/lithium/blob/develop/lithium-fabric-mixin-config.md). Lithium keeps vanilla's behaviour, which is the same rule Horizon follows.
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| Hopper caching, inventory change listeners and hopper sleeping | Every hopper looks up its source and target containers and tries a transfer every tick, even when nothing can move | `game/machines.rs` ticks every hopper on each tick. It checks the block's name and cooldown, then runs the transfer attempt. Nothing is cached or put to sleep | **later** |
| Block entity sleeping (furnaces, brewing stands, campfires, crafters, shulker boxes, sculk) | Idle block entities still tick | `game/block_entity_ticks.rs` ticks every ticker each tick, as vanilla does | **later** |
| Entity collisions: faster block access, chunk caching, the supporting block first | Collision scans read blocks through generic level lookups | Collision scans already run over Horizon's own section arrays (`horizon-world` `block_collision_scan`). A counting cursor that does fewer lookups is open as #659 | **has** (more in #659) |
| Specialised cuboid and empty `VoxelShape`s, precomputed shape arrays | Generic voxel shape intersection and merging | `horizon-core::geometry` has its own immutable shapes and box lists (`docs/collision-geometry.md`) | **has** |
| POI search and portal search | POI lookups go through vanilla's stream-heavy search | Section-indexed POIs (`poi_sections.rs`), searched in vanilla's order | **has** |
| Pathfinding: cached node types per block | Node evaluation reads the same blocks over and over | Not measured. Mob pathfinding costs haven't been profiled against vanilla yet | **later** (profile first) |
| Explosions: one access per block on a ray, skip air, skip exposure work | Each ray step reads the block and the fluid again | `game/explosion.rs` follows vanilla's loop. It hasn't been profiled, and TNT chains are the case to measure | **later** (profile first) |
| Random ticks: roll the chance early, fast block search | Sections with nothing that ticks randomly are still walked | Sections with nothing random-tickable are skipped (`docs/random-tick-eligibility-benchmark.md`) | **has** |
| Tick scheduler: faster collections, packed scheduled ticks | Boxed `ScheduledTick` objects in hash sets | Horizon's own tick heap and index (`horizon-core::ticks`, `docs/scheduled-ticks.md`) | **has** |
| Collections and allocation (fastutil maps, cached `values()`, fewer `BlockPos`, no Streams) | JVM allocation, boxing, GC | Rust value types, no GC | **n/a** |
| Math inlining, a smaller sine table | JIT inlining limits | Compiled and inlined by rustc. Horizon keeps vanilla's `Mth.sin` table where results depend on it | **n/a** |
| Chunk access without debug locks or bounds validation | `PalettedContainer`'s thread-check lock | Horizon doesn't have that lock (`docs/performance/paletted-container-access-results.md`) | **n/a** |
| Palette replacement and compaction | Hash palette resizing on the JVM | Horizon's own paletted containers (`horizon-core::paletted`) | **has** |
| Game event dispatchers made only for sections with a listener | Every section holds a dispatcher | Not checked. This is small next to the items above | **no** (until a profile shows it) |
| Item entity merging grouped by item type (experimental) | Each item entity checks every nearby item to merge with | Not present. It matters only with thousands of dropped items (see #996: fresh worlds drop about twice as many items as vanilla) | **later**, after #996 |
| World border cache, redstone wire double reads, fluid flow reads, framed-map scans | Small repeated reads | Each is minor. Fix them where a profile points at them | **no** |
## FerriteCore (memory)
Source: [FerriteCore's summary of its changes](https://github.com/malte0811/FerriteCore/blob/main/summary.md).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| One `FastMap` of neighbour states per block instead of a table per state | About 600 MB of per-state neighbour tables in large packs | Block states are numeric ids with flat tables, with no object per state | **n/a** |
| Compact property storage per state | A per-state `ImmutableMap` | Same as above | **n/a** |
| Cached multipart predicates, shared multipart models | Hundreds of thousands of model and predicate objects | Models are baked once into a shared library (`docs/shared-model-library-benchmark.md`) | **has** |
| Deduplicated baked-quad vertex arrays | Identical `int[]` per quad | Section meshes are built per section from the shared baked models, and no per-quad arrays are kept | **has** |
| Deduplicated shape caches | Duplicate cached `VoxelShape`s per state | One table of distinct shapes, indexed per state (`horizon-world` `CachedCollisionRegistry`) | **has** |
| String deduplication in model locations | Duplicate `String`s from the constructor's round-trips | No `ModelResourceLocation` objects, so the round-trip doesn't exist | **n/a** |
| Partly loaded chunk NBT kept between loading steps | The whole tag is kept | Chunks are decoded into Horizon's own structures | **n/a** |
Horizon's process memory is tracked against vanilla in `docs/process-memory.md`.
## ImmediatelyFast (client draw submission)
Source: [ImmediatelyFast on Modrinth](https://modrinth.com/mod/immediatelyfast). Its gains show only when the CPU is the bottleneck.
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| A batching buffer for immediate-mode draws (entities, block entities, particles, text, GUI) | Many small draw calls and uploads | The HUD is one atlas and one batch of quads (`hud.rs`). Entities are grouped by material (`gfx/entity_batch.rs`). Terrain uses indirect multi-draws (`gfx.rs`, `gfx/flat.rs`) | **has** |
| HUD batching, fewer GUI draw calls | One draw per GUI element | Same batch (`hud.rs`) | **has** |
| Fast text lookup, a bigger font atlas | Per-character lookups, atlas thrash with large fonts | Glyphs live in maps in `font.rs`, drawn into the same HUD atlas | **has** |
| One texture for all map item textures | One texture per visible map | Not measured. It matters only with walls of maps in item frames | **later** (with a map-wall scene) |
| Cached closed-chest geometry | Re-tessellating chests each frame | Merged in #728 (Online5277) | **has** |
| Buffered sign text instead of redrawing it each frame | Text redrawn every frame | Sign text is recorded as text calls in the batch. Not measured with sign-heavy scenes | **later** (with a sign-heavy scene) |
| Fewer framebuffer switches | GL state changes between render types | wgpu render passes are fixed by Horizon's pipeline | **n/a** |
| GL error checking off | `glGetError` overhead | No GL. Release builds run without wgpu's backend validation and, outside DirectX 12, without its indirect-draw validation (`gfx.rs` `instance_descriptor`) | **n/a** |
## Gnetum (HUD rendering)
Source: [Gnetum on Modrinth](https://modrinth.com/mod/gnetum).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| Draw the HUD into an offscreen buffer in several passes, one pass per frame, and show the last complete buffer | The HUD is redrawn every frame | Horizon redraws its one HUD batch every frame | **no**. The HUD would then update at a lower rate than vanilla's, which changes what players see. Horizon's HUD is already one batch. |
| Adaptive distribution and a separate HUD FPS cap | Same | Same | **no**, for the same reason |
## ScalableLux (lighting, from Starlight)
Source: [ScalableLux on Modrinth](https://modrinth.com/mod/scalablelux).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| Starlight's light engine: per-section light arrays and direct propagation queues | Vanilla's light engine is a bottleneck in fast chunk generation | Horizon has its own engine with vanilla's rules (`light.rs`). New chunks are lit on the streaming worker pool, and edits are relit incrementally (`relight_block`). See `docs/performance/lighting-worker-results.md` | **has** |
| Parallel light updates | One light thread | Chunk lighting already runs on several workers during streaming | **has**. Measure the light share of fresh-chunk generation before doing more. |
## Structure Layout Optimizer (jigsaw structures)
Source: [Structure Layout Optimizer on Modrinth](https://modrinth.com/mod/structure-layout-optimizer).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| An octree of piece boxes instead of a `VoxelShape` for free space | Voxel shape boolean ops slow down as pieces add up | Free space is a container box minus a list of placed boxes (`worldgen/structure/jigsaw.rs` `Free`). That avoids the voxel shapes, but the scan is linear per test | **has**. An octree pays only for structures with hundreds of pieces. |
| Skip jigsaw blocks that face the structure boundary or another piece | Every parent jigsaw is matched against every child | Not present | **later** (time trial chambers and ancient cities first) |
| Skip pool elements already found not to fit at this spot | Weighted pools repeat an element once per weight point, and it is re-tested each time | Not present. Only the variant that keeps the result identical applies (no deduplication of the list, which changes layouts) | **later** |
| Cull positions outside the chunk before processors run | Whole templates are processed and then mostly discarded | Not checked | **later** (same measurement) |
| Palette-based template storage | Templates keep every block and NBT object | Templates are Horizon's own structures. Memory isn't measured | **no** (until memory shows it) |
## AsyncParticles (particles)
Source: [AsyncParticles on Modrinth](https://modrinth.com/mod/asyncparticles).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| Particle culling | Off-screen particles are built anyway | Vanilla already skips quad particles whose position is outside the frustum (`QuadParticleGroup.extractRenderState`, `Frustum.pointInFrustum`). Horizon builds every particle's quad (`particles.rs` `render`). This is a parity gap as well as a cost | **later** (vanilla's frustum point test) |
| Vertex transformation on the GPU | Every particle's quad is built on the CPU and uploaded every frame | Same in Horizon (`particles.rs` `render`). Instancing would upload one record per particle | **later** (measure with 10k+ particles) |
| Particle ticks on another thread | Particle ticks on the client thread | Horizon ticks particles on the game thread | **no**. Vanilla's particle ticks read the level, and racing them against block changes risks parity. Revisit only if a profile shows particle ticks are big. |
| Asynchronous light cache for particles | A light lookup per particle per frame | `particles.rs` looks light up per particle | **later**, with the item above |
| Texture animation one frame late | Animated textures tick on the client tick | Not adopted | **no**, because it changes timing |
## Ixeris (input and the render thread)
Source: [Ixeris on Modrinth](https://modrinth.com/project/p8RJPJIC).
| Technique | Vanilla cost | Horizon | Verdict |
| --- | --- | --- | --- |
| Event polling on the main thread, rendering on its own thread | GLFW event polling and JNI upcalls take time from rendering | winit, without JNI. Input and rendering share the event loop (`game.rs`, `ApplicationHandler`) | **n/a** for the JNI part |
| Buffered raw input (`GetRawInputBuffer`) | One `GetRawInputData` call per event. An 8000 Hz mouse costs a lot on Windows | winit reads `WM_INPUT` one message at a time | **later**. Measure on Windows with a high-polling-rate mouse before changing it. |
| Cached GLFW state | Round trips to the main thread | No GLFW | **n/a** |
| A frame limiter that sleeps, then spins | Imprecise sleeps | Horizon has its own limiter | **no** (no reported problem) |
## Worth measuring
Each of these needs a profile or a benchmark first. They are ordered by the expected gain. They are in the LATER list in `~/code/horizon-parity/FEATURES.md`, not filed as issues:
1. Hopper and block-entity sleeping, with vanilla-identical results (Lithium). Benchmark: a hopper and furnace array.
2. Particles: vanilla's frustum point test (a parity fix too), then GPU-built quads (AsyncParticles). Benchmark: 1,000+ campfires.
3. Jigsaw placement: skip blocked connectors and already-failed elements without changing the layout (Structure Layout Optimizer). Benchmark: trial chambers and ancient cities in fresh worlds, `docs/generation-scan-benchmark.md`.
4. Windows raw input in batches for high-polling-rate mice (Ixeris). Benchmark: an 8000 Hz mouse on Windows.
5. Profile before choosing a technique: mob pathfinding, TNT explosions, item merging after #996, map walls and sign-heavy scenes.
-53
View File
@@ -1,53 +0,0 @@
# Measuring resident process memory
Horizon 0.6.0 records process RSS alongside JVM heap and nonheap pools. RSS
counts resident pages belonging to the Minecraft process, including native
allocations and mapped pages. It helps detect cases where reducing heap use
merely moves memory elsewhere. It is not unique physical-memory ownership or
a complete accounting of GPU allocations, shared pages, compressed memory,
swap, or Apple Silicon unified memory. Do not add heap to RSS: heap pages can
already be included in it.
Minecraft 26.3 already ships OSHI 6.9.0 and JNA on both client and server. The
original mod uses those dependencies without embedding them. Native reader
initialization and refresh happen on a low-priority daemon thread, never on
the render or server-tick thread. One reader serves overlapping frame and
tick recordings. Reads run about once per second only while recordings are
active; the scheduler stops polling when the last recording finishes. Ordinary
gameplay without a benchmark performs no resident-memory reads.
Reports include these optional schema-1 fields:
| Field | Meaning |
| --- | --- |
| `process_rss_peak_sampled_bytes` | Largest successful resident-memory sample during this recording |
| `process_rss_last_sampled_bytes` | Last successful sample, which can precede recording completion |
| `process_rss_samples` | Number of successful samples delivered to this recording |
| `process_rss_sample_interval_ms` | Requested 1,000 ms interval; reads can take longer |
| `process_rss_last_sample_age_ms` | Time from the last successful sample to report completion |
Unsupported or failed reads leave these fields absent if no sample succeeded;
zero is not used as a substitute. Frames and ticks continue recording. A read
that completes after a recording finishes is ignored. Sampled peaks can miss
brief spikes, and the initial read can be delayed by native-library startup.
The offline viewer compares RSS separately from heap/nonheap and warns about
stale resident samples. Keep recording duration, workload and warm-up matched
when comparing peaks. Process CPU counters cover the whole process over the
recording wall time, including native work and the integrated server; they do
not identify which thread or mod caused CPU use.
`gc_time_ms` sums collector MXBean collection-time counters. With concurrent
collectors these can include cycle duration and overlapping phases; it is not a
direct stop-the-world pause measurement. The viewer calls it collection time.
Use [JFR pause events](profiling.md) when investigating GC-induced stalls.
A three-pair heap experiment compared 3 GiB and 1.5 GiB
maximum heaps with the same core stack. It recorded variable RSS savings, nearly
unchanged average FPS and increased GC accounting; playable defaults stayed
unchanged.
The native-read test exercises the real library, overlapping recordings,
background execution, idle cancellation and unavailable-reader behavior on
the build platform. This validates measurement availability, not an FPS or
RAM optimization result.
+4 -12
View File
@@ -1,7 +1,7 @@
# Find the next bottleneck
Frame and tick recordings describe player-visible timings. A private Java Flight
Recorder capture helps identify the code doing work or allocating objects. Use
For Java comparison runs, a private Java Flight Recorder capture helps identify
the code doing work or allocating objects. Use
native Java 25, with `jcmd` and `java` from a JDK. Keep normal benchmark runs separate
from profiling runs because sampling adds overhead.
@@ -53,8 +53,7 @@ allocated bytes or proving that one object type owns most of the allocation.
GC pause totals/maximum use top-level `jdk.GCPhasePause`, excluding nested phases
and concurrent collection-cycle duration. `gc_cycles` is a separate count.
`heap_used_peak_at_gc_bytes` considers recorded GC heap summaries and can miss
peaks between collections. It is separate from the game's once-per-second heap
and process-RSS measurements.
peaks between collections. It is separate from externally sampled process RSS.
The reader ignores unselected event types and never extracts properties or
arguments. `event_span_seconds` is the span of all observed event timestamps,
@@ -70,12 +69,5 @@ python3 tools/test_jfr.py
This creates a real JDK recording of bounded allocations and GC, then compares
the reader's counter differences, observation counts, GC counts and heap summaries
against `jfr print --json`. It also verifies escaped thread names and that sampled
weights do not appear as numeric allocation totals. CI runs it on Windows, Linux
and Apple Silicon macOS. The reader was independently checked against the older
weights do not appear as numeric allocation totals. The manual `jfr-analysis` CI job runs both collector fixtures on the Linux game runner. The reader was independently checked against the older
real-game profile's JDK JSON export for 85 threads.
The post-queue software-rendered profile confirms remaining allocations on the
integrated-server and chunk-meshing threads. It is not a matched comparison with
the older capture: world/cache state, heap and object-header flags differ. It
cannot establish M2 frame gains. Mac captures are needed before choosing a larger
renderer or native-kernel replacement.
+9
View File
@@ -312,6 +312,15 @@ and by hand; pushes to `launcher` and `launcher-*` branches only build.
- Published with `--latest=false`: the repository's latest release stays a game release. Notes list the pull
requests under New and Fixed; Kira posts launcher releases without pinging.
Making a launcher release GitHub's latest instead would break servers: every `horizon-server` already installed,
`install.sh` and `install.ps1` ask for `releases/latest` and expect the game's archives there, and a server that
can't update can't fetch the fix either. So the game release stays the latest, and it sends people to the launcher
(#1647): its notes open with a "Want to play? Download the Horizon launcher" block (`PLAYERS` in
`tools/release_train.py`: raw HTML, which GitHub shows and launchers' notes skip; the site's Releases page leaves
it out too), and its archives carry labels ("Game files the
launcher installs: Linux (…)") that GitHub shows in place of the file names. Everything that downloads matches
the asset's name, which is unchanged.
Starting by hand, pausing and rolling back work as for the game (`gh workflow run launcher …`,
`RELEASES_PAUSED`, `gh release edit [email protected] --draft`). Launchers older than the withdrawn release then
see the previous one as the newest; launchers that already updated keep running.
-3
View File
@@ -75,11 +75,8 @@ separate implementation. [Geometry and collision scope](collision-geometry.md).
```sh
cargo test --workspace
cargo run --release --bin voxel-bench -- 100 > .local/voxel-benchmark.csv
```
The dependency-free occupancy experiment compares scalar and bitset exposed-face counting for synthetic 16³ sections. It has no real mesh, material/AO/light work, neighboring-section context, JNI bridge or game integration. Speedups are kernel-only, not Minecraft FPS.
Public branding and new mod downloads use **Horizon**. The existing Fabric mod
ID and `dev.horizonlab` namespace remain stable for installed instances and
measurement compatibility. The installer backs up both old `horizon-lab-*.jar`
-183
View File
@@ -1,183 +0,0 @@
# Java mods and Horizon: what can carry over
Players ask "will my mods work?". Short answer: **Fabric and NeoForge jars
will not run in Horizon as they are**, and no bridge we could build would
change that for more than a handful of mods. What carries over is the
*content* (data packs, resource packs, the data and assets inside mod jars)
and the *ideas* (native Horizon ports of the mods people use most, written
with [Horizon's own mod system](../mods.md)). This page is the reasoning.
## How Java mods work
### Fabric
- **Fabric Loader** finds jars in `mods/`, reads `fabric.mod.json`
(entrypoints, dependencies, mixin configs, access wideners), and starts the
game through a class loader that can rewrite classes as they load.
- **Mixin** (SpongePowered) is how nearly every mod changes the game: a mixin
class names a Minecraft class (`net.minecraft.client.gui.hud.InGameHud`) and
injects code at points inside its methods (`@Inject(method = "render", at =
@At("TAIL"))`, `@Redirect`, `@ModifyVariable`, `@Overwrite`...). The bytecode
of the vanilla class is rewritten at load time. Access wideners make private
fields and methods public for the same purpose.
- **Fabric API** is a set of modules that give mods events and registries
instead of raw mixins: `fabric-lifecycle-events` (`ClientTickEvents`,
`ServerTickEvents`), `fabric-rendering` (`HudRenderCallback`,
`WorldRenderEvents`), `fabric-command-api`, `fabric-key-binding-api`,
`fabric-networking-api` (custom payloads), `fabric-registry-sync`,
`fabric-resource-loader`, `fabric-screen-api`, `fabric-item-group`,
`fabric-events-interaction` and about forty more. **Fabric API itself is
implemented with mixins** into the same vanilla classes.
- Mods mix the two freely: a mod using Fabric API events still calls
`MinecraftClient.getInstance().player.getBlockPos()`,
`world.getBlockState(pos)`, `DrawContext.drawText(...)`: direct calls into
vanilla's classes, by their mapped names.
### NeoForge (and Forge)
- An event bus (`NeoForge.EVENT_BUS`, mod buses) with hundreds of events,
`DeferredRegister` for registries, data attachments and capabilities,
networking payloads, config specs. NeoForge patches vanilla's source
directly (binary patches) to fire its events.
- Mods also use mixins and access transformers, and call vanilla classes
directly, as on Fabric.
### Data and assets in a jar
Besides code, a mod jar carries the same folders as packs:
`assets/<namespace>/` (models, blockstates, textures, sounds, language files,
shaders) and `data/<namespace>/` (recipes, loot tables, tags, advancements,
predicates, functions, worldgen: biomes, features, structures). These are
**vanilla formats**, read by vanilla's own code.
## Why a Java bridge cannot run them
Horizon is a reimplementation in Rust: there are no `net.minecraft` classes
at runtime, no `InGameHud`, no `ClientPlayerEntity`, no bytecode to rewrite.
1. **Mixins have nothing to bind to.** A mixin is a patch against a specific
method's bytecode. Without vanilla's classes there is no method, no
injection point and no local variable to modify. This is not a missing
feature we could add: it is the mechanism itself.
2. **Fabric API is mixins too.** A shim Fabric API would have to be
re-implemented from scratch as Java classes calling into Horizon. That part
alone is possible for the event modules.
3. **Mods call vanilla directly.** Even "pure Fabric API" mods use vanilla's
classes for everything else: the player, the world, the block states, the
renderer, the text drawing. Running them means providing Java classes with
vanilla's names and behaviour, backed by Horizon's state: in effect a
second Minecraft, in Java, on top of Horizon. Thousands of classes, in the
mapped names of every mapping set (Yarn, Mojang, intermediary), version by
version.
4. **The JVM itself** would ship with every install (a trimmed JRE is still
40-60 MB, the full one 150+ MB), start in a second or more, hold a few
hundred MB of heap before any mod loads, and bring garbage-collector pauses
back into the frame: the costs Horizon exists to remove. JNI calls cost
tens to hundreds of nanoseconds each and every one crosses with copies.
5. **No sandbox.** Java mods are fully trusted native-speed code with file
and network access. Horizon's mods are sandboxed and their permissions are
shown; a Java bridge would be the one hole in that.
**What a bridge could support**, if we ever built one: a stable subset of
Fabric API events (client and server tick, HUD render callback, client
commands, key bindings, custom payloads), with a tiny "vanilla view" API
(player position, held item, chat), for mods written only against that
subset. Surveying the most-downloaded Fabric mods, nearly none qualify: the
popular ones are popular precisely because they reach into rendering, the
world or the UI. The cost (a JVM in the game, a second API surface to keep
stable) is far above the benefit. **We do not build it.** If that changes,
the bridge would sit on top of the WIT API like any other mod runtime, so the
sandbox would still hold.
## What we do instead
### 1. Data-driven content (packs and the data in mod jars)
Horizon already reads vanilla resource packs and data packs (functions,
function tags, predicates today). Extending data-pack coverage to recipes,
loot tables, tags, advancements and worldgen JSON brings most "content"
packs over with no mod code at all, and it is vanilla behaviour, so the parity
harnesses check it.
For **Fabric/NeoForge jars**, the launcher can offer "import the data": read
`data/` and `assets/` from the jar and load them as a data pack plus a
resource pack. What works: new recipes, loot changes, tags, advancements,
functions, worldgen tweaks that only use vanilla blocks, retextures, language
files. What does not: anything referring to a block, item or entity the jar
registers in Java code (the ids do not exist); the import skips those files and
lists them. With Horizon's planned `registries` interface, a native port can
register those ids and reuse the jar's JSON models, textures, recipes and loot
as they are.
### 2. Native Horizon ports of popular mods
Horizon itself covers the performance mods: Sodium, Lithium, FerriteCore,
ModernFix, EntityCulling and ImmediatelyFast exist because vanilla Java is
slow, and Horizon's renderer and simulation are the replacement (see
[benchmark](../benchmark.md)). Zoom (OK Zoomer, Zoomify), FPS and keystroke
overlays, a minimap and auto-reconnect are already built in as
[extras](../extras.md).
#### Port first
Ranked by players reached per unit of API work. Each needs the planned
interfaces listed in [mods.md](../mods.md#planned-interfaces).
| Mod (Java) | What it does | Horizon port | Needs |
|---|---|---|---|
| **Mod Menu** | list mods, open their settings | built in: Options > Mods (done), settings pages next | `ui` |
| **AppleSkin** | food saturation and exhaustion on the HUD and tooltips | SDK mod | `inventory-read`, `ui` |
| **Jade / WTHIT** | what the crosshair points at: block, entity, harvest tool, container contents | SDK mod | `target`, `ui` |
| **JEI / EMI / REI** | browse items, recipes and uses | SDK mod (or built in: Horizon already has the recipe book's data) | `recipes`, `inventory-read`, `ui` |
| **Xaero's Minimap / JourneyMap** | minimap, world map, waypoints | the minimap is an extra; a world map and waypoints as an SDK mod | `world-read`, `render`, `storage` |
| **Litematica** | schematic overlay to build from | SDK mod | `world-read`, `render`, `storage`, `ui` |
| **Mouse Tweaks / Inventory Profiles Next** | drag and sort inventory | built in or SDK mod | `inventory-read` + a sanctioned click API |
| **Continuity / CIT** | connected and custom item textures | built into the renderer (pack formats) | none: resource-pack features |
| **Distant Horizons** | low-detail far terrain | a Horizon renderer feature, not a mod | none |
| **Shader packs** | shaders | removed for now; a clean-room Horizon renderer feature, later | none |
| **Simple Voice Chat** | proximity voice | server + client mod | `server`, `net`, audio (later) |
### 3. A porting guide (outline)
To be written as `docs/porting-from-fabric.md` once API 0.2 lands:
1. **Separate logic from glue.** Most of a mod is plain logic (maths,
formatting, data structures): port it to Rust once, unit-test it on the
host.
2. **Map events.**
| Fabric | Horizon |
|---|---|
| `ClientModInitializer.onInitializeClient` | `Mod::init`, returning a `Registration` |
| `ClientTickEvents.END_CLIENT_TICK` | `Registration::on_tick`, `Mod::tick` |
| `HudRenderCallback` | `hud::set_panel` when the content changes (retained), or `on_frame` |
| `ClientCommandRegistrationCallback` | `Registration::command`, `Mod::command` |
| `ClientReceiveMessageEvents` | `Registration::on_chat`, `Mod::chat` |
| `KeyBindingHelper.registerKeyBinding` | `keys` (planned) |
| `Registry.register(Registries.ITEM, ...)` | `registries` (planned) with JSON in `data/` |
| `ServerPlayNetworking` payloads | `net` (planned) |
| config libraries (Cloth Config, YACL) | `storage` + a `ui` settings page (planned) |
3. **Replace mixins with hooks.** For each mixin, write down what it observes
or changes. If a WIT hook covers it, use the hook. If not, open an issue
asking for the hook ("I need X before/after Y"): hooks are added to the API
for everyone instead of each mod patching the game.
4. **Declare capabilities** in `horizon-mod.toml`, the least the mod needs.
5. **Reuse assets**: textures, models, language files and recipes move as
they are into the `.hmod`'s `assets/` and `data/` (with `registries`).
6. **Test** with `cargo horizon-mod run` and hot reload; add unit tests for the
logic; publish with a signed `.hmod`.
## Sources
- Fabric documentation (`docs.fabricmc.net`): mod structure, Mixin
introduction, Fabric API modules and events.
- SpongePowered Mixin wiki: injection points and how class transformation
works.
- NeoForge documentation (`docs.neoforged.net`): events, registries,
networking.
- Modrinth's most-downloaded Fabric mods, for the porting list.
- Horizon's own Java test mod (`mods/horizon`, built by `tools/build_mod.py`
and the CI `portable-mod` job) is a Fabric mod used by the benchmarks and
parity oracles: it shows the Mixin pattern (`FrameHookMixin`) this page
describes, and stays a developer tool, not a player-facing mod.
+3 -1
View File
@@ -33,6 +33,7 @@ harness named checks that loader against vanilla).
| Overlays per format | `pack.mcmeta` `overlays` | works | `test_resource_pack_parity` (an overlay for 26.3 applied, an older one not) |
| Filters | `pack.mcmeta` `filter` | works | `test_resource_pack_parity` (`packs/pattern.rs` regex subset) |
| Folder and zip packs, order, built-in packs | `resourcepacks/` | works | `test_resource_pack_parity`, `test_pack_selection_parity` |
| Server resource packs: the prompt, the server list's Server Resource Packs setting, download (250 MiB, SHA-1, `downloads/` keeps 20), applied on top of the player's packs, removed on leaving | the server's `resource_pack_push`/`_pop` | works, no download toasts | `tools/server_pack_e2e.py` (against vanilla's server and Horizon's), unit tests in `server_packs.rs`; `test_server_resource_pack_parity` for Horizon's server |
| Other namespaces | `assets/<namespace>/` | works | `test_resource_pack_parity` |
| Block states: variants, multipart, weights, rotations, uvlock | `blockstates/` | works | `test_blockstate_model_parity`, `test_model_choice_parity`, `test_blockstate_transform_parity` |
| Block models: parents, elements, rotations, faces, cullface, tint, shade, light emission, ambient occlusion | `models/block/` | works | `test_model_resolution_parity`, `test_model_baking_parity`, `test_section_mesh_parity` |
@@ -74,7 +75,7 @@ harness named checks that loader against vanilla).
| Core shaders | `shaders/core/`, `shaders/include/` | **no** | Horizon draws with WGSL; a pack's GLSL core shaders aren't run |
| GPU warn list, regional compliance | `gpu_warnlist.json`, `regional_compliancies.json` | n/a | OpenGL driver warnings and play-time notices for some regions; not used |
Summary: 44 rows; 31 work, 3 partly, 8 don't (interpolated animation, TrueType fonts, credits and end poem,
Summary: 45 rows; 32 work, 3 partly, 8 don't (interpolated animation, TrueType fonts, credits and end poem,
villager hats, trims on worn armour, locator bar styles, post effects, core shaders), 2 don't apply. Except the
shaders, the missing pieces are missing vanilla features too, and each vanilla parity fix covers the pack case as well.
@@ -83,6 +84,7 @@ shaders, the missing pieces are missing vanilla features too, and each vanilla p
| Gap | Size | Where |
| --- | --- | --- |
| Animation `interpolate` | small | fix PR (`gfx.rs` `animate_sprites`) |
| Server pack download toasts (`download.pack.*`) | small | `game/server_packs.rs` |
| GUI scaling `width`/`height`, `stretch_inner`, `tile`, `stretch` | small | next fix PR (`ui.rs`) |
| Villager hats | small | [#334](https://github.com/ludovic111/horizon/issues/334) |
| `blur`/`clamp` from packs | small | [#335](https://github.com/ludovic111/horizon/issues/335) |
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff. Show more