Compare commits

...
Author SHA1 Message Date
Ludovic Marie 6a32ee68d4 Merge pull request #1725 from RVZO6/feature/text-display-rendering
Client: render server text displays and lobby labels
2026-10-25 09:07:59 +01:00
Ludovic Marie 0e73edceff Merge pull request #1718 from RVZO6/feature/text-display-state
Client: model text-display metadata against the Java wire codec
2026-10-25 09:07:02 +01:00
Ludovic Marie f7bd3e381b Merge pull request #1729 from RVZO6/fix/stop-sound-packet
Client: honor server stop-sound event and source filters
2026-10-25 09:06:02 +01:00
Ludovic MarieandClaude Sonnet 5.5 ea0905e8a2 lan_e2e: no blank lines inside the docstring after the merge
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 08:58:25 +01:00
Ludovic Marie b149a0ec3f Merge main into fix/stop-sound-packet: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

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

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

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

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

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:16:44 +01:00
Ludovic Marie 6017830623 Merge pull request #1719 from rdbtCVS/t3/13ed5f77
Fix crops remaining after server-side farmland trampling
2026-10-25 08:16:06 +01:00
Ludovic MarieandClaude Sonnet 5.5 fb34815ce1 Merge main into server-farmland-trampling: keep both appended lan_e2e checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 08:07:28 +01:00
Ludovic Marie f49e3a2d24 Merge pull request #1726 from rdbtCVS/t3/9a51354a
Fix offhand eating and drinking cancellation
2026-10-25 08:07:06 +01:00
Ludovic Marie cb0865e242 Merge pull request #1724 from rdbtCVS/t3/19c12e83
Fix spectator collision after dimension travel
2026-10-25 08:07:03 +01:00
Ludovic Marie a27322f250 Merge pull request #1716 from rdbtCVS/t3/395312eb
Fix double-chest inventory and hotbar clicks
2026-10-25 08:05:50 +01:00
Ludovic Marie ed3243ca62 Merge pull request #1730 from rdbtCVS/fix/launcher-java-pairing
Launcher: use one compatible JDK for resource preparation
2026-10-25 08:05:45 +01:00
Ludovic Marie f59bb8b8d5 Merge pull request #1738 from rdbtCVS/fix/pre-push-ref-history
Scope pre-push history checks to pushed refs
2026-10-25 07:54:48 +01:00
Ludovic Marie 00705d8431 Merge pull request #1630 from some-du6e/fix/ipv4-first 2026-10-25 07:10:05 +01:00
Ludovic Marie 0564ab0bcd Merge pull request #1748 from ludovic111/fix/server-player-targets-barrier
server_player_targets harness: end each step by the server's clock, not 2.5 s
2026-10-25 06:39:50 +01:00
Ludovic Marie eb1fdf5fb4 Merge pull request #1745 from ludovic111/fix/command-exec-random-count
command_exec harness: start the script without random sequences rolled before the setup
2026-10-25 06:35:31 +01:00
Ludovic Marie 2ee008f8fa Merge pull request #1646 from ludovic111/feat/mod-registry-tools 2026-10-25 06:34:00 +01:00
Ludovic Marie 8a0303ed62 Merge pull request #1642 from ludovic111/feat/mod-check 2026-10-25 06:32:11 +01:00
Ludovic MarieandClaude Opus 5.5 b15e46c3af lan_e2e stopsound: each target's client gets the stop (status counts stops)
Sounds can't be observed playing without an audio device, so the control
status counts each stopped selection, as it counts the sounds heard.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:13:11 +01:00
Ludovic MarieandClaude Opus 5.5 659bfad86c Merge the text-display model fixups (#1718) into the renderer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:13:00 +01:00
Ludovic MarieandClaude Opus 5.5 76408b734e Text displays: an Align enum, and the text shared rather than copied
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:12:31 +01:00
Ludovic MarieandClaude Opus 5.5 b0918f3be1 /stopsound: send each targeted server player the stop-sound packet
StopSoundCommand sends ClientboundStopSoundPacket to every target. The
command stopped the server's own sounds instead, so on the server thread
(single player's default) and for LAN guests nothing stopped.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:48:29 +01:00
Ludovic MarieandClaude Opus 5.5 6acf7fb6ab server_player_targets harness: end each step by the server's clock
Each step waited a fixed 2.5 s. When the machine is loaded the vanilla server stalls for longer than that (CI on
#1571 and #1585: after Bob's trip to the Nether, vanilla's `tp Bob 4.5 -60 0.5` from the console ran a step late),
so its packets were filed under the next step and the transcripts differed although both servers sent the same.

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

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

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

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

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

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

Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 04:15:14 +00:00
Ludovic Marie 1ef5b82b7d Merge pull request #1571 from ludovic111/fix/home-leak-tests
Tools and harnesses start the game in scratch folders, not a player's
2026-10-25 05:07:12 +01:00
rdbtCVSandOpenCode gpt-6.1-sol 5d19fcaa06 Dismount players before command teleports
Reuse stop_riding before applying a player teleport destination so the riding tick cannot return the player to the vehicle. Add vanilla command parity and both-mode LAN regressions for mounted teleports.

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

Fixes #1365

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-24 22:52:51 -04:00
Ludovic Marie f2f58aca94 Merge pull request #1690 from ludovic111/fix/remote-items-fall 2026-10-25 03:45:53 +01:00
Ludovic Marie f9e3244281 Merge pull request #1715 from ludovic111/fix/launcher-remove-async
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 03:30:13 +01:00
rdbtCVSandOpenCode c36e81916b Fix double-chest inventory and hotbar clicks
Co-Authored-By: OpenCode (GPT-6.1-Sol) <[email protected]>
2026-10-25 02:24:16 +00:00
Ludovic MarieandClaude Sonnet 5.5 87f377b29c launcher: remove a version on a background thread
Clicking Remove deleted the version's thousands of files on the UI thread, so
the whole launcher froze for seconds (more on Windows). The removal now runs
on a thread and ends with Msg::Removed. The version leaves the installed list
at once (nothing can launch, select or prepare it) and its row shows
"Removing…" with a moving bar; installing it again is refused until it is gone.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 03:24:05 +01:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol 6e86e07781 Multiplayer: keep spectator collision state after dimension travel
Restore the current game mode's spectator flag when remote_teleport creates a replacement player body. Add an end-to-end wall traversal regression for Nether travel and return, covering the integrated-server host, LAN guest and in-frame host.

Fixes #1299

Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-25 02:22:41 +00:00
Ludovic Marie 4bb7f96f81 Merge pull request #1634 from ludovic111/feat/mod-index 2026-10-25 02:52:31 +01:00
Ludovic Marie 89f426c48f Merge pull request #1648 from ludovic111/fix/remote-fall-hurt 2026-10-25 02:38:44 +01:00
some-du6eandClaude Opus 5.5 f66c257ae5 Advancement chat hover: normalise the title with canonical_component
scoreboard::canonical_component already re-encodes a component as vanilla
does, list form included, so decorate_name uses it instead of its own copy.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:33:22 -06:00
Ludovic MarieandClaude Opus 5.5 a26297058a Trims: a Horizon host sends them too
The host wrote no trim component, so single player on its server thread,
LAN guests and dedicated-server players saw armour without its trim.
network_stack now takes the connection's synced-registry lookup for any
registry and writes Component::Trim when both names are in it.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:08:31 +02:00
Ludovic 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 f07d66c7cf Trims: their own arm in Component::read
The trim decode sat inside the read-past fallback and matched the kind
there; it now has a TRIM arm, and the fallback only reads past. Long
comment lines wrapped.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:29:12 +02:00
Ludovic MarieandClaude Opus 5.5 3e3ab7a413 Merge main into fix/ipv4-first: IPv4 first for SRV targets too
Main's SRV resolver (#1578) added Connection::connect_server; both connect paths now
go through connect_addresses, which puts IPv4 first, as ServerAddressResolver.SYSTEM's
InetAddress.getByName does for the original host and the SRV target alike.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:52 +02:00
Ludovic Marie 65d7c4b027 Merge pull request #1563 from ludovic111/pa1/remove-cull-leaves 2026-10-24 22:50:34 +02:00
Ludovic Marie 7becd7fdca Merge pull request #1559 from ludovic111/pa1/remove-mod-surveys 2026-10-24 22:48:55 +02:00
some-du6eandClaude Opus 5.5 4ad05aa94a Show armour trims on items from a server
Decode the trim component as its material and pattern registry ids when both
are references (a direct holder stays read past) and name them through the
server's synced trim_material and trim_pattern registries, as enchantments
are, so worn and held trimmed armour draws its trim in multiplayer.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 14:44:28 -06:00
Ludovic MarieandClaude Opus 5.5 c6980fb2b7 Drop the lang.rs test: layers_selected_language_over_english covers it
It reads the same files the same way (assets/pack.mcmeta's language
section, a language offered only with its file, Language::load over
en_us). Leaving the client out also keeps this PR to horizon-prepare, so
its check doesn't rerun the client's harnesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:43:26 +02:00
Ludovic MarieandClaude Opus 5.5 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 7c1dd49810 Renderer: leave out solid quads that face away from the eye
Meshing sorts each section's and distant tile's solid quads into seven
buckets: one per axis direction (all four corners in one axis plane, both
triangles wound the same way) and one for everything else, and keeps each
axis bucket's extreme plane. A quad facing +X at x = p is a back face from
an eye at x <= p, which the solid pipeline culls anyway, so the frame draws
only the buckets that can face the eye, merged into runs of adjacent
buckets: one to three indirect draws per section on the existing page-run,
occlusion-culling and flat paths. The eye comes from the frame's
view-projection, so view bobbing is exact. The picture does not change;
`horizon-ctl section-sides on|off` switches it for A/B checks.

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

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

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

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

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

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

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

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

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

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

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

Fixes #1506.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:21:53 +02:00
Ludovic MarieandClaude Opus 5.5 a3b4ed57b2 runtime.py: two blank lines between functions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:21:21 +02:00
Ludovic MarieandClaude Opus 5.5 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
362 changed files with 17925 additions and 10429 deletions

No files matched your search

+1
View File
@@ -3,3 +3,4 @@
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+1 -1
View File
@@ -1,2 +1,2 @@
#!/bin/sh
exec python3 tools/check_public_tree.py --history
exec python3 tools/check_public_tree.py --pre-push
+1 -95
View File
@@ -430,7 +430,7 @@ jobs:
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_float_log_parity.py
portable-mod:
jfr-analysis:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
@@ -442,101 +442,7 @@ jobs:
with:
distribution: temurin
java-version: '25'
- name: Compile original mod and test metrics
run: python tools/build_mod.py
- name: Verify streaming JFR analysis against the JDK exporter
run: python tools/test_jfr.py
- name: Verify ZGC pause and allocation event interpretation
run: python tools/test_jfr.py --gc zgc
- name: Build pinned portable packs
run: python tools/build_pack.py
- name: Measure isolated worker pacing on this platform
run: python -c "from tools.build_mod import benchmark_workers; benchmark_workers()"
- name: Measure isolated debounce queue polling
run: python -c "from tools.build_mod import benchmark_debounce; benchmark_debounce()"
- name: Measure wakeable idle queue latency
run: python -c "from tools.build_mod import benchmark_idle; benchmark_idle()"
- name: Measure object allocations with compact headers off and on
run: python -c "from tools.build_mod import benchmark_headers; benchmark_headers()"
- name: Test pack integrity and comparisons
run: python -m unittest discover -s tests -v
- name: Start recommended OpenGL macOS clients and verify framebuffer readback
if: runner.os == 'macOS'
run: |
python tools/test_client_graphics.py --native-display
cp dist/client-graphics-smoke.json dist/client-graphics-macos-title.json
python tools/test_client_graphics.py --native-display --world
cp dist/client-graphics-smoke.json dist/client-graphics-macos-world.json
- name: Compile the private dispatch fixture
run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()"
- name: Compile the private integrated-server graphics fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('server-graphics', 'dev.horizonlab.metrics.ServerGraphicsIntegration', fixture_dependencies={})"
- name: Compile the private tick-manager state fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
- name: Compile the private terrain tick membership fixture
run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()"
- name: Compile the private culling whitelist fixture
run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()"
- name: Compile the private culling ray geometry fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('culling-rays', 'dev.horizonlab.dispatch.CullingRayIntegration', ['entityculling'], ['CullingRayAccess'])"
- name: Compile the private terrain position lookup fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-position', 'dev.horizonlab.dispatch.PositionIntegration')"
- name: Compile the private terrain quad reuse fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('quad-reuse', 'dev.horizonlab.dispatch.QuadReuseIntegration')"
- name: Compile the private object-size agent and terrain quad fixture
run: python -c "from tools.test_quad_sizes import build_agent; from tools.build_mod import build_integration_fixture; build_agent(); build_integration_fixture('quad-size', 'dev.horizonlab.dispatch.QuadSizeIntegration')"
- name: Compile the private terrain read shutdown fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')"
- name: Install headless graphics libraries for applied-hook checks
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0
- name: Verify integrated-server graphics report against the actual device
run: python tools/test_server_graphics.py ${{ runner.os == 'macOS' && '--native-display' || '' }}
- name: Exercise the applied dispatcher hooks and upstream rejection path
if: runner.os == 'Linux'
run: python tools/test_dispatch.py
- name: Verify applied tick membership transitions and allocation
if: runner.os == 'Linux'
run: python tools/test_tick_dedup.py
- name: Verify live culling whitelist registration and empty iteration
if: runner.os == 'Linux'
run: python tools/test_culling_empty.py
- name: Compare applied culling ray geometry and provider queries
if: runner.os == 'Linux'
run: python tools/test_culling_rays.py
- name: Verify actual full-data position lookup equivalence
if: runner.os == 'Linux'
run: python tools/test_terrain_position.py
- name: Verify actual terrain quad geometry and allocation
if: runner.os == 'Linux'
run: python tools/test_quad_reuse.py
- name: Measure actual builder and quad shallow sizes in both header modes
if: runner.os == 'Linux'
run: python tools/test_quad_sizes.py
- name: Verify interrupted DH reads and preserved shared futures
if: runner.os == 'Linux'
run: python tools/test_terrain_read.py
- name: Upload original mod and pack manifests only
uses: actions/upload-artifact@v4
with:
name: horizon-linux
path: |
dist/*.mrpack
dist/horizon-*.jar
dist/worker-pacing-benchmark.txt
dist/debounce-queue-benchmark.txt
dist/idle-queue-benchmark.txt
dist/object-header-benchmark.txt
dist/dispatch-integration.json
dist/tick-dedup-integration.json
dist/culling-empty-integration.json
dist/terrain-read-integration.json
dist/culling-rays-integration.json
dist/terrain-position-integration.json
dist/quad-reuse-integration.json
dist/quad-size-integration.json
dist/server-graphics-smoke.json
dist/client-graphics-smoke.json
dist/client-graphics-macos-title.json
dist/client-graphics-macos-world.json
retention-days: 3
+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
+11
View File
@@ -39,6 +39,17 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
had been written from another project's source; they come back only as a clean-room rewrite from a written
description of the pack format.
## Before asking for review
Generated
+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
+2 -2
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
serde_json = "1.0.151"
[lints]
+268
View File
@@ -0,0 +1,268 @@
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
//! when given a key, and for a mod a trial run in the sandbox: it must start,
//! survive ticks, frames, a chat line and each of its commands, and import
//! no more than its capabilities.
use horizon_mods::{
Clock, Mods, Output, Package, Player, Snapshot, Status,
index::{self, Facts, Kind},
manifest::component_imports,
};
use serde_json::{Value, json};
use std::path::Path;
/// How long the trial run plays: two seconds of game.
const TICKS: u64 = 40;
const FRAMES_PER_TICK: u64 = 3;
pub struct Report {
pub kind: Kind,
pub facts: Facts,
pub sha256: String,
pub size: u64,
pub signed: bool,
/// A mod's component imports.
pub imports: Vec<String>,
/// A mod's commands, each run once.
pub commands: Vec<String>,
/// What the mod showed during the trial run.
pub output: Vec<String>,
}
impl Report {
pub fn json(&self) -> Value {
let requires: Vec<Value> = self
.facts
.requires
.iter()
.map(|r| json!({"id": r.id, "version": r.version, "sha256": r.sha256}))
.collect();
json!({
"kind": self.kind.name(),
"id": self.facts.id,
"version": self.facts.version,
"api": self.facts.api,
"side": self.facts.side,
"capabilities": self.facts.capabilities,
"game": self.facts.game,
"requires": requires,
"sha256": self.sha256,
"size": self.size,
"signed": self.signed,
"imports": self.imports,
"commands": self.commands,
"output": self.output,
})
}
pub fn text(&self) -> String {
let f = &self.facts;
let mut out = format!(
"{} {} ({}",
f.id.as_deref().unwrap_or("resource pack"),
f.version.as_deref().unwrap_or(""),
self.kind.name()
);
if let Some(side) = f.side {
let side = serde_json::to_value(side).unwrap_or_default();
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
} else if !f.api.is_empty() {
out += &format!(", format {}", f.api);
}
if let Some(game) = &f.game {
out += &format!(", for {game}");
}
let signed = if self.signed {
"signature verified"
} else {
"unsigned"
};
out += &format!(
")\n {} bytes, SHA-256 {}, {signed}\n",
self.size, self.sha256
);
for c in &f.capabilities {
out += &format!(" may {}\n", c.describe());
}
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
if self.kind == Kind::Mod {
let commands = if self.commands.is_empty() {
String::new()
} else {
format!(", /{}", self.commands.join(", /"))
};
out += &format!(
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
TICKS * FRAMES_PER_TICK
);
}
out
}
}
/// The kind a file's extension says.
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" => Some(Kind::Mod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
_ => None,
}
}
/// Checks the file at `path` as `kind`; `key` and `signature` (the
/// `.minisig` text) check its signature.
pub fn check(
path: &Path,
kind: Kind,
key: Option<&str>,
signature: Option<&str>,
) -> Result<Report, String> {
let size = std::fs::metadata(path)
.map_err(|e| format!("{}: {e}", path.display()))?
.len();
if size > index::MAX_FILE {
return Err(format!("over {} MiB", index::MAX_FILE >> 20));
}
index::open(&kind)?;
let facts = index::inspect(&kind, path)?;
let signed = match (key, signature) {
(Some(key), Some(sig)) => {
index::verify_signature(key, sig, path)?;
true
}
(None, None) => false,
_ => return Err("--key and --signature go together".into()),
};
let mut report = Report {
kind,
facts,
sha256: index::sha256_file(path)?,
size,
signed,
imports: Vec::new(),
commands: Vec::new(),
output: Vec::new(),
};
if report.kind == Kind::Mod {
trial_run(path, &mut report)?;
}
Ok(report)
}
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
/// storage lands in a folder removed afterwards.
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
let package = Package::read(path)?;
let wasm = package.wasm.as_deref().ok_or("the .hmod has no mod.wasm")?;
report.imports = component_imports(wasm)?;
package
.manifest
.check_imports(report.imports.iter().map(String::as_str))?;
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let copy = dir.join("mod.hmod");
let result = std::fs::copy(path, &copy)
.map_err(|e| e.to_string())
.and_then(|_| play(&copy, report));
let _ = std::fs::remove_dir_all(&dir);
result
}
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
let failed = |mods: &Mods| match &mods.entries()[0].status {
Status::Failed(reason) => Some(reason.clone()),
_ => None,
};
if let Some(reason) = failed(&mods) {
return Err(format!("it didn't start: {reason}"));
}
report.commands = mods.commands().map(str::to_owned).collect();
for t in 0..TICKS {
mods.set_snapshot(Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
});
mods.tick(t);
for _ in 0..FRAMES_PER_TICK {
mods.frame(1.0 / 60.0);
}
}
mods.chat("<Steve> hello");
for name in report.commands.clone() {
mods.command(&name);
}
for out in mods.drain() {
report.output.push(match out {
Output::Chat { text, .. } => format!("chat: {text}"),
Output::Feedback { text, error } => {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
});
}
match failed(&mods) {
Some(reason) => Err(format!("it stopped: {reason}")),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-mods/tests/index")
.join(name)
}
#[test]
fn checks_each_kind_its_own_way() {
let pack = fixture("cozy-1.0.0.hpack");
let report = check(&pack, kind_of(&pack).unwrap(), None, None).unwrap();
assert_eq!(report.facts.requires[0].id, "coords_hud");
assert!(
report
.text()
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
);
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
let hmod = fixture("coords_hud-0.1.0.hmod");
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
let key = key.lines().last().unwrap();
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
.err()
.unwrap();
assert!(e.contains("didn't start"), "{e}");
let e = check(
&hmod,
Kind::Mod,
Some(key),
Some("untrusted comment: x\nRUQ=\n"),
)
.err()
.unwrap();
assert!(e.contains("signature"), "{e}");
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
assert!(e.contains("shader packs open"), "{e}");
assert!(check(&pack, Kind::Mod, None, None).is_err());
}
}
+40 -1
View File
@@ -4,10 +4,16 @@
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
//! the mod; run `build` again and the game reloads it.
//! - `check <file> [--json]`: what the registry checks of a submission
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
//! - `registry build|review|check|list`: the registry repository's CI.
use horizon_mods::{
Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
};
mod check;
mod registry;
use std::{
path::{Path, PathBuf},
process::{Command, ExitCode, Stdio},
@@ -28,7 +34,9 @@ fn main() -> ExitCode {
println!("Built {}", p.display());
}),
Some("run") => run(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -150,6 +158,37 @@ fn build(release: bool) -> Result<PathBuf, String> {
Ok(path)
}
/// `check <file>`: the registry's checks of a submission, printed; fails
/// when one does.
fn check(args: &[String]) -> Result<(), String> {
let file = args
.first()
.filter(|a| !a.starts_with("--"))
.ok_or("cargo horizon-mod check <file>")?;
let path = Path::new(file);
let kind = match flag(args, "--kind") {
Some(k) => horizon_mods::index::Kind::from(k.to_string()),
None => check::kind_of(path)
.ok_or("pass --kind mod|modpack|resourcepack: the extension doesn't say")?,
};
let signature = flag(args, "--signature")
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
.transpose()?;
let report = check::check(path, kind, flag(args, "--key"), signature.as_deref());
if args.iter().any(|a| a == "--json") {
let json = match &report {
Ok(r) => serde_json::json!({"ok": true, "report": r.json()}),
Err(e) => serde_json::json!({"ok": false, "error": e}),
};
println!("{json}");
}
let report = report?;
if !args.iter().any(|a| a == "--json") {
print!("{}", report.text());
}
Ok(())
}
fn run(args: &[String]) -> Result<(), String> {
let path = build(true)?;
let game = flag(args, "--game")
+159
View File
@@ -0,0 +1,159 @@
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! workflow downloads the files and publishes what this writes.
use horizon_mods::index::{
FILE_HOST,
registry::{self, Sources},
};
use std::path::Path;
/// Where the registry's site is published (its icons).
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
cargo horizon-mod registry list <checkout>";
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
super::flag(args, name)
}
fn positional(args: &[String]) -> Vec<&str> {
let mut out = Vec::new();
let mut skip = false;
for a in args {
if skip {
skip = false;
} else if a == "--json" {
} else if a.starts_with("--") {
skip = true;
} else {
out.push(a.as_str());
}
}
out
}
fn lines(errors: Vec<String>) -> String {
format!("{} problem(s):\n {}", errors.len(), errors.join("\n "))
}
pub fn main(args: &[String]) -> Result<(), String> {
let rest = args.get(1..).unwrap_or_default();
let pos = positional(rest);
match args.first().map(String::as_str) {
Some("build") => {
let [checkout] = pos[..] else {
return Err(USAGE.into());
};
let (files, out) = (flag(rest, "--files"), flag(rest, "--out"));
let (Some(files), Some(out)) = (files, out) else {
return Err(USAGE.into());
};
let generated = flag(rest, "--generated")
.map(str::to_owned)
.or_else(|| std::env::var("REGISTRY_GENERATED").ok())
.unwrap_or_default();
let index = registry::build(&Sources {
registry: Path::new(checkout),
files: Path::new(files),
release: flag(rest, "--release").unwrap_or(FILE_HOST),
site: flag(rest, "--site").unwrap_or(SITE),
generated,
})
.map_err(lines)?;
let text = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
std::fs::write(out, text + "\n").map_err(|e| format!("{out}: {e}"))?;
println!(
"Wrote {out}: {} project(s), {} taken down",
index.projects.len(),
index.revoked.len()
);
Ok(())
}
Some("review") => {
let [base, head] = pos[..] else {
return Err(USAGE.into());
};
let author = flag(rest, "--author").ok_or(USAGE)?;
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review(&base, &head, author);
if rest.iter().any(|a| a == "--json") {
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
println!("{json}");
} else {
for n in &review.notes {
println!("note: {n}");
}
for e in &review.errors {
println!("error: {e}");
}
}
if review.errors.is_empty() {
Ok(())
} else {
Err(format!(
"{} change(s) the registry refuses",
review.errors.len()
))
}
}
// `check` (and its trial run) on each file `head` lists and `base`
// doesn't: a pull request's new files.
Some("check") => {
let [base, head] = pos[..] else {
return Err(USAGE.into());
};
let files = Path::new(flag(rest, "--files").ok_or(USAGE)?);
let old: Vec<String> = match registry::load(Path::new(base)) {
Ok((entries, _)) => entries
.iter()
.flat_map(|e| e.versions.iter().map(|v| v.sha256.clone()))
.collect(),
// The first pull request has no entries to compare with.
Err(_) => Vec::new(),
};
let (entries, _) = registry::load(Path::new(head)).map_err(lines)?;
let mut failed = 0;
for e in &entries {
for v in e.versions.iter().filter(|v| !old.contains(&v.sha256)) {
println!("{} {} ({}):", e.id, v.version, v.file);
let report = crate::check::check(
&files.join(&v.sha256),
e.kind.clone(),
e.key.as_deref(),
v.signature.as_deref(),
);
match report {
Ok(r) => print!("{}", r.text()),
Err(err) => {
failed += 1;
println!(" error: {err}");
}
}
}
}
if failed == 0 {
Ok(())
} else {
Err(format!("{failed} file(s) failed their check"))
}
}
// The files CI must download for `build`: `<sha256> <id> <version> <file>`.
Some("list") => {
let [checkout] = pos[..] else {
return Err(USAGE.into());
};
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
for e in &entries {
for v in &e.versions {
println!("{} {} {} {}", v.sha256, e.id, v.version, v.file);
}
}
Ok(())
}
_ => Err(USAGE.into()),
}
}
+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,
@@ -85,6 +85,7 @@ mod sprint_probe;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
mod text_display_fixture;
mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trading_fixture;
@@ -170,6 +171,7 @@ probes! {
team_rules_fixture,
toggle_keys_probe,
tooltip_display_fixture,
text_display_fixture,
trading_fixture,
translucency_fixture,
weather_fixture,
@@ -5,7 +5,7 @@
//!
//! usage: pack_screen_fixture RESOURCES CASES OUTPUT
use horizon_client::{
hud::HudAtlas,
hud::{HudAtlas, PACK_ICON_SIZE},
lang,
pack_screen::{Compat, PackInfo, PackScreen, Source},
packs::{self, Position},
@@ -13,7 +13,7 @@ use horizon_client::{
text::Text,
};
use serde_json::{Value, json};
use std::{fmt::Write as _, fs, path::PathBuf};
use std::{fmt::Write as _, fs, path::PathBuf, sync::Arc};
/// Styled runs: [text, rgb or null, bold, italic, underlined, strikethrough, obfuscated].
pub fn runs(t: &Text) -> Value {
@@ -105,7 +105,9 @@ pub fn main() -> horizon_client::Result<()> {
Some("unknown") => Compat::Unknown,
_ => Compat::Compatible,
},
icon: p["icon"].as_bool().unwrap_or(false),
icon: p["icon"].as_bool().unwrap_or(false).then(|| {
Arc::from(vec![0; (PACK_ICON_SIZE * PACK_ICON_SIZE * 4) as usize])
}),
required: p["required"].as_bool().unwrap_or(false),
position: if p["position"] == "top" {
Position::Top
@@ -83,7 +83,7 @@ pub fn main() -> horizon_client::Result<()> {
let mut extra = String::new();
match kind {
"give" => count = book.add_recipes(&index, &list()).0,
"take" => count = book.remove_recipes(&index, &list()),
"take" => count = book.remove_recipes(&index, &list()).len(),
"seen" => book.remove_highlight(op["id"].as_str().unwrap_or_default()),
"set" => {
let i = match op["type"].as_str().unwrap_or_default() {
@@ -136,7 +136,6 @@ fn scene(
blend_radius,
smooth_lighting,
cutout_leaves,
cull_leaves: false,
};
let section_y = int(&row["section_y"])?;
let started = Instant::now();
@@ -0,0 +1,37 @@
use horizon_client::text_display::TextDisplayData;
use horizon_protocol::{ids, packets::play::Clientbound};
use std::{error::Error, io::Write};
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let mut out = std::fs::File::create(&args[2])?;
for line in std::fs::read_to_string(&args[1])?.lines() {
let input: serde_json::Value = serde_json::from_str(line)?;
let hex = input["wire"].as_str().unwrap();
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
.collect::<Result<_, _>>()?;
let Clientbound::SetEntityData(packet) =
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
else {
unreachable!()
};
let mut display = TextDisplayData::default();
display.apply(&packet.values);
if let Some(angles) = input["angles"].as_array() {
let a: Vec<_> = angles.iter().map(|v| v.as_f64().unwrap() as f32).collect();
writeln!(
out,
"{}",
serde_json::to_string(&display.matrix([a[0], a[1]], [a[2], a[3]]).to_cols_array())?
)?;
continue;
}
let mut state = serde_json::to_value(&display)?;
state["text"] = display.text.plain().into();
state["alignment"] = format!("{:?}", display.align()).to_uppercase().into();
writeln!(out, "{}", state)?;
}
Ok(())
}
+12
View File
@@ -161,6 +161,9 @@ pub struct Audio {
/// How many times each positional sound played (the control socket's `status`),
/// with or without an audio device.
pub heard: std::collections::BTreeMap<String, u32>,
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
/// any), with or without an audio device, as `heard`.
pub stopped: std::collections::BTreeMap<String, u32>,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -595,6 +598,7 @@ impl Audio {
source_override: None,
capture: None,
heard: Default::default(),
stopped: Default::default(),
}
}
#[cfg(not(feature = "audio"))]
@@ -825,6 +829,14 @@ impl Audio {
/// and/or a source.
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
let event = event.map(crate::sound_events::normalize);
let selection = format!(
"{} {}",
source
.and_then(|s| crate::settings::SOUND_SOURCES.get(s))
.map_or("*", |s| s),
event.as_deref().unwrap_or("*")
);
*self.stopped.entry(selection).or_default() += 1;
self.engine
.stop_matching(event.as_deref(), source, &mut self.backend);
}
+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);
}
}
}
+54 -32
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;
@@ -152,6 +154,7 @@ mod storage;
mod structures;
mod subtitle_hud;
mod sulfur_cubes;
mod text_displays;
mod titles;
mod tnt;
mod totem;
@@ -1126,6 +1129,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,
@@ -1418,6 +1423,8 @@ struct Game<'a> {
dragon_model: Option<horizon_client::mobs::Part>,
/// Map textures waiting for the next HUD batch to upload them.
map_patches: Vec<horizon_client::font::Patch>,
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
text_display_vertices: usize,
/// Player skin downloads, and which skin each atlas skin slot holds
/// (least recently shown first).
skin_downloads: RefCell<horizon_client::skins::Downloader>,
@@ -4042,6 +4049,20 @@ impl Game<'_> {
self.capture_loaded_world();
Ok(())
}
/// Landing rules (farmland trampling, turtle eggs) wrote or removed the block
/// raw: finish `Level.setBlock`'s neighbour updates, so a crop on trampled
/// farmland breaks. The local player's and every server player's landings.
fn landing_block_updates(&mut self, event: &horizon_client::landing::FallEvent) {
use horizon_client::landing::FallEvent;
let (position, flags) = match *event {
FallEvent::Write {
position, flags, ..
} => (position, flags as i32),
FallEvent::Destroy { position, .. } => (position, level_flags::UPDATE_ALL),
_ => return,
};
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
}
fn tick(&mut self) -> Result<()> {
self.tick_debug_keys()?;
self.tick_debug_charts();
@@ -4256,23 +4277,7 @@ impl Game<'_> {
};
self.audio.play(name, Some(feet), volume, pitch);
}
MotionEvent::Fall(horizon_client::landing::FallEvent::Write {
position,
flags,
..
}) => {
// Landing rules (farmland trampling, turtle eggs) wrote the block raw.
self.with_level(|b, level, ticks| {
b.notify_written(level, ticks, position, flags as i32)
});
}
MotionEvent::Fall(horizon_client::landing::FallEvent::Destroy {
position, ..
}) => {
self.with_level(|b, level, ticks| {
b.notify_written(level, ticks, position, level_flags::UPDATE_ALL)
});
}
MotionEvent::Fall(event) => self.landing_block_updates(&event),
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
@@ -4410,7 +4415,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 +4776,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);
@@ -6761,6 +6773,7 @@ impl Game<'_> {
let mut see_through = std::mem::take(&mut self.see_through_vertices);
see_through.clear();
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
self.see_through_vertices = see_through;
// Entity shadows (EntityRenderer.extractShadow).
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
@@ -9080,21 +9093,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 +10998,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 +11204,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 +11590,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 +11685,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(),
@@ -11866,6 +11886,8 @@ impl<'a> Game<'a> {
breath_clouds: Vec::new(),
dragon_model: prepared.dragon_model,
map_patches: Vec::new(),
text_displays: Default::default(),
text_display_vertices: 0,
skin_downloads: RefCell::new(horizon_client::skins::Downloader::new(
std::env::current_dir()
.unwrap_or_default()
@@ -13254,7 +13276,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());
+131 -29
View File
@@ -881,30 +881,40 @@ pub(super) fn adv_name(a: &adv::Advancement) -> Text {
let Some(d) = &a.display else {
return Text::literal(a.id.clone());
};
let color = match d.frame {
Frame::Challenge => text::DARK_PURPLE,
_ => text::GREEN,
};
// Advancement.decorateName: the title in the frame's colour (styles
// merged into it), a new line and the description.
let coloured = text_of(&d.title).with_color(color);
let mut component = coloured.component();
if let Some(o) = component.as_object_mut() {
o.insert(
"extra".into(),
serde_json::json!(["\n", text_of(&d.description).component()]),
);
}
let tooltip = coloured
.append("\n")
.append(text_of(&d.description))
.with_component(component);
let color = frame_color(d.frame);
let title = text_of(&d.title).with_style(&Style {
hover: Some(Box::new(tooltip)),
hover: Some(Box::new(decorate_name(d))),
..Style::default()
});
Text::translate("chat.square_brackets", &[title]).with_color(color)
}
fn frame_color(frame: Frame) -> u32 {
match frame {
Frame::Challenge => text::DARK_PURPLE,
_ => text::GREEN,
}
}
/// `Advancement.decorateName`: a copy of the title with the frame's colour
/// under its own style (mergeStyles), then a new line and the description
/// after the title's own siblings, so both inherit the title's root style.
/// Built on the title's JSON (a list as its first element with the rest as
/// siblings): a parsed Text moves a styled root's style down a level once it
/// has siblings.
fn decorate_name(d: &adv::Display) -> Text {
let mut component = match horizon_client::scoreboard::canonical_component(&d.title) {
Value::String(s) => serde_json::json!({ "text": s }),
c => c,
};
if let Some(o) = component.as_object_mut() {
let frame = Text::empty().with_color(frame_color(d.frame)).component();
o.entry("color").or_insert_with(|| frame["color"].clone());
let extra = o.entry("extra").or_insert_with(|| serde_json::json!([]));
if let Some(extra) = extra.as_array_mut() {
extra.extend(["\n".into(), d.description.clone()]);
}
}
text_of(&component).with_component(component)
}
impl Game<'_> {
/// `AdvancementCommands.Action.GRANT` on one advancement (or one of
/// its criteria): false when nothing changed.
@@ -1051,17 +1061,10 @@ impl Game<'_> {
}
}
}
/// A text component from advancement JSON (`translate` or `text`).
/// An advancement's title or description: a whole component (ComponentSerialization.CODEC),
/// with its siblings and styles, as a plugin's `{"text":"","extra":[...]}` needs.
pub(super) fn text_of(v: &Value) -> Text {
if let Some(key) = v["translate"].as_str() {
Text::translate(key, &[])
} else if let Some(s) = v["text"].as_str() {
Text::literal(s)
} else if let Some(s) = v.as_str() {
Text::literal(s)
} else {
Text::empty()
}
horizon_client::scoreboard::comp_text(v)
}
#[cfg(test)]
@@ -1095,6 +1098,105 @@ mod tests {
(seen, None)
}
#[test]
fn titles_keep_their_siblings_and_styles() {
let title = text_of(&serde_json::json!({
"text": "",
"extra": [{"text": "Quest ", "color": "gold"}, "Board"],
}));
assert_eq!(title.plain(), "Quest Board");
assert_eq!(title.spans[0].style.color, Some(text::GOLD));
assert_eq!(title.spans[1].style.color, None);
let vanilla = text_of(&serde_json::json!({"translate": "advancements.story.root.title"}));
assert_eq!(
vanilla,
Text::translate("advancements.story.root.title", &[])
);
assert_eq!(
vanilla.translation_key(),
Some("advancements.story.root.title")
);
}
fn announced(title: Value, description: Value) -> adv::Advancement {
adv::Advancement {
id: "quests:board".into(),
parent: None,
display: Some(adv::Display {
icon: "minecraft:book".into(),
title,
description,
frame: Frame::Task,
background: None,
show_toast: true,
announce: true,
hidden: false,
}),
criteria: Vec::new(),
requirements: Vec::new(),
rewards: adv::Rewards::default(),
}
}
/// The chat hover: the JSON a client receives, and the spans drawn here.
fn hover(a: &adv::Advancement) -> (Value, Text) {
let local = decorate_name(a.display.as_ref().unwrap());
let sent = adv_name(a).component()["with"][0]["hover_event"]["value"].clone();
(sent, local)
}
#[test]
fn chat_hover_keeps_the_titles_siblings_before_the_description() {
let a = announced(
serde_json::json!({"text": "", "extra": [{"text": "Quest ", "color": "gold"}, "Board"]}),
serde_json::json!("Open the board"),
);
let (sent, local) = hover(&a);
let sent = horizon_client::scoreboard::comp_text(&sent);
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
let colors = |t: &Text| t.spans.iter().map(|s| s.style.color).collect::<Vec<_>>();
let (gold, green) = (Some(text::GOLD), Some(text::GREEN));
assert_eq!(colors(&sent), [gold, green, green, green]);
// The spans drawn here come from the same JSON a client receives.
assert_eq!(colors(&local), colors(&sent));
}
#[test]
fn chat_hover_description_takes_a_styled_titles_own_style() {
// mergeStyles keeps the title's gold; the description inherits it.
let object = announced(
serde_json::json!({"text": "Quest", "color": "gold", "bold": true, "extra": [" Board"]}),
serde_json::json!("Open the board"),
);
let list = announced(
serde_json::json!([{"text": "Quest", "color": "gold", "bold": true}, " Board"]),
serde_json::json!("Open the board"),
);
for a in [object, list] {
let (sent, local) = hover(&a);
let sent = horizon_client::scoreboard::comp_text(&sent);
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
let last = &sent.spans.last().unwrap().style;
assert_eq!((last.color, last.bold), (Some(text::GOLD), Some(true)));
assert_eq!(local.spans.last().unwrap().style, *last);
}
}
#[test]
fn chat_hover_of_a_vanilla_advancement() {
let a = announced(
serde_json::json!({"translate": "advancements.story.root.title"}),
serde_json::json!({"translate": "advancements.story.root.description"}),
);
assert_eq!(
hover(&a).0,
serde_json::json!({
"translate": "advancements.story.root.title",
"color": "green",
"extra": ["\n", {"translate": "advancements.story.root.description"}],
})
);
}
#[test]
fn toasts_slide_in_squared_and_out_after_five_seconds() {
let mut toast = recipes(1);
+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") {
+6 -1
View File
@@ -1082,6 +1082,8 @@ impl Game<'_> {
self.for_player(t, |g| g.teleport_to(Target::Player, pos, rotation));
}
Target::Player => {
// ServerPlayer.teleport: removeVehicle before placing the player.
self.stop_riding();
if let Some(p) = self.player.as_mut() {
p.position = v;
p.velocity = Vec3::new(0.0, 0.0, 0.0);
@@ -1783,8 +1785,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);
@@ -59,7 +59,15 @@ impl Game<'_> {
self.recipe_unlocked(&unlocked);
count as i32
} else {
self.level.recipe_book.remove_recipes(index, &recipes) as i32
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
let count = removed.len() as i32;
// A network player's client drops them from its book.
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
count
};
tracker.track(&h, n);
}
@@ -345,7 +353,26 @@ impl Game<'_> {
Some(Arg::Resource(s)) => Some(s.clone()),
_ => None,
};
self.audio.stop_sounds(sound.as_deref(), source);
// StopSoundCommand: each target's connection gets the packet; the
// window's own player stops its sounds.
let packet = C::StopSound(play::StopSound {
source: source.map(|s| s as i32),
sound: sound.clone(),
});
for &t in &targets {
match t {
Target::Player if self.lan_acting.is_some() => {
self.guest_packets.push(packet.clone());
}
Target::Player => self.audio.stop_sounds(sound.as_deref(), source),
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
}
}
_ => {}
}
}
let key = match (source, &sound) {
(Some(_), Some(_)) => "commands.stopsound.success.source.sound",
(Some(_), None) => "commands.stopsound.success.source.any",
+11
View File
@@ -226,6 +226,16 @@ impl Game<'_> {
}
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
}
"section-sides" => {
let r = self.renderer.as_mut().ok_or("no renderer")?;
match words.get(1).copied() {
None => {}
Some("on") => r.set_section_sides(true),
Some("off") => r.set_section_sides(false),
Some(other) => return Err(format!("unknown section sides mode {other}").into()),
}
Ok(Some(json!({ "ok": true, "sides": r.section_sides() })))
}
"sculk" => Ok(Some(self.sculk_command(&words)?)),
"status" => Ok(Some(self.control_status())),
"pad" => Ok(Some(self.pad_command(&words)?)),
@@ -981,6 +991,7 @@ impl Game<'_> {
status["destroying"] = json!(destroying);
status["physical_keys"] = json!(physical_keys);
status["heard"] = json!(self.audio.heard);
status["stopped_sounds"] = json!(self.audio.stopped);
// Boats, minecarts, armor stands, item frames and paintings.
status["objects"] = json!(self.objects_status());
status["lightning"] = json!({"bolts": self.bolts.len(), "struck": self.lightning_seen[0],
+3 -1
View File
@@ -978,7 +978,9 @@ impl Game<'_> {
m.ai.target = Some(target);
}
}
MobEvent::Projectile(p) => {
MobEvent::Projectile(mut p) => {
self.next_entity += 1;
p.id = self.next_entity;
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
self.creatures.projectiles.push(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
+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);
+23 -13
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;
@@ -1926,7 +1939,7 @@ impl Game<'_> {
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sneaking = g.sneaking;
let sounds = self.as_guest(host, conn, |game| {
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
let Some(player) = game.player.as_mut() else {
@@ -2023,20 +2036,17 @@ impl Game<'_> {
// A hurt since the last tick marked the velocity (Entity.markHurt):
// ServerEntity.sendChanges sends it as it is after this travel.
let marked = std::mem::take(&mut player.health.sync_velocity);
let sounds = events
.into_iter()
.filter_map(|e| match e {
MotionEvent::HurtSound(sound) => Some(sound),
_ => None,
})
.collect::<Vec<_>>();
(sounds, marked.then_some(player.velocity))
(events, marked.then_some(player.velocity))
});
let Some((sounds, marked)) = sounds else {
let Some((events, marked)) = events else {
return;
};
for sound in sounds {
self.player_hurt_sound(sound, at);
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
_ => {}
}
}
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
g.send(&C::SetEntityMotion(play::SetEntityMotion {
@@ -48,10 +48,9 @@ impl Game<'_> {
if let Some(id) = state.mob
&& state.session.villager
{
// startTrading with the guest's reputation (their effects are
// not modelled on the host: no Hero of the Village).
// startTrading with the guest's reputation. The session already
// captured their Hero effect while they acted as the player.
state.session.reputation = self.villager_reputation_of(id, uuid);
state.session.hero = None;
state.session.update_special_prices();
}
let title = self.merchant_display_name(&state);
+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| {
+203 -39
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) => {
@@ -596,6 +612,21 @@ impl Game<'_> {
continue;
};
let value = match c {
// Read past, not decoded: the item keeps its prototype's value.
Component::Raw(_) => continue,
// Trim materials and patterns are synced registries, as enchantments are.
Component::Trim { material, pattern } => {
let (Some(material), Some(pattern)) = (
r.registries.name("minecraft:trim_material", *material),
r.registries.name("minecraft:trim_pattern", *pattern),
) else {
continue;
};
Tag::compound([
("material", Tag::String(material.into())),
("pattern", Tag::String(pattern.into())),
])
}
Component::Int(v) if *kind == component::RARITY => {
let rarity = ["common", "uncommon", "rare", "epic"];
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
@@ -827,6 +858,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 +1402,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 +1481,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,11 +1509,9 @@ 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.move_remote_text_display(id, position, rotation);
self.turn_remote_projectile(id, rotation);
}
fn remove_entity(&mut self, id: i32) {
@@ -1569,6 +1617,12 @@ impl Game<'_> {
}
}
}
/// Find a game slot in the menu's complete, contiguous slot mapping.
fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
(0..)
.map_while(|slot| Self::menu_slot(menu, slot))
.position(|slot| slot == internal)
}
/// The workstation screen of a menu type a server opens.
pub(super) fn remote_station_kind(menu: &str) -> Option<super::stations::Kind> {
use super::stations::Kind;
@@ -1773,6 +1827,9 @@ impl Game<'_> {
self.audio.play(&name, Some(at), p.volume, p.pitch);
}
}
C::StopSound(p) => self
.audio
.stop_sounds(p.sound.as_deref(), p.source.map(|s| s as usize)),
C::AddEntity(p) => {
let kind = self
.remote
@@ -1794,6 +1851,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,
@@ -1804,6 +1863,7 @@ impl Game<'_> {
p.data,
);
}
self.move_remote_text_display(p.id, p.position, Some((rotation[0], rotation[1])));
}
C::RemoveEntities(p) => {
for id in p.ids {
@@ -2112,17 +2172,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 +2260,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 +2997,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 +3010,7 @@ impl Game<'_> {
stack,
position: at,
previous: at,
velocity: Vec3::ZERO,
velocity,
on_ground: false,
age: 0,
pickup_delay: 0,
@@ -2939,6 +3018,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;
@@ -2994,6 +3090,8 @@ impl Game<'_> {
}
None => {
let mut player = PlayerMotion::new(self.motion, position)?;
// Player.tick derives noPhysics from the current game mode.
player.spectator = self.spectator();
player.velocity = delta;
self.player = Some(player);
self.pad.player_replaced();
@@ -3136,6 +3234,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 +3264,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 +3308,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 +3348,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 +3410,54 @@ 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) {
if running {
self.tick_text_displays();
}
// EntityFluidInteraction.update for the entities this window only displays: the
// server never sends the fluid contact, and the renderers read it (cod, salmon and
// tropical fish lie on their side without `in_water`, #1591).
// 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 +3560,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 {
@@ -4126,11 +4264,13 @@ impl Game<'_> {
return;
};
let stack = self.stack_in(slot).cloned();
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
// A drawn bow, a charging crossbow, a raised shield...: held until
// the button lets go (or another slot is picked).
// the button lets go.
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
self.using = (slot == self.selected || slot == super::shield::OFFHAND_SLOT)
.then_some((slot, ticks + 1));
self.using = in_hand.then_some((slot, ticks + 1));
return;
}
let consumable = stack
@@ -4144,7 +4284,7 @@ impl Game<'_> {
// Button release belongs to the next handleKeybinds phase, which
// sends RELEASE_USE_ITEM. Do not silently clear a use begun by a
// quick press/release between ticks.
if slot != self.selected {
if !in_hand {
self.using = None;
return;
}
@@ -4465,12 +4605,10 @@ impl Game<'_> {
for &(slot, button, input) in ops {
let slot = match slot {
None => -999,
Some(internal) => {
match (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal)) {
Some(s) => s as i16,
None => return false,
}
}
Some(internal) => match Self::menu_slot_index(&menu, internal) {
Some(s) => s as i16,
None => return false,
},
};
clicks.push((slot, button, input));
}
@@ -4539,7 +4677,7 @@ impl Game<'_> {
/// such slot.
pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool {
let (container, menu) = self.remote_menu();
let slot = (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal));
let slot = Self::menu_slot_index(&menu, internal);
let Some(slot) = slot else { return false };
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
@@ -4974,6 +5112,8 @@ impl Game<'_> {
.collect::<Vec<_>>(),
// The player's own model exists (third person and the inventory draw it).
"player_model": self.player_model.is_some(),
"text_display_vertices": self.text_display_vertices,
"text_displays": self.text_displays.len(),
"health": r.health.0,
"food": r.health.1,
"decode_errors": r.decode_errors,
@@ -5398,4 +5538,28 @@ mod tests {
}
assert_eq!(Game::menu_slot("inventory", 46), None);
}
#[test]
fn remote_clicks_find_slots_past_the_old_large_chest_cutoff() {
for (menu, internal, index) in [
("minecraft:generic_9x6", 18, 63),
("minecraft:generic_9x6", 19, 64),
("minecraft:generic_9x6", 0, 81),
("minecraft:generic_9x6", 8, 89),
("minecraft:generic_9x6", 353, 53),
("minecraft:generic_9x5", 8, 80),
("minecraft:generic_9x4", 8, 71),
("minecraft:generic_9x3", 8, 62),
("inventory", 504, 45),
] {
assert_eq!(
Game::menu_slot_index(menu, internal),
Some(index),
"{menu}, game slot {internal}"
);
}
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 354), None);
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 504), None);
assert_eq!(Game::menu_slot_index("unknown", 0), None);
}
}
@@ -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));
}
}
@@ -24,7 +24,12 @@ impl Game<'_> {
let at = Vec3::new(position[0], position[1], position[2]);
let block = position.map(|v| v.floor() as i32);
self.remove_remote_object(id);
if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
if kind == "minecraft:text_display" {
self.text_displays.insert(
id as i32,
super::text_displays::RemoteTextDisplay::new(position, yaw),
);
} else if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
let mut c = super::minecarts::Minecart::new(id, cart, at);
c.yaw = yaw;
c.previous_yaw = yaw;
@@ -93,6 +98,10 @@ impl Game<'_> {
/// `handleSetEntityData` for an object: what changes how it looks.
pub(super) fn remote_object_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
if let Some(display) = self.text_displays.get_mut(&id) {
display.metadata(values);
return;
}
let id = id as u32;
for (index, _, value) in values {
if self.item_frames.iter().any(|f| f.id == id) {
@@ -273,6 +282,7 @@ impl Game<'_> {
/// Every object a server sent (a new connection, level or dimension).
pub(super) fn clear_remote_objects(&mut self) {
self.text_displays.clear();
self.carts.clear();
self.boats.clear();
self.armor_stands.clear();
@@ -281,6 +291,7 @@ impl Game<'_> {
}
pub(super) fn remove_remote_object(&mut self, id: u32) {
self.text_displays.remove(&(id as i32));
self.carts.retain(|c| c.id != id);
self.boats.retain(|b| b.id != id);
self.armor_stands.retain(|a| a.id != id);
@@ -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;
@@ -24,6 +24,14 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
"minecraft:llama_spit" => ProjectileKind::LlamaSpit,
"minecraft:wind_charge" => ProjectileKind::WindCharge,
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
// Homing state stays on the server; the client interpolates its positions.
"minecraft:shulker_bullet" => ProjectileKind::ShulkerBullet {
target: horizon_client::mobs::Who::Player,
dir: None,
steps: 0,
delta: [0.0; 3],
seed: 0,
},
"minecraft:wither_skull" => ProjectileKind::WitherSkull { dangerous: false },
_ => return None,
})
@@ -185,11 +193,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;
@@ -13,6 +13,7 @@ pub const HELP: &[&str] = &[
"signedit X Y Z [front|back] open the sign editor for the sign at X Y Z",
"section-draws [flat|indirect] how terrain sections are drawn (A/B checks); replies the mode",
"section-cull [on|off] occlusion culling of solid sections (A/B checks); replies the mode",
"section-sides [on|off] leave out solid quads facing away from the eye (A/B checks); replies the mode",
];
impl Game<'_> {
@@ -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,
@@ -0,0 +1,302 @@
//! Server text displays: metadata, interpolation, billboard transforms and world text.
use super::Game;
use horizon_client::{
hud::{HudBatch, rgba},
mesher::Vertex,
text::Text,
text_display::{Align, TextDisplayData},
};
use horizon_protocol::entity_data::Value;
pub(super) struct RemoteTextDisplay {
pub data: TextDisplayData,
from: TextDisplayData,
shown: TextDisplayData,
position: glam::DVec3,
previous: glam::DVec3,
target: glam::DVec3,
rotation: [f32; 2],
old_rotation: [f32; 2],
target_rotation: [f32; 2],
steps: i32,
age: i64,
start: i64,
duration: i32,
dirty: bool,
start_dirty: bool,
duration_dirty: bool,
initialized: bool,
/// `Display.lastProgress`: the progress the last frame drew, where a new
/// interpolation starts from.
last_progress: f32,
/// `TextDisplay.clientDisplayCache`: the split lines, until the render
/// state changes.
lines: Option<Lines>,
}
/// `TextDisplay.CachedInfo`: each line and its width, and the widest.
struct Lines {
lines: Vec<(Text, f32)>,
width: f32,
}
/// `Display.RENDER_STATE_IDS` and `TextDisplay.TEXT_RENDER_STATE_IDS`: the
/// data a render state is built from (not the view range, size or glow).
fn renders(id: u8) -> bool {
matches!(id, 11..=16 | 18 | 19 | 23..=27)
}
impl RemoteTextDisplay {
pub fn new(position: [f64; 3], yaw: f32) -> Self {
Self {
data: Default::default(),
from: Default::default(),
shown: Default::default(),
position: position.into(),
previous: position.into(),
target: position.into(),
rotation: [yaw, 0.0],
old_rotation: [yaw, 0.0],
target_rotation: [yaw, 0.0],
steps: 0,
age: 0,
start: 0,
duration: 0,
dirty: true,
start_dirty: false,
duration_dirty: false,
initialized: false,
last_progress: 0.0,
lines: None,
}
}
fn progress(&self, partial: f32) -> f32 {
if self.duration <= 0 {
1.0
} else {
(((self.age - self.start) as f32 + partial) / self.duration as f32).clamp(0.0, 1.0)
}
}
pub fn metadata(&mut self, values: &[(u8, i32, Value)]) {
self.start_dirty |= values.iter().any(|v| v.0 == 8);
self.duration_dirty |= values.iter().any(|v| v.0 == 9);
self.dirty |= values.iter().any(|v| renders(v.0));
self.data.apply(values);
}
fn tick(&mut self) {
self.age += 1;
self.previous = self.position;
self.old_rotation = self.rotation;
if self.steps > 0 {
self.position += (self.target - self.position) / f64::from(self.steps);
for i in 0..2 {
self.rotation[i] +=
angle(self.target_rotation[i] - self.rotation[i]) / self.steps as f32;
}
self.steps -= 1;
}
if self.start_dirty {
self.start = self.age + i64::from(self.data.interpolation_delay);
}
if self.duration_dirty {
self.duration = self.data.interpolation_duration;
}
if self.dirty {
self.from = if self.initialized && self.duration > 0 {
self.shown.blend(&self.from, self.last_progress)
} else {
self.data.clone()
};
self.shown = self.data.clone();
self.initialized = true;
self.lines = None;
}
self.dirty = false;
self.start_dirty = false;
self.duration_dirty = false;
}
fn move_to(&mut self, position: [f64; 3], rotation: Option<(f32, f32)>) {
self.target = position.into();
if let Some((yaw, pitch)) = rotation {
self.target_rotation = [yaw, pitch];
}
self.steps = self.data.teleport_duration.clamp(0, 59);
if self.steps == 0 {
self.position = self.target;
self.previous = self.target;
self.rotation = self.target_rotation;
self.old_rotation = self.rotation;
}
}
}
fn angle(d: f32) -> f32 {
(d + 180.0).rem_euclid(360.0) - 180.0
}
impl Game<'_> {
pub(super) fn move_remote_text_display(
&mut self,
id: i32,
position: [f64; 3],
rotation: Option<(f32, f32)>,
) {
if let Some(d) = self.text_displays.get_mut(&id) {
d.move_to(position, rotation);
}
}
pub(super) fn tick_text_displays(&mut self) {
for display in self.text_displays.values_mut() {
display.tick();
}
}
pub(super) fn text_display_geometry(
&mut self,
camera: glam::DVec3,
partial: f64,
normal: &mut Vec<Vertex>,
see_through: &mut Vec<Vertex>,
) {
self.text_display_vertices = 0;
let atlas = &self.gui.atlas;
let front = self.camera_type == super::perspective::CameraType::ThirdPersonFront;
let angles = if front {
[self.yaw + 180.0, -self.pitch]
} else {
[self.yaw, self.pitch]
};
for display in self.text_displays.values_mut() {
display.last_progress = display.progress(partial as f32);
let d = display.shown.blend(&display.from, display.last_progress);
let position = display.previous.lerp(display.position, partial);
let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance);
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
continue;
}
let rotation = std::array::from_fn(|i| {
display.old_rotation[i]
+ angle(display.rotation[i] - display.old_rotation[i]) * partial as f32
});
let mut matrix = glam::Mat4::from_translation((position - camera).as_vec3())
* d.matrix(rotation, angles)
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
* glam::Mat4::from_scale(glam::Vec3::splat(-0.025));
if !matrix.is_finite() {
continue;
}
// TextDisplayRenderer.splitLines: Font.split, no indent on wrapped lines.
let Lines { lines, width } = display.lines.get_or_insert_with(|| {
let lines: Vec<_> = atlas
.font
.split(&d.text, d.line_width as f32)
.into_iter()
.map(|line| {
let width = atlas.font.width_text(&line).ceil();
(line, width)
})
.collect();
let width = lines.iter().map(|l| l.1).fold(0.0, f32::max);
Lines { lines, width }
});
let width = *width;
let height = lines.len() as f32 * 10.0 - 1.0;
matrix *=
glam::Mat4::from_translation(glam::Vec3::new(1.0 - width / 2.0, -height, 0.0));
let mut batch = HudBatch::new([1, 1]);
let bg = if d.flags & 4 != 0 {
self.settings.text_background(0.25)
} else {
d.background as u32
};
if bg != 0 {
batch.fill(
atlas,
-1.0,
-1.0,
width + 1.0,
height + 1.0,
rgba(
(bg >> 16) as u8,
(bg >> 8) as u8,
bg as u8,
(bg >> 24) as u8,
),
);
}
let align = d.align();
for (i, (line, line_width)) in lines.iter().enumerate() {
let x = match align {
Align::Left => 0.0,
Align::Right => width - line_width,
Align::Center => (width - line_width) / 2.0,
};
batch.text_styled(
atlas,
line,
x,
i as f32 * 10.0,
rgba(255, 255, 255, d.opacity as u8),
d.flags & 1 != 0,
);
}
let world_light = self.world.light(
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
);
let light = if d.brightness == -1 {
horizon_client::block_entity_render::vertex_light(world_light)
} else {
((d.brightness as u32 >> 20 & 15) * 16)
| (((d.brightness as u32 >> 4 & 15) * 16) << 8)
};
let through = d.flags & 2 != 0;
let out = if through {
&mut *see_through
} else {
&mut *normal
};
for quad in batch.vertices.as_chunks::<6>().0 {
for i in [0, 1, 2, 5] {
let v = quad[i];
let point =
matrix.transform_point3(glam::Vec3::new(v.pos[0], v.pos[1], v.depth));
out.push(Vertex {
pos: point.to_array(),
uv: v.uv,
color: v.color,
light,
layer: if through { 2 } else { 0 },
normal: [0; 4],
});
}
}
self.text_display_vertices += batch.vertices.len() / 6 * 4;
self.map_patches.extend(batch.patches);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_and_position_interpolation_keep_their_separate_clocks() {
let mut d = RemoteTextDisplay::new([0.0; 3], 0.0);
d.tick();
d.metadata(&[
(8, 1, Value::Int(0)),
(9, 1, Value::Int(4)),
(10, 1, Value::Int(2)),
(11, 0, Value::Floats(vec![4.0, 0.0, 0.0])),
]);
d.tick();
assert_eq!(d.shown.blend(&d.from, d.progress(0.5)).translation[0], 0.5);
d.move_to([2.0, 0.0, 0.0], Some((90.0, 0.0)));
d.tick();
assert_eq!(d.position.x, 1.0);
assert_eq!(d.rotation[0], 45.0);
d.tick();
assert_eq!(d.position.x, 2.0);
assert_eq!(d.shown.blend(&d.from, d.progress(0.0)).translation[0], 2.0);
}
}
@@ -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;
+82 -20
View File
@@ -6,7 +6,7 @@ use crate::{
environment::Sky,
graphics::GraphicsApi,
hud::{HudBatch, HudRenderer},
mesher::{SectionMesh, TerrainVertex, Vertex},
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
sprites::SpriteAnimation,
};
use glam::{DVec3, Mat4, Vec3, Vec4};
@@ -156,9 +156,9 @@ struct SectionGpu {
page: u32,
start: u64,
solid: u32,
/// Where the solid quads' buckets by facing side end (`mesher::Sides`).
sides: Sides,
translucent: u32,
/// The solid quads by facing: back-facing groups are skipped.
faces: crate::mesher::FaceGroups,
sort: translucency::SortState,
sorted_indices: Option<(u32, u64)>,
/// Its id for flat draws (`flat::Flat::add`), when they are on.
@@ -325,6 +325,8 @@ pub struct Renderer {
/// counts reach a multi-draw's entries (Vulkan, Metal).
cull: Option<cull::Cull>,
cull_on: bool,
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
sides_on: bool,
sky: wgpu::RenderPipeline,
dark_disc: wgpu::RenderPipeline,
lines: wgpu::RenderPipeline,
@@ -2157,7 +2159,7 @@ impl Renderer {
cull::Cull::new(&device, config.format.remove_srgb_suffix(), depth_test);
cull.always = cull_env.as_deref() == Ok("all");
if !cull.always && adapter.get_info().backend == wgpu::Backend::Metal {
cull.min_draws = cull::TILED_MIN_DRAWS;
cull.min_sections = cull::TILED_MIN_SECTIONS;
}
cull
});
@@ -2204,6 +2206,7 @@ impl Renderer {
translucent,
flat_on: flat.is_some(),
cull_on: cull.is_some(),
sides_on: true,
cull,
flat,
sky,
@@ -2453,6 +2456,14 @@ impl Renderer {
self.flat_on = on;
Ok(())
}
/// Whether solid quads facing away from the eye are left out.
pub fn section_sides(&self) -> bool {
self.sides_on
}
/// Switches leaving out solid quads facing away, for A/B checks.
pub fn set_section_sides(&mut self, on: bool) {
self.sides_on = on;
}
/// Whether solid sections are occlusion culled (`cull`).
pub fn section_cull(&self) -> bool {
self.cull_on
@@ -2575,8 +2586,12 @@ impl Renderer {
page,
start,
solid,
sides: if mesh.sides.quads() == solid {
mesh.sides
} else {
Sides::unsorted(solid)
},
translucent,
faces: mesh.faces,
sort: translucency::SortState::new(
mesh.translucent
.as_chunks::<4>()
@@ -3042,6 +3057,9 @@ impl Renderer {
};
let camera = frame.camera;
let camera_section = camera_section(camera);
// Solid quads facing away from the eye are left out: the bob moves
// the eye off `camera`, so it is found from the projection.
let eye = projection_center(view_proj).filter(|_| self.sides_on);
let culling = !frame.title && frame.cave_culling;
if culling {
crate::spans::time("occlusion", || {
@@ -3106,10 +3124,6 @@ impl Renderer {
// as one list of indirect draws over the section arena.
self.draws.clear();
self.draw_pages.clear();
// The eye, camera-relative: view bobbing moves it off the camera.
let eye = (frame.bob * crate::camera::view_matrix(frame.yaw, frame.pitch))
.inverse()
.transform_point3(Vec3::ZERO);
// Occlusion culling leaves the sections its last result did not see
// to phase 2, after the others, with instance counts of 0. Whether
// the frame has draws enough goes by the last one's.
@@ -3117,7 +3131,7 @@ impl Renderer {
.cull
.as_mut()
.filter(|c| {
self.cull_on && !frame.title && self.multi_draw && self.section_draws >= c.min_draws
self.cull_on && !frame.title && self.multi_draw && visible.len() >= c.min_sections
})
.and_then(|cull| cull.begin(&self.device).then_some(cull));
self.phase2_draws.clear();
@@ -3135,12 +3149,11 @@ impl Renderer {
let start = draws.len();
let instances = u32::from(first_phase);
let mut indices = 0;
// Only the solid groups that can face the eye.
s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| {
let first = s.start + u64::from(first) * 4;
draws.push([quads * 6, instances, 0, first as u32, i as u32]);
pages.push((s.page, None));
facing_runs(&s.sides, eye, v.origin, |first, quads| {
indices += quads * 6;
let base = s.start as u32 + first * 4;
draws.push([quads * 6, instances, 0, base, i as u32]);
pages.push((s.page, None));
});
let runs = if first_phase {
&mut self.solid_runs
@@ -3178,10 +3191,10 @@ impl Renderer {
for (i, v) in visible_lods.iter().enumerate() {
let s = &self.lods[&v.key].mesh;
let start = self.draws.len();
s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| {
let first = s.start + u64::from(first) * 4;
self.draws
.push([quads * 6, 1, 0, first as u32, (visible.len() + i) as u32]);
let instance = (visible.len() + i) as u32;
facing_runs(&s.sides, eye, v.origin, |first, quads| {
let base = s.start as u32 + first * 4;
self.draws.push([quads * 6, 1, 0, base, instance]);
self.draw_pages.push((s.page, None));
});
self.solid_runs
@@ -3520,7 +3533,9 @@ impl Renderer {
}
let flat_frame = match &mut self.flat {
Some(flat)
if self.flat_on && !self.draws.is_empty() && self.draws.len() >= flat.min_draws =>
if self.flat_on
&& !self.draws.is_empty()
&& self.drawn_sections >= flat.min_sections =>
{
let sections = &self.sections;
let lods = &self.lods;
@@ -4202,6 +4217,34 @@ fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, 0.05)
}
/// Where a perspective `view_proj` sees from, in its own (camera-relative)
/// space: the point whose clip x, y and w are all 0. `None` for a
/// projection without one (orthographic).
fn projection_center(view_proj: Mat4) -> Option<Vec3> {
let m = view_proj.as_dmat4();
let rows = [m.row(0), m.row(1), m.row(3)];
let a = glam::DMat3::from_cols(
DVec3::new(rows[0].x, rows[1].x, rows[2].x),
DVec3::new(rows[0].y, rows[1].y, rows[2].y),
DVec3::new(rows[0].z, rows[1].z, rows[2].z),
);
let det = a.determinant();
if !det.is_normal() {
return None;
}
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
Some((a.inverse() * -b).as_vec3())
}
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
/// face `eye` (`projection_center`), the mesh at the camera-relative
/// `origin`; all of them when there is no eye.
fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], mut draw: impl FnMut(u32, u32)) {
match eye {
Some(eye) => sides.visible((eye - Vec3::from(origin)).to_array(), draw),
None if sides.quads() > 0 => draw(0, sides.quads()),
None => {}
}
}
fn frustum(m: Mat4) -> [Vec4; 6] {
let r = |i| m.row(i);
let planes = [
@@ -4429,6 +4472,25 @@ mod tests {
assert_eq!(super::outline_width(3840), 5.0);
}
#[test]
fn the_projection_center_follows_the_view_bob() {
use glam::{Mat4, Vec3};
let projection = super::world_projection(70.0, 16.0 / 9.0, 512.0);
let view = crate::camera::view_matrix(30.0, 20.0);
let eye = super::projection_center(projection * view).unwrap();
assert!(eye.length() < 1e-5, "{eye}");
// A pose translated by the bob: the eye is where the pose's inverse
// takes the view's origin.
let bob = Mat4::from_rotation_z(0.02) * Mat4::from_translation(Vec3::new(0.03, -0.08, 0.0));
let expected = (bob * view).inverse().transform_point3(Vec3::ZERO);
let eye = super::projection_center(projection * bob * view).unwrap();
assert!(eye.distance(expected) < 1e-5, "{eye} {expected}");
assert!(expected.length() > 0.05);
assert!(
super::projection_center(Mat4::orthographic_rh(-1.0, 1.0, -1.0, 1.0, 0.0, 1.0))
.is_none()
);
}
#[test]
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
let validated = |backends| {
super::instance_descriptor(backends)
+12 -10
View File
@@ -46,13 +46,15 @@ const RETEST: u32 = 8;
/// more than they save. Else one frame in `PROBE` tests every section.
const MIN_SHARE: f32 = 0.1;
const PROBE: u32 = 64;
/// On Metal, section draws from which a frame culls. Apple's GPUs store and
/// reload the whole frame at each of the passes the test adds, a fixed cost
/// that the sections it hides repay only at high render distances: on an M4
/// Pro at 1080p and render distance 12 (1,000–1,600 draws) culling added
/// about 0.05 ms of GPU time while hiding 15–30% of the solid indices; at
/// 32 (drawn flat, over this many draws) it saved 0.25–1.6 ms.
pub const TILED_MIN_DRAWS: usize = 4096;
/// On Metal, sections in view from which a frame culls. Apple's GPUs store
/// and reload the whole frame at each of the passes the test adds, a fixed
/// cost that the sections it hides repay only at high render distances: on
/// an M4 Pro at 1080p and render distance 12 (1,000–1,600 draws) culling
/// added about 0.05 ms of GPU time while hiding 15–30% of the solid
/// indices; at 32 (over 4,096 draws) it saved 0.25–1.6 ms. Those draws were
/// 2.2 to 2.5 per section; a section's draw count now depends on the sides
/// it shows the eye (`mesher::Sides`), so the bar counts sections.
pub const TILED_MIN_SECTIONS: usize = 1600;
/// Boxes over more than this share of the screen count as seen untested.
const BIG: f32 = 0.25;
/// Results read back at once, some frames late so the CPU never waits.
@@ -148,8 +150,8 @@ pub struct Cull {
pub hidden: ([usize; 2], f32),
/// Cull every frame, whatever the hidden share (A/B checks).
pub always: bool,
/// Frames with fewer section draws than this do not cull.
pub min_draws: usize,
/// Frames with fewer sections in view than this do not cull.
pub min_sections: usize,
}
fn buffer(
@@ -348,7 +350,7 @@ impl Cull {
stamp: 0,
hidden: ([0; 2], 1.0),
always: false,
min_draws: 0,
min_sections: 0,
}
}
+12 -10
View File
@@ -96,17 +96,19 @@ pub struct Flat {
pub calls: Vec<Call>,
pub phase1_calls: usize,
pub solid_calls: usize,
/// Frames with fewer section draws keep the per-section draws.
pub min_draws: usize,
/// Frames with fewer sections in view keep the per-section draws.
pub min_sections: usize,
range_align: u64,
}
/// Section draws from which a frame is drawn flat. Below it the index list
/// costs the GPU more than the draw calls cost the CPU: on an M4 Pro at
/// render distance 16 (2,900 draws) the copy and the list add about 0.4 ms
/// of GPU time for 0.5–0.8 ms less CPU time; at 32 (9,300 draws) the CPU
/// saves 1.3 ms.
const MIN_DRAWS: usize = 4096;
/// Sections in view (and distant tiles) from which a frame is drawn flat.
/// Below it the index list costs the GPU more than the draw calls cost the
/// CPU: on an M4 Pro at render distance 16 (2,900 draws) the copy and the
/// list add about 0.4 ms of GPU time for 0.5–0.8 ms less CPU time; at 32
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
const MIN_SECTIONS: usize = 1600;
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
/// shaders can read storage buffers large enough for an index page.
@@ -337,10 +339,10 @@ impl Flat {
calls: Vec::new(),
phase1_calls: 0,
solid_calls: 0,
min_draws: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
0
} else {
MIN_DRAWS
MIN_SECTIONS
},
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
.max(1),
+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],
+55 -4
View File
@@ -432,18 +432,21 @@ impl Guest {
}
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
/// `synced_id` names an entry of a synced registry (enchantments, trim
/// materials and patterns) by its id on this connection.
pub fn stack_to_network(
item_id: impl Fn(&str) -> i32,
enchantment_id: impl Fn(&str) -> Option<i32>,
synced_id: impl Fn(&str, &str) -> Option<i32>,
s: &Option<ItemStack>,
) -> Option<Stack> {
network_stack(&item_id, &enchantment_id, s)
network_stack(&item_id, &synced_id, s)
}
fn network_stack(
item_id: &dyn Fn(&str) -> i32,
enchantment_id: &dyn Fn(&str) -> Option<i32>,
synced_id: &dyn Fn(&str, &str) -> Option<i32>,
s: &Option<ItemStack>,
) -> Option<Stack> {
let enchantment_id = |name: &str| synced_id("minecraft:enchantment", name);
let s = s.as_ref()?;
let mut stack = Stack::new(item_id(&s.item), s.count as i32);
if s.damage > 0 {
@@ -481,6 +484,25 @@ fn network_stack(
horizon_protocol::item::Component::Int(rgb),
));
}
// ArmorTrim: both holders as references into the synced registries.
if let Some((material, pattern)) = &d.trim {
let id = |registry: &str, name: &str| {
if name.contains(':') {
synced_id(registry, name)
} else {
synced_id(registry, &format!("minecraft:{name}"))
}
};
if let (Some(material), Some(pattern)) = (
id("minecraft:trim_material", material),
id("minecraft:trim_pattern", pattern),
) {
stack.added.push((
horizon_protocol::item::component::TRIM,
horizon_protocol::item::Component::Trim { material, pattern },
));
}
}
if let Some(id) = d.map_id {
stack.added.push((
horizon_protocol::item::component::MAP_ID,
@@ -504,7 +526,7 @@ fn network_stack(
let charged = d
.charged
.iter()
.filter_map(|c| network_stack(item_id, enchantment_id, &Some(c.clone())))
.filter_map(|c| network_stack(item_id, synced_id, &Some(c.clone())))
.collect();
stack.added.push((
horizon_protocol::item::component::CHARGED_PROJECTILES,
@@ -545,3 +567,32 @@ fn network_stack(
}
Some(stack)
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_protocol::item::{Component, component};
#[test]
fn trims_go_as_synced_registry_references() {
let mut chestplate = ItemStack::new("minecraft:diamond_chestplate", 1);
chestplate.data_mut().trim = Some(("minecraft:gold".into(), "coast".into()));
let synced_id = |registry: &str, name: &str| match (registry, name) {
("minecraft:trim_material", "minecraft:gold") => Some(4),
("minecraft:trim_pattern", "minecraft:coast") => Some(1),
_ => None,
};
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate.clone())).unwrap();
assert_eq!(
stack.component(component::TRIM),
Some(&Component::Trim {
material: 4,
pattern: 1
})
);
// A name the connection's registry lacks sends no trim.
chestplate.data_mut().trim = Some(("minecraft:x".into(), "coast".into()));
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate)).unwrap();
assert_eq!(stack.component(component::TRIM), None);
}
}
+4 -2
View File
@@ -351,7 +351,7 @@ impl Hub {
pub fn stack(&mut self, s: &Option<ItemStack>) -> Option<horizon_protocol::item::Stack> {
let id = s.as_ref().map(|s| self.item_id(&s.item))?;
let sync = self.sync.clone();
stack_to_network(|_| id, |name| sync.id("minecraft:enchantment", name), s)
stack_to_network(|_| id, |registry, name| sync.id(registry, name), s)
}
pub fn broadcast(&self, packet: &C) {
@@ -1827,7 +1827,9 @@ impl Hub {
fn flush_advancements(&mut self, i: usize, show: bool) {
let sync = self.sync.clone();
let g = &mut self.guests[i];
let unlocked = std::mem::take(&mut g.recipes_unlocked);
let mut unlocked = std::mem::take(&mut g.recipes_unlocked);
// A recipe given and taken before this flush must not reappear.
unlocked.retain(|id| g.recipe_book.knows(id));
if let Some(add) = sync.advancements.recipes_added(&unlocked, &sync) {
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
}
+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,
}
}
}
+3
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;
@@ -175,6 +177,7 @@ pub mod tab_list;
pub mod telemetry;
pub mod testgen;
pub mod text;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
pub mod title;
+2 -5
View File
@@ -689,7 +689,7 @@ impl Lod {
}
let finer = finer.remove(&key).unwrap_or_default();
let input = snapshot.mesh_input(key, finer);
let mesh = mesher.mesh(
let mut mesh = mesher.mesh(
input.key,
&input.columns(),
&covered,
@@ -698,6 +698,7 @@ impl Lod {
&input.neighbors,
&input.finer,
);
mesh.sort_solid();
meshes.push((input.key, mesh));
}
Done::Mesh {
@@ -1727,10 +1728,6 @@ mod tests {
let expected = (key.size() * key.size() - 16 * 16) as f32;
assert_eq!(top_area(&mesh.solid, 70.0), expected);
assert_eq!(top_area(&mesh.translucent, 80.0 - 1.0 / 9.0), expected);
assert_eq!(
mesh.faces.quads.iter().sum::<u32>() as usize,
mesh.solid.len() / 4
);
assert!(mesh.translucent.iter().all(|v| v.light == 240));
let side = |axis: usize, plane: f32, low: f32, high: f32| {
let other = if axis == 0 { 2 } else { 0 };
+1 -3
View File
@@ -2,7 +2,7 @@
use super::{Column, Span, TileKey};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
mesher::{FaceGroups, OPAQUE_LAYER, SectionMesh, TerrainVertex},
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex},
streaming::WorkerContext,
};
use horizon_assets::model::Direction;
@@ -558,7 +558,6 @@ fn mesh_columns_with_edges(
emit(&mut mesh, key, rect)
}
}
mesh.faces = FaceGroups::group(&mut mesh.solid);
mesh
}
@@ -1003,7 +1002,6 @@ mod tests {
}
let mesh = mesh(&columns);
assert_eq!(mesh.solid.len(), 6 * 4);
assert_eq!(mesh.faces.quads.iter().sum::<u32>(), 6);
assert!(mesh.translucent.is_empty());
for q in mesh.solid.as_chunks::<4>().0.iter() {
let center = q
+17 -23
View File
@@ -3,9 +3,9 @@
//! `ModelBlockRenderer`/`BlockModelLighter` (smooth light, ambient occlusion,
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
//! follow `FluidRenderer` (see `fluid`).
mod facing;
mod fluid;
mod lighting;
mod sides;
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
@@ -13,7 +13,6 @@ use crate::{
chunk::{Chunk, MIN_Y},
models::catalog::BakedFace,
};
pub use facing::{FaceGroups, GROUPS as FACE_GROUPS};
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
@@ -22,6 +21,7 @@ use horizon_world::{
fluid::{FluidKind, FluidState},
registry::BlockStateId,
};
pub use sides::Sides;
use std::sync::Arc;
/// An entity, item and effect vertex (the entity pipelines), with a face
@@ -116,8 +116,10 @@ mod vertex_tests {
}
pub struct SectionMesh {
pub solid: Vec<TerrainVertex>,
/// `solid`'s quads by the side they face (`facing`).
pub faces: FaceGroups,
/// The buckets `solid` is sorted in by the side its quads face: empty
/// until `sort_solid`, which the meshing workers call; `solid` is in
/// model order before.
pub sides: Sides,
pub translucent: Vec<TerrainVertex>,
/// VisGraph: bit `a * 6 + b` is set when faces `a` and `b` (Direction
/// order) are connected through non-opaque blocks.
@@ -131,13 +133,20 @@ impl Default for SectionMesh {
fn default() -> Self {
Self {
solid: Vec::new(),
faces: FaceGroups::default(),
sides: Sides::default(),
translucent: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
}
}
}
impl SectionMesh {
/// Sorts the solid quads by the side they face, for the renderer to
/// leave out those facing away from the eye (`sides`).
pub fn sort_solid(&mut self) {
self.sides = sides::sort(&mut self.solid);
}
}
pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
@@ -230,8 +239,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 +284,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 +446,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,18 +697,10 @@ 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).
pub fn mesh(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
let mut out = self.tesselate(region, sy);
out.faces = facing::group(&mut out.solid);
out
}
/// The section's quads in model order, before `facing::group`.
pub(crate) fn tesselate(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
let mut out = SectionMesh::default();
let center = region[1][1].expect("centre chunk");
let section = &center.sections[sy as usize];
@@ -773,12 +770,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(
-267
View File
@@ -1,267 +0,0 @@
//! Solid quads grouped by the side they face, as Sodium's `ModelQuadFacing`
//! does. A quad in the plane `x = p` facing +X is front-facing only while the
//! eye is at `x > p`, and terrain culls back faces, so when the eye is on the
//! back side of every quad of a group the renderer skips the group's draw:
//! the GPU would have discarded all of it after shading its vertices. The
//! picture is unchanged; the vertex work for those quads is gone.
use super::TerrainVertex;
use glam::Vec3;
/// Groups in buffer order: the negative sides, quads not in an axis plane
/// (rotated model elements, sloped fluid tops), then the positive sides.
/// With the eye outside a section on every axis, one side per axis is
/// visible, so the drawn groups form one to three runs.
pub const GROUPS: usize = 7;
const UNALIGNED: usize = 3;
/// The group of a quad facing `-axis` (0 x, 1 y, 2 z) is `NEGATIVE[axis]`, of
/// one facing `+axis` `GROUPS - 1 - NEGATIVE[axis]`.
const NEGATIVE: [usize; 3] = [0, 2, 1];
/// How far (blocks) the eye must be behind every plane of a group before
/// the group is skipped: rounding on the GPU never makes such a quad
/// front-facing.
const MARGIN: f32 = 1.0 / 16.0;
/// The solid quads' groups, filled by `group`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FaceGroups {
/// Quads per group, in buffer order.
pub quads: [u32; GROUPS],
/// Per axis group, the plane nearest the side the group faces: the
/// lowest plane of a positive group and the highest of a negative one.
planes: [f32; GROUPS],
}
impl Default for FaceGroups {
/// Everything unaligned: always drawn.
fn default() -> Self {
Self {
quads: [0; GROUPS],
planes: [0.0; GROUPS],
}
}
}
impl FaceGroups {
/// Group arbitrary-sized terrain quads, including distant LOD tiles.
pub fn group(solid: &mut Vec<TerrainVertex>) -> Self {
group(solid)
}
/// Whether group `g` can hold a front-facing quad for an eye at `eye`,
/// relative to the section's minimum corner.
pub fn visible(&self, g: usize, eye: Vec3) -> bool {
if g == UNALIGNED {
return true;
}
let (axis, positive) = axis(g);
if positive {
eye[axis] > self.planes[g] - MARGIN
} else {
eye[axis] < self.planes[g] + MARGIN
}
}
/// The quad ranges (first quad, quad count) to draw for an eye at `eye`
/// relative to the section's minimum corner, adjacent groups merged.
pub fn ranges(&self, eye: Vec3, mut out: impl FnMut(u32, u32)) {
let mut first = 0;
let mut run: Option<(u32, u32)> = None;
for g in 0..GROUPS {
let quads = self.quads[g];
if quads > 0 {
if self.visible(g, eye) {
run = Some(match run {
Some((start, len)) => (start, len + quads),
None => (first, quads),
});
} else if let Some((start, len)) = run.take() {
out(start, len);
}
}
first += quads;
}
if let Some((start, len)) = run {
out(start, len);
}
}
}
/// The axis and sign of each axis group (`UNALIGNED`'s entry is unused).
const AXES: [(usize, bool); GROUPS] = {
let mut axes = [(0, false); GROUPS];
let mut axis = 0;
while axis < 3 {
axes[NEGATIVE[axis]] = (axis, false);
axes[GROUPS - 1 - NEGATIVE[axis]] = (axis, true);
axis += 1;
}
axes
};
/// The axis and sign of axis group `g`.
fn axis(g: usize) -> (usize, bool) {
debug_assert_ne!(g, UNALIGNED);
AXES[g]
}
/// The group of one quad, and its plane: an axis group only when all four
/// corners lie exactly in one axis plane.
fn facing(quad: &[TerrainVertex]) -> (usize, f32) {
let p = |i: usize| Vec3::from(quad[i].pos);
let normal = (p(1) - p(0)).cross(p(2) - p(0));
for axis in 0..3 {
let [a, b] = [(axis + 1) % 3, (axis + 2) % 3];
let plane = quad[0].pos[axis];
if normal[axis] != 0.0
&& normal[a] == 0.0
&& normal[b] == 0.0
&& quad.iter().all(|v| v.pos[axis] == plane)
{
let negative = NEGATIVE[axis];
let g = if normal[axis] > 0.0 {
GROUPS - 1 - negative
} else {
negative
};
return (g, plane);
}
}
(UNALIGNED, 0.0)
}
/// Reorder `solid` by group, keeping the order of quads within a group (so
/// coplanar quads such as a grass block's overlay still draw after their
/// base), and return the groups.
pub fn group(solid: &mut Vec<TerrainVertex>) -> FaceGroups {
let mut groups = FaceGroups::default();
for (g, plane) in groups.planes.iter_mut().enumerate() {
if g != UNALIGNED {
*plane = if axis(g).1 { f32::MAX } else { f32::MIN };
}
}
let faces: Vec<u8> = solid
.as_chunks::<4>()
.0
.iter()
.map(|quad| {
let (g, plane) = facing(quad);
groups.quads[g] += 1;
if g != UNALIGNED {
let nearest = &mut groups.planes[g];
*nearest = if axis(g).1 {
nearest.min(plane)
} else {
nearest.max(plane)
};
}
g as u8
})
.collect();
if faces.windows(2).all(|w| w[0] <= w[1]) {
return groups;
}
let mut next = [0usize; GROUPS];
let mut start = 0;
for g in 0..GROUPS {
next[g] = start;
start += groups.quads[g] as usize;
}
let mut sorted = vec![solid[0]; solid.len()];
for (quad, &g) in solid.as_chunks::<4>().0.iter().zip(&faces) {
let at = next[g as usize] * 4;
sorted[at..at + 4].copy_from_slice(quad);
next[g as usize] += 1;
}
*solid = sorted;
groups
}
#[cfg(test)]
mod tests {
use super::*;
fn vertex(pos: [f32; 3], layer: u16) -> TerrainVertex {
TerrainVertex::new(pos, [0.0; 2], layer, 0, 0)
}
/// A unit quad in the plane `axis = plane`, wound to face `sign`, tagged
/// with `layer` so tests can follow it.
fn quad(axis: usize, plane: f32, sign: f32, layer: u16) -> Vec<TerrainVertex> {
let corner = |u: f32, v: f32| {
let mut p = [0.0; 3];
p[axis] = plane;
p[(axis + 1) % 3] = u;
p[(axis + 2) % 3] = v;
vertex(p, layer)
};
let mut q = vec![
corner(0.0, 0.0),
corner(1.0, 0.0),
corner(1.0, 1.0),
corner(0.0, 1.0),
];
if sign < 0.0 {
q.reverse();
}
q
}
#[test]
fn quads_group_by_the_side_their_winding_faces() {
for a in 0..3 {
for sign in [-1.0, 1.0] {
let q = quad(a, 4.0, sign, 0);
let (g, plane) = facing(&q);
assert_eq!(axis(g), (a, sign > 0.0), "axis {a} sign {sign}");
assert_eq!(plane, 4.0);
let normal = (Vec3::from(q[1].pos) - Vec3::from(q[0].pos))
.cross(Vec3::from(q[2].pos) - Vec3::from(q[0].pos));
assert_eq!(normal[a].signum(), sign);
}
}
// A cross plant's diagonal quad faces no axis.
let diagonal = [
[0.0, 0.0, 0.0],
[1.0, 0.0, 1.0],
[1.0, 1.0, 1.0],
[0.0, 1.0, 0.0],
]
.map(|p| vertex(p, 0));
assert_eq!(facing(&diagonal).0, UNALIGNED);
}
#[test]
fn grouping_is_stable_and_skips_only_back_facing_groups() {
// +X at x = 3 and x = 7 (layers 1, 2), -X at x = 5 (3), an unaligned
// quad (4), a coplanar overlay on the x = 3 quad (5).
let diagonal = [
[0.0, 0.0, 0.0],
[1.0, 0.0, 1.0],
[1.0, 1.0, 1.0],
[0.0, 1.0, 0.0],
]
.map(|p| vertex(p, 4));
let mut solid = [
quad(0, 3.0, 1.0, 1),
quad(0, 5.0, -1.0, 3),
diagonal.to_vec(),
quad(0, 7.0, 1.0, 2),
quad(0, 3.0, 1.0, 5),
]
.concat();
let groups = group(&mut solid);
let layers: Vec<u16> = solid.chunks(4).map(|q| q[0].layer).collect();
assert_eq!(layers, [3, 4, 1, 2, 5]);
let ranges = |eye: Vec3| {
let mut out = Vec::new();
groups.ranges(eye, |first, count| out.push((first, count)));
out
};
// Between the planes: every group can face the eye.
assert_eq!(ranges(Vec3::new(4.0, 8.0, 8.0)), [(0, 5)]);
// Beyond x = 7: the -X quad at x = 5 faces away.
assert_eq!(ranges(Vec3::new(20.0, 8.0, 8.0)), [(1, 4)]);
// Before x = 3: every +X quad faces away.
assert_eq!(ranges(Vec3::new(-10.0, 8.0, 8.0)), [(0, 2)]);
// Within the margin of the nearest plane: still drawn.
assert_eq!(ranges(Vec3::new(3.0, 8.0, 8.0)), [(0, 5)]);
}
}
@@ -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))
);
}
+346
View File
@@ -0,0 +1,346 @@
//! A mesh's solid quads sorted by the side they face, so that the renderer
//! can leave out the quads turned away from the eye before the GPU shades
//! their vertices.
//!
//! A quad in the plane `x = p` whose corners go counter-clockwise seen from
//! +X faces +X. From an eye at `x < p` it is seen from behind, and at
//! `x = p` edge on; the solid pipeline culls back faces, so it draws nothing
//! from there. A bucket of such quads draws nothing at all while the eye is
//! at or below the lowest of their planes, and likewise for the other five
//! axis directions. The seventh bucket holds every other quad (sloped,
//! rotated, degenerate, or whose two triangles wind opposite ways): it is
//! drawn from everywhere.
//!
//! The buckets lie one after another in the mesh, in an order chosen so that
//! the buckets an eye can see are usually one or two runs of quads: one draw
//! each. Inside a bucket quads keep their model order, so coplanar quads
//! facing one way (an overlay on its base) still draw in that order.
use super::TerrainVertex;
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
/// above (+Y, the commonest case), each pair of horizontal sides with a
/// common corner is then one run, or two.
const BUCKETS: [Bucket; 7] = [
Bucket::Side(0, true),
Bucket::Side(2, true),
Bucket::Other,
Bucket::Side(1, true),
Bucket::Side(2, false),
Bucket::Side(0, false),
Bucket::Side(1, false),
];
const OTHER: usize = 2;
/// How far, in blocks, the eye must be behind a bucket's planes before the
/// bucket is left out. The renderer computes both the eye and the section
/// origins in `f32`, with errors far below this.
const MARGIN: f32 = 1.0 / 16.0;
#[derive(Clone, Copy)]
enum Bucket {
/// Quads in a plane across `axis` facing its positive (`true`) or
/// negative direction.
Side(usize, bool),
Other,
}
/// Where each bucket of a mesh's solid quads ends, and the planes that tell
/// whether it can face the eye.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Sides {
/// The solid quads up to the end of each bucket, in `BUCKETS` order.
ends: [u32; 7],
/// Per bucket facing a positive direction, its lowest plane; facing a
/// negative one, its highest (unused for the other bucket).
planes: [f32; 7],
}
impl Sides {
/// `quads` solid quads in one bucket drawn from everywhere: for meshes
/// that were not sorted.
pub fn unsorted(quads: u32) -> Self {
let mut ends = [0; 7];
ends[OTHER..].fill(quads);
Self {
ends,
planes: [0.0; 7],
}
}
/// The number of solid quads the buckets hold.
pub fn quads(&self) -> u32 {
self.ends[6]
}
/// Calls `draw(first, count)` for each run of quads an eye at `eye`
/// (relative to the mesh's origin) can see the front of, nearest the
/// start first. Empty buckets do not break a run.
pub fn visible(&self, eye: [f32; 3], mut draw: impl FnMut(u32, u32)) {
let mut start = 0;
let mut run: Option<u32> = None;
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = self.ends[i];
if end == start {
continue;
}
let seen = match *bucket {
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
Bucket::Other => true,
};
match (seen, run) {
(true, None) => run = Some(start),
(false, Some(first)) => {
draw(first, start - first);
run = None;
}
_ => {}
}
start = end;
}
if let Some(first) = run {
draw(first, start - first);
}
}
}
/// The bucket of a quad: the axis plane it lies in and the side its
/// triangles (corners 0, 1, 2 and 0, 2, 3, as the renderer draws them) face.
fn bucket(quad: &[TerrainVertex; 4]) -> usize {
let p = quad.map(|v| v.pos);
for axis in 0..3 {
if p.iter().any(|v| v[axis] != p[0][axis]) {
continue;
}
// The normal's component along `axis`, twice each triangle's area.
let (u, w) = ((axis + 1) % 3, (axis + 2) % 3);
let area = |a: usize, b: usize, c: usize| {
(p[b][u] - p[a][u]) * (p[c][w] - p[a][w]) - (p[b][w] - p[a][w]) * (p[c][u] - p[a][u])
};
let (first, second) = (area(0, 1, 2), area(0, 2, 3));
let positive = first >= 0.0 && second >= 0.0;
let negative = first <= 0.0 && second <= 0.0;
if positive == negative {
// Opposite windings, or no area at all.
return OTHER;
}
return BUCKETS
.iter()
.position(|b| matches!(*b, Bucket::Side(a, s) if a == axis && s == positive))
.expect("every side has a bucket");
}
OTHER
}
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
let mut counts = [0u32; 7];
let mut planes = [0f32; 7];
for (quad, &b) in quads.iter().zip(&buckets) {
let b = usize::from(b);
if let Bucket::Side(axis, positive) = BUCKETS[b] {
let p = quad[0].pos[axis];
planes[b] = match (counts[b], positive) {
(0, _) => p,
(_, true) => planes[b].min(p),
(_, false) => planes[b].max(p),
};
}
counts[b] += 1;
}
let mut ends = [0u32; 7];
let mut total = 0;
for (end, count) in ends.iter_mut().zip(counts) {
total += count;
*end = total;
}
if counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &b) in quads.iter().zip(&buckets) {
let slot = &mut next[usize::from(b)];
sorted[*slot as usize] = *quad;
*slot += 1;
}
*solid = sorted.into_flattened();
}
Sides { ends, planes }
}
#[cfg(test)]
mod tests {
use super::*;
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
}
/// A unit block's six faces at `origin`, wound counter-clockwise seen
/// from outside, as block models are.
fn cube(origin: [f32; 3], layer: u16) -> Vec<TerrainVertex> {
let [x, y, z] = origin;
let (a, b, c) = (x + 1.0, y + 1.0, z + 1.0);
[
[[a, y, z], [a, b, z], [a, b, c], [a, y, c]], // +X
[[x, y, z], [x, y, c], [x, b, c], [x, b, z]], // -X
[[x, b, z], [x, b, c], [a, b, c], [a, b, z]], // +Y
[[x, y, z], [a, y, z], [a, y, c], [x, y, c]], // -Y
[[x, y, c], [a, y, c], [a, b, c], [x, b, c]], // +Z
[[x, y, z], [x, b, z], [a, b, z], [a, y, z]], // -Z
]
.into_iter()
.flat_map(|q| quad(q, layer))
.collect()
}
fn runs(sides: &Sides, eye: [f32; 3]) -> Vec<(u32, u32)> {
let mut out = Vec::new();
sides.visible(eye, |first, count| out.push((first, count)));
out
}
/// The quads of `solid` whose triangles face `eye`, by their layer.
fn front_facing(solid: &[TerrainVertex], eye: [f32; 3]) -> Vec<u16> {
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
solid
.as_chunks::<4>()
.0
.iter()
.filter(|q| {
[[0, 1, 2], [0, 2, 3]].iter().any(|t| {
let (e1, e2) = (sub(q[t[1]].pos, q[t[0]].pos), sub(q[t[2]].pos, q[t[0]].pos));
let n = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
let to_eye = sub(eye, q[t[0]].pos);
n[0] * to_eye[0] + n[1] * to_eye[1] + n[2] * to_eye[2] > 0.0
})
})
.map(|q| q[0].layer)
.collect()
}
#[test]
fn cube_faces_go_to_their_buckets_in_order() {
let mut solid = cube([3.0, 4.0, 5.0], 0);
for (i, v) in solid.iter_mut().enumerate() {
v.layer = (i / 4) as u16;
}
let sides = sort(&mut solid);
assert_eq!(sides.ends, [1, 2, 2, 3, 4, 5, 6]);
let layers: Vec<u16> = solid.iter().step_by(4).map(|v| v.layer).collect();
// +X, +Z, +Y, -Z, -X, -Y.
assert_eq!(layers, [0, 4, 2, 5, 1, 3]);
assert_eq!(sides.planes[0], 4.0); // +X at x = 4
assert_eq!(sides.planes[5], 3.0); // -X at x = 3
assert_eq!(sides.planes[3], 5.0); // +Y at y = 5
}
#[test]
fn the_drawn_runs_hold_every_quad_that_faces_the_eye() {
// Blocks across a section, with a sloped quad and a bowtie.
let mut solid = Vec::new();
let mut layer = 0;
for origin in [
[0.0, 0.0, 0.0],
[7.0, 3.0, 12.0],
[15.0, 15.0, 15.0],
[2.0, 9.0, 4.0],
] {
for q in cube(origin, 0).chunks(4) {
solid.extend(q.iter().map(|v| TerrainVertex { layer, ..*v }));
layer += 1;
}
}
let slope = [
[1.0, 1.0, 1.0],
[2.0, 1.0, 1.0],
[2.0, 2.0, 2.0],
[1.0, 2.0, 2.0],
];
let bowtie = [
[5.0, 1.0, 1.0],
[5.0, 2.0, 2.0],
[5.0, 1.0, 2.0],
[5.0, 2.0, 1.0],
];
for corners in [slope, bowtie] {
solid.extend(quad(corners, layer));
layer += 1;
}
let sides = sort(&mut solid);
assert_eq!(sides.quads(), u32::from(layer));
let eyes = [-20.0f32, -0.5, 0.0, 3.5, 8.0, 15.99, 16.0, 30.0];
for &x in &eyes {
for &y in &eyes {
for &z in &eyes {
let eye = [x, y, z];
let mut drawn = Vec::new();
for (first, count) in runs(&sides, eye) {
drawn.extend(
solid[first as usize * 4..(first + count) as usize * 4]
.iter()
.step_by(4)
.map(|v| v.layer),
);
}
for layer in front_facing(&solid, eye) {
assert!(drawn.contains(&layer), "{eye:?} misses quad {layer}");
}
}
}
}
}
#[test]
fn runs_merge_across_hidden_and_empty_buckets() {
let mut solid = cube([0.0; 3], 0);
let sides = sort(&mut solid);
// From above, +X and +Z: +X, +Z, (no other), +Y is one run.
assert_eq!(runs(&sides, [8.0, 8.0, 8.0]), [(0, 3)]);
// From below, -X and -Z: -Z, -X, -Y.
assert_eq!(runs(&sides, [-8.0, -8.0, -8.0]), [(3, 3)]);
// From above, -X and +Z: +Z, +Y, then -X.
assert_eq!(runs(&sides, [-8.0, 8.0, 8.0]), [(1, 2), (4, 1)]);
// Inside the block every face is seen from behind.
assert!(runs(&sides, [0.5; 3]).is_empty());
// Level with its top, the eye may see it: the margin keeps it.
assert_eq!(runs(&sides, [0.5, 1.0, 0.5]), [(2, 1)]);
}
#[test]
fn unsorted_meshes_draw_everything() {
let sides = Sides::unsorted(9);
assert_eq!(sides.quads(), 9);
assert_eq!(runs(&sides, [-100.0; 3]), [(0, 9)]);
assert!(runs(&Sides::unsorted(0), [0.0; 3]).is_empty());
}
#[test]
fn one_bucket_keeps_the_mesh_as_it_is() {
let mut solid: Vec<_> = (0..3)
.flat_map(|i| {
quad(
[
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[1.0, 1.0, 1.0],
[1.0, 0.0, 1.0],
],
i,
)
})
.collect();
let before = solid.clone();
let sides = sort(&mut solid);
assert_eq!(sides.ends, [3; 7]);
assert_eq!(
solid.iter().map(|v| v.layer).collect::<Vec<_>>(),
before.iter().map(|v| v.layer).collect::<Vec<_>>()
);
}
}
@@ -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":[
+6 -4
View File
@@ -787,13 +787,15 @@ impl Settings {
}
(displays, added)
}
/// `ServerRecipeBook.removeRecipes`: the displays removed.
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> usize {
let mut displays = 0;
/// `ServerRecipeBook.removeRecipes`: the display ids removed, which its
/// `ClientboundRecipeBookRemovePacket` lists (a transmute recipe has one
/// per material count).
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> Vec<i32> {
let mut displays = Vec::new();
for id in ids {
if self.recipes.remove(id) {
self.to_be_displayed.remove(id);
displays += index.displays(id);
displays.extend(index.display_ids(id));
}
}
displays
+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();
}
}
+2 -6
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,14 +498,14 @@ 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,
biomes: &context.biomes,
visibility,
};
let mesh = mesher.mesh(&borrow(&region), sy);
let mut mesh = mesher.mesh(&borrow(&region), sy);
mesh.sort_solid();
faces = Some((context.tables.clone(), mesher.visibility));
Done::Meshed(x, sy, z, mesh, seq)
}
@@ -556,7 +554,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 +1174,6 @@ impl Streaming {
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
cutout_leaves: self.cutout_leaves,
cull_leaves: self.cull_leaves,
},
),
);
+169
View File
@@ -0,0 +1,169 @@
//! Display/TextDisplay synced data (26.3), checked against vanilla accessors
//! and ClientboundSetEntityDataPacket's own stream codec.
use crate::text::Text;
use horizon_protocol::entity_data::Value;
use serde::Serialize;
use std::sync::Arc;
/// `TextDisplay.Align`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Align {
Center,
Left,
Right,
}
/// `Display` and `TextDisplay`'s synced data (ids 8 to 27); the default is
/// what their `defineSynchedData` starts with.
#[derive(Clone, Debug, Serialize)]
pub struct TextDisplayData {
pub interpolation_delay: i32,
pub interpolation_duration: i32,
pub teleport_duration: i32,
pub translation: [f32; 3],
pub scale: [f32; 3],
pub left_rotation: [f32; 4],
pub right_rotation: [f32; 4],
pub billboard: u8,
pub brightness: i32,
pub view_range: f32,
pub shadow_radius: f32,
pub shadow_strength: f32,
pub width: f32,
pub height: f32,
pub glow_color: i32,
/// Shared: a frame's interpolated copy of the data must not copy it.
#[serde(skip)]
pub text: Arc<Text>,
pub line_width: i32,
pub background: i32,
pub opacity: i8,
pub flags: u8,
}
impl Default for TextDisplayData {
fn default() -> Self {
Self {
interpolation_delay: 0,
interpolation_duration: 0,
teleport_duration: 0,
translation: [0.0; 3],
scale: [1.0; 3],
left_rotation: [0.0, 0.0, 0.0, 1.0],
right_rotation: [0.0, 0.0, 0.0, 1.0],
billboard: 0,
brightness: -1,
view_range: 1.0,
shadow_radius: 0.0,
shadow_strength: 1.0,
width: 0.0,
height: 0.0,
glow_color: -1,
text: Arc::new(Text::empty()),
line_width: 200,
background: 0x4000_0000,
opacity: -1,
flags: 0,
}
}
}
impl TextDisplayData {
/// DisplayRenderer's billboard orientation followed by Transformation's T*L*S*R.
pub fn matrix(&self, entity: [f32; 2], camera: [f32; 2]) -> glam::Mat4 {
let [yaw, pitch] = match self.billboard {
1 => [camera[0] - 180.0, entity[1]],
2 => [entity[0], -camera[1]],
3 => [camera[0] - 180.0, -camera[1]],
_ => entity,
};
let orientation = glam::Quat::from_euler(
glam::EulerRot::YXZ,
-yaw * 0.017_453_292,
pitch * 0.017_453_292,
0.0,
);
glam::Mat4::from_quat(orientation)
* glam::Mat4::from_translation(self.translation.into())
* glam::Mat4::from_quat(glam::Quat::from_array(self.left_rotation))
* glam::Mat4::from_scale(self.scale.into())
* glam::Mat4::from_quat(glam::Quat::from_array(self.right_rotation))
}
pub fn blend(&self, from: &Self, t: f32) -> Self {
let mut out = self.clone();
for i in 0..3 {
out.translation[i] =
from.translation[i] + t * (self.translation[i] - from.translation[i]);
out.scale[i] = from.scale[i] + t * (self.scale[i] - from.scale[i]);
}
out.left_rotation = glam::Quat::from_array(from.left_rotation)
.slerp(glam::Quat::from_array(self.left_rotation), t)
.to_array();
out.right_rotation = glam::Quat::from_array(from.right_rotation)
.slerp(glam::Quat::from_array(self.right_rotation), t)
.to_array();
let int = |a: i32, b: i32| a + (t * (b as f32 - a as f32)).floor() as i32;
out.opacity = int(i32::from(from.opacity), i32::from(self.opacity)) as i8;
out.background = (0..4).fold(0, |color, channel| {
color
| (int(
(from.background as u32 >> (channel * 8) & 255) as i32,
(self.background as u32 >> (channel * 8) & 255) as i32,
) << (channel * 8))
});
out
}
/// Omitted fields retain their previous values.
pub fn apply(&mut self, values: &[(u8, i32, Value)]) {
for (id, _, value) in values {
match (id, value) {
(8, Value::Int(v)) => self.interpolation_delay = *v,
(9, Value::Int(v)) => self.interpolation_duration = *v,
(10, Value::Int(v)) => self.teleport_duration = *v,
(11, Value::Floats(v)) if v.len() == 3 => self.translation.copy_from_slice(v),
(12, Value::Floats(v)) if v.len() == 3 => self.scale.copy_from_slice(v),
(13, Value::Floats(v)) if v.len() == 4 => self.left_rotation.copy_from_slice(v),
(14, Value::Floats(v)) if v.len() == 4 => self.right_rotation.copy_from_slice(v),
(15, Value::Byte(v)) => self.billboard = *v as u8,
(16, Value::Int(v)) => self.brightness = *v,
(17, Value::Float(v)) => self.view_range = *v,
(18, Value::Float(v)) => self.shadow_radius = *v,
(19, Value::Float(v)) => self.shadow_strength = *v,
(20, Value::Float(v)) => self.width = *v,
(21, Value::Float(v)) => self.height = *v,
(22, Value::Int(v)) => self.glow_color = *v,
(23, Value::Text(v)) => self.text = Arc::new(crate::entity_nbt::name_text(v)),
(24, Value::Int(v)) => self.line_width = *v,
(25, Value::Int(v)) => self.background = *v,
(26, Value::Byte(v)) => self.opacity = *v,
(27, Value::Byte(v)) => self.flags = *v as u8,
_ => {}
}
}
}
/// `TextDisplay.getAlign`: the left flag wins over the right one.
pub fn align(&self) -> Align {
if self.flags & 8 != 0 {
Align::Left
} else if self.flags & 16 != 0 {
Align::Right
} else {
Align::Center
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn partial_updates_retain_values_and_bad_vectors_do_not_panic() {
let mut d = TextDisplayData::default();
d.apply(&[(24, 1, Value::Int(80)), (27, 0, Value::Byte(24))]);
d.apply(&[(11, 0, Value::Floats(vec![1.0]))]);
assert_eq!(d.line_width, 80);
assert_eq!(d.align(), Align::Left);
assert_eq!(d.translation, [0.0; 3]);
}
}
@@ -215,14 +215,15 @@ 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,
biomes: &context.biomes,
visibility: context.tables.visibility.clone(),
};
let fresh = mesher.mesh(&borrow(&region), sy);
// The workers sort solid quads by side, as uploads expect.
let mut fresh = mesher.mesh(&borrow(&region), sy);
fresh.sort_solid();
let bytes = |m: &SectionMesh| {
[
bytemuck::cast_slice::<_, u8>(&m.solid).to_vec(),
+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
+613
View File
@@ -0,0 +1,613 @@
//! 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()),
};
if matches!(project.kind, Kind::Shader | Kind::Other(_)) {
return Err(format!(
"this Horizon can't install a {} yet",
project.kind.name()
));
}
inspect(&project.kind, path)?.matches(project, version)?;
Ok(trust)
}
/// What a file says about itself, read by its kind's own check
/// ([`inspect`]). The registry's CI writes these into the index; the
/// launcher compares them with the index before it installs.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Facts {
/// A mod's or a modpack's manifest id; None for a resource pack.
pub id: Option<String>,
pub version: Option<String>,
/// A mod's API line, a modpack's format, a resource pack's pack format.
pub api: String,
pub side: Option<Side>,
/// In the manifest's order.
pub capabilities: Vec<Capability>,
/// The Minecraft version, when the file names one (a modpack does).
pub game: Option<String>,
pub requires: Vec<Requirement>,
}
impl Facts {
/// Whether the file is the one `version` of `project` describes.
pub fn matches(&self, project: &Project, version: &Version) -> Result<(), String> {
let (mut caps, mut listed) = (self.capabilities.clone(), version.capabilities.clone());
caps.sort();
listed.sort();
let differs = self.id.as_ref().is_some_and(|id| *id != project.id)
|| self.version.as_ref().is_some_and(|v| *v != version.version)
|| self.api != version.api
|| self.side != version.side
|| caps != listed
|| self.game.as_ref().is_some_and(|g| *g != version.game)
|| self.requires != version.requires;
if !differs {
return Ok(());
}
Err(match project.kind {
Kind::Mod => format!(
"its horizon-mod.toml ({} {}, API {}, {:?}, {}) isn't what the index lists",
self.id.as_deref().unwrap_or("?"),
self.version.as_deref().unwrap_or("?"),
self.api,
self.side,
names(&self.capabilities)
),
Kind::Modpack => format!("its {} isn't what the index lists", modpack::MANIFEST),
_ => format!("its pack format {} isn't what the index lists", self.api),
})
}
}
/// Reads and checks a file as its kind: a mod's manifest and content, a
/// modpack's manifest and overrides, a resource pack's `pack.mcmeta` and
/// paths. Shader packs and kinds this build doesn't know are refused: their
/// checks come with their formats.
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
match kind {
Kind::Mod => {
let m = Package::read(path)?.manifest;
crate::Content::read(path, &m)?;
Ok(Facts {
capabilities: m.capabilities,
id: Some(m.id),
version: Some(m.version),
api: m.api,
side: Some(m.side),
..Facts::default()
})
}
Kind::Modpack => {
let m = Modpack::read(path)?.manifest;
Ok(Facts {
requires: m.mods.iter().map(modpack::PackMod::requirement).collect(),
id: Some(m.id),
version: Some(m.version),
api: m.format.to_string(),
game: Some(m.game),
..Facts::default()
})
}
Kind::ResourcePack => Ok(Facts {
api: check_resource_pack(path)?,
..Facts::default()
}),
Kind::Shader => Err(
"Horizon's own shader pack format isn't out yet: shader packs open when it is".into(),
),
Kind::Other(name) => Err(format!("this Horizon can't read a {name} yet")),
}
}
/// A resource pack: a zip with `pack.mcmeta` at its root holding a `pack`
/// object, and only plain paths. Returns its pack format (`pack_format`, or
/// `min_format` in newer packs), empty when it names none.
fn check_resource_pack(path: &Path) -> Result<String, 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}"))?;
let pack = &json["pack"];
if !pack.is_object() {
return Err("pack.mcmeta: no \"pack\" object".into());
}
let format = [&pack["pack_format"], &pack["min_format"]]
.into_iter()
.find(|f| !f.is_null())
.map(|f| match f {
serde_json::Value::Array(parts) => parts
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("."),
other => other.to_string(),
})
.unwrap_or_default();
Ok(format)
}
/// The kinds the registry takes submissions for. Shader packs wait for
/// Horizon's own format; other kinds for theirs.
pub fn open(kind: &Kind) -> Result<(), String> {
match kind {
Kind::Mod | Kind::Modpack | Kind::ResourcePack => Ok(()),
Kind::Shader => Err(
"shader packs open when Horizon's own shader pack format is out (Iris and OptiFine packs stay on Modrinth)".into(),
),
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
}
}
fn names(caps: &[Capability]) -> String {
let names: Vec<String> = caps.iter().map(ToString::to_string).collect();
format!("[{}]", names.join(", "))
}
pub mod registry;
#[cfg(test)]
mod tests;
+415
View File
@@ -0,0 +1,415 @@
//! The registry repository (`ludovic111/horizon-mods`, docs/api/mod-index.md):
//! one `mods/<id>.toml` per project, written by the submission form or by
//! hand and reviewed in a pull request, plus `revoked.toml`. Its CI turns
//! them into `index.json` with [`build`], reading every listed file, and
//! checks a pull request's changes with [`review`].
//!
//! What an entry can't say for itself (a mod's API, side and capabilities,
//! a modpack's mods, a file's size) is read from the file, never typed.
pub use super::open;
use super::{File, Index, Kind, Project, Revoked, SCHEMA, Version, hex64, inspect, valid_id};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// The folder of entries in the registry repository.
pub const ENTRIES: &str = "mods";
/// The list of files taken down.
pub const REVOKED: &str = "revoked.toml";
/// Ids the site uses for its own pages (`/mods/submit`).
const RESERVED: [&str; 1] = ["submit"];
/// The biggest icon, a PNG in `icons/`.
const MAX_ICON: u64 = 256 << 10;
/// `mods/<id>.toml`.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Entry {
pub id: String,
pub kind: Kind,
pub name: String,
pub summary: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default)]
pub authors: Vec<String>,
/// GitHub logins allowed to publish updates.
pub owners: Vec<String>,
pub license: String,
pub source: String,
/// `icons/<id>.png` in the registry repository, or an `https://` address.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// The author's minisign public key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
/// Newest first.
pub versions: Vec<EntryVersion>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct EntryVersion {
pub version: String,
/// `YYYY-MM-DD`.
pub published: String,
/// The Minecraft version it is for.
pub game: String,
/// The name players download, with the kind's extension.
pub file: String,
/// Lowercase hex: pins the reviewed file.
pub sha256: String,
/// The `.minisig` text, when the project has a key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub changelog: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RevokedFile {
#[serde(default)]
revoked: Vec<Revoked>,
}
/// Where [`build`] reads and what it writes into the index.
pub struct Sources<'a> {
/// A checkout of the registry repository.
pub registry: &'a Path,
/// Every listed file, named by its SHA-256 (CI downloads them there).
pub files: &'a Path,
/// Where the files are published: `<files>/<id>-<version>/<file>`
/// ([`super::FILE_HOST`]; `file:///` for a local test registry).
pub release: &'a str,
/// Where the registry's site is (its icons): `https://.../horizon-mods/`.
pub site: &'a str,
/// RFC 3339.
pub generated: String,
}
/// The release tag a version's file is published under.
pub fn tag(id: &str, version: &str) -> String {
format!("{id}-{version}")
}
/// Reads `mods/*.toml` and `revoked.toml`: every problem found, not only the
/// first, so a pull request's check lists them all.
pub fn load(registry: &Path) -> Result<(Vec<Entry>, Vec<Revoked>), Vec<String>> {
let mut errors = Vec::new();
let mut entries = Vec::new();
let dir = registry.join(ENTRIES);
let mut paths: Vec<PathBuf> = match std::fs::read_dir(&dir) {
Ok(read) => read.filter_map(|e| e.ok().map(|e| e.path())).collect(),
Err(e) => return Err(vec![format!("{}: {e}", dir.display())]),
};
paths.sort();
for path in paths {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let Some(stem) = name.strip_suffix(".toml") else {
errors.push(format!("{ENTRIES}/{name}: only <id>.toml files go here"));
continue;
};
let parsed = std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|text| toml::from_str::<Entry>(&text).map_err(|e| e.to_string()));
match parsed {
Ok(entry) if entry.id == stem => entries.push(entry),
Ok(entry) => errors.push(format!(
"{ENTRIES}/{name}: its id is {:?}; the file must be {ENTRIES}/{}.toml",
entry.id, entry.id
)),
Err(e) => errors.push(format!("{ENTRIES}/{name}: {e}")),
}
}
let revoked = match std::fs::read_to_string(registry.join(REVOKED)) {
Ok(text) => match toml::from_str::<RevokedFile>(&text) {
Ok(file) => file.revoked,
Err(e) => {
errors.push(format!("{REVOKED}: {e}"));
Vec::new()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => {
errors.push(format!("{REVOKED}: {e}"));
Vec::new()
}
};
if errors.is_empty() {
Ok((entries, revoked))
} else {
Err(errors)
}
}
/// A version string that is also a plain release tag and file name part.
fn plain_version(v: &str) -> bool {
!v.is_empty()
&& v.len() <= 64
&& v.as_bytes()[0].is_ascii_alphanumeric()
&& v.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b))
}
fn plain_date(d: &str) -> bool {
let b = d.as_bytes();
b.len() == 10
&& b[4] == b'-'
&& b[7] == b'-'
&& b.iter()
.enumerate()
.all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit())
}
/// One project's entry and its files, as the index lists them.
pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
let mut errors = Vec::new();
let id = &entry.id;
if !valid_id(id) {
return Err(vec![format!("{id:?} is not [a-z0-9_]{{1,64}}")]);
}
if RESERVED.contains(&id.as_str()) {
return Err(vec![format!("{id:?} is reserved: pick another id")]);
}
open(&entry.kind).map_err(|e| vec![format!("{id}: {e}")])?;
if entry.owners.is_empty() {
errors.push(format!("{id}: needs at least one owner (a GitHub login)"));
}
if entry.summary.chars().count() > 160 {
errors.push(format!("{id}: the summary is over 160 characters"));
}
let icon = match entry.icon.as_deref() {
None => None,
Some(url) if url.starts_with("https://") => Some(url.to_owned()),
Some(path) => {
let ok = path
.strip_prefix("icons/")
.is_some_and(|name| super::plain_name(name) && name.ends_with(".png"));
let size = std::fs::metadata(src.registry.join(path)).map(|m| m.len());
match (ok, size) {
(true, Ok(n)) if n <= MAX_ICON => Some(format!("{}{path}", src.site)),
(true, Ok(_)) => {
errors.push(format!("{id}: {path} is over {} KiB", MAX_ICON >> 10));
None
}
(true, Err(e)) => {
errors.push(format!("{id}: {path}: {e}"));
None
}
(false, _) => {
errors.push(format!(
"{id}: icon {path:?} is neither icons/<name>.png nor https://"
));
None
}
}
}
};
if entry.versions.is_empty() {
errors.push(format!("{id}: has no version"));
}
let mut versions = Vec::new();
for v in &entry.versions {
match version(entry, v, src) {
Ok(v) => versions.push(v),
Err(e) => errors.push(format!("{id} {}: {e}", v.version)),
}
}
if !errors.is_empty() {
return Err(errors);
}
Ok(Project {
id: id.clone(),
kind: entry.kind.clone(),
name: entry.name.clone(),
summary: entry.summary.clone(),
description: entry.description.clone(),
authors: entry.authors.clone(),
owners: entry.owners.clone(),
license: entry.license.clone(),
source: entry.source.clone(),
icon,
key: entry.key.as_ref().map(|k| k.trim().to_owned()),
versions,
})
}
fn version(entry: &Entry, v: &EntryVersion, src: &Sources) -> Result<Version, String> {
if !plain_version(&v.version) {
return Err("a version is letters, digits, '.', '-' and '_'".into());
}
if !plain_date(&v.published) {
return Err(format!("published {:?} is not YYYY-MM-DD", v.published));
}
hex64(&v.sha256)?;
let path = src.files.join(&v.sha256);
let size = std::fs::metadata(&path)
.map_err(|e| format!("its file {}: {e}", v.sha256))?
.len();
if super::sha256_file(&path)? != v.sha256 {
return Err("the file isn't the one its sha256 names".into());
}
match (&entry.key, &v.signature) {
(Some(key), Some(sig)) => super::verify_signature(key, sig, &path)?,
(Some(_), None) => return Err("the project has a key: sign the file with it".into()),
(None, Some(_)) => return Err("signed, but the project has no key".into()),
(None, None) => {}
}
let facts = inspect(&entry.kind, &path)?;
if facts.id.as_ref().is_some_and(|id| *id != entry.id) {
return Err(format!(
"the file's id is {:?}, not {:?}",
facts.id.unwrap_or_default(),
entry.id
));
}
if facts.version.as_ref().is_some_and(|x| *x != v.version) {
return Err(format!(
"the file says version {:?}",
facts.version.unwrap_or_default()
));
}
if facts.game.as_ref().is_some_and(|g| *g != v.game) {
return Err(format!(
"the file is for {:?}, the entry says {:?}",
facts.game.unwrap_or_default(),
v.game
));
}
Ok(Version {
version: v.version.clone(),
published: v.published.clone(),
changelog: v.changelog.clone(),
api: facts.api,
side: facts.side,
capabilities: facts.capabilities,
game: v.game.clone(),
requires: facts.requires,
file: File {
url: format!("{}{}/{}", src.release, tag(&entry.id, &v.version), v.file),
name: v.file.clone(),
size,
sha256: v.sha256.clone(),
signature: v.signature.clone(),
},
})
}
impl Entry {
/// The entry as `mods/<id>.toml` holds it (the submission form writes it).
pub fn to_toml(&self) -> Result<String, String> {
toml::to_string(self).map_err(|e| e.to_string())
}
}
/// The index of the whole registry: every entry and file checked, then the
/// index's own rules ([`Index::check`]: names, a modpack's mods present).
pub fn build(src: &Sources) -> Result<Index, Vec<String>> {
let (mut entries, revoked) = load(src.registry)?;
entries.sort_by(|a, b| a.id.cmp(&b.id));
let mut errors = Vec::new();
let mut projects = Vec::new();
for entry in &entries {
match project(entry, src) {
Ok(p) => projects.push(p),
Err(e) => errors.extend(e),
}
}
if !errors.is_empty() {
return Err(errors);
}
let index = Index {
schema: SCHEMA,
generated: src.generated.clone(),
projects,
revoked,
};
let local = src.release.starts_with("file:///");
index.check_with(local).map_err(|e| vec![e])?;
Ok(index)
}
/// What a pull request changes, for its reviewers.
#[derive(Debug, Default, PartialEq)]
pub struct Review {
/// Changes the registry refuses whoever reviews them.
pub errors: Vec<String>,
/// What reviewers should look at: new projects, new owners or keys, a
/// change by someone who isn't an owner.
pub notes: Vec<String>,
}
/// Compares the entries of a pull request (`head`) with `main`'s (`base`):
/// a published file never changes and is never removed (take it down in
/// `revoked.toml`), a project's kind never changes, and changes by someone
/// who isn't an owner, or to owners and keys, are pointed out.
pub fn review(base: &[Entry], head: &[Entry], author: &str) -> Review {
let mut r = Review::default();
for old in base {
let Some(new) = head.iter().find(|e| e.id == old.id) else {
r.errors.push(format!(
"{}: removed; take its files down in {REVOKED} instead",
old.id
));
continue;
};
if new == old {
continue;
}
if !old.owners.iter().any(|o| o.eq_ignore_ascii_case(author)) {
r.notes.push(format!(
"{}: changed by {author}, who isn't one of its owners ({})",
old.id,
old.owners.join(", ")
));
}
if new.kind != old.kind {
r.errors.push(format!("{}: its kind can't change", old.id));
}
for v in &old.versions {
match new.versions.iter().find(|w| w.version == v.version) {
None => r.errors.push(format!(
"{} {}: removed; take it down in {REVOKED} instead",
old.id, v.version
)),
Some(w) if w.sha256 != v.sha256 || w.file != v.file => r.errors.push(format!(
"{} {}: a published file never changes: publish a new version",
old.id, v.version
)),
Some(_) => {}
}
}
if new.owners != old.owners {
r.notes.push(format!(
"{}: owners {} -> {}",
old.id,
old.owners.join(", "),
new.owners.join(", ")
));
}
if new.key != old.key {
r.notes.push(format!(
"{}: its signing key changes; updates signed with the old one stop working",
old.id
));
}
let added = new.versions.len().saturating_sub(old.versions.len());
if added > 0 {
r.notes.push(format!("{}: {added} new version(s)", old.id));
}
}
for new in head {
if !base.iter().any(|e| e.id == new.id) {
r.notes.push(format!(
"{}: a new {} by {}",
new.id,
new.kind.name(),
new.owners.join(", ")
));
}
}
r
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,237 @@
//! A registry checkout holding the sample index's mod and modpack builds the
//! sample index (`tests/index/index-v1.json`) exactly.
use super::*;
const HMOD: &str = "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53";
const HPACK: &str = "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65";
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 hud_entry() -> String {
let p = sample();
let sig = p.project("coords_hud").unwrap().versions[0]
.file
.signature
.clone()
.unwrap();
format!(
r#"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 = "icons/coords_hud.png"
key = "RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb"
[[versions]]
version = "0.1.0"
published = "2026-10-24"
game = "26.3"
file = "coords_hud-0.1.0.hmod"
sha256 = "{HMOD}"
signature = """{sig}"""
"#
)
}
const COZY: &str = r#"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"
game = "26.3"
file = "cozy-1.0.0.hpack"
sha256 = "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65"
"#;
const REVOKED_TOML: &str = r#"[[revoked]]
id = "bad_mod"
sha256 = "1111111111111111111111111111111111111111111111111111111111111111"
reason = "Sent chat messages it didn't declare."
date = "2026-10-24"
"#;
/// A registry checkout and its downloaded files, in a scratch folder.
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Scratch {
let dir =
std::env::temp_dir().join(format!("horizon-registry-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for sub in ["repo/mods", "repo/icons", "files"] {
std::fs::create_dir_all(dir.join(sub)).unwrap();
}
let s = Scratch(dir);
s.write("mods/coords_hud.toml", &hud_entry());
s.write("mods/cozy.toml", COZY);
s.write("revoked.toml", REVOKED_TOML);
s.write("icons/coords_hud.png", "png");
for (name, hash) in [("coords_hud-0.1.0.hmod", HMOD), ("cozy-1.0.0.hpack", HPACK)] {
std::fs::copy(fixtures().join(name), s.0.join("files").join(hash)).unwrap();
}
s
}
fn write(&self, path: &str, text: &str) {
std::fs::write(self.0.join("repo").join(path), text).unwrap();
}
fn build(&self) -> Result<Index, Vec<String>> {
let (repo, files) = (self.0.join("repo"), self.0.join("files"));
build(&Sources {
registry: &repo,
files: &files,
release: super::super::FILE_HOST,
site: "https://ludovic111.github.io/horizon-mods/",
generated: "2026-10-24T18:00:00Z".into(),
})
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn builds_the_sample_index() {
let s = Scratch::new("sample");
let built = s.build().unwrap();
let sample = sample();
assert_eq!(built.projects, sample.projects[..2]);
assert_eq!(built.revoked, sample.revoked);
assert_eq!(built.generated, sample.generated);
// What it writes reads back.
Index::parse(&serde_json::to_string_pretty(&built).unwrap()).unwrap();
}
#[test]
fn lists_every_problem_of_a_registry() {
let s = Scratch::new("bad");
// A file that isn't the one its hash names, an unsigned version of a
// signed project, a shader pack, and a mod whose file is another mod.
std::fs::write(s.0.join("files").join(HPACK), b"other bytes").unwrap();
s.write(
"mods/coords_hud.toml",
&hud_entry().replace("signature = \"\"\"", "changelog = \"\"\""),
);
s.write(
"mods/glow.toml",
&COZY
.replace("\"cozy\"", "\"glow\"")
.replace("modpack", "shader"),
);
s.write(
"mods/other.toml",
&hud_entry()
.replace("\"coords_hud\"", "\"other\"")
.replace("key = ", "# key = ")
.replace("signature = \"\"\"", "changelog = \"\"\""),
);
let errors = s.build().unwrap_err();
let has = |part: &str| errors.iter().any(|e| e.contains(part));
assert!(
has("cozy 1.0.0: the file isn't the one its sha256 names"),
"{errors:?}"
);
assert!(has("coords_hud 0.1.0: the project has a key"), "{errors:?}");
assert!(has("glow: shader packs open when"), "{errors:?}");
assert!(
has("other 0.1.0: the file's id is \"coords_hud\""),
"{errors:?}"
);
assert_eq!(errors.len(), 4, "{errors:?}");
}
#[test]
fn an_entry_is_named_by_its_id_and_has_only_known_fields() {
let s = Scratch::new("names");
s.write("mods/hud.toml", &hud_entry());
s.write("mods/notes.txt", "");
s.write("mods/cozy.toml", &format!("{COZY}homepage = \"x\"\n"));
let errors = load(&s.0.join("repo")).unwrap_err();
assert_eq!(errors.len(), 3, "{errors:?}");
assert!(errors[0].contains("cozy.toml") && errors[0].contains("homepage"));
assert!(errors[1].contains("must be mods/coords_hud.toml"));
assert!(errors[2].contains("only <id>.toml"));
}
#[test]
fn an_entry_written_reads_back() {
let entry: Entry = toml::from_str(&hud_entry()).unwrap();
let text = entry.to_toml().unwrap();
assert_eq!(toml::from_str::<Entry>(&text).unwrap(), entry);
assert!(text.contains("[[versions]]"), "{text}");
}
#[test]
fn a_review_protects_published_files() {
let base: Entry = toml::from_str(&hud_entry()).unwrap();
let cozy: Entry = toml::from_str(COZY).unwrap();
let one = std::slice::from_ref(&base);
let same = review(one, one, "someone");
assert_eq!(same, Review::default());
let mut update = base.clone();
let mut next = update.versions[0].clone();
next.version = "0.2.0".into();
update.versions.insert(0, next);
let r = review(one, std::slice::from_ref(&update), "ludovic111");
assert!(r.errors.is_empty(), "{r:?}");
assert_eq!(r.notes, ["coords_hud: 1 new version(s)"]);
let r = review(one, &[update], "stranger");
assert!(r.notes[0].contains("isn't one of its owners"), "{r:?}");
let mut swapped = base.clone();
swapped.versions[0].sha256 = HPACK.into();
swapped.kind = Kind::ResourcePack;
swapped.key = None;
let r = review(&[base.clone(), cozy.clone()], &[swapped], "ludovic111");
assert_eq!(r.errors.len(), 3, "{r:?}");
assert!(r.errors.iter().any(|e| e.contains("cozy: removed")));
assert!(r.errors.iter().any(|e| e.contains("never changes")));
assert!(r.errors.iter().any(|e| e.contains("kind can't change")));
assert!(r.notes.iter().any(|n| n.contains("signing key")));
let r = review(&[], &[cozy], "newcomer");
assert_eq!(r.notes, ["cozy: a new modpack by ludovic111"]);
}
#[test]
fn only_open_kinds_are_taken() {
assert!(open(&Kind::Mod).is_ok() && open(&Kind::ResourcePack).is_ok());
assert!(open(&Kind::Shader).unwrap_err().contains("Iris"));
assert!(open(&Kind::Other("world".into())).is_err());
let mut submit: Entry = toml::from_str(COZY).unwrap();
submit.id = "submit".into();
let dir = std::env::temp_dir();
let src = Sources {
registry: &dir,
files: &dir,
release: super::super::FILE_HOST,
site: "https://x.test/",
generated: String::new(),
};
assert!(project(&submit, &src).unwrap_err()[0].contains("reserved"));
assert!(plain_version("1.0.0-beta.2") && !plain_version("../1") && !plain_version("1 0"));
assert!(plain_date("2026-10-24") && !plain_date("24/10/2026"));
}
+165
View File
@@ -0,0 +1,165 @@
//! 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("75".into())
);
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,
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff. Show more