Compare commits

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:36:48 +02:00
Ludovic MarieandClaude Opus 5.5 7c27b85fbd server_pack_e2e: start the game and the servers through lab.game_env
The new home-leak test rejects scripts that set HORIZON_NO_AUDIO by hand; this one, added on main
since, did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:08:31 +02:00
Ludovic Marie 75d70a1a74 Merge pull request #1584 from ludovic111/feat/shader-packs-read 2026-10-25 01:55:19 +02:00
Ludovic Marie 856f0c2e40 Merge pull request #1583 from ludovic111/docs/platform-apis 2026-10-25 01:53:57 +02:00
Ludovic Marie 213f1bbb2e Merge pull request #1582 from ludovic111/docs/server-api-reference 2026-10-25 01:52:31 +02:00
Ludovic MarieandClaude Opus 5.5 0253d9bc47 Multiplayer: no play packets after the client acknowledges a reconfiguration
A proxy switching servers (Velocity, BungeeCord) sends the client back to
configuration. The network thread acknowledged it, but the game thread kept
ticking the old level and its client_tick_end went out right after the
acknowledgement, where the server reads it as configuration packet 13.
Velocity drops the client: "A packet did not decode successfully", shown as
"An internal error occurred in your connection".

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

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

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

Fixes #1647

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:00:21 +02:00
Ludovic Marie d6e5fb5331 Merge pull request #1606 from rdbtCVS/t3/vectorize-perlin-noise
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 00:59:02 +02:00
Ludovic Marie 3b3590ea03 Merge pull request #1580 from levzzz5154/feat/boat-passengers
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 00:54:21 +02:00
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 MarieandClaude Opus 5.5 d604409be2 A client's own fall no longer hurts it: the server's hurt starts the tilt
As a client (multiplayer, a LAN guest, single player on its server
thread) Horizon ran its own fall damage, fire, freezing, berry bushes and
starvation through the local hurt, so the camera's hurt tilt started even
when the server cancelled the damage (lobbies that turn fall damage off).
Vanilla's client never hurts itself: Entity.hurt does nothing outside a
ServerLevel and FoodData.tick is ServerPlayer's. Its hurt animation comes
from the server: LocalPlayer.hurtTo on a health drop, and
ClientboundDamageEventPacket / ClientboundHurtAnimationPacket for itself.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:52 +02:00
Ludovic Marie 65d7c4b027 Merge pull request #1563 from ludovic111/pa1/remove-cull-leaves 2026-10-24 22:50:34 +02:00
Ludovic Marie 7becd7fdca Merge pull request #1559 from ludovic111/pa1/remove-mod-surveys 2026-10-24 22:48:55 +02:00
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 37c36b548a Merge main into docs/server-api-reference
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:09:34 +02:00
Ludovic MarieandClaude Opus 5.5 6e8a96a734 Boats push the player, and startRiding's quiet failures
- AbstractBoat.push(player) moves the player too: the boat's own tick and
  the player's pushEntities run on the player's client in vanilla
  (pushableBy lets a client push only its local player), so the window's
  player is pushed in-frame and a server's client pushes itself in
  remote_pushes, as it does for mobs.
- A mob the boat would take that can't get in (boarding cooldown, boat
  under water) is left alone, not pushed: the tick calls startRiding,
  which fails quietly, instead of push.
- Mob.startRiding drops the mob's lead.
- look_at_entity reuses the guest's eye position.

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

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

Fixes #1591.

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

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

Fixes #1592.

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

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

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

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

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

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

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

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

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

Nothing uses it yet: players see no change.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:22:10 +02:00
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 MarieandClaude Sonnet 5.5 d271176f23 Tools and harnesses start the game in scratch folders, not a player's
The game logs into HORIZON_HOME and falls back to the player's own game folder when it is unset. Harnesses
that set only HORIZON_NO_AUDIO (and test_native_client, which set nothing) wrote logs, and on a world run
advancements, into ~/Library/Application Support/horizon.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:34:04 +02:00
Ludovic Marie 71292d2ed7 Merge pull request #1562 from ludovic111/feat/server-pack-status 2026-10-24 18:12:16 +02:00
Ludovic Marie 46f4237b67 Merge pull request #1565 from ludovic111/fix/remote-villager-data 2026-10-24 18:03:07 +02:00
Ludovic 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
308 changed files with 12397 additions and 9915 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'
+2 -1
View File
@@ -45,9 +45,10 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
+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
+59 -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",
]
@@ -1330,8 +1331,10 @@ name = "horizon-mods"
version = "0.1.0"
dependencies = [
"log",
"minisign-verify",
"serde",
"serde_json",
"sha2",
"toml",
"wasmtime",
"wit-component 0.239.0",
@@ -1377,6 +1380,7 @@ dependencies = [
"getrandom 0.3.4",
"horizon-core",
"horizon-world",
"ipconfig",
"serde_json",
"sha1",
"sha2",
@@ -1430,6 +1434,13 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-shaders"
version = "0.1.0"
dependencies = [
"zip",
]
[[package]]
name = "horizon-telemetry"
version = "0.1.0"
@@ -1653,6 +1664,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"
@@ -1897,6 +1921,12 @@ dependencies = [
"libc",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3137,6 +3167,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 +3617,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 +4333,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 +4471,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-shaders", "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());
+17 -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;
@@ -468,6 +472,8 @@ impl Game<'_> {
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
"health": p.health.health(),
"absorption": p.health.absorption(),
// The hurt animation's ticks left (the camera's hurt tilt).
"hurt_time": p.health.hurt_animation_time,
"air": p.air,
"food": p.health.food.food,
"saturation": p.health.food.saturation,
@@ -592,10 +598,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| {
+135 -28
View File
@@ -140,9 +140,13 @@ pub(super) struct Remote {
stepped: HashMap<i32, SteppedInterpolation>,
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
head_lerps: HashMap<i32, (f32, u32)>,
/// Item entities waiting for their item data.
pending_items: HashMap<i32, [f64; 3]>,
/// Item entities waiting for their item data: where they are and their
/// motion.
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
health: (f32, i32, f32),
/// `LocalPlayer.flashOnSetHealth`: set by the first health the server
/// sends; from then on a drop starts the hurt animation.
flash_on_set_health: bool,
game_mode: i32,
pub chat: Vec<(String, Instant)>,
/// Lines received since joining, for the control socket's events.
@@ -305,6 +309,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 => {
@@ -401,6 +415,7 @@ impl Game<'_> {
head_lerps: HashMap::new(),
pending_items: HashMap::new(),
health: (20.0, 20, 5.0),
flash_on_set_health: false,
game_mode: 0,
chat: Vec::new(),
chat_received: 0,
@@ -453,6 +468,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 +843,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;
@@ -1369,7 +1387,7 @@ impl Game<'_> {
}
if let Some(r) = self.remote.as_mut() {
if kind == "minecraft:item" {
r.pending_items.insert(id, position);
r.pending_items.insert(id, (position, Vec3::ZERO));
}
r.entities.insert(
id,
@@ -1448,6 +1466,23 @@ impl Game<'_> {
m.on_ground = on_ground;
return;
}
if let Some(p) = r.pending_items.get_mut(&id) {
p.0 = position;
}
// ItemEntity and FallingBlockEntity keep Entity's NO_OP
// InterpolationHandler: moveOrInterpolateTo puts them there at
// once, and their own tick moves them on between the server's
// updates (`tick_remote_entities`).
let at = Vec3::new(position[0], position[1], position[2]);
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.position = at;
d.on_ground = on_ground;
return;
}
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.position = at;
return;
}
// AbstractInterpolationHandler.interpolateTo: without a rotation,
// the one it was heading for.
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
@@ -1459,9 +1494,6 @@ impl Game<'_> {
steps: 3,
},
);
if let Some(p) = r.pending_items.get_mut(&id) {
*p = position;
}
}
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
self.turn_remote_projectile(id, rotation);
@@ -1794,6 +1826,8 @@ impl Game<'_> {
}
}
self.spawn_entity(p.id, &kind, p.position, rotation);
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
self.remote_motion(p.id, p.velocity);
if !self.spawn_remote_object(p.id, &kind, p.position, rotation[0], p.data) {
self.spawn_remote_projectile(
p.id,
@@ -2112,17 +2146,29 @@ impl Game<'_> {
pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]);
}
}
// The same packet for another entity: Entity.lerpMotion.
C::SetEntityMotion(p) => self.remote_motion(p.id, p.velocity),
// ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent
// jolts the walk animation and restarts the hurt flash. Its hurt
// sound comes as a sound from Horizon's host, which plays it itself.
// This player's own hurt tilt and flash come from the server too
// (sent to the player itself): its own moves never hurt it.
C::DamageEvent(p) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt();
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.limb_speed = 1.5;
m.hurt_time = 10;
}
}
C::HurtAnimation(p) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt();
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.hurt_time = 10;
}
}
@@ -2188,9 +2234,14 @@ impl Game<'_> {
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
}
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
// ClientPacketListener.handleSetHealth: LocalPlayer.hurtTo.
C::SetHealth(p) => {
if let Some(r) = self.remote.as_mut() {
let flash = self.remote.as_mut().is_some_and(|r| {
r.health = (p.health, p.food, p.saturation);
std::mem::replace(&mut r.flash_on_set_health, true)
});
if flash && let Some(pl) = self.player.as_mut() {
pl.health.flash_health_change(p.health);
}
self.apply_remote_health();
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
@@ -2920,7 +2971,9 @@ impl Game<'_> {
item.stack = stack;
return;
}
let Some(p) = position else { return };
let Some((p, velocity)) = position else {
return;
};
let at = Vec3::new(p[0], p[1], p[2]);
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
self.dropped.push(ItemEntity {
@@ -2931,7 +2984,7 @@ impl Game<'_> {
stack,
position: at,
previous: at,
velocity: Vec3::ZERO,
velocity,
on_ground: false,
age: 0,
pickup_delay: 0,
@@ -2939,6 +2992,23 @@ impl Game<'_> {
ticks: 0,
});
}
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
/// entities whose own tick moves them on a client: dropped items and
/// falling blocks.
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.velocity = v;
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.velocity = v;
} else if let Some(p) = self
.remote
.as_mut()
.and_then(|r| r.pending_items.get_mut(&id))
{
p.1 = v;
}
}
fn apply_remote_health(&mut self) {
let Some((health, food, saturation)) = self.remote.as_ref().map(|r| r.health) else {
return;
@@ -3136,6 +3206,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(());
};
@@ -3163,6 +3236,7 @@ impl Game<'_> {
);
player.velocity = Vec3::ZERO;
} else if !dead && !riding {
player.client_side = true;
player.velocity = player.velocity.add_vector(pushed);
MotionWorld {
entities: &entity_collisions,
@@ -3206,7 +3280,7 @@ impl Game<'_> {
_ => {}
}
}
// The server owns health and hunger: undo local food/fall changes.
// The server owns health and hunger.
self.apply_remote_health();
self.tick_avatar(before, false);
// The player's own model (third person, the inventory's preview).
@@ -3246,9 +3320,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 +3382,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;
};
@@ -3414,20 +3529,12 @@ impl Game<'_> {
l.steps -= 1;
}
}
// ItemEntity.tick runs on a client too: gravity, collisions and
// friction move the item between the server's updates (every 20
// ticks), and only the server removes it.
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
for d in self.dropped.iter_mut().filter(|_| running) {
d.previous = d.position;
d.age += 1;
if let Some(l) = lerps.get_mut(&(d.id as i32))
&& l.steps > 0
{
let n = f64::from(l.steps);
d.position = Vec3::new(
d.position.x + (l.target[0] - d.position.x) / n,
d.position.y + (l.target[1] - d.position.y) / n,
d.position.z + (l.target[2] - d.position.z) / n,
);
l.steps -= 1;
}
d.tick(world, facts, collision, false);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
for (item, ticks, at, eye, pitch, yaw) in using {
@@ -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));
}
}
@@ -2,7 +2,7 @@
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallingBlock, Game};
use super::{FallStep, FallingBlock, Game};
use horizon_client::projectiles::{Projectile, ProjectileKind};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_protocol::entity_data::Value;
@@ -185,11 +185,17 @@ impl Game<'_> {
t.previous = t.position;
t.position = step(t.id, t.position);
}
for f in &mut self.falling {
f.previous = f.position;
f.age += 1;
f.position = step(f.id, f.position);
// FallingBlockEntity.tick runs on a client too: the block falls by
// itself between the server's updates (every 20 ticks), then waits
// where it lands for the server to place it and remove it.
let mut falling = std::mem::take(&mut self.falling);
for f in &mut falling {
if let Ok(FallStep::Landed(_)) = self.fall_step(f, false) {
// Block.updateEntityMovementAfterFallOn, then the air drag.
f.velocity = Vec3::new(f.velocity.x * 0.98, 0.0, f.velocity.z * 0.98);
}
}
self.falling = falling;
for p in &mut self.creatures.projectiles {
p.previous = p.position;
p.age += 1;
@@ -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();
+19
View File
@@ -253,6 +253,22 @@ impl PlayerHealth {
self.hurt_animation_time -= 1;
}
}
/// `LivingEntity.animateHurt` / `handleDamageEvent` on a client: the hurt
/// tilt and red flash restart.
pub fn animate_hurt(&mut self) {
self.hurt.hurt_duration = 10;
self.hurt.hurt_time = 10;
self.hurt_animation_time = 10;
}
/// `LocalPlayer.hurtTo` with `flashOnSetHealth`: a drop below the current
/// health starts the hurt animation.
pub fn flash_health_change(&mut self, health: f32) {
let damage = self.health() - health;
if damage > 0.0 {
self.hurt.last_hurt = damage;
self.animate_hurt();
}
}
}
impl PlayerMotion {
pub fn hurt(
@@ -274,6 +290,9 @@ impl PlayerMotion {
amount: f32,
blocking: Option<Blocking>,
) -> Result<BlockOutcome> {
if self.client_side {
return Ok(BlockOutcome::default());
}
let source = resources
.damage
.source::<()>(source, None, None, None)
+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(),
+3
View File
@@ -55,6 +55,9 @@ impl PlayerMotion {
Ok(())
}
pub fn food_tick(&mut self, resources: &HealthResources, rules: HealthRules) -> Result<()> {
if self.client_side {
return Ok(());
}
let h = &mut self.health;
let mut world = TickWorld {
resources,
+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"))
+57
View File
@@ -300,6 +300,10 @@ pub struct PlayerMotion {
/// it travels on the server: `isLocalInstanceAuthoritative` is false, so
/// its moves check no fall damage.
pub server_copy: bool,
/// A client's copy of its own player (`Level.isClientSide`): only the
/// server hurts it (`Entity.hurt` does nothing on a client) and ticks its
/// food (`ServerPlayer.tick`). Its hurt animation comes from the server.
pub client_side: bool,
/// `Entity.applyEffectsFromBlocks` runs after travel (always in the game;
/// differential fixtures turn it off where their reference does).
pub block_effects: bool,
@@ -465,6 +469,7 @@ impl PlayerMotion {
freeze_immune: false,
old_position: position,
server_copy: false,
client_side: false,
block_effects: true,
powder_snow_entered: Vec::new(),
hurt_log: Vec::new(),
@@ -2860,6 +2865,58 @@ mod tests {
}
}
}
/// A client's own fall (multiplayer, a LAN guest, single player on its
/// server thread) plays its landing but never hurts it: only the server
/// does (`Entity.hurt` on a client), so a server that cancels fall damage
/// leaves the screen still.
#[test]
fn a_clients_own_fall_does_not_hurt_it() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
for client_side in [false, true] {
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 20.0, 20.5)).unwrap();
player.client_side = client_side;
player.fall_distance = 30.0;
let mut w = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
w.move_self(Vec3::new(0.0, -30.0, 0.0)).unwrap();
assert!(w.player.ground.on_ground);
assert_eq!(w.player.fall_distance, 0.0);
// LivingEntity.causeFallDamage's sounds play on both sides.
assert!(w.player.events.iter().any(|e| matches!(
e,
MotionEvent::Fall(crate::landing::FallEvent::Sound {
sound: crate::landing::SceneSound::BigFall,
..
})
)));
let hurt = w.player.health.health() < 20.0;
assert_eq!(hurt, !client_side);
assert_eq!(w.player.health.hurt_animation_time > 0, !client_side);
// The server's health then starts the client's hurt animation.
if client_side {
w.player.health.flash_health_change(20.0);
assert_eq!(w.player.health.hurt_animation_time, 0);
w.player.health.flash_health_change(12.0);
assert_eq!(w.player.health.hurt_animation_time, 10);
}
}
}
#[test]
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
let root = 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);
}
}
+131 -20
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(),
@@ -135,7 +147,12 @@ impl Session {
pub fn poll(&self) -> Vec<Event> {
self.events.try_iter().collect()
}
/// Send a play packet; silently dropped once the connection is gone.
/// Send a play packet; silently dropped once the connection is gone,
/// and while the server has the connection back in configuration (a
/// proxy switching servers). Vanilla's `handleConfigurationStart` clears
/// the level before it acknowledges, so no play packet follows the
/// acknowledgement; one that did would reach the server as a
/// configuration packet, and Velocity drops such a client.
pub fn send(&self, packet: play::Serverbound) {
if self.debug
&& !matches!(
@@ -147,12 +164,31 @@ impl Session {
{
eprintln!("[net] -> {packet:?}");
}
if let Some(w) = self.writer.lock().unwrap().as_mut()
let mut writer = self.writer.lock().unwrap();
if self.configuring.load(Ordering::Relaxed) {
return;
}
if let Some(w) = writer.as_mut()
&& w.send(&packet.encode()).is_err()
{
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 +289,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 +305,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 +419,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 +459,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 +490,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 +517,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 +532,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 +610,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 +653,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 {
@@ -638,6 +695,60 @@ mod tests {
assert!(thread.join().unwrap().is_err());
}
#[test]
fn play_packets_wait_out_a_reconfiguration() {
let (client, server) = horizon_protocol::connection::Pipe::pair();
let (mut reader, writer) = Connection::local(client).split();
let (send, events) = channel();
let session = Session {
events,
writer: Arc::new(Mutex::new(Some(writer))),
configuring: Arc::new(AtomicBool::new(false)),
thread: None,
address: "local".into(),
name: "Player".into(),
counters: Arc::new(Mutex::new(None)),
debug: false,
};
let (thread_writer, thread_configuring) =
(session.writer.clone(), session.configuring.clone());
let thread = std::thread::spawn(move || {
receive_play(
&mut reader,
&thread_writer,
&thread_configuring,
&Language::new(),
&send,
)
});
let mut server = Connection::local(server);
let tick_end = play::Serverbound::ClientTickEnd(Empty);
server
.send(&play::Clientbound::StartConfiguration(Empty).encode())
.unwrap();
let (id, body) = server.recv().unwrap();
assert_eq!(
play::Serverbound::decode(id, &body).unwrap(),
play::Serverbound::ConfigurationAcknowledged(Empty)
);
// The game thread still ticks the old level: nothing of it goes out.
session.send(tick_end.clone());
server
.send(&cfg::Clientbound::FinishConfiguration(Empty).encode())
.unwrap();
let (id, body) = server.recv().unwrap();
assert_eq!(
cfg::Serverbound::decode(id, &body).unwrap(),
cfg::Serverbound::FinishConfiguration(Empty)
);
// Back in play, the game's packets flow again.
session.send(tick_end.clone());
let (id, body) = server.recv().unwrap();
assert_eq!(play::Serverbound::decode(id, &body).unwrap(), tick_end);
drop(session);
assert!(thread.join().unwrap().is_err());
}
#[test]
fn server_brand_payload_is_decoded_and_invalid_or_unrelated_payloads_are_ignored() {
let (send, events) = channel();
+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);
}
+10
View File
@@ -17,6 +17,9 @@ wasmtime = { version = "36.0.18", default-features = false, features = ["runtime
# Turns a mod's core module into a component (cargo horizon-mod build, the tests).
wit-component = { version = "0.239", optional = true }
wit-parser = { version = "0.239", optional = true }
# The registry index's checks (feature `index`): SHA-256, and minisign signatures (no dependencies of its own).
sha2 = { version = "0.10", optional = true }
minisign-verify = { version = "0.3", optional = true }
[features]
# Loads and runs mods (the game's `mods` feature); without it only manifests,
@@ -24,11 +27,18 @@ wit-parser = { version = "0.239", optional = true }
# game's tests and probes) skip wasmtime and cranelift.
runtime = ["dep:wasmtime"]
pack = ["dep:wit-component", "dep:wit-parser"]
# The mod registry's index (docs/api/mod-index.md): the launcher, the registry server and its CI.
index = ["dep:sha2", "dep:minisign-verify"]
# Runs real mods: `cargo test -p horizon-mods --features runtime,pack`.
[[test]]
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
+538
View File
@@ -0,0 +1,538 @@
//! The mod registry's index, `index.json` (docs/api/mod-index.md): every
//! reviewed mod and modpack, its versions, the file of each with its size,
//! SHA-256 and minisign signature, and the files taken down. The launcher
//! reads it to browse and install, the website to show it, and the
//! registry's CI writes it.
//!
//! [`check_file`] is what a file must pass before it is installed: its size
//! and hash are the ones the index lists, its signature verifies against the
//! project's pinned key, and what its own manifest says matches the entry.
use crate::{
Capability, Package, Side,
modpack::{self, Modpack},
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{io::Read, path::Path};
/// The index format this build reads and writes. A reader refuses a newer
/// one rather than misread it.
pub const SCHEMA: u32 = 1;
/// The biggest file the registry takes (a `.hmod` with content, a `.hpack`).
pub const MAX_FILE: u64 = 64 << 20;
/// Where files may be downloaded from: the registry's release assets.
pub const FILE_HOST: &str = "https://github.com/ludovic111/horizon-mods/releases/download/";
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Index {
pub schema: u32,
/// When CI built it, RFC 3339.
#[serde(default)]
pub generated: String,
#[serde(default)]
pub projects: Vec<Project>,
#[serde(default)]
pub revoked: Vec<Revoked>,
}
/// What a project is. New kinds (data packs, worlds, themes) can be added
/// without a new schema: a reader keeps a kind it doesn't know as
/// [`Kind::Other`] and doesn't offer it.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum Kind {
/// A `.hmod`.
Mod,
/// A `.hpack`: registry mods played together as a profile.
Modpack,
/// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon.
ResourcePack,
/// A Horizon shader pack (the native WGSL format, not Iris or OptiFine
/// packs). Listed by the schema; submissions open with its API.
Shader,
Other(String),
}
impl Kind {
pub fn name(&self) -> &str {
match self {
Kind::Mod => "mod",
Kind::Modpack => "modpack",
Kind::ResourcePack => "resourcepack",
Kind::Shader => "shader",
Kind::Other(name) => name,
}
}
/// The extension of its files; None for a kind this build doesn't know.
pub fn extension(&self) -> Option<&'static str> {
match self {
Kind::Mod => Some("hmod"),
Kind::Modpack => Some("hpack"),
Kind::ResourcePack | Kind::Shader => Some("zip"),
Kind::Other(_) => None,
}
}
}
impl From<String> for Kind {
fn from(name: String) -> Kind {
match name.as_str() {
"mod" => Kind::Mod,
"modpack" => Kind::Modpack,
"resourcepack" => Kind::ResourcePack,
"shader" => Kind::Shader,
_ => Kind::Other(name),
}
}
}
impl From<Kind> for String {
fn from(kind: Kind) -> String {
kind.name().to_owned()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Project {
/// The manifest's id, `[a-z0-9_]{1,64}`: unique across the index.
pub id: String,
pub kind: Kind,
pub name: String,
/// One line for lists.
pub summary: String,
/// Markdown, for the project's page.
#[serde(default)]
pub description: String,
#[serde(default)]
pub authors: Vec<String>,
/// The GitHub logins allowed to publish its updates.
#[serde(default)]
pub owners: Vec<String>,
/// An SPDX expression.
pub license: String,
/// Where its source code is, `https://`.
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// The author's minisign public key: every version must be signed with
/// it. None: unsigned (the SHA-256 still pins the reviewed file).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
/// Newest first.
pub versions: Vec<Version>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Version {
pub version: String,
/// The day it was published, `YYYY-MM-DD`.
#[serde(default)]
pub published: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub changelog: String,
/// A mod's API line (`0.1`), from its manifest; a modpack's format (`1`).
pub api: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side: Option<Side>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<Capability>,
/// The Minecraft version it is for.
pub game: String,
/// A modpack's mods, as its manifest lists them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<Requirement>,
pub file: File,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Requirement {
pub id: String,
pub version: String,
pub sha256: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct File {
pub name: String,
pub url: String,
pub size: u64,
/// Lowercase hex.
pub sha256: String,
/// The `.minisig` file's text.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
/// A file taken down: by hash, or every version of `id` when `sha256` is
/// None. The launcher refuses to install it and turns installed copies off.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Revoked {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
pub reason: String,
#[serde(default)]
pub date: String,
}
/// How far a checked file is trusted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Trust {
/// Signed with the project's pinned key.
Verified,
/// The project has no key: only the hash pins it.
Unsigned,
}
impl Index {
/// Parses and checks an index (see [`Index::check`]).
pub fn parse(text: &str) -> Result<Index, String> {
Self::parse_with(text, false)
}
/// [`Index::parse`] for a local test registry, whose files may be
/// `file://` paths (trying a registry out before it is published).
pub fn parse_local(text: &str) -> Result<Index, String> {
Self::parse_with(text, true)
}
fn parse_with(text: &str, local_files: bool) -> Result<Index, String> {
#[derive(Deserialize)]
struct Head {
schema: u32,
}
let head: Head = serde_json::from_str(text).map_err(|e| format!("not a mod index: {e}"))?;
if head.schema > SCHEMA {
return Err(format!(
"the mod index is schema {}; this build reads {SCHEMA}: update Horizon",
head.schema
));
}
let index: Index = serde_json::from_str(text).map_err(|e| format!("mod index: {e}"))?;
index.check_with(local_files)?;
Ok(index)
}
/// Every rule a reader relies on: unique valid ids, at least one
/// version each, plain file names with the kind's extension, `https`
/// URLs, hex hashes, keys and signatures that decode, and each
/// modpack's mods present in the index with the same hash.
pub fn check(&self) -> Result<(), String> {
self.check_with(false)
}
fn check_with(&self, local_files: bool) -> Result<(), String> {
for (i, p) in self.projects.iter().enumerate() {
let at = |e: String| format!("{}: {e}", p.id);
if !valid_id(&p.id) {
return Err(format!("project id {:?} is not [a-z0-9_]{{1,64}}", p.id));
}
if self.projects[..i].iter().any(|q| q.id == p.id) {
return Err(at("listed twice".into()));
}
if p.name.trim().is_empty() || p.license.trim().is_empty() {
return Err(at("needs a name and a licence".into()));
}
https(&p.source).map_err(at)?;
if let Some(icon) = &p.icon {
https(icon).map_err(at)?;
}
if let Some(key) = &p.key {
minisign_verify::PublicKey::from_base64(key.trim())
.map_err(|e| at(format!("key: {e}")))?;
}
if p.versions.is_empty() {
return Err(at("has no version".into()));
}
for (j, v) in p.versions.iter().enumerate() {
let at = |e: String| format!("{} {}: {e}", p.id, v.version);
if v.version.trim().is_empty()
|| p.versions[..j].iter().any(|w| w.version == v.version)
{
return Err(at("empty or repeated version".into()));
}
let f = &v.file;
let extension_ok = p
.kind
.extension()
.is_none_or(|e| f.name.ends_with(&format!(".{e}")));
if !plain_name(&f.name) || !extension_ok {
return Err(at(format!(
"{:?} is not a plain file name for a {}",
f.name,
p.kind.name()
)));
}
if !(local_files && f.url.starts_with("file:///")) {
https(&f.url).map_err(at)?;
}
hex64(&f.sha256).map_err(at)?;
if f.size == 0 || f.size > MAX_FILE {
return Err(at(format!(
"size {} is not 1 byte to {} MiB",
f.size,
MAX_FILE >> 20
)));
}
match (&p.key, &f.signature) {
(Some(_), None) => {
return Err(at("unsigned, but the project has a key".into()));
}
(None, Some(_)) => return Err(at("signed, but the project has no key".into())),
(Some(_), Some(sig)) => {
minisign_verify::Signature::decode(sig)
.map_err(|e| at(format!("signature: {e}")))?;
}
(None, None) => {}
}
for r in &v.requires {
let found = self
.project(&r.id)
.and_then(|q| q.versions.iter().find(|w| w.version == r.version));
match found {
Some(w) if w.file.sha256 == r.sha256 => {}
Some(_) => {
return Err(at(format!(
"{} {}: another file than the index's",
r.id, r.version
)));
}
None => {
return Err(at(format!(
"needs {} {}, which is not in the index",
r.id, r.version
)));
}
}
}
}
}
for r in &self.revoked {
if let Some(h) = &r.sha256 {
hex64(h).map_err(|e| format!("revoked {}: {e}", r.id))?;
}
}
Ok(())
}
pub fn project(&self, id: &str) -> Option<&Project> {
self.projects.iter().find(|p| p.id == id)
}
/// Why the file of `id` with this hash was taken down, if it was.
pub fn revoked(&self, id: &str, sha256: &str) -> Option<&Revoked> {
self.revoked.iter().find(|r| {
r.id == id
&& r.sha256
.as_ref()
.is_none_or(|h| h.eq_ignore_ascii_case(sha256))
})
}
}
/// `[a-z0-9_]{1,64}`, the manifests' id rule.
pub fn valid_id(id: &str) -> bool {
(1..=64).contains(&id.len())
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
}
fn https(url: &str) -> Result<(), String> {
match url.strip_prefix("https://") {
Some(rest) if !rest.is_empty() && !url.chars().any(char::is_whitespace) => Ok(()),
_ => Err(format!("{url:?} is not an https:// address")),
}
}
fn hex64(h: &str) -> Result<(), String> {
if h.len() == 64
&& h.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
Ok(())
} else {
Err(format!("{h:?} is not a lowercase hex SHA-256"))
}
}
/// One plain file name: no folder, no hidden file, nothing a file system
/// reads as something else.
fn plain_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 128
&& !name.starts_with('.')
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
}
/// The SHA-256 of a file, lowercase hex.
pub fn sha256_file(path: &Path) -> Result<String, String> {
let mut file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 1 << 16];
loop {
let n = file.read(&mut buf).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex(&hasher.finalize()))
}
pub fn sha256(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Checks a minisign signature (`.minisig` text) of `path` against `key`.
pub fn verify_signature(key: &str, signature: &str, path: &Path) -> Result<(), String> {
let key =
minisign_verify::PublicKey::from_base64(key.trim()).map_err(|e| format!("key: {e}"))?;
let sig =
minisign_verify::Signature::decode(signature).map_err(|e| format!("signature: {e}"))?;
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut verifier = key
.verify_stream(&sig)
.map_err(|e| format!("signature: {e}"))?;
let mut reader = std::io::BufReader::new(file);
let mut buf = vec![0u8; 1 << 16];
loop {
let n = reader.read(&mut buf).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
verifier.update(&buf[..n]);
}
verifier
.finalize()
.map_err(|_| "the signature doesn't match the project's key".to_string())
}
/// Checks the file at `path` against its entry before it is installed:
/// size, SHA-256, revocation, signature, and that its manifest says what the
/// index says (id, version, API, side, capabilities; a modpack's mods).
pub fn check_file(
index: &Index,
project: &Project,
version: &Version,
path: &Path,
) -> Result<Trust, String> {
let size = std::fs::metadata(path)
.map_err(|e| format!("{}: {e}", path.display()))?
.len();
if size != version.file.size {
return Err(format!(
"{} bytes, the index lists {}",
size, version.file.size
));
}
let hash = sha256_file(path)?;
if hash != version.file.sha256 {
return Err("its SHA-256 isn't the one the index lists: nothing was installed".into());
}
if let Some(r) = index.revoked(&project.id, &hash) {
return Err(format!("{} was taken down: {}", project.name, r.reason));
}
let trust = match (&project.key, &version.file.signature) {
(Some(key), Some(sig)) => {
verify_signature(key, sig, path)?;
Trust::Verified
}
(None, None) => Trust::Unsigned,
_ => return Err("its signature and the project's key don't go together".into()),
};
match project.kind {
Kind::Mod => {
let m = Package::read(path)?.manifest;
crate::Content::read(path, &m)?;
let mut caps = m.capabilities.clone();
caps.sort();
let mut listed = version.capabilities.clone();
listed.sort();
let differs = m.id != project.id
|| m.version != version.version
|| m.api != version.api
|| Some(m.side) != version.side
|| caps != listed;
if differs {
return Err(format!(
"its horizon-mod.toml ({} {}, API {}, {:?}, {}) isn't what the index lists",
m.id,
m.version,
m.api,
m.side,
names(&caps)
));
}
}
Kind::ResourcePack => check_resource_pack(path)?,
Kind::Shader | Kind::Other(_) => {
return Err(format!(
"this Horizon can't install a {} yet",
project.kind.name()
));
}
Kind::Modpack => {
let pack = Modpack::read(path)?;
let m = &pack.manifest;
let mods: Vec<Requirement> = m.mods.iter().map(modpack::PackMod::requirement).collect();
if m.id != project.id
|| m.version != version.version
|| m.format.to_string() != version.api
|| m.game != version.game
|| mods != version.requires
{
return Err(format!(
"its {} isn't what the index lists",
modpack::MANIFEST
));
}
}
}
Ok(trust)
}
/// A resource pack: a zip with `pack.mcmeta` at its root holding a `pack`
/// object, and only plain paths.
fn check_resource_pack(path: &Path) -> Result<(), String> {
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
for i in 0..zip.len() {
let name = zip
.by_index_raw(i)
.map_err(|e| e.to_string())?
.name()
.to_owned();
if name.starts_with('/') || name.split('/').any(|part| part == "..") || name.contains('\\')
{
return Err(format!("{name:?} is not a plain path"));
}
}
let mut meta = String::new();
zip.by_name("pack.mcmeta")
.map_err(|_| "pack.mcmeta: missing".to_string())?
.take(1 << 20)
.read_to_string(&mut meta)
.map_err(|e| format!("pack.mcmeta: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&meta).map_err(|e| format!("pack.mcmeta: {e}"))?;
if !json["pack"].is_object() {
return Err("pack.mcmeta: no \"pack\" object".into());
}
Ok(())
}
fn names(caps: &[Capability]) -> String {
let names: Vec<String> = caps.iter().map(ToString::to_string).collect();
format!("[{}]", names.join(", "))
}
#[cfg(test)]
mod tests;
+162
View File
@@ -0,0 +1,162 @@
//! The sample index (`tests/index/index-v1.json`, the one docs/api/mod-index.md
//! shows) and its files: `coords_hud-0.1.0.hmod`, signed with the throwaway
//! key `test.pub` by minisign 0.12 (its secret key was deleted), and
//! `cozy-1.0.0.hpack`, unsigned.
use super::*;
use std::path::PathBuf;
const OTHER_KEY: &str = "RWSe8ZD+cTOAqNd/jnXTmDtoKp0c0HxkGMrBBWcWQeEkPmcHB+XkaE9S";
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/index")
}
fn sample() -> Index {
Index::parse(&std::fs::read_to_string(fixtures().join("index-v1.json")).unwrap()).unwrap()
}
fn check(index: &Index, id: &str, file: &Path) -> Result<Trust, String> {
let p = index.project(id).unwrap();
check_file(index, p, &p.versions[0], file)
}
#[test]
fn reads_the_sample_and_keeps_unknown_kinds() {
let index = sample();
assert_eq!(index.schema, SCHEMA);
let kinds: Vec<&str> = index.projects.iter().map(|p| p.kind.name()).collect();
assert_eq!(kinds, ["mod", "modpack", "world"]);
assert_eq!(index.projects[2].kind, Kind::Other("world".into()));
let hud = index.project("coords_hud").unwrap();
assert_eq!(
hud.versions[0].capabilities,
[Capability::GameRead, Capability::Hud, Capability::Chat]
);
// What CI writes reads back the same.
let again = Index::parse(&serde_json::to_string(&index).unwrap()).unwrap();
assert_eq!(again, index);
}
#[test]
fn files_pass_against_their_entries() {
let index = sample();
let hmod = fixtures().join("coords_hud-0.1.0.hmod");
assert_eq!(check(&index, "coords_hud", &hmod), Ok(Trust::Verified));
let hpack = fixtures().join("cozy-1.0.0.hpack");
assert_eq!(check(&index, "cozy", &hpack), Ok(Trust::Unsigned));
let world = index.project("skyblock_start").unwrap();
let e = check_file(&index, world, &world.versions[0], &hpack).unwrap_err();
assert!(e.contains("bytes"), "{e}");
}
#[test]
fn a_changed_file_key_or_manifest_is_refused() {
let index = sample();
let dir = std::env::temp_dir().join(format!("horizon-index-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let hmod = fixtures().join("coords_hud-0.1.0.hmod");
let mut bytes = std::fs::read(&hmod).unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 1;
let tampered = dir.join("coords_hud-0.1.0.hmod");
std::fs::write(&tampered, &bytes).unwrap();
let e = check(&index, "coords_hud", &tampered).unwrap_err();
assert!(e.contains("SHA-256"), "{e}");
let mut other_key = index.clone();
other_key.projects[0].key = Some(OTHER_KEY.into());
let e = check(&other_key, "coords_hud", &hmod).unwrap_err();
assert!(e.contains("key"), "{e}");
let mut more_caps = index.clone();
more_caps.projects[0].versions[0].capabilities.pop();
let e = check(&more_caps, "coords_hud", &hmod).unwrap_err();
assert!(e.contains("isn't what the index lists"), "{e}");
let mut revoked = index.clone();
revoked.revoked.push(Revoked {
id: "coords_hud".into(),
sha256: None,
reason: "Test.".into(),
date: String::new(),
});
let e = check(&revoked, "coords_hud", &hmod).unwrap_err();
assert!(e.contains("taken down: Test."), "{e}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn revocations_match_by_hash_or_by_id() {
let index = sample();
let ones = "1".repeat(64);
assert!(index.revoked("bad_mod", &ones).is_some());
assert!(index.revoked("bad_mod", &"2".repeat(64)).is_none());
assert!(index.revoked("coords_hud", &ones).is_none());
}
#[test]
fn refuses_broken_indexes() {
let text = std::fs::read_to_string(fixtures().join("index-v1.json")).unwrap();
let newer = text.replacen("\"schema\": 1", "\"schema\": 2", 1);
assert!(Index::parse(&newer).unwrap_err().contains("schema 2"));
let broken = |edit: &dyn Fn(&mut Index)| {
let mut index = sample();
edit(&mut index);
index.check().unwrap_err()
};
let e = broken(&|i| {
let twin = i.projects[0].clone();
i.projects.push(twin)
});
assert!(e.contains("listed twice"), "{e}");
let e = broken(&|i| i.projects[0].versions[0].file.url = "http://example.org/x.hmod".into());
assert!(e.contains("https"), "{e}");
// A local test registry may list files on disk; the published one may not.
let local = text.replace(
"https://github.com/ludovic111/horizon-mods/releases/download/coords_hud-0.1.0/",
"file:///tmp/",
);
assert!(Index::parse(&local).unwrap_err().contains("https"));
assert!(Index::parse_local(&local).is_ok());
let e = broken(&|i| i.projects[0].versions[0].file.name = "../coords_hud.hmod".into());
assert!(e.contains("plain file name"), "{e}");
let e = broken(&|i| i.projects[0].versions[0].file.name = "coords_hud.zip".into());
assert!(e.contains("plain file name"), "{e}");
let e = broken(&|i| i.projects[0].versions[0].file.signature = None);
assert!(e.contains("unsigned"), "{e}");
let e = broken(&|i| i.projects[0].key = Some("not a key".into()));
assert!(e.contains("key"), "{e}");
let e = broken(&|i| i.projects[1].versions[0].requires[0].version = "9.9".into());
assert!(e.contains("not in the index"), "{e}");
let e = broken(&|i| i.projects[1].versions[0].requires[0].sha256 = "2".repeat(64));
assert!(e.contains("another file"), "{e}");
let e = broken(&|i| i.projects[0].id = "Coords".into());
assert!(e.contains("is not"), "{e}");
}
#[test]
fn resource_packs_need_a_pack_mcmeta() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("horizon-index-rp-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let pack = |files: &[(&str, &str)]| {
let path = dir.join("look.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
for (name, text) in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
path
};
let meta = r#"{"pack": {"description": "Look", "min_format": 75, "max_format": 75}}"#;
assert_eq!(check_resource_pack(&pack(&[("pack.mcmeta", meta)])), Ok(()));
let e = check_resource_pack(&pack(&[("assets/x.png", "")])).unwrap_err();
assert!(e.contains("missing"), "{e}");
let e = check_resource_pack(&pack(&[("pack.mcmeta", "{}")])).unwrap_err();
assert!(e.contains("\"pack\""), "{e}");
let e = check_resource_pack(&pack(&[("pack.mcmeta", meta), ("../x", "")])).unwrap_err();
assert!(e.contains("plain path"), "{e}");
std::fs::remove_dir_all(&dir).unwrap();
}
+3
View File
@@ -6,7 +6,10 @@
//! Nothing here runs unless a mod is installed and turned on: an empty
//! [`Mods`] makes no engine and every hook returns at once.
pub mod content;
#[cfg(feature = "index")]
pub mod index;
pub mod manifest;
pub mod modpack;
#[cfg(feature = "runtime")]
mod runtime;
+6 -3
View File
@@ -1,5 +1,5 @@
//! `horizon-mod.toml`, a mod's manifest, and the `.hmod` file around it.
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::{
fmt,
io::Read,
@@ -20,6 +20,9 @@ pub const APIS: [&str; 2] = [API, "0.2"];
/// Namespaces no mod may claim: vanilla's, the convention tags' (mods add
/// to those, docs/mods.md) and the game's own.
const RESERVED_NAMESPACES: [&str; 3] = ["minecraft", "c", "horizon"];
/// A mod's data (capability `storage`), in `horizon-mods/` beside the mods
/// (`find` never mistakes it for a mod: it has no manifest).
pub const STORAGE_DIR: &str = ".horizon-mod-data";
/// The biggest `mod.wasm` a mod may ship.
const MAX_WASM: u64 = 32 << 20;
@@ -48,7 +51,7 @@ pub struct Manifest {
pub namespaces: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Side {
/// Runs in the player's game only (HUD, keys, chat commands).
@@ -62,7 +65,7 @@ pub enum Side {
/// What a mod may do beyond writing its log. Each is a WIT interface the
/// component imports; the player sees them before turning the mod on.
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum Capability {
GameRead,
+299
View File
@@ -0,0 +1,299 @@
//! `.hpack`, a Horizon modpack (docs/api/mod-index.md): registry mods
//! played together as a profile. A zip holding `horizon-modpack.toml`, which
//! names each mod by id, version and SHA-256 (the files themselves come from
//! the registry), and optionally `overrides/`: settings and resource packs
//! copied into the profile's game folder.
use crate::manifest::STORAGE_DIR;
use serde::Deserialize;
use std::{
io::Read,
path::{Path, PathBuf},
};
/// The manifest's file name in a `.hpack`.
pub const MANIFEST: &str = "horizon-modpack.toml";
/// The manifest formats this build reads.
pub const FORMAT: u32 = 1;
const OVERRIDES: &str = "overrides/";
/// Bytes per override file and in all, unpacked.
const MAX_OVERRIDE: u64 = 64 << 20;
const MAX_OVERRIDES: u64 = 256 << 20;
/// The settings files an override may replace.
const SETTINGS: [&str; 3] = ["options.txt", "horizon-graphics.txt", "horizon-extras.txt"];
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub format: u32,
/// `[a-z0-9_]{1,64}`: its profile's folder.
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub authors: Vec<String>,
/// The Minecraft version it is for.
pub game: String,
#[serde(default)]
pub mods: Vec<PackMod>,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PackMod {
pub id: String,
pub version: String,
pub sha256: String,
/// Turned on in the profile (`horizon-mods.txt`).
#[serde(default = "on")]
pub on: bool,
}
fn on() -> bool {
true
}
#[cfg(feature = "index")]
impl PackMod {
pub fn requirement(&self) -> crate::index::Requirement {
crate::index::Requirement {
id: self.id.clone(),
version: self.version.clone(),
sha256: self.sha256.clone(),
}
}
}
impl Manifest {
pub fn parse(text: &str) -> Result<Manifest, String> {
let m: Manifest = toml::from_str(text).map_err(|e| format!("{MANIFEST}: {e}"))?;
if m.format != FORMAT {
return Err(format!(
"{MANIFEST}: format {}; this Horizon reads {FORMAT}",
m.format
));
}
let id_ok = |id: &str| {
(1..=64).contains(&id.len())
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
};
if !id_ok(&m.id) {
return Err(format!(
"{MANIFEST}: id {:?} is not [a-z0-9_]{{1,64}}",
m.id
));
}
for (i, pm) in m.mods.iter().enumerate() {
let hex = pm.sha256.len() == 64
&& pm
.sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if !id_ok(&pm.id) || !hex || pm.version.is_empty() {
return Err(format!(
"{MANIFEST}: mod {:?} needs an id, a version and a lowercase hex sha256",
pm.id
));
}
if m.mods[..i].iter().any(|other| other.id == pm.id) {
return Err(format!("{MANIFEST}: mod {} is listed twice", pm.id));
}
}
Ok(m)
}
}
/// A `.hpack` as read from disk: its manifest and its override files, by
/// path under `overrides/`.
#[derive(Debug)]
pub struct Modpack {
pub manifest: Manifest,
pub overrides: Vec<String>,
}
impl Modpack {
pub fn read(path: &Path) -> Result<Modpack, String> {
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
let mut text = String::new();
zip.by_name(MANIFEST)
.map_err(|_| format!("{MANIFEST}: missing"))?
.take(1 << 20)
.read_to_string(&mut text)
.map_err(|e| format!("{MANIFEST}: {e}"))?;
let manifest = Manifest::parse(&text)?;
let (mut overrides, mut total) = (Vec::new(), 0u64);
for i in 0..zip.len() {
let entry = zip.by_index_raw(i).map_err(|e| e.to_string())?;
let name = entry.name();
if name == MANIFEST || entry.is_dir() {
continue;
}
let Some(rest) = name.strip_prefix(OVERRIDES) else {
return Err(format!(
"{name}: a modpack holds only {MANIFEST} and overrides/"
));
};
check_override(rest, &manifest)?;
if entry.size() > MAX_OVERRIDE {
return Err(format!("{name} is over {} MiB", MAX_OVERRIDE >> 20));
}
total += entry.size();
overrides.push(rest.to_owned());
}
if total > MAX_OVERRIDES {
return Err(format!("overrides are over {} MiB", MAX_OVERRIDES >> 20));
}
overrides.sort();
Ok(Modpack {
manifest,
overrides,
})
}
/// Writes the overrides of the pack at `path` into the game folder
/// `home`, each checked again.
pub fn extract_overrides(path: &Path, home: &Path) -> Result<Vec<PathBuf>, String> {
let pack = Modpack::read(path)?;
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
let mut written = Vec::new();
for name in &pack.overrides {
let mut entry = zip
.by_name(&format!("{OVERRIDES}{name}"))
.map_err(|e| format!("{name}: {e}"))?;
let target = home.join(name);
if let Some(dir) = target.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let mut out =
std::fs::File::create(&target).map_err(|e| format!("{}: {e}", target.display()))?;
std::io::copy(&mut (&mut entry).take(MAX_OVERRIDE), &mut out)
.map_err(|e| format!("{}: {e}", target.display()))?;
written.push(target);
}
Ok(written)
}
}
/// An override's path under `overrides/`: a settings file, a resource pack
/// zip, or the stored settings of one of the pack's mods. Plain parts only.
fn check_override(path: &str, manifest: &Manifest) -> Result<(), String> {
let bad = || {
format!(
"overrides/{path}: only {}, resourcepacks/<name>.zip and horizon-mods/{STORAGE_DIR}/<a listed mod>/ may be overridden",
SETTINGS.join(", ")
)
};
let parts: Vec<&str> = path.split('/').collect();
let plain = |p: &&str| {
!p.is_empty()
&& !p.starts_with('.')
&& p.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-+ ".contains(&b))
};
let ok = match parts.as_slice() {
[file] => SETTINGS.contains(file),
["resourcepacks", name] => plain(name) && name.ends_with(".zip"),
["horizon-mods", dir, id, rest @ ..] => {
*dir == STORAGE_DIR
&& manifest.mods.iter().any(|m| m.id == *id)
&& !rest.is_empty()
&& rest.iter().all(plain)
}
_ => false,
};
if ok { Ok(()) } else { Err(bad()) }
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
const PACK: &str = r#"
format = 1
id = "cozy"
name = "Cozy"
version = "1.0.0"
game = "26.3"
[[mods]]
id = "coords_hud"
version = "0.1.0"
sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
"#;
fn write_pack(dir: &Path, manifest: &str, files: &[(&str, &[u8])]) -> PathBuf {
let path = dir.join("cozy.hpack");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
let options = zip::write::SimpleFileOptions::default();
zip.start_file(MANIFEST, options).unwrap();
zip.write_all(manifest.as_bytes()).unwrap();
for (name, bytes) in files {
zip.start_file(*name, options).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
path
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("horizon-hpack-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reads_a_pack_and_writes_its_overrides() {
let dir = scratch("read");
let storage = format!("overrides/horizon-mods/{STORAGE_DIR}/coords_hud/corner.txt");
let path = write_pack(
&dir,
PACK,
&[
("overrides/options.txt", b"renderDistance:8\n"),
("overrides/resourcepacks/Cozy Look.zip", b"zip"),
(&storage, b"top-left"),
],
);
let pack = Modpack::read(&path).unwrap();
assert_eq!(pack.manifest.mods[0].id, "coords_hud");
assert!(pack.manifest.mods[0].on);
assert_eq!(pack.overrides.len(), 3);
let home = dir.join("home");
Modpack::extract_overrides(&path, &home).unwrap();
let options = std::fs::read_to_string(home.join("options.txt")).unwrap();
assert_eq!(options, "renderDistance:8\n");
let corner = home.join(format!("horizon-mods/{STORAGE_DIR}/coords_hud/corner.txt"));
assert!(corner.is_file());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn refuses_what_a_pack_may_not_hold() {
let dir = scratch("refuse");
for name in [
"mods/evil.jar",
"overrides/../../.bashrc",
"overrides/horizon-mods/evil.hmod",
"overrides/resourcepacks/../options.txt",
"overrides/servers.dat",
"overrides/horizon-mods/.horizon-mod-data/other_mod/x",
"overrides/resourcepacks/.hidden.zip",
] {
let path = write_pack(&dir, PACK, &[(name, b"x")]);
assert!(Modpack::read(&path).is_err(), "{name} was accepted");
}
let twice = format!("{PACK}{}", &PACK[PACK.find("[[mods]]").unwrap()..]);
assert!(Manifest::parse(&twice).unwrap_err().contains("twice"));
let newer = PACK.replace("format = 1", "format = 2");
assert!(Manifest::parse(&newer).unwrap_err().contains("format 2"));
let short = PACK.replace("0000000000000000", "00");
assert!(Manifest::parse(&short).is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
}
+1 -4
View File
@@ -49,9 +49,6 @@ const STORAGE_MAX_KEYS: usize = 64;
/// a cheap-in-fuel loop replacing one key with large payloads needs its own
/// cap, separate from the on-disk quota it would otherwise stay under.
const STORAGE_WRITE_BUDGET: u64 = 1 << 20;
/// A mod's data, beside its `horizon-mods` folder (`find`'s `path.parent()`
/// never has a manifest there, so `find` never mistakes it for a mod).
const STORAGE_DIR: &str = ".horizon-mod-data";
/// What mods may read of the game, copied once per tick by the game.
#[derive(Clone, Debug, Default)]
@@ -237,7 +234,7 @@ impl Mods {
let data_dir = path
.parent()
.unwrap_or(Path::new("."))
.join(STORAGE_DIR)
.join(crate::manifest::STORAGE_DIR)
.join(&m.id);
let state = State {
name: m.name.clone(),
+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()
);
}
}
}
@@ -0,0 +1,4 @@
untrusted comment: signature from minisign secret key
RUTVtuGb/GRXcIa5yCyCHFGFt55tj3T/FLUDv3ecNLVNNiQqQu7xyBrfc3wsvMHmudsEq6yr+QF21XN3+hnr/Ged3ULr5rv3zgE=
trusted comment: coords_hud 0.1.0
IESQkJdTzLOvWegcGFxOMrQbnwJp2OKSSxoCKAxdfwyRb+9atdF+Q5/OpbdYurMnrNN1X1ybb43AszPVecdiCw==
Binary file not shown.
@@ -0,0 +1,109 @@
{
"schema": 1,
"generated": "2026-10-24T18:00:00Z",
"projects": [
{
"id": "coords_hud",
"kind": "mod",
"name": "Coordinates HUD",
"summary": "Your coordinates, facing and the time of day in a corner of the screen.",
"description": "`/where` puts them in chat, `/coords` hides or shows the panel.",
"authors": [
"Horizon"
],
"owners": [
"ludovic111"
],
"license": "MIT",
"source": "https://github.com/ludovic111/horizon/tree/main/crates/horizon-mod-sdk/examples/coords-hud",
"icon": "https://ludovic111.github.io/horizon-mods/icons/coords_hud.png",
"key": "RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb",
"versions": [
{
"version": "0.1.0",
"published": "2026-10-24",
"api": "0.1",
"side": "client",
"capabilities": [
"game-read",
"hud",
"chat"
],
"game": "26.3",
"file": {
"name": "coords_hud-0.1.0.hmod",
"url": "https://github.com/ludovic111/horizon-mods/releases/download/coords_hud-0.1.0/coords_hud-0.1.0.hmod",
"size": 438,
"sha256": "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53",
"signature": "untrusted comment: signature from minisign secret key\nRUTVtuGb/GRXcIa5yCyCHFGFt55tj3T/FLUDv3ecNLVNNiQqQu7xyBrfc3wsvMHmudsEq6yr+QF21XN3+hnr/Ged3ULr5rv3zgE=\ntrusted comment: coords_hud 0.1.0\nIESQkJdTzLOvWegcGFxOMrQbnwJp2OKSSxoCKAxdfwyRb+9atdF+Q5/OpbdYurMnrNN1X1ybb43AszPVecdiCw==\n"
}
}
]
},
{
"id": "cozy",
"kind": "modpack",
"name": "Cozy",
"summary": "Coordinates and a calm render distance.",
"authors": [
"Horizon"
],
"owners": [
"ludovic111"
],
"license": "MIT",
"source": "https://github.com/ludovic111/horizon-mods",
"versions": [
{
"version": "1.0.0",
"published": "2026-10-24",
"api": "1",
"game": "26.3",
"requires": [
{
"id": "coords_hud",
"version": "0.1.0",
"sha256": "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53"
}
],
"file": {
"name": "cozy-1.0.0.hpack",
"url": "https://github.com/ludovic111/horizon-mods/releases/download/cozy-1.0.0/cozy-1.0.0.hpack",
"size": 471,
"sha256": "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65"
}
}
]
},
{
"id": "skyblock_start",
"kind": "world",
"name": "Skyblock start",
"summary": "A kind this reader doesn't know yet: kept, not offered.",
"license": "CC0-1.0",
"source": "https://example.org/skyblock",
"future_field": true,
"versions": [
{
"version": "1",
"api": "1",
"game": "26.3",
"file": {
"name": "skyblock.hworld",
"url": "https://example.org/skyblock.hworld",
"size": 1,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
}
]
}
],
"revoked": [
{
"id": "bad_mod",
"sha256": "1111111111111111111111111111111111111111111111111111111111111111",
"reason": "Sent chat messages it didn't declare.",
"date": "2026-10-24"
}
]
}
+2
View File
@@ -0,0 +1,2 @@
untrusted comment: minisign public key 705764FC9BE1B6D5
RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb
+5 -5
View File
@@ -814,7 +814,7 @@ pub fn carry(
if kind == "structure" {
let path = rel.trim_start_matches("worldgen/structure/");
let structure = format!("{ns}:{}", path.trim_end_matches(".json"));
changed |= crate::worldgen::disables(&structure, &mut v);
changed |= crate::legacy_structure_mods::yung::disables(&structure, &mut v);
changed |=
crate::worldgen::add_piece_counts(&structure, &mut v, &piece_counts);
}
@@ -829,9 +829,9 @@ pub fn carry(
// Additions to other sets and pools, as files the game merges.
let modifier = serde_json::from_slice::<Value>(&bytes).ok();
let types = &mut report.worldgen_types;
if let Some((kind, mut added)) =
modifier.and_then(|v| crate::worldgen::modifier(&v, loaded, types))
{
if let Some((kind, mut added)) = modifier.and_then(|v| {
crate::legacy_structure_mods::lithostitched::modifier(&v, loaded, types)
}) {
crate::processors::carry(
kind,
&mut added,
@@ -910,7 +910,7 @@ pub fn carry(
}
// Entries the mod adds to other structure sets in Java code, for the
// structures it has.
for (file, set, entries) in crate::worldgen::code_set_entries(id) {
for (file, set, entries) in crate::legacy_structure_mods::ctov::code_set_entries(id) {
let entries: Vec<Value> = entries
.into_iter()
.filter(|(s, _)| {
@@ -0,0 +1,179 @@
//! Choice Theorem's Overhauled Village: the entries it adds to other
//! structure sets in Java code, and its `modular_compat` processor.
use crate::processors::{ProcessorData, referenced};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
/// ChoiceTheorem's Overhauled Village's villages and outposts, which it adds
/// to vanilla's `villages` and `pillager_outposts` sets in Java code
/// (`CTOV.registerstructure`, through lithostitched), with its default
/// config: every village type in three sizes, weighted 10, 4 and 1, and
/// every outpost weighted 1. Vanilla's own entries stay (weight 1 each).
const CTOV_VILLAGES: [&str; 21] = [
"beach",
"christmas",
"desert",
"desert_oasis",
"dark_forest",
"jungle",
"jungle_tree",
"mesa",
"mesa_fortified",
"mountain",
"mountain_alpine",
"mushroom",
"plains",
"plains_fortified",
"savanna",
"savanna_na",
"snowy_igloo",
"swamp",
"swamp_fortified",
"taiga",
"taiga_fortified",
];
const CTOV_OUTPOSTS: [&str; 11] = [
"beach",
"dark_forest",
"desert",
"jungle",
"badlands",
"mountain",
"plains",
"savanna",
"snowy",
"swamp",
"taiga",
];
/// The entries a mod adds to other structure sets in Java code, as its
/// default config has them: (file name, set, `[(structure, weight)]`).
/// Lithostitched appends them in the order of a hash map of every modifier
/// in the pack, which carry cannot know; they are written in the order the
/// mod registers them. Each set picks by weight, so how often each starts
/// is the same.
pub(crate) fn code_set_entries(
mod_id: &str,
) -> Vec<(&'static str, &'static str, Vec<(String, u32)>)> {
match mod_id {
"ctov" => {
let outposts = CTOV_OUTPOSTS
.iter()
.map(|v| (format!("ctov:pillager_outpost_{v}"), 1))
.collect();
let villages = [("small", 10), ("medium", 4), ("large", 1)]
.into_iter()
.flat_map(|(size, weight)| {
CTOV_VILLAGES
.iter()
.map(move |v| (format!("ctov:{size}/village_{v}"), weight))
})
.collect();
vec![
("pillager_outposts", "minecraft:pillager_outposts", outposts),
("villages", "minecraft:villages", villages),
]
}
_ => Vec::new(),
}
}
/// Choice Theorem's Overhauled Village's processor that runs the list
/// `reference` when mod `modid` is loaded (ModularCompatProcessor).
const MODULAR_COMPAT: &str = "ctov:modular_compat";
/// Notes which mods its compat processors name are `loaded`.
pub(crate) fn mark_loaded(data: &mut ProcessorData, loaded: &dyn Fn(&str) -> bool) {
fn walk(v: &Value, out: &mut BTreeSet<String>) {
match v {
Value::Object(o) => {
if o.get("processor_type").and_then(Value::as_str) == Some(MODULAR_COMPAT) {
if let Some(id) = o.get("modid").and_then(Value::as_str) {
out.insert(id.to_owned());
}
}
o.values().for_each(|v| walk(v, out));
}
Value::Array(a) => a.iter().for_each(|v| walk(v, out)),
_ => {}
}
}
let mut named = BTreeSet::new();
data.lists.values().for_each(|l| walk(l, &mut named));
data.compat_loaded = named.into_iter().filter(|m| loaded(m)).collect();
}
/// A `ctov:modular_compat` processor in a list being carried, at `depth`
/// (see [`super::lithostitched`]'s references): counted in `carried`, and
/// the processors of the list it names when its mod is loaded, else none.
/// `None` for any other processor, or past the depth limit.
pub(super) fn modular_compat(
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
depth: usize,
) -> Option<Vec<Value>> {
if p["processor_type"] != MODULAR_COMPAT || depth >= 8 {
return None;
}
*carried.entry(MODULAR_COMPAT.into()).or_default() += 1;
let loaded = p["modid"]
.as_str()
.is_some_and(|m| data.compat_loaded.contains(m));
Some(
loaded
.then(|| referenced(&p["reference"], data))
.flatten()
.unwrap_or_default(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::processors::carry;
use serde_json::json;
#[test]
fn ctov_joins_vanillas_villages_and_outposts() {
let sets = code_set_entries("ctov");
let (file, set, outposts) = &sets[0];
assert_eq!(
(*file, *set),
("pillager_outposts", "minecraft:pillager_outposts")
);
assert_eq!(outposts.len(), 11);
assert!(outposts.iter().all(|(_, w)| *w == 1));
let (_, set, villages) = &sets[1];
assert_eq!(*set, "minecraft:villages");
assert_eq!(villages.len(), 63);
assert_eq!(villages[0], ("ctov:small/village_beach".into(), 10));
assert_eq!(villages[21], ("ctov:medium/village_beach".into(), 4));
assert_eq!(
villages[62],
("ctov:large/village_taiga_fortified".into(), 1)
);
assert!(code_set_entries("mvs").is_empty());
}
#[test]
fn modular_compat_runs_its_list_only_with_its_mod() {
let mut data = ProcessorData::default();
let swap = json!({"processor_type": "minecraft:rule", "rules": []});
data.lists
.insert("v:house_ns".into(), json!({"processors": [swap.clone()]}));
let compat = |m: &str| json!({"processor_type": "ctov:modular_compat", "modid": m, "reference": "v:house_ns"});
let gravity = json!({"processor_type": "minecraft:gravity"});
data.lists.insert(
"v:house".into(),
json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]}),
);
data.mark_loaded(&|m| m == "ns");
assert_eq!(data.compat_loaded, BTreeSet::from(["ns".to_owned()]));
let mut v = json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]});
let mut carried = BTreeMap::new();
assert!(carry("processor_list", &mut v, &data, &mut carried));
assert_eq!(v, json!({"processors": [swap, gravity]}));
assert_eq!(carried["ctov:modular_compat"], 2);
}
}
@@ -0,0 +1,503 @@
//! Lithostitched: its jigsaw structure, its worldgen modifiers and its
//! processors.
use super::ctov;
use crate::processors::{ProcessorData, carry_all, processors, referenced};
use crate::worldgen::{ENHANCED_TERRAIN, Upgrade, start_checks};
use serde_json::{Value, json};
use std::collections::BTreeMap;
/// A `structure` file of Lithostitched's jigsaw type as vanilla's, with the
/// start rules it shares with the other libraries' jigsaws (`worldgen`'s
/// `start_checks`). `None` for any other file.
pub(crate) fn upgrade(
kind: &str,
v: &mut Value,
lost: &mut BTreeMap<String, usize>,
) -> Option<bool> {
if kind != "structure" || v["type"] != "lithostitched:jigsaw" {
return None;
}
start_checks(v, "lowest", lost, false);
if v.as_object_mut()
.and_then(|o| o.remove(ENHANCED_TERRAIN))
.is_some()
{
*lost.entry(ENHANCED_TERRAIN.into()).or_default() += 1;
}
v["type"] = "minecraft:jigsaw".into();
Some(true)
}
/// A lithostitched worldgen modifier
/// (`data/<ns>/lithostitched/worldgen_modifier/`) that adds to structure
/// sets or template pools, as a file the game merges into them (the key
/// `horizon:add_to`, `horizon_client::mod_content`): (kind, file). The
/// modifier's conditions are checked against `loaded`, the mods ported
/// along: a compat addition for a mod that is not would place that mod's
/// blocks, which the game does not have. Other modifier types are left out.
/// Both are counted in `report`.
pub(crate) fn modifier(
v: &Value,
loaded: &dyn Fn(&str) -> bool,
report: &mut Upgrade,
) -> Option<(&'static str, Value)> {
let t = v["type"].as_str().unwrap_or_default();
let mut left = |why: &str| *report.modifiers_left.entry(why.to_owned()).or_default() += 1;
let (kind, targets, from, to) = match t {
"lithostitched:add_structure_set_entries" => {
("structure_set", "structure_sets", "entries", "structures")
}
"lithostitched:add_template_pool_elements" => {
("template_pool", "template_pools", "elements", "elements")
}
_ => {
left(t);
return None;
}
};
if !conditions_hold(v, loaded) {
left("conditions");
return None;
}
let targets: Option<Vec<&str>> = match &v[targets] {
Value::String(s) => Some(vec![s.as_str()]),
Value::Array(a) => a.iter().map(Value::as_str).collect(),
_ => None,
};
let Some(targets) = targets.filter(|t| !t.is_empty() && t.iter().all(|t| !t.starts_with('#')))
else {
left("target");
return None;
};
let entries = v[from].as_array().cloned().unwrap_or_default();
for target in &targets {
*report.added_to.entry((*target).to_owned()).or_default() += entries.len();
}
Some((
kind,
serde_json::json!({"horizon:add_to": targets, to: entries}),
))
}
/// Whether a modifier's load conditions hold: lithostitched's `predicate`,
/// and the loader's conditions (NeoForge's, else Fabric's). A condition
/// type not known here does not hold.
fn conditions_hold(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let all = |list: &Value, test: &dyn Fn(&Value) -> bool| {
list.as_array().is_some_and(|a| a.iter().all(test))
};
fn predicate(p: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| predicate(q, loaded);
let list = &p["predicates"];
match p["type"]
.as_str()
.map(|t| t.trim_start_matches("lithostitched:"))
{
Some("true") => true,
Some("mod_loaded") => p["mod_id"].as_str().is_some_and(loaded),
Some("not") => !predicate(&p["predicate"], loaded),
Some("all_of") => list.as_array().is_some_and(|a| a.iter().all(test)),
Some("any_of") => list.as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
fn neoforge(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| neoforge(q, loaded);
match c["type"].as_str() {
Some("neoforge:true") => true,
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
Some("neoforge:not") => !neoforge(&c["value"], loaded),
Some("neoforge:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
Some("neoforge:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
fn fabric(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| fabric(q, loaded);
let mods = || {
c["values"]
.as_array()
.into_iter()
.flatten()
.map(Value::as_str)
};
match c["condition"].as_str() {
Some("fabric:true") => true,
Some("fabric:all_mods_loaded") => mods().all(|m| m.is_some_and(loaded)),
Some("fabric:any_mods_loaded") => mods().any(|m| m.is_some_and(loaded)),
Some("fabric:not") => !fabric(&c["value"], loaded),
Some("fabric:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
Some("fabric:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
let litho = v.get("predicate").is_none_or(|p| predicate(p, loaded));
let loader = match (
v.get("neoforge:conditions"),
v.get("fabric:load_conditions"),
) {
(Some(c), _) => all(c, &|c| neoforge(c, loaded)),
// One condition or a list of them.
(None, Some(c)) if c.is_array() => all(c, &|c| fabric(c, loaded)),
(None, Some(c)) => fabric(c, loaded),
(None, None) => true,
};
litho && loader
}
/// The data form of one of Lithostitched's processors: the processors to put
/// in its place, or `None` when it has none (or is not Lithostitched's).
pub(crate) fn processor(
kind: &str,
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> Option<Vec<Value>> {
match kind {
"lithostitched:apply_random" => apply_random(p, data, carried).map(|p| vec![p]),
"lithostitched:condition" => condition(p, data, carried).map(|p| vec![p]),
"lithostitched:block_swap" => {
// The same map; the game's swap keeps the properties both have.
p["blocks"].as_object()?;
Some(vec![
json!({"processor_type": "horizon:block_swap", "blocks": p["blocks"]}),
])
}
"lithostitched:set_block" => {
// A simple or weighted state provider, the block entity left as
// it is.
let provider = p["state_provider"]["type"].as_str()?;
let modifier = p["block_entity_modifier"]["type"].as_str();
let plain =
modifier.is_none_or(|m| m.trim_start_matches("minecraft:") == "passthrough");
if !plain
|| !matches!(
provider,
"minecraft:simple_state_provider" | "minecraft:weighted_state_provider"
)
{
return None;
}
let mut out = p.clone();
out["processor_type"] = "horizon:set_block".into();
if let Some(o) = out.as_object_mut() {
o.remove("block_entity_modifier");
}
Some(vec![out])
}
_ => None,
}
}
/// The references (Lithostitched's, and CTOV's compat processors) in a
/// processor list written in; whether any was.
pub(crate) fn expand_references(
list: Vec<Value>,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> (Vec<Value>, bool) {
references(list, data, carried, 0)
}
/// A processor list inside a carried processor: an id stays (the game reads
/// that file, carried on its own); an inline one is carried here.
fn nested(list: &Value, data: &ProcessorData, carried: &mut BTreeMap<String, usize>) -> Value {
let mut list = list.clone();
if let Some(ps) = processors(&mut list) {
*ps = carry_all(std::mem::take(ps), data, carried).0;
}
list
}
/// Lithostitched's `apply_random` as `horizon:apply_random`: its weighted
/// lists, or its set of lists at weight 1. A tag is not carried.
fn apply_random(
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> Option<Value> {
let lists = &p["processor_lists"];
let weighted = lists.as_array().is_some_and(|a| {
a.iter()
.all(|e| e.get("weight").is_some() && e.get("data").is_some())
});
let entries: Vec<(Value, Value)> = if weighted {
lists
.as_array()?
.iter()
.map(|e| (e["weight"].clone(), e["data"].clone()))
.collect()
} else {
match lists {
Value::String(id) if !id.starts_with('#') => vec![(1.into(), lists.clone())],
Value::Array(a) if a.iter().any(|x| x.get("processor_type").is_some()) => {
vec![(1.into(), lists.clone())]
}
Value::Array(a)
if a.iter()
.all(|x| x.as_str().is_some_and(|s| !s.starts_with('#')) || x.is_object()) =>
{
a.iter().map(|x| (1.into(), x.clone())).collect()
}
Value::Object(_) => vec![(1.into(), lists.clone())],
_ => return None,
}
};
let lists: Vec<Value> = entries
.into_iter()
.map(|(weight, list)| json!({"weight": weight, "data": nested(&list, data, carried)}))
.collect();
let mut out = json!({"processor_type": "horizon:apply_random", "processor_lists": lists});
if let Some(mode) = p.get("mode") {
out["mode"] = mode.clone();
}
Some(out)
}
/// Lithostitched's `condition` as `horizon:condition`, when the game runs
/// every condition in it (true, all_of, any_of, not, random_chance,
/// matching_blocks with plain property values); else it stays Java.
fn condition(
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> Option<Value> {
fn runs(c: &Value) -> bool {
if let Some(all) = c.as_array() {
return all.iter().all(runs);
}
let kind = c["type"].as_str().unwrap_or_default();
match kind.strip_prefix("lithostitched:").unwrap_or(kind) {
"true" | "random_chance" => true,
"all_of" | "any_of" => c["conditions"]
.as_array()
.is_some_and(|a| a.iter().all(runs)),
"not" => runs(&c["condition"]),
"matching_blocks" => c.get("properties").is_none_or(|p| {
p.as_object()
.is_some_and(|o| o.values().all(Value::is_string))
}),
_ => false,
}
}
if !runs(&p["if_true"]) {
return None;
}
// One processor or a list of them.
let mut branch = |key: &str| -> Value {
let list = match p.get(key) {
None => Vec::new(),
Some(Value::Array(a)) => a.clone(),
Some(one) => vec![one.clone()],
};
carry_all(list, data, carried).0.into()
};
let (then, otherwise) = (branch("then"), branch("else"));
let mut out = json!({"processor_type": "horizon:condition", "if_true": p["if_true"],
"then": then, "else": otherwise});
if let Some(mode) = p.get("random_mode") {
out["random_mode"] = mode.clone();
}
Some(out)
}
/// Lithostitched's references in a list, each replaced by the processors of
/// the lists it names, in order (references in them too, to a depth of 8).
/// One naming a tag or a list the mod does not have stays, to be dropped.
/// Whether any was replaced.
fn references(
list: Vec<Value>,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
depth: usize,
) -> (Vec<Value>, bool) {
let mut out = Vec::with_capacity(list.len());
let mut changed = false;
for p in list {
if let Some(inner) = ctov::modular_compat(&p, data, carried, depth) {
changed = true;
out.extend(references(inner, data, carried, depth + 1).0);
continue;
}
let named = (p["processor_type"] == "lithostitched:reference" && depth < 8)
.then(|| referenced(&p["processor_lists"], data))
.flatten();
match named {
Some(inner) => {
*carried.entry("lithostitched:reference".into()).or_default() += 1;
out.extend(references(inner, data, carried, depth + 1).0);
changed = true;
}
None => out.push(p),
}
}
(out, changed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::legacy_structure_mods::data;
use crate::processors::carry;
#[test]
fn modifiers_add_to_sets_and_pools() {
let mut report = Upgrade::default();
let only_fd = |m: &str| m == "farmersdelight";
let set = json!({"type": "lithostitched:add_structure_set_entries",
"structure_sets": "minecraft:villages",
"entries": [{"structure": "x:village", "weight": 4}]});
let (kind, added) = modifier(&set, &only_fd, &mut report).unwrap();
assert_eq!(kind, "structure_set");
assert_eq!(
added,
json!({"horizon:add_to": ["minecraft:villages"],
"structures": [{"structure": "x:village", "weight": 4}]})
);
// A compat addition holds only when its mod is ported along.
let pool = |modid: &str| {
json!({"type": "lithostitched:add_template_pool_elements",
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}],
"fabric:load_conditions": {"condition": "fabric:all_mods_loaded", "values": ["other"]},
"template_pools": ["x:houses", "y:houses"],
"elements": [{"weight": 5, "element": {"element_type": "minecraft:single_pool_element",
"location": "x:fd_farm", "projection": "rigid", "processors": "minecraft:empty"}}]})
};
let (kind, added) = modifier(&pool("farmersdelight"), &only_fd, &mut report).unwrap();
assert_eq!(kind, "template_pool");
assert_eq!(added["horizon:add_to"], json!(["x:houses", "y:houses"]));
assert_eq!(added["elements"][0]["weight"], 5);
assert!(modifier(&pool("create"), &only_fd, &mut report).is_none());
// Fabric's conditions when there are no NeoForge ones; lithostitched's own.
let fabric = json!({"type": "lithostitched:add_structure_set_entries",
"fabric:load_conditions": [{"condition": "fabric:not",
"value": {"condition": "fabric:any_mods_loaded", "values": ["create"]}}],
"predicate": {"type": "lithostitched:all_of", "predicates": [
{"type": "lithostitched:mod_loaded", "mod_id": "farmersdelight"},
{"type": "lithostitched:true"}]},
"structure_sets": "minecraft:villages", "entries": []});
assert!(modifier(&fabric, &only_fd, &mut report).is_some());
let tagged = json!({"type": "lithostitched:add_structure_set_entries",
"structure_sets": "#x:sets", "entries": []});
assert!(modifier(&tagged, &only_fd, &mut report).is_none());
let features = json!({"type": "lithostitched:add_features"});
assert!(modifier(&features, &only_fd, &mut report).is_none());
assert_eq!(
report.added_to,
BTreeMap::from([
("minecraft:villages".into(), 1),
("x:houses".into(), 1),
("y:houses".into(), 1),
])
);
assert_eq!(
report.modifiers_left,
BTreeMap::from([
("conditions".into(), 1),
("lithostitched:add_features".into(), 1),
("target".into(), 1),
])
);
}
#[test]
fn lithostitched_block_swaps_become_the_games() {
let mut list = json!({"processors": [
{"processor_type": "lithostitched:block_swap",
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}},
{"processor_type": "lithostitched:block_swap", "blocks": []}]});
let mut carried = BTreeMap::new();
assert!(carry("processor_list", &mut list, &data(), &mut carried));
let p = list["processors"].as_array().unwrap();
assert_eq!(
p[0],
json!({"processor_type": "horizon:block_swap",
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}})
);
// A map that is not one stays Java (dropped later).
assert_eq!(p[1]["processor_type"], "lithostitched:block_swap");
}
#[test]
fn lithostitched_references_are_written_in() {
let mut data = data();
let gravity = json!({"processor_type": "minecraft:gravity", "heightmap": "WORLD_SURFACE_WG", "offset": 0});
data.lists
.insert("v:road".into(), json!({"processors": [gravity.clone()]}));
data.lists.insert(
"v:house".into(),
json!({"processors": [
{"processor_type": "lithostitched:reference", "processor_lists": "v:road"},
{"processor_type": "lithostitched:block_swap", "blocks": {"minecraft:dirt": "minecraft:grass_block"}}]}),
);
let mut list = json!({"processors": [
{"processor_type": "lithostitched:reference", "processor_lists": ["v:house", "v:road"]},
{"processor_type": "lithostitched:reference", "processor_lists": "#v:roads"},
{"processor_type": "lithostitched:reference", "processor_lists": ["v:nowhere"]}]});
let mut carried = BTreeMap::new();
assert!(carry("processor_list", &mut list, &data, &mut carried));
let p = list["processors"].as_array().unwrap();
// House (its own reference to road first, then its swap), then road;
// the swap inside is carried too.
assert_eq!(p[0], gravity);
assert_eq!(p[1]["processor_type"], "horizon:block_swap");
assert_eq!(p[2], gravity);
// A tag or a list the mod lacks stays, to be dropped.
assert_eq!(p[3]["processor_lists"], "#v:roads");
assert_eq!(p[4]["processor_lists"], json!(["v:nowhere"]));
assert_eq!(carried["lithostitched:reference"], 2);
}
#[test]
fn lithostitched_random_and_condition_become_the_games() {
let mut data = data();
let swap = json!({"processor_type": "lithostitched:block_swap",
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
data.lists
.insert("v:green".into(), json!({"processors": [swap.clone()]}));
let mut list = json!({"processors": [
{"processor_type": "lithostitched:apply_random", "mode": "per_piece", "processor_lists": [
{"weight": 3, "data": "minecraft:empty"},
{"weight": 1, "data": {"processors": [
{"processor_type": "lithostitched:reference", "processor_lists": "v:green"},
{"processor_type": "v:java"}]}}]},
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
"processor_lists": ["v:green", "v:red"]},
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
"processor_lists": "#v:colours"},
{"processor_type": "lithostitched:condition",
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
"then": swap, "else": {"processor_type": "lithostitched:set_block", "state_provider":
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}},
{"processor_type": "lithostitched:condition",
"if_true": {"type": "lithostitched:matching_biomes", "biomes": "#c:is_cold"},
"then": []}]});
let mut carried = BTreeMap::new();
assert!(carry("processor_list", &mut list, &data, &mut carried));
let p = list["processors"].as_array().unwrap();
let green = json!({"processor_type": "horizon:block_swap",
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
// Inline lists are carried; Java left in them is dropped later.
assert_eq!(
p[0],
json!({"processor_type": "horizon:apply_random", "mode": "per_piece", "processor_lists": [
{"weight": 3, "data": "minecraft:empty"},
{"weight": 1, "data": {"processors": [green.clone(), {"processor_type": "v:java"}]}}]})
);
assert_eq!(
p[1]["processor_lists"],
json!([{"weight": 1, "data": "v:green"}, {"weight": 1, "data": "v:red"}])
);
assert_eq!(p[2]["processor_type"], "lithostitched:apply_random");
assert_eq!(
p[3],
json!({"processor_type": "horizon:condition",
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
"then": [green], "else": [{"processor_type": "horizon:set_block", "state_provider":
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}]})
);
assert_eq!(p[4]["processor_type"], "lithostitched:condition");
assert_eq!(carried["lithostitched:apply_random"], 2);
assert_eq!(carried["lithostitched:condition"], 1);
}
}
@@ -0,0 +1,32 @@
//! The conversion of three families of structure mods' data, kept apart from
//! the general conversion code (`worldgen`, `processors`, `carry`):
//!
//! - [`yung`]: YUNG's API and the mods built on it (Better Dungeons, Desert
//! Temples, Jungle Temples, Witch Huts, Ocean Monuments, Strongholds,
//! Fortresses, Mineshafts), written under `horizon:yungsapi` and the other
//! keys `horizon_world::worldgen::structure::yung` reads.
//! - [`lithostitched`]: Lithostitched's jigsaw, worldgen modifiers and
//! processors.
//! - [`ctov`]: Choice Theorem's Overhauled Village's code-registered set
//! entries and its modular compat processor.
//!
//! The general code reaches them only through the `pub(crate)` entry points
//! of these three modules. Nothing in them is used by another mod's
//! conversion.
pub(crate) mod ctov;
pub(crate) mod lithostitched;
pub(crate) mod yung;
#[cfg(test)]
fn data() -> crate::processors::ProcessorData {
let mut d = crate::processors::ProcessorData::default();
d.spawners.insert(
"repurposed_structures:dungeons/badlands".into(),
vec![
("minecraft:husk".into(), 10.),
("minecraft:cave_spider".into(), 3.),
],
);
d.replace_vanilla.insert(("mvs".into(), "village".into()));
d
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -13,6 +13,7 @@ pub mod fetch;
pub mod import;
pub mod inventory;
pub mod jar;
pub(crate) mod legacy_structure_mods;
pub mod modrinth;
pub mod oracle;
pub mod pipeline;
File diff suppressed because it is too large. Load diff
+27 -726
View File
@@ -13,6 +13,11 @@
//! by name. Each rewrite is counted in the carry report, and every type left
//! as it is is listed, so the coverage report can say what generates and how
//! closely.
//!
//! YUNG's, Lithostitched's and CTOV's worldgen files are converted by
//! `legacy_structure_mods`, called from [`upgrade`], [`crate::carry`] and the
//! processors.
use crate::legacy_structure_mods::{lithostitched, yung};
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet},
@@ -21,8 +26,7 @@ use std::{
};
/// Structure types that are vanilla's jigsaw with extra start rules.
const JIGSAWS: [&str; 3] = [
"lithostitched:jigsaw",
const JIGSAWS: [&str; 2] = [
"moogs_structures:moogs_structures_generic_jigsaw_structure",
"repurposed_structures:generic_jigsaw_structure",
];
@@ -57,35 +61,6 @@ const SPREADS: [&str; 2] = [
"moogs_structures:advanced_random_spread",
"repurposed_structures:advanced_random_spread",
];
/// YUNG's placements: vanilla's random spread with an enhanced exclusion
/// zone (several sets), plus, for the temples, no start near an ocean or a
/// river, and for strongholds, starts only in odd rings around the origin.
const YUNG_SPREADS: [&str; 4] = [
"yungsapi:enhanced_random_spread",
"betterdeserttemples:desert_temple",
"betterjungletemples:jungle_temple",
"betterstrongholds:stronghold",
];
/// YUNG's structures and the vanilla structure type each one's mod turns
/// off (its DisableVanilla*Mixin, on by default in each mod's config).
const YUNG_REPLACES: [(&str, &str); 7] = [
(
"betterdeserttemples:desert_temple",
"minecraft:desert_pyramid",
),
(
"betterjungletemples:jungle_temple",
"minecraft:jungle_temple",
),
("betterwitchhuts:witch_hut", "minecraft:swamp_hut"),
(
"betteroceanmonuments:ocean_monument",
"minecraft:ocean_monument",
),
("betterstrongholds:stronghold", "minecraft:stronghold"),
("betterfortresses:fortress", "minecraft:fortress"),
("bettermineshafts:mineshaft_", "minecraft:mineshaft"),
];
/// Mo' Structures' placement (`mostructures:type`) takes its spacing and
/// separation from the mod's config, not its files: the defaults it writes
/// (MoStructuresConfig), by config key, as (spacing, separation).
@@ -130,8 +105,8 @@ const LAYOUT_RULES: [&str; 4] = [
];
/// The libraries' terrain kernel field, the game's
/// `horizon:enhanced_terrain_adaptation` (docs/mods.md).
const ENHANCED_TERRAIN: &str = "enhanced_terrain_adaptation";
const HORIZON_ENHANCED_TERRAIN: &str = "horizon:enhanced_terrain_adaptation";
pub(crate) const ENHANCED_TERRAIN: &str = "enhanced_terrain_adaptation";
pub(crate) const HORIZON_ENHANCED_TERRAIN: &str = "horizon:enhanced_terrain_adaptation";
/// Fields of older versions the mods' codecs no longer read: their own
/// structures ignore them, and so does carry. Repurposed's jungle fortress
/// and End stronghold still write `vertical_distance_from_start_piece`.
@@ -181,195 +156,6 @@ pub struct Upgrade {
/// `data/<id>/worldgen/<kind>/horizon_added/<name>.json`.
pub const ADDED: &str = "horizon_added";
/// A lithostitched worldgen modifier
/// (`data/<ns>/lithostitched/worldgen_modifier/`) that adds to structure
/// sets or template pools, as a file the game merges into them (the key
/// `horizon:add_to`, `horizon_client::mod_content`): (kind, file). The
/// modifier's conditions are checked against `loaded`, the mods ported
/// along: a compat addition for a mod that is not would place that mod's
/// blocks, which the game does not have. Other modifier types are left out.
/// Both are counted in `report`.
pub fn modifier(
v: &Value,
loaded: &dyn Fn(&str) -> bool,
report: &mut Upgrade,
) -> Option<(&'static str, Value)> {
let t = v["type"].as_str().unwrap_or_default();
let mut left = |why: &str| *report.modifiers_left.entry(why.to_owned()).or_default() += 1;
let (kind, targets, from, to) = match t {
"lithostitched:add_structure_set_entries" => {
("structure_set", "structure_sets", "entries", "structures")
}
"lithostitched:add_template_pool_elements" => {
("template_pool", "template_pools", "elements", "elements")
}
_ => {
left(t);
return None;
}
};
if !conditions_hold(v, loaded) {
left("conditions");
return None;
}
let targets: Option<Vec<&str>> = match &v[targets] {
Value::String(s) => Some(vec![s.as_str()]),
Value::Array(a) => a.iter().map(Value::as_str).collect(),
_ => None,
};
let Some(targets) = targets.filter(|t| !t.is_empty() && t.iter().all(|t| !t.starts_with('#')))
else {
left("target");
return None;
};
let entries = v[from].as_array().cloned().unwrap_or_default();
for target in &targets {
*report.added_to.entry((*target).to_owned()).or_default() += entries.len();
}
Some((
kind,
serde_json::json!({"horizon:add_to": targets, to: entries}),
))
}
/// Whether a modifier's load conditions hold: lithostitched's `predicate`,
/// and the loader's conditions (NeoForge's, else Fabric's). A condition
/// type not known here does not hold.
fn conditions_hold(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let all = |list: &Value, test: &dyn Fn(&Value) -> bool| {
list.as_array().is_some_and(|a| a.iter().all(test))
};
fn predicate(p: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| predicate(q, loaded);
let list = &p["predicates"];
match p["type"]
.as_str()
.map(|t| t.trim_start_matches("lithostitched:"))
{
Some("true") => true,
Some("mod_loaded") => p["mod_id"].as_str().is_some_and(loaded),
Some("not") => !predicate(&p["predicate"], loaded),
Some("all_of") => list.as_array().is_some_and(|a| a.iter().all(test)),
Some("any_of") => list.as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
fn neoforge(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| neoforge(q, loaded);
match c["type"].as_str() {
Some("neoforge:true") => true,
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
Some("neoforge:not") => !neoforge(&c["value"], loaded),
Some("neoforge:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
Some("neoforge:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
fn fabric(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| fabric(q, loaded);
let mods = || {
c["values"]
.as_array()
.into_iter()
.flatten()
.map(Value::as_str)
};
match c["condition"].as_str() {
Some("fabric:true") => true,
Some("fabric:all_mods_loaded") => mods().all(|m| m.is_some_and(loaded)),
Some("fabric:any_mods_loaded") => mods().any(|m| m.is_some_and(loaded)),
Some("fabric:not") => !fabric(&c["value"], loaded),
Some("fabric:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
Some("fabric:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
let litho = v.get("predicate").is_none_or(|p| predicate(p, loaded));
let loader = match (
v.get("neoforge:conditions"),
v.get("fabric:load_conditions"),
) {
(Some(c), _) => all(c, &|c| neoforge(c, loaded)),
// One condition or a list of them.
(None, Some(c)) if c.is_array() => all(c, &|c| fabric(c, loaded)),
(None, Some(c)) => fabric(c, loaded),
(None, None) => true,
};
litho && loader
}
/// ChoiceTheorem's Overhauled Village's villages and outposts, which it adds
/// to vanilla's `villages` and `pillager_outposts` sets in Java code
/// (`CTOV.registerstructure`, through lithostitched), with its default
/// config: every village type in three sizes, weighted 10, 4 and 1, and
/// every outpost weighted 1. Vanilla's own entries stay (weight 1 each).
const CTOV_VILLAGES: [&str; 21] = [
"beach",
"christmas",
"desert",
"desert_oasis",
"dark_forest",
"jungle",
"jungle_tree",
"mesa",
"mesa_fortified",
"mountain",
"mountain_alpine",
"mushroom",
"plains",
"plains_fortified",
"savanna",
"savanna_na",
"snowy_igloo",
"swamp",
"swamp_fortified",
"taiga",
"taiga_fortified",
];
const CTOV_OUTPOSTS: [&str; 11] = [
"beach",
"dark_forest",
"desert",
"jungle",
"badlands",
"mountain",
"plains",
"savanna",
"snowy",
"swamp",
"taiga",
];
/// The entries a mod adds to other structure sets in Java code, as its
/// default config has them: (file name, set, `[(structure, weight)]`).
/// Lithostitched appends them in the order of a hash map of every modifier
/// in the pack, which carry cannot know; they are written in the order the
/// mod registers them. Each set picks by weight, so how often each starts
/// is the same.
pub fn code_set_entries(mod_id: &str) -> Vec<(&'static str, &'static str, Vec<(String, u32)>)> {
match mod_id {
"ctov" => {
let outposts = CTOV_OUTPOSTS
.iter()
.map(|v| (format!("ctov:pillager_outpost_{v}"), 1))
.collect();
let villages = [("small", 10), ("medium", 4), ("large", 1)]
.into_iter()
.flat_map(|(size, weight)| {
CTOV_VILLAGES
.iter()
.map(move |v| (format!("ctov:{size}/village_{v}"), weight))
})
.collect();
vec![
("pillager_outposts", "minecraft:pillager_outposts", outposts),
("villages", "minecraft:villages", villages),
]
}
_ => Vec::new(),
}
}
/// Rewrites one `data/<ns>/worldgen/<kind>/...` file in place; whether it
/// changed.
/// `set_tags` are the mod's structure set tags, expanded
@@ -474,15 +260,16 @@ fn upgrade_types(
set_tags: &BTreeMap<String, Vec<String>>,
) -> bool {
let lost = &mut report.rules_lost;
// YUNG's and Lithostitched's types are converted apart from this code.
if let Some(changed) =
yung::upgrade(kind, v, set_tags, lost).or_else(|| lithostitched::upgrade(kind, v, lost))
{
report.mapped += usize::from(changed);
return changed;
}
let mut unsupported = |t: &str| *report.unsupported.entry(t.to_owned()).or_default() += 1;
let changed = match kind {
"structure" => match v["type"].as_str() {
// Better Dungeons' small Nether dungeon is YUNG's jigsaw behind a
// config switch that ships on.
Some("yungsapi:yung_jigsaw" | "betterdungeons:small_nether_dungeon") => {
yung_structure(v, lost);
true
}
Some(t)
if JIGSAWS.contains(&t)
|| NETHER_JIGSAWS.contains(&t)
@@ -591,11 +378,6 @@ fn upgrade_types(
true
}
Some("mostructures:type") => mo_placement(&mut v["placement"], lost),
Some(t) if YUNG_SPREADS.contains(&t) => {
let t = t.to_owned();
yung_placement(&t, &mut v["placement"], set_tags, lost);
true
}
Some(t) if !t.starts_with("minecraft:") => {
unsupported(t);
false
@@ -615,6 +397,10 @@ fn upgrade_types(
}
}
for element in elements {
if yung::upgrade_pool_element(element, lost) {
changed = true;
continue;
}
let Some(t) = element["element_type"].as_str() else {
continue;
};
@@ -629,9 +415,6 @@ fn upgrade_types(
}
element["element_type"] = (*to).into();
changed = true;
} else if t == "yungsapi:yung_single_element" {
yung_element(element, lost);
changed = true;
} else if t == "formations:single_pool_element" {
// Vanilla's element with its own ground level (1 when
// missing, as its codec reads it).
@@ -653,167 +436,10 @@ fn upgrade_types(
changed
}
/// YUNG's API's jigsaw as vanilla's, laid out by its own assembler in the
/// game (`horizon:yungsapi`, `horizon_world::worldgen::structure::yung`):
/// the heights pieces stay within move under the key, its terrain
/// adaptation under `horizon:enhanced_terrain_adaptation`. A start offset in
/// the chunk is lost.
fn yung_structure(v: &mut Value, lost: &mut BTreeMap<String, usize>) {
let Some(o) = v.as_object_mut() else {
return;
};
o.insert("type".into(), "minecraft:jigsaw".into());
let mut rules = serde_json::Map::new();
for key in ["min_y", "max_y"] {
if let Some(y) = o.remove(key) {
rules.insert(key.into(), y);
}
}
// YungJigsawStructure's adaptation (NONE by default): every piece's
// junctions, and the start's box grown by its radius.
let own = o.remove(ENHANCED_TERRAIN);
let mut kernel = yung_terrain(own.as_ref().unwrap_or(&Value::Null), lost);
kernel["rigid_junctions"] = true.into();
kernel["inflate"] = true.into();
o.insert(HORIZON_ENHANCED_TERRAIN.into(), kernel);
for key in ["x_offset_in_chunk", "z_offset_in_chunk"] {
if o.remove(key).is_some_and(|n| n != 0) {
*lost.entry(key.into()).or_default() += 1;
}
}
o.insert("horizon:yungsapi".into(), rules.into());
}
/// YUNG's single pool element as vanilla's, its rules under
/// `horizon:yungsapi`: name and `max_count`, depth range, priority,
/// `ignore_bounds`, its condition, dead-end pool and modifiers; its terrain
/// adaptation under `horizon:enhanced_terrain_adaptation`.
fn yung_element(element: &mut Value, lost: &mut BTreeMap<String, usize>) {
let Some(o) = element.as_object_mut() else {
return;
};
o.insert(
"element_type".into(),
"minecraft:single_pool_element".into(),
);
let mut rules = serde_json::Map::new();
for (from, to) in [
("name", "name"),
("max_count", "max_count"),
("min_required_depth", "min_depth"),
("max_possible_depth", "max_depth"),
("is_priority", "priority"),
("ignore_bounds", "ignore_bounds"),
] {
if let Some(x) = o.remove(from) {
rules.insert(to.into(), x);
}
}
if let Some(c) = o.remove("condition") {
rules.insert("condition".into(), yung_condition(&c, lost));
}
// A piece's own adaptation, NONE included, replaces its structure's.
if let Some(a) = o.remove(ENHANCED_TERRAIN) {
o.insert(HORIZON_ENHANCED_TERRAIN.into(), yung_terrain(&a, lost));
}
if let Some(pool) = o.remove("deadend_pool").filter(Value::is_string) {
rules.insert("deadend_pool".into(), pool);
}
let modifiers: Vec<Value> = o
.remove("modifiers")
.and_then(|m| m.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|m| yung_modifier(m, lost))
.collect();
if !modifiers.is_empty() {
rules.insert("modifiers".into(), modifiers.into());
}
o.insert("horizon:yungsapi".into(), rules.into());
}
/// One of YUNG's structure modifiers in the game's terms: its condition and
/// its transform and delay_generation actions, on the piece itself (`self`,
/// the API's only target). Another target loses the modifier, another action
/// just that action.
fn yung_modifier(m: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
let target = m["target_selector"]["type"].as_str().unwrap_or_default();
if target != "yungsapi:self" {
*lost
.entry(format!("yungsapi modifier target {target}"))
.or_default() += 1;
return None;
}
let mut actions = Vec::new();
for a in m["actions"].as_array().into_iter().flatten() {
let int = |k: &str| a[k].as_i64().unwrap_or(0);
match a["type"].as_str().unwrap_or_default() {
"yungsapi:transform" => actions.push(serde_json::json!({"type": "transform",
"output": a["output"].clone(),
"offset": [int("x_offset"), int("y_offset"), int("z_offset")]})),
"yungsapi:delay_generation" => {
actions.push(serde_json::json!({"type": "delay_generation"}))
}
other => {
*lost
.entry(format!("yungsapi modifier action {other}"))
.or_default() += 1
}
}
}
let condition = match m.get("condition") {
Some(c) => yung_condition(c, lost),
None => serde_json::json!({"type": "true"}),
};
Some(serde_json::json!({"condition": condition, "actions": actions}))
}
/// YUNG's API's terrain adaptation as the game's kernel: its two presets
/// spelled out, NONE (also the default) as size 0. An aquifer override
/// (none of ATM10's) is lost.
fn yung_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Value {
let kernel = |size: i32, distance: i32, top: &str, bottom: &str| {
serde_json::json!({"kernel_size": size, "kernel_distance": distance,
"top": top, "bottom": bottom})
};
match a["type"].as_str() {
Some("yungsapi:carved_top_no_beard_large") => kernel(24, 16, "carve", "none"),
Some("yungsapi:carved_top_no_beard_small") => kernel(12, 6, "carve", "none"),
Some("yungsapi:custom") => {
let mut out = serde_json::Map::new();
for key in [
"kernel_size",
"kernel_distance",
"top",
"bottom",
"bottom_offset",
"padding",
] {
if let Some(x) = a.get(key) {
out.insert(key.into(), x.clone());
}
}
if a.get("aquifer_override")
.is_some_and(|x| x["type"] != "yungsapi:none")
{
*lost.entry("yungsapi aquifer_override".into()).or_default() += 1;
}
moogs_terrain(&out.into(), lost).unwrap_or_else(|| kernel(0, 0, "none", "none"))
}
None | Some("yungsapi:none") => kernel(0, 0, "none", "none"),
Some(other) => {
*lost
.entry(format!("yungsapi terrain adaptation {other}"))
.or_default() += 1;
kernel(0, 0, "none", "none")
}
}
}
/// MoogsStructureLib's terrain adaptation: already the game's shape (with
/// its band), when it has the fields the game needs (else it is lost, as
/// the library's codec would refuse it).
fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
pub(crate) fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value> {
let ok = ["kernel_size", "kernel_distance"]
.iter()
.all(|k| a[k].is_u64())
@@ -824,58 +450,15 @@ fn moogs_terrain(a: &Value, lost: &mut BTreeMap<String, usize>) -> Option<Value>
ok.then(|| a.clone())
}
/// One of YUNG's structure conditions in the game's terms. `mod_loaded`
/// holds for Minecraft alone: a mod that needs code is not ported, and the
/// pieces it guards would place its blocks. A condition the game does not
/// have does not hold, and is counted.
fn yung_condition(c: &Value, lost: &mut BTreeMap<String, usize>) -> Value {
let t = c["type"].as_str().unwrap_or_default();
let list = |lost: &mut BTreeMap<String, usize>| -> Value {
let all = c["conditions"].as_array().into_iter().flatten();
all.map(|x| yung_condition(x, lost))
.collect::<Vec<_>>()
.into()
};
let opt = |k: &str| c.get(k).cloned().unwrap_or(Value::Null);
match t.trim_start_matches("yungsapi:") {
"always_true" => serde_json::json!({"type": "true"}),
"not" => {
serde_json::json!({"type": "not", "condition": yung_condition(&c["condition"], lost)})
}
"all_of" => serde_json::json!({"type": "all_of", "conditions": list(lost)}),
"any_of" => serde_json::json!({"type": "any_of", "conditions": list(lost)}),
"depth" => serde_json::json!({"type": "depth",
"min_depth": opt("min_required_depth"), "max_depth": opt("max_possible_depth")}),
"altitude" => serde_json::json!({"type": "altitude",
"bottom": opt("bottom_cutoff_y"), "top": opt("top_cutoff_y")}),
"random_chance" => serde_json::json!({"type": "random_chance", "chance": opt("chance")}),
"rotation" => serde_json::json!({"type": "rotation", "rotations": opt("rotations")}),
"piece_in_horizontal_direction" => {
// "yungsapi:all" (or no list) matches any piece.
let pieces: Vec<Value> = c["pieces"].as_array().cloned().unwrap_or_default();
let any = pieces.is_empty() || pieces.iter().any(|p| p == "yungsapi:all");
serde_json::json!({"type": "piece_in_horizontal_direction",
"pieces": if any { Vec::new() } else { pieces },
"range": opt("range"), "rotation": opt("rotation")})
}
// The game reads "yungsapi:all" (or no list) as any piece.
"piece_in_range" => serde_json::json!({"type": "piece_in_range",
"pieces": c.get("pieces").cloned().unwrap_or_else(|| Vec::<Value>::new().into()),
"horizontal_range": opt("horizontal_range"), "above_range": opt("above_range"),
"below_range": opt("below_range")}),
"mod_loaded" if c["modid"] == "minecraft" => serde_json::json!({"type": "true"}),
"mod_loaded" => serde_json::json!({"type": "false"}),
_ => {
*lost.entry(format!("yungsapi condition {t}")).or_default() += 1;
serde_json::json!({"type": "false"})
}
}
}
/// Moves a Repurposed Structures or Moog's jigsaw's start rules under
/// `horizon:start_checks`, and counts the layout rules it loses. `liquid` is
/// the block its no-start-in-liquid rule reads.
fn start_checks(v: &mut Value, liquid: &str, lost: &mut BTreeMap<String, usize>, limited: bool) {
pub(crate) fn start_checks(
v: &mut Value,
liquid: &str,
lost: &mut BTreeMap<String, usize>,
limited: bool,
) {
let Some(o) = v.as_object_mut() else {
return;
};
@@ -1043,7 +626,7 @@ fn moogs_spacing(p: &mut Value, lost: &mut BTreeMap<String, usize>) {
/// An advanced random spread's minimum distance from the world origin and
/// its super exclusion zone (sets named by a tag) under `horizon:` keys.
fn placement_rules(
pub(crate) fn placement_rules(
p: &mut Value,
set_tags: &BTreeMap<String, Vec<String>>,
lost: &mut BTreeMap<String, usize>,
@@ -1115,65 +698,6 @@ fn mo_placement(p: &mut Value, lost: &mut BTreeMap<String, usize>) -> bool {
true
}
/// One of YUNG's placements as vanilla's random spread with `horizon:` keys:
/// the enhanced exclusion zone as `horizon:exclusion_zone`; the temples'
/// ocean and river search (findBiomeHorizontal around the chunk's middle
/// block at y 120, 48 blocks, every 2 quarts) as `horizon:avoid_biomes`; the
/// strongholds' rings as `horizon:ring_sections`.
fn yung_placement(
t: &str,
p: &mut Value,
set_tags: &BTreeMap<String, Vec<String>>,
lost: &mut BTreeMap<String, usize>,
) {
placement_rules(p, set_tags, lost);
p["type"] = "minecraft:random_spread".into();
let Some(o) = p.as_object_mut() else {
return;
};
match t {
"betterdeserttemples:desert_temple" | "betterjungletemples:jungle_temple" => {
o.insert(
"horizon:avoid_biomes".into(),
serde_json::json!({"biomes": ["#minecraft:is_river", "#minecraft:is_ocean"],
"radius": 48, "step": 2, "y": 120}),
);
}
"betterstrongholds:stronghold" => {
let mut rings = serde_json::Map::new();
for (from, to) in [
("chunk_distance_to_first_ring", "distance_to_first_ring"),
("ring_chunk_thickness", "thickness"),
("max_ring_section", "max_section"),
] {
if let Some(n) = o.remove(from) {
rings.insert(to.into(), n);
}
}
o.insert("horizon:ring_sections".into(), rings.into());
}
_ => {}
}
}
/// Marks a YUNG's structure that the game can start (its type is vanilla's
/// after the upgrade) with the vanilla type its mod turns off, under
/// `horizon:disables`. One the game cannot start leaves the vanilla
/// structure in place, so the world keeps one of the two.
pub fn disables(id: &str, v: &mut Value) -> bool {
let Some((_, vanilla)) = YUNG_REPLACES.iter().find(|(s, _)| id.starts_with(s)) else {
return false;
};
if !v["type"]
.as_str()
.is_some_and(|t| t.starts_with("minecraft:"))
{
return false;
}
v["horizon:disables"] = serde_json::json!([vanilla]);
true
}
/// The mod's structure set tags (`data/<ns>/tags/worldgen/structure_set/`),
/// by tag id, with the tags they include expanded. Optional entries
/// (`{"id": ..., "required": false}`) count as their id.
@@ -1590,94 +1114,6 @@ mod tests {
assert_eq!(report.rules_lost["super_exclusion_zone"], 1);
}
#[test]
fn yungs_jigsaws_keep_their_rules() {
let mut report = Upgrade::default();
let mut s = json!({"type": "yungsapi:yung_jigsaw", "start_pool": "y:start", "size": 20,
"max_y": 60, "x_offset_in_chunk": 0, "max_distance_from_center": 80,
"enhanced_terrain_adaptation": {"type": "yungsapi:custom", "kernel_size": 24,
"kernel_distance": 24, "top": "carve", "bottom": "none"}});
assert!(upgrade("structure", &mut s, &mut report, &NO_TAGS));
assert_eq!(s["type"], "minecraft:jigsaw");
assert_eq!(s["horizon:yungsapi"], json!({"max_y": 60}));
assert_eq!(
s["horizon:enhanced_terrain_adaptation"],
json!({"kernel_size": 24, "kernel_distance": 24, "top": "carve", "bottom": "none",
"rigid_junctions": true, "inflate": true})
);
for gone in ["max_y", "x_offset_in_chunk", "enhanced_terrain_adaptation"] {
assert!(s.get(gone).is_none(), "{gone}");
}
// Better Dungeons' small Nether dungeon is the same jigsaw.
let mut nether = json!({"type": "betterdungeons:small_nether_dungeon",
"start_pool": "betterdungeons:small_nether_dungeon", "size": 10,
"start_jigsaw_name": "betterdungeons:small_nether_dungeon_anchor",
"max_distance_from_center": 80, "liquid_settings": "ignore_waterlogging"});
assert!(upgrade("structure", &mut nether, &mut report, &NO_TAGS));
assert_eq!(nether["type"], "minecraft:jigsaw");
assert_eq!(nether["horizon:yungsapi"], json!({}));
assert_eq!(
nether["horizon:enhanced_terrain_adaptation"],
json!({"kernel_size": 0, "kernel_distance": 0, "top": "none", "bottom": "none",
"rigid_junctions": true, "inflate": true})
);
assert_eq!(
nether["start_jigsaw_name"],
"betterdungeons:small_nether_dungeon_anchor"
);
assert!(report.unsupported.is_empty());
let mut pool = json!({"elements": [{"weight": 3, "element": {
"element_type": "yungsapi:yung_single_element", "location": "y:room",
"processors": "minecraft:empty", "projection": "rigid",
"name": "room", "max_count": 2, "min_required_depth": 1, "is_priority": true,
"ignore_bounds": false, "deadend_pool": "y:end",
"modifiers": [
{"condition": {"type": "yungsapi:piece_in_range", "pieces": ["y:pillar"],
"above_range": 40},
"target_selector": {"type": "yungsapi:self"},
"actions": [{"type": "yungsapi:transform", "output": ["y:covered"], "x_offset": -1},
{"type": "yungsapi:delay_generation"}, {"type": "yungsapi:spin"}]},
{"condition": {"type": "yungsapi:always_true"},
"target_selector": {"type": "yungsapi:all"}, "actions": []}],
"enhanced_terrain_adaptation": {"type": "yungsapi:carved_top_no_beard_small"},
"condition": {"type": "yungsapi:all_of", "conditions": [
{"type": "yungsapi:piece_in_horizontal_direction", "pieces": ["yungsapi:all"],
"range": 5, "rotation": "none"},
{"type": "yungsapi:not", "condition": {"type": "yungsapi:mod_loaded", "modid": "create"}},
{"type": "yungsapi:biome", "biomes": []}]}}}]});
assert!(upgrade("template_pool", &mut pool, &mut report, &NO_TAGS));
let e = &pool["elements"][0]["element"];
assert_eq!(e["element_type"], "minecraft:single_pool_element");
assert_eq!(e["location"], "y:room");
assert_eq!(
e["horizon:yungsapi"],
json!({"name": "room", "max_count": 2, "min_depth": 1, "priority": true,
"ignore_bounds": false, "condition": {"type": "all_of", "conditions": [
{"type": "piece_in_horizontal_direction", "pieces": [], "range": 5, "rotation": "none"},
{"type": "not", "condition": {"type": "false"}},
{"type": "false"}]},
"deadend_pool": "y:end",
"modifiers": [{"condition": {"type": "piece_in_range", "pieces": ["y:pillar"],
"horizontal_range": null, "above_range": 40, "below_range": null},
"actions": [{"type": "transform", "output": ["y:covered"], "offset": [-1, 0, 0]},
{"type": "delay_generation"}]}]})
);
assert!(e.get("deadend_pool").is_none() && e.get("modifiers").is_none());
assert_eq!(
e["horizon:enhanced_terrain_adaptation"],
json!({"kernel_size": 12, "kernel_distance": 6, "top": "carve", "bottom": "none"})
);
assert_eq!(
report.rules_lost,
BTreeMap::from([
("yungsapi condition yungsapi:biome".into(), 1),
("yungsapi modifier action yungsapi:spin".into(), 1),
("yungsapi modifier target yungsapi:all".into(), 1),
])
);
assert!(report.unsupported.is_empty());
}
#[test]
fn drops_processors_written_in_java() {
let mut report = Upgrade::default();
@@ -1722,59 +1158,6 @@ mod tests {
);
}
#[test]
fn yungs_placements_keep_their_rules() {
let tags = BTreeMap::from([(
"bf:avoid".to_owned(),
vec!["minecraft:nether_complexes".to_owned()],
)]);
let mut report = Upgrade::default();
let mut fortress = json!({"placement": {"type": "yungsapi:enhanced_random_spread",
"salt": 1, "spacing": 30, "separation": 20,
"enhanced_exclusion_zone": {"other_set": "#bf:avoid", "chunk_count": 10}}});
assert!(upgrade("structure_set", &mut fortress, &mut report, &tags));
assert_eq!(
fortress["placement"],
json!({"type": "minecraft:random_spread", "salt": 1, "spacing": 30, "separation": 20,
"horizon:exclusion_zone": {"other_sets": ["minecraft:nether_complexes"], "chunk_count": 10}})
);
let mut temple = json!({"placement": {"type": "betterdeserttemples:desert_temple",
"salt": 2, "spacing": 30, "separation": 20,
"exclusion_zone": {"other_set": "minecraft:villages", "chunk_count": 6}}});
assert!(upgrade("structure_set", &mut temple, &mut report, &tags));
assert_eq!(temple["placement"]["type"], "minecraft:random_spread");
assert_eq!(temple["placement"]["exclusion_zone"]["chunk_count"], 6);
assert_eq!(
temple["placement"]["horizon:avoid_biomes"],
json!({"biomes": ["#minecraft:is_river", "#minecraft:is_ocean"],
"radius": 48, "step": 2, "y": 120})
);
let mut stronghold = json!({"placement": {"type": "betterstrongholds:stronghold",
"salt": 3, "spacing": 85, "separation": 50,
"chunk_distance_to_first_ring": 80, "ring_chunk_thickness": 96}});
assert!(upgrade(
"structure_set",
&mut stronghold,
&mut report,
&tags
));
assert_eq!(
stronghold["placement"],
json!({"type": "minecraft:random_spread", "salt": 3, "spacing": 85, "separation": 50,
"horizon:ring_sections": {"distance_to_first_ring": 80, "thickness": 96}})
);
assert_eq!(report.mapped, 3);
assert!(report.unsupported.is_empty());
// The vanilla counterpart is turned off only for a structure the game starts.
let mut ported = json!({"type": "minecraft:jigsaw"});
assert!(disables("betterstrongholds:stronghold", &mut ported));
assert_eq!(ported["horizon:disables"], json!(["minecraft:stronghold"]));
let mut java = json!({"type": "bettermineshafts:mineshaft"});
assert!(!disables("bettermineshafts:mineshaft_oak", &mut java));
let mut circle = json!({"type": "minecraft:jigsaw"});
assert!(!disables("betterwitchhuts:witch_circle", &mut circle));
}
#[test]
fn piece_limited_jigsaws_keep_their_layout() {
let mut report = Upgrade::default();
@@ -1886,88 +1269,6 @@ mod tests {
assert_eq!(report.mapped, 0);
}
#[test]
fn modifiers_add_to_sets_and_pools() {
let mut report = Upgrade::default();
let only_fd = |m: &str| m == "farmersdelight";
let set = json!({"type": "lithostitched:add_structure_set_entries",
"structure_sets": "minecraft:villages",
"entries": [{"structure": "x:village", "weight": 4}]});
let (kind, added) = modifier(&set, &only_fd, &mut report).unwrap();
assert_eq!(kind, "structure_set");
assert_eq!(
added,
json!({"horizon:add_to": ["minecraft:villages"],
"structures": [{"structure": "x:village", "weight": 4}]})
);
// A compat addition holds only when its mod is ported along.
let pool = |modid: &str| {
json!({"type": "lithostitched:add_template_pool_elements",
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}],
"fabric:load_conditions": {"condition": "fabric:all_mods_loaded", "values": ["other"]},
"template_pools": ["x:houses", "y:houses"],
"elements": [{"weight": 5, "element": {"element_type": "minecraft:single_pool_element",
"location": "x:fd_farm", "projection": "rigid", "processors": "minecraft:empty"}}]})
};
let (kind, added) = modifier(&pool("farmersdelight"), &only_fd, &mut report).unwrap();
assert_eq!(kind, "template_pool");
assert_eq!(added["horizon:add_to"], json!(["x:houses", "y:houses"]));
assert_eq!(added["elements"][0]["weight"], 5);
assert!(modifier(&pool("create"), &only_fd, &mut report).is_none());
// Fabric's conditions when there are no NeoForge ones; lithostitched's own.
let fabric = json!({"type": "lithostitched:add_structure_set_entries",
"fabric:load_conditions": [{"condition": "fabric:not",
"value": {"condition": "fabric:any_mods_loaded", "values": ["create"]}}],
"predicate": {"type": "lithostitched:all_of", "predicates": [
{"type": "lithostitched:mod_loaded", "mod_id": "farmersdelight"},
{"type": "lithostitched:true"}]},
"structure_sets": "minecraft:villages", "entries": []});
assert!(modifier(&fabric, &only_fd, &mut report).is_some());
let tagged = json!({"type": "lithostitched:add_structure_set_entries",
"structure_sets": "#x:sets", "entries": []});
assert!(modifier(&tagged, &only_fd, &mut report).is_none());
let features = json!({"type": "lithostitched:add_features"});
assert!(modifier(&features, &only_fd, &mut report).is_none());
assert_eq!(
report.added_to,
BTreeMap::from([
("minecraft:villages".into(), 1),
("x:houses".into(), 1),
("y:houses".into(), 1),
])
);
assert_eq!(
report.modifiers_left,
BTreeMap::from([
("conditions".into(), 1),
("lithostitched:add_features".into(), 1),
("target".into(), 1),
])
);
}
#[test]
fn ctov_joins_vanillas_villages_and_outposts() {
let sets = code_set_entries("ctov");
let (file, set, outposts) = &sets[0];
assert_eq!(
(*file, *set),
("pillager_outposts", "minecraft:pillager_outposts")
);
assert_eq!(outposts.len(), 11);
assert!(outposts.iter().all(|(_, w)| *w == 1));
let (_, set, villages) = &sets[1];
assert_eq!(*set, "minecraft:villages");
assert_eq!(villages.len(), 63);
assert_eq!(villages[0], ("ctov:small/village_beach".into(), 10));
assert_eq!(villages[21], ("ctov:medium/village_beach".into(), 4));
assert_eq!(
villages[62],
("ctov:large/village_taiga_fortified".into(), 1)
);
assert!(code_set_entries("mvs").is_empty());
}
#[test]
fn convention_tags_include_theirs() {
let conventions = BTreeMap::from([
+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)));
}
}
+98 -17
View File
@@ -2,17 +2,7 @@
//! `.horizon/api.json` that only the server's own user can read. The CLI manages a running server through
//! it; the web panel and the MCP server will too.
//!
//! - `GET /api/v1/status`: the supervisor's state, the game's status (TPS, MSPT, players), RAM and CPU.
//! - `GET /api/v1/logs?since=N`: the log lines after the Nth.
//! - `POST /api/v1/command {"line": "..."}`: a console command; the lines it logged.
//! - `POST /api/v1/stop`, `POST /api/v1/restart`.
//! - `GET /api/v1/metrics?since=T`: the samples taken after T (seconds since 1970), up to 10 minutes.
//! - `GET /api/v1/backups`; `POST /api/v1/backups {"name": "..."}` backs up now; `POST /api/v1/restore
//! {"name": "..."}` restarts the game with that backup's world.
//! - `POST /api/v1/update {"tag": "...", "timeout": seconds}`: restarts the game on that installed release,
//! and goes back to the one in use if it isn't up within the timeout; without a tag, downloads the newest
//! release first (update.rs). `GET /api/v1/status` says how it went (`update`).
//! - `POST /api/v1/panel/login`: a one-time login link for the web panel (with the token only).
//! Its routes are [`ROUTES`], listed in docs/api/server-reference.md.
use crate::{
backup,
@@ -41,6 +31,102 @@ const LOG_LINES: usize = 2000;
/// The samples kept for the panel's graphs: 10 minutes, one a second.
const METRICS: usize = 600;
/// One endpoint of the control API: what the reference (docs/api/server-reference.md) says about it.
#[cfg_attr(
not(test),
expect(dead_code, reason = "only the reference reads all of it")
)]
pub struct Route {
pub method: &'static str,
pub path: &'static str,
/// The JSON body or query it takes, "" for none.
pub takes: &'static str,
/// The JSON it answers with.
pub answers: &'static str,
pub what: &'static str,
}
/// Every endpoint `start` and [`route`] answer; a test checks the two agree.
pub const ROUTES: [Route; 11] = [
Route {
method: "GET",
path: "/api/v1/status",
takes: "",
answers: "`phase`, `version`, `pid`, `uptime_seconds`, `engine_pid`, `engine_uptime_seconds`, `crashes`, `last_crash`, `sample` (TPS, MSPT, players, RAM, CPU), `update`",
what: "How the server does",
},
Route {
method: "GET",
path: "/api/v1/metrics",
takes: "`?since=T` (seconds since 1970)",
answers: "`{\"samples\": [...]}`: one a second, the last 10 minutes",
what: "The samples taken after T, for graphs",
},
Route {
method: "GET",
path: "/api/v1/logs",
takes: "`?since=N`",
answers: "`{\"next\": N, \"lines\": [...]}`",
what: "The log lines after the Nth (the last 2000 are kept)",
},
Route {
method: "POST",
path: "/api/v1/command",
takes: "`{\"line\": \"<one command>\"}`",
answers: "the lines the command logged",
what: "Run a console command on the running game",
},
Route {
method: "POST",
path: "/api/v1/stop",
takes: "",
answers: "`{\"ok\": true}`",
what: "Stop the game and the supervisor",
},
Route {
method: "POST",
path: "/api/v1/restart",
takes: "",
answers: "`{\"ok\": true}`",
what: "Restart the game",
},
Route {
method: "GET",
path: "/api/v1/backups",
takes: "",
answers: "`{\"backups\": [{\"name\", \"bytes\"}]}`",
what: "List the backups",
},
Route {
method: "POST",
path: "/api/v1/backups",
takes: "`{\"name\": \"...\"}` (optional)",
answers: "`{\"name\": \"<file>\"}`",
what: "Back up now (a running game saves first and stays up)",
},
Route {
method: "POST",
path: "/api/v1/restore",
takes: "`{\"name\": \"<backup>\"}`",
answers: "`{\"name\": \"<backup>\"}`",
what: "Restart the game with that backup's world (the current one is kept in backups/)",
},
Route {
method: "POST",
path: "/api/v1/update",
takes: "`{\"tag\": \"...\", \"timeout\": seconds}` (both optional)",
answers: "`{\"ok\": true}`; `status` says how it went (`update`)",
what: "Restart on that installed release, or download the newest first; goes back if the game isn't up within the timeout",
},
Route {
method: "POST",
path: "/api/v1/panel/login",
takes: "",
answers: "`{\"url\": \"...\"}`",
what: "A one-time login link for the web panel (with the token only, not from the panel)",
},
];
/// What the supervisor is doing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
@@ -488,12 +574,7 @@ pub fn route(shared: &Arc<Shared>, request: &Request) -> Response {
Err(e) => Response::error(409, &e),
}
}
(
_,
"/api/v1/status" | "/api/v1/logs" | "/api/v1/command" | "/api/v1/stop"
| "/api/v1/restart" | "/api/v1/backups" | "/api/v1/restore" | "/api/v1/metrics"
| "/api/v1/update",
) => Response::error(405, "wrong method"),
(_, path) if ROUTES.iter().any(|r| r.path == path) => Response::error(405, "wrong method"),
_ => Response::error(404, "no such endpoint"),
}
}
+8 -8
View File
@@ -19,22 +19,22 @@ pub const TEMPLATE: &str = r##"# Horizon server settings (docs/server.md). The s
# every start: edit this file, not that one.
[server]
motd = "A Minecraft Server"
port = 25565
motd = "A Minecraft Server" # the line under the server's name in the server list
port = 25565 # the port players connect to
bind = "" # listen on every address, or only on this one (server-ip)
max_players = 20
max_players = 20 # how many players can be online at once
online_mode = true # check players' Minecraft accounts (false: anyone joins under any name)
whitelist = true # only whitelisted players and operators can join
enforce_whitelist = false # kick online players taken off the whitelist
view_distance = 10
simulation_distance = 10
view_distance = 10 # chunks sent around each player
simulation_distance = 10 # chunks around each player where mobs, crops and redstone tick
[world]
name = "world" # the world folder
seed = "" # empty: a random seed on the first start
game_mode = "survival" # survival, creative, adventure or spectator
difficulty = "easy" # peaceful, easy, normal or hard
hardcore = false
hardcore = false # one life: a player who dies becomes a spectator
# Any other server.properties key, spelled as vanilla spells it, for example:
# spawn-protection = 16
@@ -43,7 +43,7 @@ hardcore = false
[engine]
path = "" # the game program; empty: the one installed beside horizon-server
resources = "" # the prepared game files; empty: resources/ here, else the usual places
restart_on_crash = true
restart_on_crash = true # start the game again when it crashes
[backups]
every = "6h" # automatic backups while the server runs: 30m, 6h, 1d...; "0" turns them off
@@ -58,7 +58,7 @@ allow_public = false # true lets bind be any address; never put the pane
"##;
/// Each typed setting: its section, its name, and the vanilla key it writes.
const TYPED: [(&str, &str, &str); 14] = [
pub(crate) const TYPED: [(&str, &str, &str); 14] = [
("server", "motd", "motd"),
("server", "port", "server-port"),
("server", "bind", "server-ip"),
+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()
}
+3
View File
@@ -10,6 +10,9 @@ mod http;
mod init;
mod lists;
mod panel;
mod process;
#[cfg(test)]
mod reference;
mod service;
mod settings;
mod signals;
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff. Show more