Compare commits

..
Author SHA1 Message Date
Ludovic Marie 8b3d4a069c Merge pull request #1686 from Domanffe/fix/launcher-remove-retry
Launcher: a version removal retries a locked file and leaves the UI thread
2026-10-25 14:10:32 +01:00
Ludovic Marie a839062259 Merge pull request #1694 from Domanffe/fix/swallowed-save-errors
Save: the write paths report a failure instead of dropping it
2026-10-25 14:10:24 +01:00
Ludovic Marie 6194463454 Merge pull request #1693 from Domanffe/fix/pack-overlay-windows
Packs: an untouched vanilla directory is mirrored on Windows too
2026-10-25 14:10:15 +01:00
Ludovic Marie a5f574b77e Merge pull request #1707 from Domanffe/chore/clippy-green
Clippy: clear the two warnings the whole tree fails on
2026-10-25 14:10:08 +01:00
Ludovic Marie c4573fb561 Merge pull request #1674 from sdraeger/fix/client-mods-server-thread
Client mods: tick, commands and chat on the server thread
2026-10-25 14:09:59 +01:00
Ludovic Marie 57fc1570b2 Merge pull request #1681 from socksprox/fix/join-loading-status
Multiplayer: drop the player name from the join loading line
2026-10-25 14:09:51 +01:00
Ludovic Marie 6926c76492 Merge pull request #1590 from socksprox/feat/boss-overlay 2026-10-25 14:02:40 +01:00
Ludovic Marie d2c96af142 Merge pull request #1789 from ludovic111/fix/main-unused-save-root
Fix main's build: unused save_root in open_world
2026-10-25 13:32:01 +01:00
Ludovic MarieandClaude Opus 5.5 d33f84ef73 Fix main's build: drop the unused save_root left in open_world
#1665 moved the save path into finish_open_world and #1666's merge kept the
old line, so clippy -D warnings fails on main (unused variable) since 7d0ae5658.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:24:35 +01:00
Ludovic Marie ad63f2e960 Merge pull request #1659 from ludovic111/feat/mod-registry-server 2026-10-25 12:46:00 +01:00
Ludovic Marie 7d0ae56589 Merge pull request #1666 from rdbtCVS/t3/explicit-execution-roles 2026-10-25 12:04:39 +01:00
Ludovic Marie e3de4a4604 Merge pull request #1665 from rdbtCVS/t3/separate-world-entry-loading 2026-10-25 12:02:41 +01:00
Ludovic Marie 9ae86086dc Merge pull request #1664 from Domanffe/fix/data-get-live-passengers 2026-10-25 12:00:55 +01:00
Ludovic Marie fbfec6435f Merge pull request #1588 from socksprox/feat/server-resource-packs 2026-10-25 11:49:29 +01:00
Ludovic Marie 7a3578b7d6 Merge pull request #1587 from levzzz5154/fix/predict-block-placement
Predict block placement on the client, so fast bridging stops losing clicks
2026-10-25 11:32:09 +01:00
Ludovic Marie 21ff80eb8c Merge pull request #1775 from ludovic111/docs/benchmark-your-computer
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 11:03:40 +01:00
Ludovic MarieandClaude Opus 5.5 f3213aa158 Docs: how players benchmark their computer and share the report
The Performance page now gives the launcher command (horizon-launcher play --arg=--benchmark
--arg=standard), where the report lands on each system, what it holds, and the two ways to
share it: horizon-launcher data benchmark, or the Discord.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:02:06 +01:00
Ludovic MarieandClaude Opus 5.5 71f3c23dd9 Merge main into the launcher removal branch
Main moved the removal to a thread in #1715 (Msg::Removed, the removing
row, the selection), which is this branch's UI half: main's version is
kept. What stays is backend::remove's retry on a file Windows still
holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:38:45 +01:00
Ludovic Marie 574d54e6e0 Merge pull request #1747 from ludovic111/perf/mesh-model-lookup 2026-10-25 09:38:10 +01:00
Ludovic MarieandClaude Opus 5.5 c74490a003 Multiplayer: the join line reads through lang with en_us's own fallback
lang::get_or, as the title screen's labels do; en_us 26.3 has
multiplayer.downloadingTerrain = "Loading terrain...".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:37:56 +01:00
Ludovic MarieandClaude Opus 5.5 a61c7faa23 Launcher: a version being removed shows Removing… instead of its buttons
With the removal off the UI thread, the row kept its Use and Remove
buttons, so a second Remove or Use could race the deletion. The retry
loop also reads as one match now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:34:02 +01:00
Ludovic MarieandClaude Opus 5.5 c0bf5cf954 Dedicated: the list saves log vanilla's warnings
DedicatedPlayerList logs "Failed to save user banlist: " (and ip
banlist, operators list, white-list) when a startup save fails; the
shared closure printed "Failed to load user banlist write". The closure
now takes the verb, so loads and saves read as vanilla's lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:33:13 +01:00
Ludovic MarieandClaude Opus 5.5 fb1f421117 Clippy: take the decorated region by value, and gate the unix-only test helper
receive_decorated moved the nine protos out of the box anyway (the
array iterator holds them inline), so taking [Proto; 9] and unboxing at
the three call sites costs the same and leaves boxed_local nothing to
flag on any target. The #[expect] failed on Linux, where the lint does
not fire, which is why verify was red.

scratch is only called from the #[cfg(unix)] tests, so it carries the
same cfg as installed() next to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:32:55 +01:00
Ludovic Marie 0c5d229ad0 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	Cargo.toml
2026-10-25 09:13:06 +01:00
Ludovic Marie 327a6cafc7 Merge pull request #1736 from ludovic111/feat/yung-jigsaw-draws 2026-10-25 09:08:04 +01:00
Ludovic Marie 6a32ee68d4 Merge pull request #1725 from RVZO6/feature/text-display-rendering
Client: render server text displays and lobby labels
2026-10-25 09:07:59 +01:00
Ludovic Marie 0e73edceff Merge pull request #1718 from RVZO6/feature/text-display-state
Client: model text-display metadata against the Java wire codec
2026-10-25 09:07:02 +01:00
Ludovic Marie f7bd3e381b Merge pull request #1729 from RVZO6/fix/stop-sound-packet
Client: honor server stop-sound event and source filters
2026-10-25 09:06:02 +01:00
Ludovic MarieandClaude Sonnet 5.5 ea0905e8a2 lan_e2e: no blank lines inside the docstring after the merge
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 08:58:25 +01:00
Ludovic Marie b149a0ec3f Merge main into fix/stop-sound-packet: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:13:11 +01:00
Ludovic MarieandClaude Opus 5.5 659bfad86c Merge the text-display model fixups (#1718) into the renderer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:13:00 +01:00
Ludovic MarieandClaude Opus 5.5 76408b734e Text displays: an Align enum, and the text shared rather than copied
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:12:31 +01:00
Ludovic MarieandClaude Opus 5.5 4e3e038c80 Meshing: look up a block's baked models by index, not by model name
Every block the mesher draws asked the model catalog for its models: a
BTreeMap lookup of the state, a Vec of the chosen variants, then for each
variant a clone of its model name and a string-keyed BTreeMap lookup of the
baked model, collected into a second Vec. On generated terrain that was a
fifth of the mesher's time.

The catalog now resolves each variant to its baked model's index once, at
load (CompiledChoices maps its variants), keeps states in a Vec by id, and
the mesher reuses one list of models for every block of a section. The
random draws are the same calls in the same order (CompiledChoices::for_each
is the old collect), so the same models are chosen.

examples/chunk_load_bench.rs generates and lights real terrain with the
vanilla generator and meshes it on one thread, with a checksum of the meshes:
identical meshes, 45.8 -> 35.4 us per section on the Mac mini.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:11:52 +01:00
Ludovic MarieandClaude Opus 5.5 b0918f3be1 /stopsound: send each targeted server player the stop-sound packet
StopSoundCommand sends ClientboundStopSoundPacket to every target. The
command stopped the server's own sounds instead, so on the server thread
(single player's default) and for LAN guests nothing stopped.

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

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

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

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

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

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

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

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

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

Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 04:15:14 +00:00
Ludovic MarieandClaude Opus 5.5 41f8cfa3bc YUNG's API jigsaw draws: yung_jigsaw structures and yung_single_element read as written
Clean-room, from spec-yungsapi.md §2.1 and §3.1 (no mod source read).
A `yungsapi:yung_jigsaw` structure lays out with the jigsaw assembler and
YUNG's draws: the start pool's file entries shuffled, then the rotation,
then a weighted pick in the shuffled order; each jigsaw's target pool is
drawn by weight from its shuffled entries without replacement, an element
whose `name` already has `max_count` pieces skipped at no other draw.
`yungsapi:yung_single_element` places what a single element places; its
pool entry carries the name and limit.

tools/test_structure_layout_parity.py --profile betterdungeons reads the
mod's own data from its jar into an overlay of the 26.3 resources and
compares the mod world's pieces: 82 of 84 Better Dungeons starts match
piece for piece (60 small, 11 skeleton, 11 zombie dungeons, up to 790
pieces); the other 2 sit in 26.3's sulfur_caves, which 1.21.1 lacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:08:43 +01:00
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
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
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
Alexander Krylov 961af30997 Clippy: clear the two warnings that the whole tree fails on
The update tests' scratch helper only feeds unix-gated tests, and
receive_decorated takes the box the queue already put the protos in.
2026-10-25 04:34:05 +03:00
Alexander Krylov a96dccf691 Save: the write paths report a failure instead of dropping it
The moderation lists on a dedicated server, command storage, boss bars, the
world clocks, the difficulty and the emergency save before a crash all used
let _ = and lost the error. write_dat also swallowed the rename that moves the
current file aside, which made the following rename fail on the existing path
with nothing reporting it.
2026-10-25 03:57:31 +03:00
Alexander Krylov 6d1f6965d2 Packs: an untouched vanilla directory is mirrored on Windows too (#1688)
link cannot symlink a directory on Windows, so mirror has to recurse into one
itself, but it only did so when a pack had filters. An untouched directory fell
through to the link branch and vanished from the overlay, which took
textures/font and the panorama with it and made a pack that overrides a font
crash the client before its window.
2026-10-25 03:57:00 +03:00
some-du6eandClaude Opus 5.5 f66c257ae5 Advancement chat hover: normalise the title with canonical_component
scoreboard::canonical_component already re-encodes a component as vanilla
does, list form included, so decorate_name uses it instead of its own copy.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:39:57 -06:00
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 e55b5b7c00 Boss events: custom bars first, as a joining player gets them
CustomBossEvents.onPlayerConnect adds a joining player to its bars before any
entity, dragon fight or raid tells it about theirs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:21:03 +02:00
Alexander Krylov a93c1507f9 Launcher: a version removal retries a locked file and leaves the UI thread
remove_dir_all stops at the first file another process still holds and leaves
the rest deleted, so a removal that worked reported os error 5. Retry five times
250ms apart and treat a missing folder as done. The window froze because the
deletion ran from the update loop; it now runs on a thread and reports back
with Msg::Removed, as install already did.
2026-10-25 03:16:56 +03:00
Ludovic MarieandClaude Opus 5.5 a26297058a Trims: a Horizon host sends them too
The host wrote no trim component, so single player on its server thread,
LAN guests and dedicated-server players saw armour without its trim.
network_stack now takes the connection's synced-registry lookup for any
registry and writes Component::Trim when both names are in it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:15:25 +02:00
Ludovic Marie 149864c3b4 Merge remote-tracking branch 'origin/main' into perf/face-cull-cr1
# Conflicts:
#	crates/horizon-client/src/mesher/leaf_culling_tests.rs
2026-10-25 02:15:14 +02:00
Ludovic MarieandClaude Opus 5.5 42afd721e7 Server packs: the download toast shows over the connection screen
A server sends its packs in the configuration phase, while the joining
screen covers the frame; vanilla's ToastManager draws over every screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:04:39 +02:00
Ludovic MarieandClaude Opus 5.5 75c009e3fc Boss events: one path from the host's diff to every overlay
The host diffed each viewer's boss events into a Change enum, then turned the
changes into packets for guests and, through a second match, into setter calls
on the window's own overlay; the client turned packets into the same setters a
third way. The diff now yields ClientboundBossEventPacket values, guests get
them as packets and the local overlay applies them with BossOverlay::apply,
the same function a server's packets reach. Gone: Change, Snap, Sent,
apply_local, boss_packet and the overlay's nine-argument add and setters.
UUIDUtil.uuidFromIntArray joins uuid_ints in lan::playerdata.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:53:03 +02:00
socksproxandDevin ba09b086a6 Multiplayer: drop the player name from the join loading line
The pre-world overlay said "Joined as {name}; loading terrain..." after
Event::Joined. Vanilla's LevelLoadingScreen only ever draws the
multiplayer.downloadingTerrain text there — use that translation, no
name.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 01:49:31 +02:00
Ludovic MarieandClaude Opus 5.5 f07d66c7cf Trims: their own arm in Component::read
The trim decode sat inside the read-past fallback and matched the kind
there; it now has a TRIM arm, and the fallback only reads past. Long
comment lines wrapped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:44:21 +02:00
Ludovic MarieandClaude Opus 5.5 6d735c26d3 Server packs: the download toast follows DownloadedPackSource's notifier
What the toast showed differed from vanilla 26.3 in several ways:
- the title and the failure line had no arguments: download.pack.title takes
  the request number and the batch size, download.pack.failed the failures
  and the batch size;
- sizes read "1.3 KiB" style; com.mojang.realmsclient.Unit.humanReadable gives
  "1.3 KB" ("512 B" below 1 KiB, half-up rounding), checked against the jar;
- each update didn't restart SystemToast's 5 s display time, so a download
  longer than 5 s lost its toast, and the Notification Time option was ignored;
- a finished batch removed the toast at once instead of forceHide's slide out;
- the failure title sat at y 7 in white; SystemToast draws titles yellow, and a
  title without a message at y 12;
- a second batch replaced the first one's channel and lost its events.

The download thread now reports HttpUtil's listener calls (requestStart,
downloadStart with the Content-Length, downloadedBytes, requestFinished), and
each batch gets a PackNotifier (createDownloadNotifier) with its own toast. The
toast is a shared toasts::SystemToast; the system background moved to
toasts.rs with a y, so the pack toasts stack under an advancement toast.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:29:12 +02:00
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 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
rdbtCVSandOpenAI Codex GPT-6.1 c41a053c15 refactor: separate client world entry from authoritative loading
Give integrated and dedicated startup a synchronous loading entry point without client deferral or integrated-server selection. Keep client orchestration and background loading unchanged.

Co-Authored-By: OpenAI Codex GPT-6.1 <[email protected]>
2026-10-24 23:53:29 +01:00
rdbtCVSandOpenAI Codex 0d1736da8f Make startup and world-loading execution roles explicit
Separate client, integrated-server and dedicated-server roles from connection state. Use roles for startup, presentation work and server dimension loading while preserving in-frame client simulation.

Co-Authored-By: OpenAI Codex (GPT-6.1) <[email protected]>
2026-10-24 23:53:00 +01:00
Alexander KrylovandHermes Agent 977ef7181f Data: /data get entity writes a mob's live riders
A mounted mob answered "Found no elements matching Passengers": the command
built the entity with the flat serializer, so `Passengers` was never in the
tag, even though the game had the relationship and saving a world kept it.

Persistence already writes the tree (`Game::mob_tree_nbt`, which also carries
the leash), and vanilla's command path uses the same one, so the command reads
it now.

Fixes #1519.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-25 01:51:47 +03:00
socksproxandDevin 282ecf16a9 Tests: fix boss-event verify failures
boss_event_round_trips missed its #[test] attribute, failing clippy's
dead-code lint; unknown_packets_keep_their_payload decoded boss_event,
which this PR gives a real decoder, so the garbage payload no longer
comes back Unknown — it uses player_look_at, which still has none.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:45:16 +02:00
Ludovic MarieandClaude Opus 5.5 7b56b59e7c Mod registry: the submission form's service (crates/horizon-mod-registry)
Its own workspace, as the telemetry server's, for its own Railway service.
GitHub sign-in with no scope (the user's token reads the login once and is
dropped), signed-cookie sessions, an origin check and a CSRF token, six
submissions per account and sixty in all per hour. A submission runs the
file's own checks (horizon_mods::index::inspect, the signature, the entry's
fields through registry::project), uploads it to the registry's `staging`
release as <sha256>.<extension>, writes mods/<id>.toml on a new branch with
the submitter as co-author, and opens the pull request. Only an entry's
owners publish updates; a key change is a pull request by hand. Shader packs
are refused until their format exists.

Tested end to end against a mock GitHub: sign-in, the signed sample mod,
what lands on GitHub, and what is refused.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:42:17 +02:00
Ludovic MarieandClaude Opus 5.5 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
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
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 3f1a7b28af Placement prediction: one helper for predicted blocks, the sound where the block went
- predict_use and predict_place set their blocks through one
  predict_blocks (retainKnownServerState), instead of two copies of the
  loop.
- remote_can_place returns where BlockItem.place put the block, and the
  placer hears the place sound there: before, set_block only played it at
  the clicked face's neighbour, so a block that replaced grass or snow
  where the player clicked made no sound for its placer now that the
  server leaves the placer out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:39:13 +02:00
Simon Dräger 891e1c1283 Run client mod hooks on the server thread 2026-10-24 13:21:23 -07:00
socksproxandDevin a5f5d8df34 Server packs: the download progress toast
#1570 landed the client pack manager without vanilla's
PackDownloadNotificationComponent: the SystemToast that follows a
download with download.pack.progress.percent or .bytes and stays as
download.pack.failed when a batch fails. The download queue reports
requestStart/downloadedBytes/downloadFinished on a channel (vanilla's
DownloadProgressListener), the game ticks them into a toast it slides
in for the vanilla 5 seconds with the toast in/out sounds.

Co-Authored-By: Devin <[email protected]>
2026-10-24 21:49:01 +02:00
levzzzandClaude Opus 5.5 90b5e162ae Play a placed block's sound once for its placer
BlockItem.place plays it with playSound(player, ...): the server sends it to
the others, and the placer's client plays it as its block appears. The
server sent it to the placer too, so each block sounded twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 00:38:39 +05:00
socksproxandDevin d742d725f4 Client: the boss-overlay — BossEvent bars, wither darkening and world fog
The clientbound boss_event packet (add/update/remove/style/progress), a
BossOverlay that renders each bar, darkens the world under a wither, and
suppresses the create-the-world fog — vanilla's BossHealthOverlay and
GameRenderer behaviour on a real server.

Co-Authored-By: Devin <[email protected]>
2026-10-24 21:29:08 +02:00
Ludovic MarieandClaude Opus 5.5 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
levzzzandClaude Opus 5.5 352645b417 Predict block placement on the client, so fast bridging stops losing clicks
MultiPlayerGameMode.useItemOn places the block on the client's level at
once, kept under the use's sequence until BlockChangedAck. Without it the
next fast click aimed past the block still on its way from the server, the
server refused it, and the arm swung for nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 00:07:31 +05:00
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 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 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
119 changed files with 10730 additions and 981 deletions

No files matched your search

+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
+34
View File
@@ -0,0 +1,34 @@
name: mod-registry
# The submission service is its own workspace (crates/horizon-mod-registry), so the checks job doesn't build
# it: this one does, when it or horizon-mods (the checks it shares with the launcher and the game) changes.
on:
push:
branches: [main]
paths:
- 'crates/horizon-mods/**'
- 'crates/horizon-mod-registry/**'
- '.github/workflows/mod-registry.yml'
pull_request:
paths:
- 'crates/horizon-mods/**'
- 'crates/horizon-mod-registry/**'
- '.github/workflows/mod-registry.yml'
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: crates/horizon-mod-registry
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: crates/horizon-mod-registry
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo +stable fmt --check
- run: cargo +stable clippy --all-targets --locked -- -D warnings
- run: cargo +stable test --locked
+2
View File
@@ -39,6 +39,8 @@ 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
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
resolver = "2"
+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()),
}
}
+50 -15
View File
@@ -4,15 +4,15 @@ use crate::json::{Result, error};
use horizon_core::random::RandomSource;
#[derive(Clone, Debug)]
enum Choice {
Single(Variant),
enum Choice<T> {
Single(T),
Weighted {
variants: Vec<Variant>,
variants: Vec<T>,
ends: Vec<i32>,
total: i32,
},
}
impl Choice {
impl Choice<Variant> {
fn new(value: &VariantSet) -> Result<Self> {
match value {
VariantSet::Single(value) => Ok(Self::Single(value.clone())),
@@ -41,7 +41,23 @@ impl Choice {
}
}
}
fn select(&self, random: &mut impl RandomSource) -> &Variant {
}
impl<T> Choice<T> {
fn map<U>(&self, f: &mut impl FnMut(&T) -> U) -> Choice<U> {
match self {
Self::Single(value) => Choice::Single(f(value)),
Self::Weighted {
variants,
ends,
total,
} => Choice::Weighted {
variants: variants.iter().map(&mut *f).collect(),
ends: ends.clone(),
total: *total,
},
}
}
fn select(&self, random: &mut impl RandomSource) -> &T {
match self {
Self::Single(value) => value,
Self::Weighted {
@@ -60,10 +76,11 @@ impl Choice {
}
/// Compile once per definition and share across its bound block states. Prefix
/// sums avoid rebuilding weight totals or scanning all entries during selection.
/// `map` swaps each variant for what it stands for (its baked model).
#[derive(Clone, Debug)]
pub struct CompiledChoices {
variants: Vec<Choice>,
multipart: Vec<Choice>,
pub struct CompiledChoices<T = Variant> {
variants: Vec<Choice<T>>,
multipart: Vec<Choice<T>>,
}
impl CompiledChoices {
pub fn new(definition: &BlockStateDefinition) -> Result<Self> {
@@ -82,10 +99,19 @@ impl CompiledChoices {
.collect::<Result<_>>()?,
})
}
}
impl<T> CompiledChoices<T> {
/// The same choices, with `f` of each variant in its place.
pub fn map<U>(&self, mut f: impl FnMut(&T) -> U) -> CompiledChoices<U> {
CompiledChoices {
variants: self.variants.iter().map(|c| c.map(&mut f)).collect(),
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
}
}
/// The variant `particleMaterial` comes from: the first entry of a weighted
/// list (`WeightedVariants`) and, for a multipart root, of its first
/// selector whether or not that selector applies (`MultiPartModel`).
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&Variant> {
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&T> {
let choice = match selected {
SelectedModels::Variant(index) => self.variants.get(*index)?,
SelectedModels::Multipart(_) => self.multipart.first()?,
@@ -95,14 +121,23 @@ impl CompiledChoices {
Choice::Weighted { variants, .. } => variants.first()?,
})
}
/// Append selected variants without allocating intermediate part lists. A
/// multipart root consumes nextLong even for an empty selected subset, then
/// resets the same random source to that seed before each selected part.
/// Append selected variants without allocating intermediate part lists.
pub fn collect<'a>(
&'a self,
selected: &SelectedModels,
random: &mut impl RandomSource,
output: &mut Vec<&'a Variant>,
output: &mut Vec<&'a T>,
) -> Result<()> {
self.for_each(selected, random, |v| output.push(v))
}
/// Each selected variant, in order. A multipart root consumes nextLong
/// even for an empty selected subset, then resets the same random source
/// to that seed before each selected part.
pub fn for_each<'a>(
&'a self,
selected: &SelectedModels,
random: &mut impl RandomSource,
mut f: impl FnMut(&'a T),
) -> Result<()> {
match selected {
SelectedModels::Variant(index) => {
@@ -110,7 +145,7 @@ impl CompiledChoices {
.variants
.get(*index)
.ok_or_else(|| error("invalid variant selection"))?;
output.push(choice.select(random));
f(choice.select(random));
}
SelectedModels::Multipart(indices) => {
if indices.iter().any(|index| *index >= self.multipart.len()) {
@@ -119,7 +154,7 @@ impl CompiledChoices {
let seed = random.next_long();
for index in indices {
random.set_seed(seed);
output.push(self.multipart[*index].select(random));
f(self.multipart[*index].select(random));
}
}
}
@@ -0,0 +1,168 @@
//! What a column costs to load, on real terrain: generates and lights the
//! columns around a point with the vanilla generator, as a world being explored
//! does, then meshes every section of the inner columns on this one thread, so a
//! change to generation or meshing shows as time per column and per section.
//!
//! cargo run --release -p horizon-client --example chunk_load_bench -- \
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 8]
//! [--threads N] [--rounds 3]
//!
//! The default centre is the village of `tools/perf_bench.py`. The generation
//! figure is wall time with N workers (the game's own pool); the mesh figure is
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
//! work on the machine changes it less. The checksum of the meshes shows that an
//! A/B pair of builds made the same ones.
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::HEIGHT,
light::LightTables,
mesher::{MeshTables, Mesher, Region, borrow},
models,
spans::thread_cpu,
streaming::{Streaming, WorkerContext},
vanillagen::VanillaTerrain,
world::World,
};
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
use horizon_world::registry::BlockStateId;
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::PathBuf,
sync::Arc,
thread,
time::{Duration, Instant},
};
type Error = Box<dyn std::error::Error>;
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1)?.parse().ok())
.unwrap_or(default)
}
fn main() -> Result<(), Error> {
let args: Vec<String> = std::env::args().collect();
let root = PathBuf::from(args.get(1).ok_or("usage: chunk_load_bench <resources>")?);
let seed: i64 = flag(&args, "--seed", 1234);
let radius: i32 = flag(&args, "--radius", 8);
let rounds: usize = flag(&args, "--rounds", 3);
let threads: usize = flag(
&args,
"--threads",
thread::available_parallelism().map_or(2, |n| n.get()),
);
let center = args
.iter()
.position(|a| a == "--center")
.map(|i| -> Result<(i32, i32), Error> {
Ok((
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
))
})
.transpose()?
.unwrap_or((-60, 10));
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let visibility = models::load_visibility(&root, assets.registry)?;
let biomes = Arc::new(Biomes::load(&root)?);
let context = Arc::new(WorkerContext {
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
biomes: biomes.clone(),
assets: assets.clone(),
render,
});
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
// Generation and light, on the game's worker pool. Meshing is left out
// here and measured on its own below.
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
streaming.mesh = false;
streaming.center = center;
streaming.radius = radius;
let mut world = World::new(BlockStateId(0), 0);
let started = Instant::now();
let mut quiet = 0;
while quiet < 100 {
let before = world.chunks.len();
let _ = streaming.update(&mut world);
thread::sleep(Duration::from_millis(2));
quiet = if streaming.pending() == 0 && before == world.chunks.len() {
quiet + 1
} else {
0
};
}
let generated = started.elapsed() - Duration::from_millis(2 * 100);
let columns = world.chunks.len();
println!(
"generated and lit {columns} columns in {:.0} ms with {threads} workers ({:.2} ms per column)",
generated.as_secs_f64() * 1e3,
generated.as_secs_f64() * 1e3 / columns.max(1) as f64
);
// Meshing every section of the columns whose neighbours are all loaded.
let inner = radius - 1;
let regions: Vec<(Region, i32)> = (center.0 - inner..=center.0 + inner)
.flat_map(|x| (center.1 - inner..=center.1 + inner).map(move |z| (x, z)))
.filter_map(|(x, z)| {
let region: Region = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
world
.chunks
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
.cloned()
})
});
region
.iter()
.flatten()
.all(Option::is_some)
.then_some(region)
})
.flat_map(|region| (0..HEIGHT / 16).map(move |sy| (region.clone(), sy)))
.collect();
let mut best = Duration::MAX;
let (mut vertices, mut checksum) = (0, 0);
for _ in 0..rounds.max(1) {
let mut mesher = Mesher {
nether_shading: false,
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility: context.tables.visibility.clone(),
};
vertices = 0;
let mut hash = DefaultHasher::new();
let mut spent = Duration::ZERO;
for (region, sy) in &regions {
let started = thread_cpu().ok_or("no thread CPU clock here")?;
let mesh = mesher.mesh(&borrow(region), *sy);
spent += thread_cpu().ok_or("no thread CPU clock here")? - started;
vertices += mesh.solid.len() + mesh.translucent.len();
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
}
checksum = hash.finish();
best = best.min(spent);
}
println!(
"meshed {} sections ({vertices} vertices, checksum {checksum:016x}) in {:.1} ms of CPU ({:.1} us per section)",
regions.len(),
best.as_secs_f64() * 1e3,
best.as_secs_f64() * 1e6 / regions.len().max(1) as f64
);
Ok(())
}
@@ -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,
@@ -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() {
@@ -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);
}
+243
View File
@@ -0,0 +1,243 @@
//! `BossHealthOverlay` and `LerpingBossEvent`: the boss bars a client is
//! showing, in the order their add packets arrived.
use crate::{
hud_overlays::BossBar,
scoreboard::{comp_text, tag_to_json},
text::Text,
};
use horizon_protocol::packets::play::{BossAction, BossEvent};
/// `LerpingBossEvent.LERP_MILLISECONDS`.
const LERP_MS: u128 = 100;
/// `Util.getMillis`.
pub fn now_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
/// One `LerpingBossEvent`.
#[derive(Clone, Debug)]
struct Event {
id: u128,
name: Text,
/// The progress the lerp started from (`progress`).
progress: f32,
/// `targetPercent`.
target: f32,
/// `setTime`.
set_at: u128,
color: u8,
overlay: u8,
darken: bool,
music: bool,
fog: bool,
}
impl Event {
/// `LerpingBossEvent.getProgress`.
fn displayed(&self, now: u128) -> f32 {
let t = ((now.saturating_sub(self.set_at)) as f32 / LERP_MS as f32).clamp(0.0, 1.0);
self.progress + (self.target - self.progress) * t
}
/// `LerpingBossEvent.setProgress`: the bar starts the next lerp from
/// whatever it is showing now.
fn set_progress(&mut self, progress: f32, now: u128) {
self.progress = self.displayed(now);
self.target = progress;
self.set_at = now;
}
}
/// `BossHealthOverlay`: the events, plus `GameRenderer`'s world darkening.
#[derive(Clone, Debug, Default)]
pub struct BossOverlay {
events: Vec<Event>,
/// `bossOverlayWorldDarkening` and the previous tick's value.
darken: f32,
darken_o: f32,
}
impl BossOverlay {
pub fn reset(&mut self) {
*self = Self::default();
}
/// `ClientboundBossEventPacket.dispatch` into `BossHealthOverlay`'s
/// handler. An id already shown keeps its place when added again.
pub fn apply(&mut self, packet: &BossEvent, now: u128) {
// An ordinal past the enum shows as its last value.
let color = |c: i32| c.clamp(0, 6) as u8;
let overlay = |o: i32| o.clamp(0, 4) as u8;
let id = packet.id;
if let BossAction::Add {
name,
progress,
color: c,
overlay: o,
darken,
music,
fog,
} = &packet.action
{
let event = Event {
id,
name: comp_text(&tag_to_json(name)),
progress: *progress,
target: *progress,
set_at: now,
color: color(*c),
overlay: overlay(*o),
darken: *darken,
music: *music,
fog: *fog,
};
match self.events.iter_mut().find(|e| e.id == id) {
Some(slot) => *slot = event,
None => self.events.push(event),
}
return;
}
if let BossAction::Remove = packet.action {
self.events.retain(|e| e.id != id);
return;
}
let Some(e) = self.events.iter_mut().find(|e| e.id == id) else {
return;
};
match &packet.action {
BossAction::UpdateProgress { progress } => e.set_progress(*progress, now),
BossAction::UpdateName { name } => e.name = comp_text(&tag_to_json(name)),
BossAction::UpdateStyle {
color: c,
overlay: o,
} => {
e.color = color(*c);
e.overlay = overlay(*o);
}
BossAction::UpdateProperties { darken, music, fog } => {
(e.darken, e.music, e.fog) = (*darken, *music, *fog);
}
BossAction::Add { .. } | BossAction::Remove => {}
}
}
/// `GameRenderer.tick` while the level runs.
pub fn tick_darken(&mut self, running: bool) {
if !running {
return;
}
self.darken_o = self.darken;
if self.should_darken() {
self.darken = (self.darken + 0.05).min(1.0);
} else if self.darken > 0.0 {
self.darken = (self.darken - 0.0125).max(0.0);
}
}
/// `GameRenderer.bossOverlayWorldDarkening`.
pub fn darken_amount(&self, partial: f32) -> f32 {
let t = partial.clamp(0.0, 1.0);
self.darken_o + (self.darken - self.darken_o) * t
}
pub fn should_darken(&self) -> bool {
self.events.iter().any(|e| e.darken)
}
pub fn should_play_music(&self) -> bool {
self.events.iter().any(|e| e.music)
}
pub fn should_create_world_fog(&self) -> bool {
self.events.iter().any(|e| e.fog)
}
/// The bars `extractRenderState` walks, with the lerped progress.
pub fn bars(&self, now: u128) -> Vec<BossBar> {
self.events
.iter()
.map(|e| BossBar {
name: e.name.clone(),
progress: e.displayed(now),
color: e.color as usize,
overlay: e.overlay as usize,
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::Tag;
fn add(id: u128, name: &str, progress: f32, darken: bool, fog: bool) -> BossEvent {
BossEvent {
id,
action: BossAction::Add {
name: Tag::String(name.into()),
progress,
color: 5,
overlay: 0,
darken,
music: false,
fog,
},
}
}
fn progress(id: u128, progress: f32) -> BossEvent {
BossEvent {
id,
action: BossAction::UpdateProgress { progress },
}
}
#[test]
fn progress_lerps_over_a_tenth_of_a_second() {
let mut overlay = BossOverlay::default();
overlay.apply(&add(1, "W", 0.0, true, false), 0);
overlay.apply(&progress(1, 1.0), 0);
assert!((overlay.bars(50)[0].progress - 0.5).abs() < 1.0e-5);
assert!((overlay.bars(100)[0].progress - 1.0).abs() < 1.0e-5);
// A new target starts from the value on screen.
overlay.apply(&progress(1, 0.0), 50);
assert!((overlay.bars(150)[0].progress - 0.0).abs() < 1.0e-5);
}
#[test]
fn darken_ramps_and_fades() {
let mut overlay = BossOverlay::default();
overlay.apply(&add(1, "W", 1.0, true, false), 0);
overlay.tick_darken(true);
assert!((overlay.darken_amount(1.0) - 0.05).abs() < 1.0e-5);
overlay.apply(
&BossEvent {
id: 1,
action: BossAction::Remove,
},
0,
);
overlay.tick_darken(true);
assert!((overlay.darken - 0.0375).abs() < 1.0e-5);
assert!(!overlay.should_play_music());
}
#[test]
fn bars_keep_arrival_order() {
let mut overlay = BossOverlay::default();
overlay.apply(&add(2, "b", 1.0, false, true), 0);
overlay.apply(&add(1, "a", 1.0, false, false), 0);
overlay.apply(&add(2, "c", 0.5, false, true), 0);
// An update for a bar this client never got is ignored.
overlay.apply(&progress(9, 0.5), 0);
let names: Vec<_> = overlay
.bars(0)
.into_iter()
.map(|b| b.name.plain())
.collect();
assert_eq!(names, ["c", "a"]);
assert!(overlay.should_create_world_fog());
overlay.apply(
&BossEvent {
id: 2,
action: BossAction::Remove,
},
0,
);
assert!(!overlay.should_create_world_fog());
}
}
+71 -37
View File
@@ -14,40 +14,64 @@ pub const STYLES: [&str; 5] = [
"notched_12",
"notched_20",
];
/// `BossBarColor.getFormatting`.
/// `BossBarColor.getFormatting`. Purple is dark blue in 26.3.
pub fn color_rgb(color: &str) -> u32 {
let name = match color {
"pink" => "red",
"red" => "dark_red",
"purple" => "dark_purple",
"purple" => "dark_blue",
other => other,
};
text::named_color(name).unwrap_or(text::WHITE)
}
/// A version-4 shaped id from a resource id. Saved boss events do not keep
/// their UUID (`CustomBossEvents` mints a new one on load).
pub fn stable_uuid(id: &str) -> u128 {
let mut h = 0xcbf29ce484222325u64;
for b in id.as_bytes() {
h ^= u64::from(*b);
h = h.wrapping_mul(0x100000001b3);
}
let most = (h as i64) & -61441 | 16384;
let least = (h.rotate_left(17) as i64) & 4_611_686_018_427_387_903 | i64::MIN;
((most as u64 as u128) << 64) | (least as u64 as u128)
}
#[derive(Clone, Debug, PartialEq)]
pub struct BossBar {
pub id: String,
/// The `ServerBossEvent` UUID. Not saved.
pub uuid: u128,
pub name: Comp,
pub color: String,
pub style: String,
pub value: i32,
pub max: i32,
/// `ServerBossEvent.visible`. A new bar is visible; nobody sees it until
/// a player is added.
pub visible: bool,
/// Whether the (single) local player is in the bar's player set.
pub player: bool,
pub darken: bool,
pub music: bool,
pub fog: bool,
/// `CustomBossEvent.players`, the UUIDs told about the bar.
pub players: Vec<[i32; 4]>,
}
impl BossBar {
pub fn new(id: &str, name: Comp) -> Self {
Self {
id: id.to_owned(),
uuid: stable_uuid(id),
name,
color: "white".into(),
style: "progress".into(),
value: 0,
max: 100,
visible: true,
player: false,
darken: false,
music: false,
fog: false,
players: Vec::new(),
}
}
/// `CustomBossEvent.getDisplayName`.
@@ -91,31 +115,34 @@ impl BossBars {
pub fn ids(&self) -> Vec<String> {
self.bars.iter().map(|b| b.id.clone()).collect()
}
/// The saved compound; the player's UUID stands for "the local player".
pub fn pack(&self, player: Option<[i32; 4]>) -> Tag {
/// The saved compound (`CustomBossEvent.Packed`).
pub fn pack(&self) -> Tag {
let mut c = Compound::new();
for b in &self.bars {
let mut e = vec![
("Name", json_to_tag(&b.name)),
("Visible", Tag::Byte(i8::from(b.visible))),
("Value", Tag::Int(b.value)),
("Max", Tag::Int(b.max)),
("Color", Tag::String(b.color.as_str().into())),
("Overlay", Tag::String(b.style.as_str().into())),
("DarkenScreen", Tag::Byte(0)),
("PlayBossMusic", Tag::Byte(0)),
("CreateWorldFog", Tag::Byte(0)),
];
let players = match (b.player, player) {
(true, Some(u)) => vec![Tag::IntArray(u.to_vec())],
_ => Vec::new(),
};
e.push(("Players", Tag::List(players)));
c.insert(b.id.as_str().into(), Tag::compound(e));
let players = b
.players
.iter()
.map(|u| Tag::IntArray(u.to_vec()))
.collect();
c.insert(
b.id.as_str().into(),
Tag::compound([
("Name", json_to_tag(&b.name)),
("Visible", Tag::Byte(i8::from(b.visible))),
("Value", Tag::Int(b.value)),
("Max", Tag::Int(b.max)),
("Color", Tag::String(b.color.as_str().into())),
("Overlay", Tag::String(b.style.as_str().into())),
("DarkenScreen", Tag::Byte(i8::from(b.darken))),
("PlayBossMusic", Tag::Byte(i8::from(b.music))),
("CreateWorldFog", Tag::Byte(i8::from(b.fog))),
("Players", Tag::List(players)),
]),
);
}
Tag::Compound(c)
}
pub fn unpack(data: &Tag, player: Option<[i32; 4]>) -> Self {
pub fn unpack(data: &Tag) -> Self {
let mut bars = Vec::new();
if let Tag::Compound(c) = data {
for (id, e) in c {
@@ -130,21 +157,30 @@ impl BossBars {
Some(Tag::Byte(v)) => i32::from(*v),
_ => d,
};
let in_players = match (get("Players"), player) {
(Some(Tag::List(l)), Some(u)) => l
let players = match get("Players") {
Some(Tag::List(l)) => l
.iter()
.any(|t| matches!(t, Tag::IntArray(a) if a.as_slice() == u)),
_ => false,
.filter_map(|t| match t {
Tag::IntArray(a) if a.len() == 4 => Some([a[0], a[1], a[2], a[3]]),
_ => None,
})
.collect(),
_ => Vec::new(),
};
let id = id.to_string_lossy();
bars.push(BossBar {
id: id.to_string_lossy(),
uuid: stable_uuid(&id),
id,
name: get("Name").map_or_else(|| literal(""), tag_to_json),
color: text("Color", "white"),
style: text("Overlay", "progress"),
value: num("Value", 0),
max: num("Max", 100),
visible: num("Visible", 0) != 0,
player: in_players,
darken: num("DarkenScreen", 0) != 0,
music: num("PlayBossMusic", 0) != 0,
fog: num("CreateWorldFog", 0) != 0,
players,
});
}
}
@@ -167,17 +203,15 @@ mod tests {
bar.value = 25;
bar.max = 50;
bar.visible = true;
bar.player = true;
let uuid = Some([1, 2, 3, 4]);
let back = BossBars::unpack(&b.pack(uuid), uuid);
bar.darken = true;
bar.players = vec![[1, 2, 3, 4]];
let back = BossBars::unpack(&b.pack());
assert_eq!(back, b);
// Another player's UUID does not include the local player.
assert!(!BossBars::unpack(&b.pack(uuid), Some([9, 9, 9, 9])).bars[0].player);
let bar = b.get("test:x").unwrap();
assert_eq!(bar.progress(), 0.5);
assert_eq!(bar.display_name().plain(), "chat.square_brackets");
assert_eq!(color_rgb("pink"), text::RED);
assert_eq!(color_rgb("purple"), 0xAA00AA);
assert_eq!(color_rgb("purple"), text::named_color("dark_blue").unwrap());
b.remove("test:x");
assert!(b.bars.is_empty());
}
+4 -2
View File
@@ -55,7 +55,7 @@ impl CommandStorage {
.iter()
.map(|(k, v)| (k.as_str().into(), v.clone()))
.collect();
let _ = save::write_dat(
if let Err(e) = save::write_dat(
&file(root, namespace),
Tag::compound([
(
@@ -64,7 +64,9 @@ impl CommandStorage {
),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
]),
);
) {
log::warn!("command storage {namespace} was not written: {e}");
}
}
}
/// `get`: an empty compound when unset.
+29
View File
@@ -198,6 +198,8 @@ pub struct FogInputs<'a> {
pub blindness: Option<(i32, bool)>,
/// The darkness effect's blend factor, while the effect is on.
pub darkness: Option<f32>,
/// `GameRenderer.bossOverlayWorldDarkening`: a wither's bar dims the fog.
pub darken_world: f32,
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
/// `createWorldFog` is shown (the Ender Dragon's).
pub boss_world_fog: bool,
@@ -263,6 +265,13 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
let brightness = (1.0 - darkness) * (1.0 - darkness);
color = color.map(|c| c * brightness);
}
// FogRenderer.computeFogColor: a boss bar's world darkening.
if input.darken_world > 0.0 {
let t = input.darken_world;
color[0] += (color[0] * 0.7 - color[0]) * t;
color[1] += (color[1] * 0.6 - color[1]) * t;
color[2] += (color[2] * 0.6 - color[2]) * t;
}
let brighten = if eye_in_water { water_vision } else { 0.0 };
if color.iter().all(|&c| c != 0.0) {
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
@@ -394,6 +403,7 @@ mod tests {
skybox: true,
blindness: None,
darkness: None,
darken_world: 0.0,
boss_world_fog: false,
}
}
@@ -422,6 +432,25 @@ mod tests {
assert_eq!(water.environmental_start, -8.0);
}
#[test]
fn boss_darkening_dims_fog_color() {
// FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel.
let sky = Sky {
fog_color: [1.0, 1.0, 1.0],
sky_color: [1.0, 1.0, 1.0],
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&sky);
input.skybox = false;
input.darken_world = 1.0;
let fog = setup_fog(&tables, &FogState::default(), &input);
assert!((fog.color[0] - 0.7).abs() < 1.0e-5);
assert!((fog.color[1] - 0.6).abs() < 1.0e-5);
assert!((fog.color[2] - 0.6).abs() < 1.0e-5);
}
#[test]
fn void_darkness_starts_32_above_the_floor() {
let sky = Sky {
fog_end: 1024.0,
+71 -41
View File
@@ -44,6 +44,7 @@ mod enchant_effects;
mod end;
mod entity_culling;
mod events;
mod execution_role;
mod explosion;
mod extras;
mod falling_damage;
@@ -154,6 +155,7 @@ mod storage;
mod structures;
mod subtitle_hud;
mod sulfur_cubes;
mod text_displays;
mod titles;
mod tnt;
mod totem;
@@ -290,8 +292,8 @@ struct Options {
/// as vanilla's does: the default. `--in-frame` (or `--no-server-thread`,
/// or `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead.
server_thread: bool,
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
integrated: bool,
/// What this Game executes; independent of its current server connection.
execution_role: execution_role::ExecutionRole,
}
impl Options {
/// The name to join servers with: the one given, else the offline name kept
@@ -348,7 +350,7 @@ impl Options {
// itself, without the event loop: it stays in the frame.
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
integrated: false,
execution_role: execution_role::ExecutionRole::Client,
};
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
let mut args = std::env::args().skip(1);
@@ -362,6 +364,7 @@ impl Options {
return Ok(None);
}
"--dedicated" => {
o.execution_role = execution_role::ExecutionRole::DedicatedServer;
o.dedicated = Some(args.by_ref().collect());
break;
}
@@ -1422,6 +1425,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>,
@@ -1566,6 +1571,8 @@ struct Game<'a> {
score: scoreboard::ScoreState,
fns: functions::FnState,
bosses: bossbars::BossState,
/// `Hud.bossOverlay`: the bars this window is showing.
boss_overlay: horizon_client::boss_overlay::BossOverlay,
exec: commands::execute::ExecState,
rand_sequences: horizon_client::random_sequences::RandomSequences,
border: horizon_client::worldborder::WorldBorder,
@@ -4046,6 +4053,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();
@@ -4064,6 +4085,8 @@ impl Game<'_> {
self.track_experience_display();
// `/tick freeze` stops the world but not the player.
let running = self.clock.world_runs();
// GameRenderer.tick: a wither's bar darkens the world, then fades.
self.boss_overlay.tick_darken(running);
if running {
self.world.game_time += 1;
if self.ui.rules.bool("advance_weather") {
@@ -4260,23 +4283,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),
@@ -4439,6 +4446,8 @@ impl Game<'_> {
});
horizon_client::spans::time("tick_particles", || self.tick_particles());
}
// ServerBossEvent broadcasts, after this level's entities have ticked.
self.publish_boss_bars();
// Minecraft.handleKeybinds: held use repeats only while no item is in
// use (a raised shield or a drawn bow places and uses nothing).
if self.right
@@ -4819,11 +4828,15 @@ impl Game<'_> {
}
let atlas_ptr: *const HudAtlas = &self.gui.atlas;
let _ = atlas_ptr;
// The ToastManager draws over every screen: a server's packs
// download while the connection screen shows.
if self.remote_overlay(&mut batch) {
self.draw_pack_toasts(&mut batch);
return batch;
}
if let Some(reason) = self.loading_reason() {
self.draw_level_loading(&mut batch, reason);
self.draw_pack_toasts(&mut batch);
return batch;
}
// Hud.extractRenderState: F1 (keyToggleGui) hides camera overlays, the
@@ -5035,6 +5048,7 @@ impl Game<'_> {
Screen::Playing | Screen::Options | Screen::Win => {}
}
self.draw_toasts(&mut batch);
self.draw_pack_toasts(&mut batch);
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
@@ -6772,6 +6786,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() {
@@ -7502,7 +7517,7 @@ impl Game<'_> {
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
(out, stars)
}
/// Load (or create) a world folder under saves/ and start streaming it.
/// Enter a local world from the client, selecting its simulation mode.
fn open_world(
&mut self,
folder: &str,
@@ -7521,20 +7536,16 @@ impl Game<'_> {
return Ok(());
}
self.join_world_rest()?;
let save_root = std::env::current_dir()?.join("saves").join(folder);
if integrated::wanted(&self.options, &save_root) {
if self
.options
.execution_role
.starts_integrated_server(self.options.server_thread)
{
return self.open_integrated(folder, name, seed, creative, create);
}
self.perf.world_opening();
let request = world_loading::WorldRequest {
save_root,
folder: folder.to_owned(),
name: name.map(str::to_owned),
seed,
creative,
create: create.cloned(),
};
let request = world_loading::WorldRequest::new(folder, name, seed, creative, create)?;
if self.window.is_some() {
self.perf.world_opening();
if self.title.is_none() {
self.title = Some(Title::new(
std::env::current_dir()?.join("saves"),
@@ -7550,6 +7561,14 @@ impl Game<'_> {
)?);
return Ok(());
}
self.load_world(request)
}
/// Load authoritative world state synchronously, without client entry or
/// integrated-server selection. Server startup must use this entry point.
fn load_world(&mut self, request: world_loading::WorldRequest) -> Result<()> {
self.join_world_rest()?;
self.perf.world_opening();
let prepared = request.prepare(&self.res, &self.pack, &self.options, &self.settings)?;
self.finish_open_world(prepared)
}
@@ -7963,6 +7982,8 @@ impl Game<'_> {
self.session_lock = None;
horizon_client::crash::clear_detail("World");
self.debug_charts.reset();
self.boss_overlay.reset();
self.bosses = Default::default();
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
self.player = None;
@@ -10096,7 +10117,7 @@ impl Game<'_> {
if !self.settings.horizon.fog
&& !underwater
&& self.darkness_fog().is_none()
&& !self.dragon_bar_shown()
&& !self.boss_overlay.should_create_world_fog()
&& self.potions.effects.get("blindness").is_none()
&& fog.environmental_end >= (self.streaming.radius * 16) as f32
{
@@ -10167,6 +10188,9 @@ impl Game<'_> {
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(),
block_flicker: self.fog.block_light_flicker,
boss_darken: self
.boss_overlay
.darken_amount(self.clock.fraction() as f32),
menu_blur: self.menu_blur(),
};
let result = match self.renderer.as_mut() {
@@ -10557,7 +10581,7 @@ impl Game<'_> {
}
// The loading screen's chunk map fills ring by ring, as vanilla's.
self.streaming.loading =
self.options.dedicated.is_none() && self.loading_reason().is_some();
self.options.execution_role.is_client() && self.loading_reason().is_some();
let (uploads, unloaded) =
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
if let Some(lod) = &mut self.lod {
@@ -11884,6 +11908,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()
@@ -11971,6 +11997,7 @@ impl<'a> Game<'a> {
score: Default::default(),
fns: Default::default(),
bosses: Default::default(),
boss_overlay: Default::default(),
exec: Default::default(),
rand_sequences: Default::default(),
border: Default::default(),
@@ -12244,7 +12271,7 @@ fn synchronous_startup(options: &Options) -> bool {
|| options.world_frames.is_some()
|| options.benchmark.is_some()
|| options.control.is_some()
|| options.dedicated.is_some()
|| !options.execution_role.is_client()
|| std::env::var_os("HORIZON_DISPENSE_FIXTURE").is_some()
|| std::env::var_os("HORIZON_SCULK_FIXTURE").is_some()
|| std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
@@ -12698,6 +12725,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
}
}
@@ -12785,7 +12813,7 @@ fn start_game<'a>(
game.sculk_fixture(&spec)?;
return Ok(None);
}
if game.options.dedicated.is_some() {
if game.options.execution_role == execution_role::ExecutionRole::DedicatedServer {
game.run_dedicated()?;
return Ok(None);
}
@@ -13062,7 +13090,7 @@ impl Loading {
);
let renderer_ms = renderer_at.elapsed().as_millis();
// Audio is created on the main thread, after the core is ready.
let audio = if options.dedicated.is_some() {
let audio = if !options.execution_role.is_client() {
Audio::silent(&options.resources)
} else {
Audio::new(&options.resources)
@@ -13237,13 +13265,15 @@ pub fn run() -> Result<()> {
let Some(mut options) = Options::read()? else {
return Ok(());
};
if options.dedicated.is_some() && !dedicated::preflight(&mut options)? {
if options.execution_role == execution_role::ExecutionRole::DedicatedServer
&& !dedicated::preflight(&mut options)?
{
return Ok(());
}
// Before any worker starts: on Linux they go behind the server thread
// and the render thread.
horizon_threads::put_helpers_behind();
if options.dedicated.is_none() {
if options.execution_role.is_client() {
// The render thread, like the server thread, ahead of the workers on
// macOS and Windows. While terrain loads, the worker pools (two with
// single player's server thread) and other programs outnumber the
@@ -13253,7 +13283,7 @@ pub fn run() -> Result<()> {
}
// Someone is playing: the anonymous ping behind the launcher's "N playing now". Not for a
// server, a benchmark or a run that quits by itself.
if options.dedicated.is_none()
if options.execution_role.is_client()
&& options.benchmark.is_none()
&& !options.offscreen
&& options.frames.is_none()
@@ -13294,7 +13324,7 @@ pub fn run() -> Result<()> {
)?;
let gui = loaded.gui.ok_or("missing menu bundle")?;
options.resources = loaded.root.clone();
let audio = if options.dedicated.is_some() {
let audio = if !options.execution_role.is_client() {
Audio::silent(&options.resources)
} else {
Audio::new(&options.resources)
+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);
+370 -52
View File
@@ -1,22 +1,29 @@
//! `/bossbar` (BossBarCommands) and drawing the custom boss bars.
use super::{
Game,
commands::{R, err},
Dim, Game,
commands::{R, Target, err},
};
use horizon_client::{
boss_overlay,
bossbar::{self, BossBars},
command::{Arg, Context},
hud_overlays, save,
scoreboard::{self as sb, Comp, comp_text},
mobs::Species,
save,
scoreboard::{self as sb, Comp, json_to_tag},
scoreboard_cmd::format_list,
text::Text,
};
use horizon_core::random::RandomSource;
use horizon_protocol::packets::play::{self, BossAction, Clientbound as C};
use horizon_world::nbt::Tag;
use std::collections::{HashMap, HashSet};
#[derive(Default)]
pub(super) struct BossState {
pub bars: BossBars,
loaded: bool,
/// What each viewer was last sent, so a change becomes one packet.
sent: HashMap<u128, HashMap<u128, Offer>>,
}
fn num(n: impl std::fmt::Display) -> Text {
@@ -36,7 +43,7 @@ impl Game<'_> {
if let Some(root) = save::read_dat(&self.bossbar_path())
&& let Some(data) = root.get("data")
{
self.bosses.bars = BossBars::unpack(data, self.level.player_uuid);
self.bosses.bars = BossBars::unpack(data);
}
self.ui.registries.bossbars = self.bosses.bars.ids();
}
@@ -47,13 +54,15 @@ impl Game<'_> {
if self.bosses.bars.bars.is_empty() && !self.bossbar_path().exists() {
return;
}
let _ = save::write_dat(
if let Err(e) = save::write_dat(
&self.bossbar_path(),
Tag::compound([
("data", self.bosses.bars.pack(self.level.player_uuid)),
("data", self.bosses.bars.pack()),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
]),
);
) {
log::warn!("boss bars were not written: {e}");
}
}
pub(in crate::game) fn bossbar_exists(&self, id: &str) -> bool {
self.bosses.bars.get(id).is_some()
@@ -68,6 +77,7 @@ impl Game<'_> {
b.value = b.value.min(b.max);
}
}
self.publish_boss_bars();
}
fn bar_id(ctx: &Context) -> String {
match ctx.get("id") {
@@ -86,6 +96,7 @@ impl Game<'_> {
self.load_bossbars();
let result = self.bossbar_command(id, ctx);
self.ui.registries.bossbars = self.bosses.bars.ids();
self.publish_boss_bars();
result
}
fn bossbar_command(&mut self, id: &str, ctx: &Context) -> R<i32> {
@@ -97,6 +108,10 @@ impl Game<'_> {
}
let name = self.component_json(ctx, "name")?;
self.bosses.bars.add(&bar, name);
if let Some(b) = self.bosses.bars.get_mut(&bar) {
// CustomBossEvents.create: Mth.createInsecureUUID(level.random).
b.uuid = insecure_uuid(&mut self.world.random);
}
let shown = self
.bosses
.bars
@@ -173,19 +188,20 @@ impl Game<'_> {
i32::from(b.visible)
}
_ => {
if b.player {
let name = self.target_name(super::commands::Target::Player);
self.feedback(Text::translate(
"commands.bossbar.get.players.some",
&[s, num(1), format_list(vec![name])],
));
} else {
let names = self.bossbar_player_names(&b.players);
let n = names.len();
if n == 0 {
self.feedback(Text::translate(
"commands.bossbar.get.players.none",
&[s],
));
} else {
self.feedback(Text::translate(
"commands.bossbar.get.players.some",
&[s, num(n), format_list(names)],
));
}
i32::from(b.player)
n as i32
}
})
}
@@ -295,31 +311,46 @@ impl Game<'_> {
Ok(0)
}
_ => {
// players [<targets>]
let include = if ctx.get("targets").is_some() {
!self.entities(ctx, "targets", true)?.is_empty()
// players [<targets>]: replace the set. No argument clears it.
let targets = if ctx.get("targets").is_some() {
self.entities(ctx, "targets", true)?
} else {
false
Vec::new()
};
let mut uuids = Vec::new();
let mut names = Vec::new();
for t in &targets {
let Some(uuid) = self.player_target_uuid(*t) else {
continue;
};
if !uuids.contains(&uuid) {
uuids.push(uuid);
names.push(self.target_name(*t));
}
}
let b = self.bosses.bars.get_mut(bar).expect("checked");
if b.player == include {
let mut had = b.players.clone();
had.sort();
let mut next = uuids.clone();
next.sort();
if had == next {
return Err(err("commands.bossbar.set.players.unchanged", &[]));
}
b.player = include;
b.players = uuids;
let n = b.players.len();
let s = shown(self);
if include {
let name = self.target_name(super::commands::Target::Player);
self.feedback(Text::translate(
"commands.bossbar.set.players.success.some",
&[s, num(1), format_list(vec![name])],
));
} else {
if n == 0 {
self.feedback(Text::translate(
"commands.bossbar.set.players.success.none",
&[s],
));
} else {
self.feedback(Text::translate(
"commands.bossbar.set.players.success.some",
&[s, num(targets.len()), format_list(names)],
));
}
Ok(i32::from(include))
Ok(n as i32)
}
}
}
@@ -333,29 +364,316 @@ impl Game<'_> {
Ok(self.resolve_component(value))
}
// ---- drawing ------------------------------------------------------------------
/// The visible custom bars (`/bossbar`), after the built-in ones.
pub(super) fn custom_bossbars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
for b in self
.bosses
.bars
.bars
.iter()
.filter(|b| b.visible && b.player)
{
bars.push(hud_overlays::BossBar {
name: comp_text(&b.name),
progress: b.progress(),
color: bossbar::COLORS
/// Names of the online players in a boss bar's set, in join order.
fn bossbar_player_names(&self, players: &[[i32; 4]]) -> Vec<Text> {
self.known_boss_players()
.into_iter()
.filter(|(uuid, _)| players.contains(uuid))
.map(|(_, name)| name)
.collect()
}
/// The players who can be told about a bar: the one in place, then guests.
fn known_boss_players(&self) -> Vec<([i32; 4], Text)> {
let mut out = Vec::new();
if self.player.is_some() {
out.push((self.acting_uuid(), self.target_name(Target::Player)));
}
if let Some(host) = &self.lan {
for g in host.hub.guests.iter().filter(|g| g.placed) {
let ints = horizon_client::lan::playerdata::uuid_ints(g.uuid);
if out.iter().any(|(uuid, _)| *uuid == ints) {
continue;
}
out.push((ints, Text::literal(g.name.clone())));
}
}
out
}
fn player_target_uuid(&self, target: Target) -> Option<[i32; 4]> {
match target {
Target::Player => Some(self.acting_uuid()),
Target::Guest(conn) => self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.position(|c| *c == b.color)
.unwrap_or(0),
overlay: bossbar::STYLES
.iter()
.position(|s| *s == b.style)
.unwrap_or(0),
});
.find(|g| g.conn == conn)
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid))
}),
_ => None,
}
}
// ---- ServerBossEvent delivery ------------------------------------------------
/// Tell every viewer the boss events they should be seeing. The window
/// that simulates the world applies them to its own overlay; everyone
/// else gets `ClientboundBossEventPacket`s.
pub(super) fn publish_boss_bars(&mut self) {
if self.remote.is_some() {
return;
}
self.load_bossbars();
let now = boss_overlay::now_millis();
let viewers = self.boss_viewers();
let live: HashSet<u128> = viewers.iter().map(|v| v.uuid).collect();
self.bosses.sent.retain(|id, _| live.contains(id));
let mut packets = Vec::new();
for viewer in &viewers {
let desired = self.boss_events_for(viewer);
for event in self.diff_boss_events(viewer, desired) {
if viewer.local {
self.boss_overlay.apply(&event, now);
} else if let Some(conn) = viewer.conn {
packets.push((conn, C::BossEvent(event)));
}
}
}
if packets.is_empty() {
return;
}
let Some(host) = self.lan.as_ref() else {
return;
};
for (conn, packet) in &packets {
host.hub.send_to(*conn, packet);
}
}
fn boss_viewers(&self) -> Vec<Viewer> {
let mut out = Vec::new();
// While `as_guest` has swapped a guest in, the host's own position
// is not in place. Their overlay updates once the tick puts it back.
let local =
self.serving.is_none() && self.options.dedicated.is_none() && self.lan_acting.is_none();
if local && let Some(p) = &self.player {
let ints = self.level.player_uuid.unwrap_or_else(|| self.acting_uuid());
out.push(Viewer {
uuid: horizon_client::lan::playerdata::ints_uuid(ints),
ints,
at: [p.position.x, p.position.y, p.position.z],
alive: !p.health.dead_or_dying(),
here: true,
local: true,
conn: None,
view_chunks: self.settings.render_distance,
});
}
if let Some(host) = &self.lan {
for g in host.hub.guests.iter().filter(|g| g.placed) {
if out.iter().any(|v| v.uuid == g.uuid) {
continue;
}
out.push(Viewer {
uuid: g.uuid,
ints: horizon_client::lan::playerdata::uuid_ints(g.uuid),
at: g.position,
alive: !g.dead,
here: g.dimension == self.dim.id(),
local: false,
conn: Some(g.conn),
view_chunks: g.view_distance,
});
}
}
out
}
/// Custom bars (`CustomBossEvents.onPlayerConnect` sends them as a player
/// joins), then the dragon, withers and the raid.
fn boss_events_for(&self, viewer: &Viewer) -> Vec<Offer> {
let mut out = Vec::new();
for b in &self.bosses.bars.bars {
if !b.visible || !b.players.contains(&viewer.ints) {
continue;
}
out.push(Offer {
id: b.uuid,
name: json_to_tag(&b.name),
progress: b.progress(),
color: color_index(&b.color),
overlay: style_index(&b.style),
darken: b.darken,
music: b.music,
fog: b.fog,
scope: Scope::Global,
});
}
if viewer.here
&& let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive)
{
out.push(Offer {
id: bossbar::stable_uuid("minecraft:ender_dragon"),
name: translate_tag("entity.minecraft.ender_dragon"),
progress,
color: 0,
overlay: 0,
darken: false,
music: true,
fog: true,
scope: Scope::Level(self.dim),
});
}
if viewer.here {
let range = f64::from(10.min(viewer.view_chunks.max(0)) * 16);
for m in self
.mobs
.iter()
.filter(|m| m.species() == Species::Wither && m.alive() && !m.removed)
{
let (dx, dz) = (m.position.x - viewer.at[0], m.position.z - viewer.at[2]);
if dx * dx + dz * dz > range * range {
continue;
}
let progress = if m.extra.counter > 0 {
1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32
} else {
(m.health / m.attrs.max_health).clamp(0.0, 1.0)
};
let name = if let Some(text) = &m.custom_name_text {
json_to_tag(&text.component())
} else if let Some(name) = &m.custom_name {
Tag::String(name.as_str().into())
} else {
translate_tag("entity.minecraft.wither")
};
out.push(Offer {
id: super::lan::entity_uuid(m.id),
name,
progress,
color: 5,
overlay: 0,
darken: true,
music: false,
fog: false,
scope: Scope::Level(self.dim),
});
}
}
if viewer.here
&& let Some((id, name, progress)) = self.raid_boss_at(viewer.at, viewer.alive)
{
out.push(Offer {
id: bossbar::stable_uuid(&format!("raid:{id}")),
name,
progress,
color: 2,
overlay: 2,
darken: false,
music: false,
fog: false,
scope: Scope::Level(self.dim),
});
}
out
}
/// The packets that take `viewer` from what it was last sent to `desired`
/// (`ServerBossEvent`'s broadcasts).
fn diff_boss_events(&mut self, viewer: &Viewer, desired: Vec<Offer>) -> Vec<play::BossEvent> {
let full = viewer.local;
let dim = self.dim;
let sent = self.bosses.sent.entry(viewer.uuid).or_default();
let mut out = Vec::new();
let event = |id, action| play::BossEvent { id, action };
sent.retain(|id, prev| {
let owned = full || prev.scope == Scope::Global || prev.scope == Scope::Level(dim);
let keep = !owned || desired.iter().any(|e| e.id == *id);
if !keep {
out.push(event(*id, BossAction::Remove));
}
keep
});
for offer in desired {
let Some(prev) = sent.get(&offer.id) else {
out.push(event(
offer.id,
BossAction::Add {
name: offer.name.clone(),
progress: offer.progress,
color: offer.color,
overlay: offer.overlay,
darken: offer.darken,
music: offer.music,
fog: offer.fog,
},
));
sent.insert(offer.id, offer);
continue;
};
let id = offer.id;
if prev.progress != offer.progress {
let progress = offer.progress;
out.push(event(id, BossAction::UpdateProgress { progress }));
}
if prev.name != offer.name {
let name = offer.name.clone();
out.push(event(id, BossAction::UpdateName { name }));
}
if (prev.color, prev.overlay) != (offer.color, offer.overlay) {
let (color, overlay) = (offer.color, offer.overlay);
out.push(event(id, BossAction::UpdateStyle { color, overlay }));
}
if (prev.darken, prev.music, prev.fog) != (offer.darken, offer.music, offer.fog) {
let (darken, music, fog) = (offer.darken, offer.music, offer.fog);
out.push(event(
id,
BossAction::UpdateProperties { darken, music, fog },
));
}
sent.insert(id, offer);
}
out
}
/// `ClientPacketListener.handleBossUpdate`.
pub(super) fn apply_boss_event(&mut self, packet: &play::BossEvent) {
self.boss_overlay.apply(packet, boss_overlay::now_millis());
}
}
/// `Mth.createInsecureUUID`.
fn insecure_uuid(random: &mut impl RandomSource) -> u128 {
let most = random.next_long() & -61441 | 16384;
let least = random.next_long() & 4_611_686_018_427_387_903 | i64::MIN;
((most as u64 as u128) << 64) | (least as u64 as u128)
}
fn translate_tag(key: &str) -> Tag {
Tag::compound([("translate", Tag::String(key.into()))])
}
fn color_index(color: &str) -> i32 {
bossbar::COLORS
.iter()
.position(|c| *c == color)
.unwrap_or(6) as i32
}
fn style_index(style: &str) -> i32 {
bossbar::STYLES
.iter()
.position(|s| *s == style)
.unwrap_or(0) as i32
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Global,
Level(Dim),
}
struct Viewer {
uuid: u128,
ints: [i32; 4],
at: [f64; 3],
alive: bool,
here: bool,
local: bool,
conn: Option<u32>,
view_chunks: i32,
}
/// A boss event as one viewer should see it, and as it was last sent.
struct Offer {
id: u128,
name: Tag,
progress: f32,
/// `BossBarColor` and `BossBarOverlay` ordinals.
color: i32,
overlay: i32,
darken: bool,
music: bool,
fog: bool,
scope: Scope,
}
+13 -3
View File
@@ -570,8 +570,14 @@ impl Game<'_> {
/// ChatScreen.handleChatInput: history, then a command or a chat line.
pub(super) fn handle_chat_input(&mut self, message: &str) {
self.ui.chat.add_recent(message);
// On a server (single player's own included) the server runs it.
// On a server (single player's own included) the server runs it,
// unless a client mod owns the command.
if self.remote.is_some() {
if let Some(command) = message.strip_prefix('/')
&& self.mod_command(command)
{
return;
}
self.send_chat(message);
return;
}
@@ -816,8 +822,12 @@ impl Game<'_> {
("data", data),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
]);
let _ = horizon_client::save::write_dat(&path, root);
let _ = write_difficulty(&self.world_root, self.ui.difficulty);
if let Err(e) = horizon_client::save::write_dat(&path, root) {
log::warn!("the world clocks were not written: {e}");
}
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
log::warn!("the difficulty was not written: {e}");
}
}
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
/// cause is known to the client).
@@ -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);
@@ -65,9 +65,10 @@ impl Game<'_> {
tag
}
Target::Mob(id) => {
let uuid = self.uuid_for(id);
let m = self.mobs.iter().find(|m| m.id == id)?;
entity_nbt::mob_to_nbt(m, uuid, self.persist.raw.get(&id))
// `saveWithoutId` writes the entity as saved, riders under
// `Passengers`: `/data get` reads the same tree persistence does (#1519).
let i = self.mobs.iter().position(|m| m.id == id)?;
self.mob_tree_nbt(i)
}
Target::Item(id) => {
let uuid = self.uuid_for(id);
@@ -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);
+3 -1
View File
@@ -185,7 +185,9 @@ impl Game<'_> {
// Minecraft.emergencySaveAndCrash: the world is saved, then the
// game stops; main writes the crash report.
let crash = horizon_client::crash::DebugCrash::new();
let _ = self.save_game();
if let Err(e) = self.save_game() {
log::error!("the emergency save before the crash failed: {e}");
}
return Err(crash.into());
}
}
+14 -13
View File
@@ -6,7 +6,7 @@
//! its simulation and the play-state hub that LAN hosting uses, configured
//! from server.properties, with the operator, whitelist and ban lists and a
//! console on standard input.
use super::{Game, Options, lan::LanSettings};
use super::{Game, Options, lan::LanSettings, world_loading::WorldRequest};
use horizon_client::{
Result,
lan::{LoginGate, NetConfig, NetLog, OnlineMode, hub::HubConfig},
@@ -218,21 +218,22 @@ impl Lists {
use_whitelist: false,
enforce_whitelist: false,
};
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
let warn = |what: &str, r: std::result::Result<(), String>| {
if let Err(e) = r {
log("WARN", &format!("Failed to load {what}: {e}"));
log("WARN", &format!("Failed to {what}: {e}"));
}
};
// DedicatedPlayerList's constructor order.
warn("user banlist", l.bans.load());
let _ = l.bans.save();
warn("ip banlist", l.ip_bans.load());
let _ = l.ip_bans.save();
warn("operators list", l.ops.load());
warn("white-list", l.white.load());
let _ = l.ops.save();
let saved = |r: std::io::Result<()>| r.map_err(|e| e.to_string());
warn("load user banlist", l.bans.load());
warn("save user banlist", saved(l.bans.save()));
warn("load ip banlist", l.ip_bans.load());
warn("save ip banlist", saved(l.ip_bans.save()));
warn("load operators list", l.ops.load());
warn("load white-list", l.white.load());
warn("save operators list", saved(l.ops.save()));
if !l.white.file.exists() {
let _ = l.white.save();
warn("save white-list", saved(l.white.save()));
}
l
}
@@ -546,13 +547,13 @@ impl Game<'_> {
let lists = Arc::new(Mutex::new(lists));
log("INFO", &format!("Preparing level \"{level_name}\""));
let folder = world_dir.to_string_lossy().into_owned();
self.open_world(
self.load_world(WorldRequest::new(
&folder,
Some(&level_name),
Some(props.seed),
game_mode == 1,
None,
)?;
)?)?;
if new_world {
self.level.hardcore = props.hardcore;
}
+23 -23
View File
@@ -12,7 +12,6 @@ use horizon_client::{
hud_layout, hud_overlays,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -2157,23 +2156,12 @@ impl Game<'_> {
});
}
}
/// `BossHealthOverlay`: the dragon's pink bar, then the withers', the
/// raids' and the custom bars, in that order.
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
/// (or a server's packets) put on this client's overlay.
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
let mut bars = Vec::new();
if let Some(d) = self.dragon.as_ref().filter(|_| self.dragon_bar_shown()) {
bars.push(hud_overlays::BossBar {
name: Text::literal(
self.translate("entity.minecraft.ender_dragon", "Ender Dragon"),
),
progress: (d.health / MAX_HEALTH).clamp(0.0, 1.0),
color: 0,
overlay: 0,
});
}
self.wither_bars(&mut bars);
self.raid_bars(&mut bars);
self.custom_bossbars(&mut bars);
let bars = self
.boss_overlay
.bars(horizon_client::boss_overlay::now_millis());
let draws = hud_overlays::boss_overlay(
batch.width as i32,
batch.height as i32,
@@ -2182,14 +2170,26 @@ impl Game<'_> {
);
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
/// The dragon fight's boss bar shows (`EnderDragonFight.dragonEvent`,
/// with `setCreateWorldFog`).
pub(super) fn dragon_bar_shown(&self) -> bool {
self.dim == Dim::End && self.dragon.is_some()
/// The dragon fight's bar for a player at `at`
/// (`EnderDragonFight.dragonEvent`, visible while the fight is not won
/// and the player is alive within 192 blocks of (0, 128, 0)).
pub(super) fn dragon_boss_progress(&self, at: [f64; 3], alive: bool) -> Option<f32> {
if self.dim != Dim::End || !alive || !self.fight.loaded || self.fight.killed {
return None;
}
let near = at[0].powi(2) + (at[1] - 128.0).powi(2) + at[2].powi(2) <= 192.0 * 192.0;
if !near {
return None;
}
Some(
self.dragon
.as_ref()
.map_or(1.0, |d| (d.health / MAX_HEALTH).clamp(0.0, 1.0)),
)
}
/// `music.dragon` while the boss bar plays music.
/// `BossHealthOverlay.shouldPlayMusic`.
pub(super) fn boss_music(&self) -> bool {
self.dim == Dim::End && self.dragon.is_some() || self.wither_near()
self.boss_overlay.should_play_music()
}
}
pub(super) struct DragonFireball {
@@ -0,0 +1,39 @@
//! A Game's execution role is fixed at construction. Connecting or disconnecting
//! changes `remote`, not this role. A client may simulate a world in-frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ExecutionRole {
Client,
IntegratedServer,
DedicatedServer,
}
impl ExecutionRole {
/// Only a client owns presentation work (audio, loading UI, chunk meshes).
pub fn is_client(self) -> bool {
self == Self::Client
}
/// The optional in-frame simulation remains a client execution mode.
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
self.is_client() && server_thread
}
}
#[cfg(test)]
mod tests {
use super::ExecutionRole::*;
#[test]
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
for (role, client, threaded) in [
(Client, true, true),
(IntegratedServer, false, false),
(DedicatedServer, false, false),
] {
assert_eq!(role.is_client(), client);
assert_eq!(role.starts_integrated_server(true), threaded);
assert!(!role.starts_integrated_server(false));
}
}
}
+6 -9
View File
@@ -12,7 +12,9 @@
//! way.
use super::{
Game, Options, PackResources, Resources, UiResources,
execution_role::ExecutionRole,
lan::{LanSettings, Owner},
world_loading::WorldRequest,
};
use horizon_client::{
Result,
@@ -26,7 +28,6 @@ use horizon_protocol::connection::Pipe;
use horizon_protocol::{crypto::offline_uuid, text::translatable};
use std::{
net::TcpListener,
path::Path,
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
@@ -170,11 +171,6 @@ struct Launch {
player: String,
}
/// Whether this world opens on a server thread: asked for.
pub(super) fn wanted(options: &Options, _world: &Path) -> bool {
options.server_thread && options.dedicated.is_none()
}
impl Game<'_> {
/// `Minecraft.hasSingleplayerServer`: a world of this window's own, run
/// in the frame or on its server thread.
@@ -215,9 +211,10 @@ impl Game<'_> {
};
let mut options = self.options.clone();
// A server without a console: no local player, no window, no control socket.
// Compatibility for gameplay checks outside startup/world loading.
options.dedicated = Some(Vec::new());
options.server_thread = false;
options.integrated = true;
options.execution_role = ExecutionRole::IntegratedServer;
options.control = None;
options.benchmark = None;
options.frames = None;
@@ -287,13 +284,13 @@ impl Game<'_> {
tasks: Receiver<Task>,
ready: &Sender<Ready>,
) -> Result<()> {
self.open_world(
self.load_world(WorldRequest::new(
&launch.folder,
launch.name.as_deref(),
launch.seed,
launch.creative,
launch.create.as_ref(),
)?;
)?)?;
let saved = Box::new(self.level.clone());
// Math.max(2, options.renderDistance): past vanilla's 32 when the
// window's render distance is (Horizon's options go to 64).
+35 -12
View File
@@ -1939,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 {
@@ -2036,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 {
@@ -2452,6 +2449,32 @@ impl Game<'_> {
.map_or(&[][..], |log| log.get(logged..).unwrap_or(&[]))
.to_vec();
let facts = self.res.facts();
// BlockItem.place: `playSound(player, ...)`, for the others only. The
// placer's client plays it as its block appears.
let placed: Vec<([f64; 3], String)> = changed
.iter()
.map(|&(x, y, z)| {
let at = [f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5];
let name = &facts.get(self.world.block(x, y, z)).sounds.place;
(at, name.trim_start_matches("minecraft:").to_owned())
})
.collect();
if let Some(capture) = self.audio.capture.as_mut() {
let mut i = heard.min(capture.len());
while i < capture.len() {
let (name, at, ..) = &capture[i];
let place = placed
.iter()
.any(|(p, n)| p == at && name.trim_start_matches("minecraft:") == n.as_str());
if place {
let (name, at, volume, pitch) = capture.remove(i);
let source = horizon_client::settings::sound_source(&name) as i32;
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
} else {
i += 1;
}
}
}
if changed.is_empty()
|| !changed.iter().all(|&(x, y, z)| {
super::remote::hand_operated(&facts.get(self.world.block(x, y, z)).name)
@@ -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);
+2 -2
View File
@@ -221,7 +221,7 @@ impl Game<'_> {
/// holds it (`self.lan` is taken during the network tick).
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
// The in-frame game keeps its one level (`enter_dimension`).
if self.options.dedicated.is_none() {
if self.options.execution_role.is_client() {
return Ok(());
}
if target != self.dim {
@@ -261,7 +261,7 @@ impl Game<'_> {
/// nobody is in it; returns the level to put back afterwards. Only a
/// server holds other levels.
pub(super) fn enter_command_level(&mut self, dim: Dim) -> Option<Dim> {
if self.options.dedicated.is_none() || dim == self.dim {
if self.options.execution_role.is_client() || dim == self.dim {
return None;
}
let back = self.dim;
+42 -32
View File
@@ -6,7 +6,6 @@ use super::{Dim, Game};
use horizon_client::mobs::villager as mobs_villager;
use horizon_client::{
effects::{EffectInstance, OmenTick},
hud_overlays,
mobs::{
Mob, Species, raider,
spawn_rules::{self, SpawnLevel},
@@ -15,9 +14,9 @@ use horizon_client::{
},
patrol::{GameplayAttributes, Member, PatrolLevel, PatrolSpawner},
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, RaiderType, Raids, Spawn},
text::Text,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_world::nbt::Tag;
use std::collections::{HashMap, HashSet};
/// Patrol and raid state owned by the game.
@@ -1063,37 +1062,18 @@ impl Game<'_> {
}
}
/// The raid boss bars: red and notched in tens, with the raid's title.
pub(super) fn raid_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
for raid in self.raiding.raids.map.values() {
if !raid.bar.visible || !raid.bar.player || raid.is_stopped() {
continue;
}
let title = self.translate("event.minecraft.raid", "Raid");
let name = match raid.bar.name {
BarName::Raid => title,
BarName::Remaining(n) => format!(
"{title} - {}",
self.translate(
"event.minecraft.raid.raiders_remaining",
"Raiders Remaining: %s"
)
.replace("%s", &n.to_string())
),
BarName::Victory => {
self.translate("event.minecraft.raid.victory.full", "Raid - Victory")
}
BarName::Defeat => {
self.translate("event.minecraft.raid.defeat.full", "Raid - Defeat")
}
};
bars.push(hud_overlays::BossBar {
name: Text::literal(name),
progress: raid.bar.progress,
color: 2,
overlay: 2,
});
/// The raid bar a living player at `at` is in (`Raid.getPlayers`): red,
/// notched in tens. The name is the translate component the client draws.
pub(super) fn raid_boss_at(&self, at: [f64; 3], alive: bool) -> Option<(i32, Tag, f32)> {
if !alive || self.dim != Dim::Overworld {
return None;
}
let block = block_of(Vec3::new(at[0], at[1], at[2]));
let (id, raid) = self.raiding.raids.nearby(block)?;
if !raid.bar.visible || raid.is_stopped() {
return None;
}
Some((id, raid_name_tag(raid.bar.name), raid.bar.progress))
}
/// A firework a villager launches to celebrate (`CelebrateVillagersSurvivedRaid`).
pub(super) fn villager_firework(&mut self, at: [f64; 3], color: usize, flight: u8) {
@@ -1239,6 +1219,36 @@ impl Game<'_> {
}
}
/// `Raid.raidEvent`'s name: the raid title, the remaining-raiders line, or
/// the victory and defeat lines.
fn raid_name_tag(name: BarName) -> Tag {
let key = |k: &str| Tag::compound([("translate", Tag::String(k.into()))]);
match name {
BarName::Raid => key("event.minecraft.raid"),
BarName::Victory => key("event.minecraft.raid.victory.full"),
BarName::Defeat => key("event.minecraft.raid.defeat.full"),
BarName::Remaining(n) => Tag::compound([
("translate", Tag::String("event.minecraft.raid".into())),
(
"extra",
Tag::List(vec![
Tag::String(" - ".into()),
Tag::compound([
(
"translate",
Tag::String("event.minecraft.raid.raiders_remaining".into()),
),
(
"with",
Tag::List(vec![Tag::String(n.to_string().as_str().into())]),
),
]),
]),
),
]),
}
}
#[cfg(test)]
mod tests {
use super::*;
+147 -46
View File
@@ -383,6 +383,7 @@ impl Game<'_> {
}
self.apply_settings();
self.clear_waypoints();
self.boss_overlay.reset();
self.remote = Some(Remote {
session,
brand: "unknown".into(),
@@ -465,6 +466,7 @@ impl Game<'_> {
self.extras_left();
}
self.clear_waypoints();
self.boss_overlay.reset();
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
@@ -612,6 +614,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())
@@ -738,6 +755,9 @@ impl Game<'_> {
self.ticks,
self.screen == Screen::Chat,
);
if self.mods.running() {
self.mods_chat(&text);
}
if let Some(r) = self.remote.as_mut() {
r.chat.push((text, Instant::now()));
r.chat_received += 1;
@@ -834,7 +854,11 @@ impl Game<'_> {
r.joined = true;
r.profile = uuid;
r.profile_name = name.clone();
r.status = format!("Joined as {name}; loading terrain...");
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Brand(brand) => {
@@ -1496,6 +1520,7 @@ impl Game<'_> {
);
}
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) {
@@ -1601,6 +1626,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;
@@ -1805,6 +1836,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
@@ -1838,6 +1872,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 {
@@ -2691,6 +2726,7 @@ impl Game<'_> {
}
// ClientPacketListener.setActionBarText, setTitleText,
// setSubtitleText, setTitlesAnimation and handleTitlesClear.
C::BossEvent(p) => self.apply_boss_event(&p),
C::SetActionBarText(p) => self.titles.set_overlay(remote_text(&p.text), false),
C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)),
C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)),
@@ -3064,6 +3100,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();
@@ -3098,6 +3136,8 @@ impl Game<'_> {
/// One client tick while connected (Minecraft.tick with a ClientLevel).
pub(super) fn tick_remote(&mut self) -> Result<()> {
self.tick_remote_level()?;
// Client mods tick on any server, single player's own included.
self.tick_mods();
// Minecraft.tick sends this even when the player cannot tick yet,
// including while waiting for chunks. Keep it outside early returns.
if self.remote.as_ref().is_some_and(|r| r.joined) {
@@ -3123,6 +3163,7 @@ impl Game<'_> {
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
// the time only while the level runs normally.
let running = self.clock.world_runs();
self.boss_overlay.tick_darken(running);
if running {
self.world.game_time += 1;
self.remote_clock_tick();
@@ -3407,6 +3448,9 @@ impl Game<'_> {
/// 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).
@@ -3969,11 +4013,17 @@ impl Game<'_> {
self.predict_item_on_block(stack, &hit);
}
if held_block {
if reacts
|| held
.as_ref()
.is_some_and(|stack| self.remote_can_place(&hit, stack))
{
let placed = if reacts {
None
} else {
held.as_ref()
.and_then(|stack| self.remote_can_place(&hit, stack))
};
if let Some(placed) = &placed {
self.predict_place(placed, seq);
self.with_hand(hand, Game::consume_held);
}
if reacts || placed.is_some() {
self.with_hand(hand, Game::swing_arm);
}
// BlockItem.place returns FAIL when placement is rejected:
@@ -4033,18 +4083,7 @@ impl Game<'_> {
{
return;
}
for &(p, state) in changes {
let pos = [p.x, p.y, p.z];
let before = self.world.block(p.x, p.y, p.z);
// BlockStatePredictionHandler.retainKnownServerState.
if let Some(r) = self.remote.as_mut() {
r.predictions
.entry(pos)
.and_modify(|e| e.0 = seq)
.or_insert((seq, before.0 as i32));
}
self.set_block(pos, state.0 as i32);
}
self.predict_blocks(changes, seq);
// `level.playSound(player, ...)`: the clicker hears it from its own use; the
// server sends it to the others only.
for (pos, sound, volume, pitch) in sounds {
@@ -4104,12 +4143,45 @@ impl Game<'_> {
})
}
/// Run BlockItem.place on a disposable level before predicting its swing.
/// The real level supplies collision shapes and building-blocking entities;
/// block writes, random draws, scheduled ticks and events stay in the preview.
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
/// MultiPlayerGameMode.useItemOn inside startPrediction, for a block item:
/// the blocks `BlockItem.place` set at `pos` are placed on this client's
/// level at once, so the next click of a fast bridge aims at them rather
/// than past them (the server would refuse it). Its place sound is the
/// placer's own (`playSound(player, ...)`: the server sends it to the
/// others only).
fn predict_place(&mut self, (pos, changes): &DryPlace, seq: i32) {
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
// set_block plays the place sound of the block at `placing`.
r.placing = Some(([pos.x, pos.y, pos.z], ticks));
}
self.predict_blocks(changes, seq);
}
/// The blocks a use changed, set on this client's level and kept as
/// predicted under the use's sequence until the server acknowledges it
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
/// server refused goes back.
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
for &(p, state) in changes {
let pos = [p.x, p.y, p.z];
let before = self.world.block(p.x, p.y, p.z);
if let Some(r) = self.remote.as_mut() {
r.predictions
.entry(pos)
.and_modify(|e| e.0 = seq)
.or_insert((seq, before.0 as i32));
}
self.set_block(pos, state.0 as i32);
}
}
/// Run BlockItem.place on a disposable level: where it placed and the
/// blocks it set. The real level supplies collision shapes and
/// building-blocking entities; random draws, scheduled ticks and events
/// stay in the preview.
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> Option<DryPlace> {
if !self.may_build() {
return false;
return None;
}
let ctx = horizon_world::level::PlaceContext {
clicked_pos: horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
@@ -4124,14 +4196,16 @@ impl Game<'_> {
};
self.with_level(|b, level, _| {
let mut level = DryLevel::new(level);
matches!(
b.place_block(
&mut level,
&mut horizon_world::level::Ticks::default(),
&ctx
),
horizon_world::level::PlaceOutcome::Placed { .. }
)
match b.place_block(
&mut level,
&mut horizon_world::level::Ticks::default(),
&ctx,
) {
horizon_world::level::PlaceOutcome::Placed { pos, .. } => {
Some((pos, level.changed.into_iter().collect()))
}
horizon_world::level::PlaceOutcome::Failed => None,
}
})
}
@@ -4233,11 +4307,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
@@ -4251,7 +4327,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;
}
@@ -4572,12 +4648,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));
}
@@ -4646,7 +4720,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);
@@ -5081,6 +5155,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,
@@ -5180,11 +5256,12 @@ type DrySound = (
f32,
f32,
);
/// The blocks a use on the dry level set.
type DryChanges = Vec<(horizon_core::position::BlockPos, BlockStateId)>;
/// What a use on the dry level changed, and the sounds it played.
type DryUse = (
Vec<(horizon_core::position::BlockPos, BlockStateId)>,
Vec<DrySound>,
);
type DryUse = (DryChanges, Vec<DrySound>);
/// Where `BlockItem.place` on the dry level placed, and the blocks it set.
type DryPlace = (horizon_core::position::BlockPos, DryChanges);
/// The blocks a guest's hand flips that its use predicts (and hears at once): a wooden or
/// copper door, trapdoor or fence gate, a lever or a button. An iron door or trapdoor has
@@ -5505,4 +5582,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);
}
}
@@ -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);
@@ -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,
})
@@ -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<'_> {
@@ -100,7 +100,10 @@ impl Game<'_> {
skybox: self.dim == Dim::Overworld,
blindness: None,
darkness: self.darkness_fog(),
boss_world_fog: self.dragon_bar_shown(),
darken_world: self
.boss_overlay
.darken_amount(self.clock.fraction() as f32),
boss_world_fog: self.boss_overlay.should_create_world_fog(),
},
)
}
+246 -3
View File
@@ -8,14 +8,19 @@ use horizon_client::{
Result,
confirm_screen::Confirm,
hud::{HudAtlas, HudBatch},
multiplayer, packs,
java_text, multiplayer, packs,
scoreboard::{comp_text, tag_to_json},
server_packs::{Prompt, ServerPacks, action, download, parse_sha1, valid_url},
text::{Text, named_color},
toasts::SystemToast,
ui,
};
use horizon_core::jmath;
use horizon_protocol::{packets::configuration::ResourcePackPush, servers};
use std::path::PathBuf;
use std::{
path::PathBuf,
sync::mpsc::{Receiver, TryRecvError},
};
/// `ClientCommonPacketListenerImpl.PackConfirmScreen`: the packs it asks
/// about, whether the last one is required, and the server's message.
@@ -75,6 +80,111 @@ pub(super) struct ServerPackState {
user: (String, u128),
/// The packs loaded on top of the player's own, bottom first.
pub(super) active: Vec<packs::Pack>,
/// The download batches' notifiers, oldest first.
notifiers: Vec<PackNotifier>,
}
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
/// own `SystemToast`.
struct PackNotifier {
progress: Receiver<download::Progress>,
total: usize,
/// `count`: the requests started.
count: usize,
/// `failCount`.
failed: usize,
/// `totalBytes`: the Content-Length of the request in flight.
size: Option<u64>,
/// The bytes it has written so far: none before the first response.
bytes: Option<u64>,
/// The last request has finished.
done: bool,
toast: Option<SystemToast>,
}
impl PackNotifier {
fn new(progress: Receiver<download::Progress>, total: usize) -> Self {
Self {
progress,
total,
count: 0,
failed: 0,
size: None,
bytes: None,
done: false,
toast: None,
}
}
/// The batch's events since the last call, with the toast updated once
/// for all of them. False once the batch has ended and its toast is gone.
fn poll(&mut self) -> bool {
let (mut changed, mut hide) = (false, false);
let ended = loop {
let event = match self.progress.try_recv() {
Ok(event) => event,
Err(e) => break e == TryRecvError::Disconnected,
};
changed |= match event {
download::Progress::RequestStart => {
self.count += 1;
true
}
download::Progress::DownloadStart(size) => {
self.size = size;
self.bytes = Some(0);
true
}
download::Progress::Bytes(bytes) => {
self.bytes = Some(bytes);
true
}
download::Progress::RequestFinished(ok) => {
self.failed += usize::from(!ok);
self.done = self.count == self.total;
// Every pack came: forceHide; else the failure title.
hide |= self.done && self.failed == 0;
self.done && self.failed > 0
}
};
};
if changed {
let (title, message) = self.text();
let (title, message) = (title.plain(), message.map(|m| m.plain()));
// SystemToast.addOrUpdate.
match &mut self.toast {
Some(toast) => toast.reset(title, message),
None => self.toast = Some(SystemToast::new(title, message)),
}
}
if hide && let Some(toast) = &mut self.toast {
toast.force_hide();
}
!ended || self.toast.is_some()
}
/// The toast's title and message.
fn text(&self) -> (Text, Option<Text>) {
if self.done && self.failed > 0 {
let failed = Text::tr("download.pack.failed", &[&self.failed, &self.total]);
return (failed, None);
}
let title = Text::tr("download.pack.title", &[&self.count, &self.total]);
let message = self.bytes.map(|bytes| match self.size {
Some(size) if size > 0 => {
Text::tr("download.pack.progress.percent", &[&(bytes * 100 / size)])
}
_ => Text::tr("download.pack.progress.bytes", &[&human_readable(bytes)]),
});
(title, message)
}
}
/// `com.mojang.realmsclient.Unit.humanReadable`, for
/// `download.pack.progress.bytes`.
fn human_readable(bytes: u64) -> String {
if bytes < 1024 {
return format!("{bytes} B");
}
let exp = (jmath::log(bytes as f64) / jmath::log(1024.0)) as i32;
let prefix = &"KMGTPE"[exp as usize - 1..exp as usize];
let value = bytes as f64 / 1024f64.powi(exp);
format!("{} {prefix}B", java_text::format_fixed(value, 1))
}
fn saves() -> PathBuf {
@@ -89,6 +199,7 @@ impl Game<'_> {
let state = &mut self.server_packs;
state.user = user;
state.prompt = None;
state.notifiers.clear();
let Some(address) = address else {
state.address = None;
state.packs.local_world();
@@ -103,6 +214,7 @@ impl Game<'_> {
/// `Minecraft.clearDownloadedResourcePacks`: every server pack goes at once.
pub(super) fn server_packs_disconnect(&mut self) {
self.server_packs.prompt = None;
self.server_packs.notifiers.clear();
self.server_packs.packs.disconnect();
self.tick_server_packs();
}
@@ -197,6 +309,7 @@ impl Game<'_> {
/// 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) {
self.server_packs.notifiers.retain_mut(PackNotifier::poll);
// A reload waits for the world's resources (`reload_packs`).
if self.rest_loader.is_some() || self.world_loader.is_some() {
return;
@@ -238,6 +351,15 @@ impl Game<'_> {
Ok(queue) => state.queue = Some(queue),
Err(e) => {
log::error!("Horizon: failed to open the download queue: {e}");
// Every request failed, as the notifier would hear it.
let (progress, events) = std::sync::mpsc::channel();
for _ in &requests {
let _ = progress.send(download::Progress::RequestStart);
let _ = progress.send(download::Progress::RequestFinished(false));
}
state
.notifiers
.push(PackNotifier::new(events, requests.len()));
let failed = requests.iter().map(|r| r.id).collect();
state.packs.downloaded(horizon_client::server_packs::Batch {
downloaded: Vec::new(),
@@ -248,8 +370,12 @@ impl Game<'_> {
}
}
let headers = download::headers(&state.user.0, state.user.1);
let (progress, events) = std::sync::mpsc::channel();
state
.notifiers
.push(PackNotifier::new(events, requests.len()));
if let Some(queue) = &state.queue {
queue.download(requests, headers);
queue.download(requests, headers, progress);
}
}
/// `DownloadedPackSource.startReload`: the packs on top of the player's
@@ -297,6 +423,29 @@ impl Game<'_> {
.layout(atlas, (w, h))
.draw(batch, atlas, self.cursor, [&yes, &no]);
}
/// The download notifiers' toasts, each in the next 32-pixel slot
/// below an advancement or recipe toast (`ToastManager`).
pub(super) fn draw_pack_toasts(&mut self, batch: &mut HudBatch) {
let multiplier = f64::from(self.settings.notification_display_time);
let mut slot = usize::from(!self.adv.toasts.is_empty());
for notifier in &mut self.server_packs.notifiers {
let Some(toast) = &mut notifier.toast else {
continue;
};
let (sound, finished) = toast.update(multiplier);
if let Some(show) = sound {
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
self.audio
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
}
if finished {
notifier.toast = None;
continue;
}
toast.draw(&self.gui.atlas, batch, 32.0 * slot as f32);
slot += 1;
}
}
/// 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 {
@@ -314,3 +463,97 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use download::Progress::*;
fn notifier(total: usize, events: &[download::Progress]) -> PackNotifier {
let (send, receive) = std::sync::mpsc::channel();
for event in events {
send.send(*event).unwrap();
}
let mut notifier = PackNotifier::new(receive, total);
assert!(notifier.poll());
notifier
}
fn texts(n: &PackNotifier) -> (serde_json::Value, Option<serde_json::Value>) {
let (title, message) = n.text();
(title.component(), message.map(|m| m.component()))
}
#[test]
fn the_toast_counts_requests_and_shows_progress() {
let n = notifier(2, &[RequestStart]);
let (title, message) = texts(&n);
assert_eq!(title["translate"], "download.pack.title");
assert_eq!(title["with"], serde_json::json!([1, 2]));
assert_eq!(message, None);
let n = notifier(2, &[RequestStart, DownloadStart(Some(400)), Bytes(100)]);
assert_eq!(
texts(&n).1.unwrap(),
serde_json::json!({"translate": "download.pack.progress.percent", "with": [25]})
);
let n = notifier(1, &[RequestStart, DownloadStart(None), Bytes(1280)]);
assert_eq!(
texts(&n).1.unwrap()["with"],
serde_json::json!([{"text": "1.3 KB"}])
);
}
#[test]
fn a_failed_pack_turns_the_toast_into_the_failure_count() {
let events = [
RequestStart,
RequestFinished(false),
RequestStart,
DownloadStart(Some(3)),
Bytes(3),
];
let (send, receive) = std::sync::mpsc::channel();
for event in events {
send.send(event).unwrap();
}
let mut n = PackNotifier::new(receive, 2);
assert!(n.poll());
// Not the last request yet: still downloading.
assert_eq!(texts(&n).0["translate"], "download.pack.title");
send.send(RequestFinished(true)).unwrap();
drop(send);
assert!(n.poll(), "the failure toast stays until it slides out");
let (title, message) = texts(&n);
assert_eq!(
title,
serde_json::json!({"translate": "download.pack.failed", "with": [1, 2]})
);
assert_eq!(message, None);
}
#[test]
fn a_finished_batch_without_a_toast_is_dropped() {
let (send, receive) = std::sync::mpsc::channel::<download::Progress>();
drop(send);
assert!(!PackNotifier::new(receive, 1).poll());
}
#[test]
fn sizes_read_as_realms_unit_human_readable() {
// com.mojang.realmsclient.Unit.humanReadable, run on the 26.3 jar.
for (bytes, text) in [
(0, "0 B"),
(1023, "1023 B"),
(1024, "1.0 KB"),
(1280, "1.3 KB"),
(1535, "1.5 KB"),
(10_000, "9.8 KB"),
(1_048_575, "1024.0 KB"),
(1_048_576, "1.0 MB"),
(52_428_800, "50.0 MB"),
(262_144_000, "250.0 MB"),
(1_073_741_824, "1.0 GB"),
] {
assert_eq!(human_readable(bytes), text, "{bytes}");
}
}
}
@@ -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);
}
}
-41
View File
@@ -2,10 +2,8 @@
//! its boss bar, and its skulls hitting and exploding.
use super::Game;
use horizon_client::{
hud_overlays,
mobs::{Damage, DamageKind, Species, Who},
projectiles::{Projectile, ProjectileKind},
text::Text,
};
impl Game<'_> {
@@ -78,45 +76,6 @@ impl Game<'_> {
}
Ok(())
}
/// The wither boss bars (purple), within range.
pub(super) fn wither_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
let Some(p) = self.player.as_ref() else {
return;
};
for m in self
.mobs
.iter()
.filter(|m| m.species() == Species::Wither && m.alive())
{
if m.position.distance_squared(p.position) > 64.0 * 64.0 {
continue;
}
let progress = if m.extra.counter > 0 {
1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32
} else {
(m.health / m.attrs.max_health).clamp(0.0, 1.0)
};
let name = m
.custom_name
.clone()
.unwrap_or_else(|| self.translate("entity.minecraft.wither", "Wither"));
bars.push(hud_overlays::BossBar {
name: Text::literal(name),
progress,
color: 5,
overlay: 0,
});
}
}
pub(super) fn wither_near(&self) -> bool {
self.player.as_ref().is_some_and(|p| {
self.mobs.iter().any(|m| {
m.species() == Species::Wither
&& m.alive()
&& m.position.distance_squared(p.position) <= 64.0 * 64.0
})
})
}
/// `WitherSkull.onHitEntity` / `onHit`: 8 damage (healing the wither 5
/// when it kills), withering for 10 seconds, then a power 1 blast.
pub(super) fn wither_skull_hit(
@@ -22,6 +22,23 @@ pub(super) struct PreparedWorld {
}
impl WorldRequest {
pub fn new(
folder: &str,
name: Option<&str>,
seed: Option<i64>,
creative: bool,
create: Option<&horizon_client::title::WorldOptions>,
) -> Result<Self> {
Ok(Self {
save_root: std::env::current_dir()?.join("saves").join(folder),
folder: folder.to_owned(),
name: name.map(str::to_owned),
seed,
creative,
create: create.cloned(),
})
}
pub fn prepare(
self,
res: &Resources,
@@ -250,7 +267,7 @@ pub(super) fn make_streaming(
streaming.nether_shading = dim == Dim::Nether;
// A server draws nothing: neither the integrated server nor the
// dedicated one meshes sections.
streaming.mesh = !options.integrated && options.dedicated.is_none();
streaming.mesh = options.execution_role.is_client();
Ok((streaming, save, locator))
}
+88 -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.
@@ -268,6 +268,8 @@ pub struct Frame<'a> {
pub night_vision: f32,
/// Torch flicker added to the block light factor of 1.4.
pub block_flicker: f32,
/// `lightmap.fsh` BossOverlayWorldDarkeningFactor (a wither's bar).
pub boss_darken: f32,
/// `Screen.extractBlurredBackground`: blur the frame by this radius
/// (`menuBackgroundBlurriness`) before the screen is drawn over it.
pub menu_blur: Option<f32>,
@@ -325,6 +327,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 +2161,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 +2208,7 @@ impl Renderer {
translucent,
flat_on: flat.is_some(),
cull_on: cull.is_some(),
sides_on: true,
cull,
flat,
sky,
@@ -2453,6 +2458,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 +2588,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>()
@@ -2778,6 +2795,7 @@ impl Renderer {
gamma: f32,
night_vision: f32,
flicker: f32,
boss_darken: f32,
) {
// lightmap.fsh: per (block, sky) level.
let brightness = |level: f32| level / (4.0 - 3.0 * level);
@@ -2795,6 +2813,8 @@ impl Renderer {
let parabolic = (2.0 * block_level - 1.0).powi(2);
let block_color = Vec3::from(sky.block_light_tint).lerp(Vec3::ONE, 0.9 * parabolic);
color += block_color * block_brightness;
// lightmap.fsh: a boss bar mixes the light toward 70/60/60%.
color = color.lerp(color * Vec3::new(0.7, 0.6, 0.6), boss_darken);
color -= Vec3::splat(darkness);
color = color.clamp(Vec3::ZERO, Vec3::ONE);
let max = color.max_element().max(1e-6);
@@ -2909,6 +2929,7 @@ impl Renderer {
(self.gamma - frame.darkness.0).max(0.0),
frame.night_vision,
frame.block_flicker,
frame.boss_darken,
);
let view_proj = self.view_projection(frame);
if frame.title {
@@ -3042,6 +3063,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 +3130,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 +3137,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 +3155,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 +3197,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 +3539,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 +4223,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 +4478,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),
+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));
}
@@ -27,6 +27,11 @@ pub fn uuid_ints(uuid: u128) -> [i32; 4] {
uuid as i32,
]
}
/// `UUIDUtil.uuidFromIntArray`.
pub fn ints_uuid(ints: [i32; 4]) -> u128 {
ints.iter()
.fold(0, |acc, &i| acc << 32 | u128::from(i as u32))
}
/// Whether a saved file exists for this player.
pub fn exists(world: &Path, uuid: u128) -> bool {
+2
View File
@@ -24,6 +24,7 @@ pub mod block_entity_render;
pub mod block_transform;
pub mod blocks;
pub mod border_render;
pub mod boss_overlay;
pub mod bossbar;
pub mod breaking;
pub mod brewing;
@@ -177,6 +178,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
+23 -16
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 {
@@ -692,12 +701,6 @@ pub struct Mesher<'a> {
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];
@@ -715,6 +718,8 @@ impl Mesher<'_> {
[center.x, center.z, base_y],
);
let info = &self.tables.info;
let catalog = &self.render.model_catalog;
let mut models = Vec::new();
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
@@ -744,13 +749,15 @@ impl Mesher<'_> {
continue;
}
let world = BlockPos::new(center.x * 16 + x, base_y + y, center.z * 16 + z);
let Ok(models) = self.render.model_catalog.collect(BlockStateId(id), world)
else {
if catalog
.models_at(BlockStateId(id), world, &mut models)
.is_err()
{
continue;
};
}
// ModelBlockRenderer: the first part decides smooth lighting
// for all; light-emitting blocks are always lit flat.
let Some((_, first)) = models.first() else {
let Some(first) = models.first() else {
continue;
};
let ao = self.smooth_lighting && state.emission == 0 && first.ambient_occlusion;
@@ -780,7 +787,7 @@ impl Mesher<'_> {
)
.unwrap_or(true);
}
for (_, model) in &models {
for model in &models {
for face in &model.faces {
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
continue;
-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)]);
}
}
+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<_>>()
);
}
}
+59 -28
View File
@@ -41,9 +41,12 @@ pub struct BakedModel {
}
type VariantKey = (String, [u8; 3], bool);
pub struct StateModels {
choices: Vec<CompiledChoices>,
states: BTreeMap<BlockStateId, (usize, SelectedModels)>,
baked: BTreeMap<VariantKey, BakedModel>,
/// Each definition's choices, a variant standing as its index in `baked`.
choices: Vec<CompiledChoices<u32>>,
/// By state id: its definition in `choices` and the models it selects.
states: Vec<Option<(usize, SelectedModels)>>,
/// Every transformed model once, with the variant it was baked from.
baked: Vec<(Variant, BakedModel)>,
}
fn key(variant: &Variant) -> VariantKey {
(variant.model.clone(), variant.rotations, variant.uv_lock)
@@ -85,7 +88,7 @@ impl StateModels {
models: &crate::model_library::ParsedModels,
) -> Result<Self> {
let mut choices = Vec::new();
let mut states = BTreeMap::new();
let mut states = vec![None; registry.len()];
let mut variants = BTreeSet::new();
let mut by_name: std::collections::HashMap<&str, Vec<BlockStateId>> =
std::collections::HashMap::new();
@@ -178,7 +181,7 @@ impl StateModels {
}
for ((id, _), selected) in native_order.into_iter().zip(bound) {
if let Some(selected) = selected {
states.insert(id, (choices.len(), selected));
states[id.0 as usize] = Some((choices.len(), selected));
}
}
for choice in definition
@@ -211,7 +214,8 @@ impl StateModels {
models.block_library_or_missing(root, names)?
}
};
let mut baked = BTreeMap::new();
let mut baked = Vec::with_capacity(variants.len());
let mut index = BTreeMap::new();
for (name, turns, lock) in variants {
let model = library.resolve(&name)?;
let particle = model
@@ -233,16 +237,22 @@ impl StateModels {
})
.collect::<Result<_>>()?;
let cube = super::cube::cube_styles(&faces);
baked.insert(
(name, turns, lock),
BakedModel {
ambient_occlusion: model.ambient_occlusion,
particle,
faces,
cube,
},
);
index.insert((name.clone(), turns, lock), baked.len() as u32);
let variant = Variant {
model: name,
rotations: turns,
uv_lock: lock,
};
let model = BakedModel {
ambient_occlusion: model.ambient_occlusion,
particle,
faces,
cube,
};
baked.push((variant, model));
}
// Every variant of every definition was baked above.
let choices = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
Ok(Self {
choices,
states,
@@ -260,26 +270,47 @@ impl StateModels {
/// `BlockStateModel.particleMaterial`: the particle sprite layer of the
/// state's first variant (see `CompiledChoices::particle_variant`).
pub fn particle(&self, state: BlockStateId) -> Option<u32> {
let (choice, selected) = self.states.get(&state)?;
let variant = self.choices[*choice].particle_variant(selected)?;
self.baked.get(&key(variant))?.particle
let (choice, selected) = self.state(state)?;
let model = self.choices[*choice].particle_variant(selected)?;
self.baked[*model as usize].1.particle
}
pub fn collect_seeded(
&self,
state: BlockStateId,
seed: i64,
) -> Result<Vec<(&Variant, &BakedModel)>> {
let (choice, selected) = self
.states
.get(&state)
.ok_or("unbound gallery block state")?;
let mut models = Vec::new();
self.each_seeded(state, seed, |(variant, model)| {
models.push((variant, model))
})?;
Ok(models)
}
/// The models a block draws at `position`, into `out` (cleared first):
/// `collect` for the mesher, which reuses one list for every block.
pub fn models_at<'a>(
&'a self,
state: BlockStateId,
position: BlockPos,
out: &mut Vec<&'a BakedModel>,
) -> Result<()> {
out.clear();
self.each_seeded(state, position_seed(position), |(_, model)| out.push(model))
}
fn each_seeded<'a>(
&'a self,
state: BlockStateId,
seed: i64,
mut f: impl FnMut(&'a (Variant, BakedModel)),
) -> Result<()> {
let (choice, selected) = self.state(state).ok_or("unbound gallery block state")?;
let mut random = LegacyRandom::new(seed);
let mut variants = Vec::new();
self.choices[*choice].collect(selected, &mut random, &mut variants)?;
variants
.into_iter()
.map(|v| Ok((v, self.baked.get(&key(v)).ok_or("unbaked block model")?)))
.collect()
self.choices[*choice].for_each(selected, &mut random, |&model| {
f(&self.baked[model as usize]);
})?;
Ok(())
}
fn state(&self, state: BlockStateId) -> Option<&(usize, SelectedModels)> {
self.states.get(state.0 as usize)?.as_ref()
}
}
+5 -1
View File
@@ -517,7 +517,11 @@ fn build_overlay_uncached(
}
}
} else if touched.contains(&child)
|| (vanilla.join(&child).is_dir() && !filters.is_empty())
// Windows cannot link a directory the way unix does (`link` above), so an
// untouched one has to be mirrored: skipping it hid textures/font and the
// title panorama from the overlay, and a font json that names those sheets
// then failed to load (#1688).
|| (vanilla.join(&child).is_dir() && (cfg!(not(unix)) || !filters.is_empty()))
{
mirror(
&child,
+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
+2 -1
View File
@@ -1913,7 +1913,8 @@ pub fn write_dat(path: &Path, tag: Tag) -> Result<()> {
Some(stem) => path.with_file_name(format!("{stem}.dat_old")),
None => path.with_file_name(format!("{file}_old")),
};
let _ = fs::rename(path, old);
// Util.safeReplaceFile: no backup, no replacement.
fs::rename(path, old).map_err(|e| format!("{}: {e}", path.display()))?;
}
fs::rename(temporary, path)?;
Ok(())
@@ -34,7 +34,21 @@ pub fn headers(name: &str, uuid: u128) -> Vec<(&'static str, String)> {
]
}
type Job = (Vec<Request>, Vec<(&'static str, String)>);
/// `HttpUtil.DownloadProgressListener`, on the channel a batch was given.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Progress {
/// `requestStart`: the batch's next request begins.
RequestStart,
/// `downloadStart`: the response came, with its Content-Length if it
/// said one.
DownloadStart(Option<u64>),
/// `downloadedBytes`: what this request has written so far.
Bytes(u64),
/// `requestFinished`: whether it succeeded.
RequestFinished(bool),
}
type Job = (Vec<Request>, Vec<(&'static str, String)>, Sender<Progress>);
/// `DownloadQueue`: batches download in order, on one thread.
pub struct Queue {
@@ -50,16 +64,24 @@ impl Queue {
std::thread::Builder::new()
.name("download-queue".into())
.spawn(move || {
for (requests, headers) in todo {
if finished.send(run(&dir, &requests, &headers)).is_err() {
for (requests, headers, progress) in todo {
if finished
.send(run(&dir, &requests, &headers, &progress))
.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));
pub fn download(
&self,
requests: Vec<Request>,
headers: Vec<(&'static str, String)>,
progress: Sender<Progress>,
) {
let _ = self.jobs.send((requests, headers, progress));
}
/// The batches finished since the last call.
pub fn finished(&self) -> Vec<Batch> {
@@ -68,11 +90,18 @@ impl Queue {
}
/// `DownloadQueue.runDownload`: each pack into `<dir>/<id>/`, logged.
fn run(dir: &Path, requests: &[Request], headers: &[(&'static str, String)]) -> Batch {
fn run(
dir: &Path,
requests: &[Request],
headers: &[(&'static str, String)],
progress: &Sender<Progress>,
) -> 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);
let _ = progress.send(Progress::RequestStart);
let result = download_file(&dir.join(&id), request, headers, MAX_SIZE, progress);
let _ = progress.send(Progress::RequestFinished(result.is_ok()));
// DownloadQueue.LogEntry, its fields in the codec's order.
let mut entry = vec![
("id", serde_json::Value::from(id)),
@@ -165,6 +194,7 @@ pub fn download_file(
request: &Request,
headers: &[(&'static str, String)],
max_size: u64,
progress: &Sender<Progress>,
) -> Result<PathBuf, String> {
let target = request.sha1.as_ref().map(|sha1| dir.join(sha1));
if let (Some(target), Some(sha1)) = (&target, &request.sha1) {
@@ -198,6 +228,7 @@ pub fn download_file(
.header("Content-Length")
.and_then(|v| v.trim().parse::<u64>().ok());
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let _ = progress.send(Progress::DownloadStart(size));
if let Some(size) = size
&& size > max_size
{
@@ -206,9 +237,13 @@ pub fn download_file(
));
}
let mut input = response.into_reader();
let mut report = |written: u64| {
let _ = progress.send(Progress::Bytes(written));
};
match (&target, &request.sha1) {
(Some(target), Some(sha1)) => {
let actual = save_hashed(&mut input, target, max_size).map_err(|e| e.to_string())?;
let actual = save_hashed(&mut input, target, max_size, &mut report)
.map_err(|e| e.to_string())?;
if &actual != sha1 {
return Err(format!(
"Hash of downloaded file ({actual}) did not match requested ({sha1})"
@@ -218,7 +253,7 @@ pub fn download_file(
}
_ => {
let tmp = dir.join(format!("download{:016x}.tmp", rand_suffix()));
let result = save_hashed(&mut input, &tmp, max_size).and_then(|actual| {
let result = save_hashed(&mut input, &tmp, max_size, &mut report).and_then(|actual| {
let path = dir.join(&actual);
if matches(&path, &actual) {
touch(&path);
@@ -237,7 +272,12 @@ fn rand_suffix() -> u64 {
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> {
fn save_hashed(
input: &mut dyn Read,
path: &Path,
max_size: u64,
progress: &mut dyn FnMut(u64),
) -> io::Result<String> {
let mut out = fs::File::create(path)?;
let mut hasher = Sha1::new();
let mut buffer = [0u8; 8196];
@@ -248,6 +288,7 @@ fn save_hashed(input: &mut dyn Read, path: &Path, max_size: u64) -> io::Result<S
break;
}
read_so_far += n as u64;
progress(read_so_far);
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})"
@@ -366,7 +407,14 @@ mod tests {
url,
sha1: Some(sha1(&body)),
};
let path = download_file(&dir, &request, &headers("Alex", 0xab), MAX_SIZE).unwrap();
let path = download_file(
&dir,
&request,
&headers("Alex", 0xab),
MAX_SIZE,
&channel().0,
)
.unwrap();
assert_eq!(path, dir.join(sha1(&body)));
assert_eq!(fs::read(&path).unwrap(), body);
let sent = server.join().unwrap();
@@ -377,7 +425,10 @@ mod tests {
url: "http://127.0.0.1:9/gone.zip".into(),
..request
};
assert_eq!(download_file(&dir, &cached, &[], MAX_SIZE).unwrap(), path);
assert_eq!(
download_file(&dir, &cached, &[], MAX_SIZE, &channel().0).unwrap(),
path
);
fs::remove_dir_all(dir).unwrap();
}
@@ -390,7 +441,7 @@ mod tests {
url,
sha1: Some("0".repeat(40)),
};
let err = download_file(&dir, &wrong, &[], MAX_SIZE).unwrap_err();
let err = download_file(&dir, &wrong, &[], MAX_SIZE, &channel().0).unwrap_err();
assert!(err.contains("did not match requested"), "{err}");
server.join().unwrap();
let dead = Request {
@@ -398,7 +449,7 @@ mod tests {
url: "http://127.0.0.1:9/pack.zip".into(),
sha1: None,
};
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE).is_err());
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE, &channel().0).is_err());
assert!(!dir.join("dead").exists());
let (url, server) = serve("200 OK", vec![0; 100]);
let large = Request {
@@ -406,7 +457,7 @@ mod tests {
url,
sha1: None,
};
let err = download_file(&dir, &large, &[], 10).unwrap_err();
let err = download_file(&dir, &large, &[], 10, &channel().0).unwrap_err();
assert!(err.contains("bigger than maximum"), "{err}");
let _ = server.join();
let (url, server) = serve("404 Not Found", Vec::new());
@@ -415,7 +466,7 @@ mod tests {
url,
sha1: None,
};
assert!(download_file(&dir, &missing, &[], MAX_SIZE).is_err());
assert!(download_file(&dir, &missing, &[], MAX_SIZE, &channel().0).is_err());
server.join().unwrap();
fs::remove_dir_all(dir).unwrap();
}
@@ -425,6 +476,7 @@ mod tests {
let dir = scratch();
let (url, server) = serve("200 OK", b"zip".to_vec());
let queue = Queue::open(dir.clone()).unwrap();
let (progress, events) = channel();
let id = 0x5b2c_7a4e_1f3d_4c8b_9a6e_2d4f_8b1c_3e5a;
queue.download(
vec![Request {
@@ -433,6 +485,7 @@ mod tests {
sha1: None,
}],
Vec::new(),
progress,
);
let batch = loop {
if let Some(b) = queue.finished().pop() {
@@ -445,6 +498,16 @@ mod tests {
.join("5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a")
.join(sha1(b"zip"));
assert_eq!(batch.downloaded, [(id, file)]);
// HttpUtil.downloadFile's listener calls, in order.
assert_eq!(
events.iter().collect::<Vec<_>>(),
[
Progress::RequestStart,
Progress::DownloadStart(Some(3)),
Progress::Bytes(3),
Progress::RequestFinished(true),
]
);
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");
+6 -5
View File
@@ -504,7 +504,8 @@ impl Streaming {
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)
}
@@ -637,7 +638,7 @@ impl Streaming {
});
self.shared.ready.notify_one();
}
fn receive_decorated(&mut self, x: i32, z: i32, region: Box<[Proto; 9]>) {
fn receive_decorated(&mut self, x: i32, z: i32, region: [Proto; 9]) {
self.generation_scan.exhausted = false;
self.decorating -= 1;
self.decorated.insert((x, z));
@@ -824,7 +825,7 @@ impl Streaming {
}
}
}
Done::Decorated(x, z, region) => self.receive_decorated(x, z, region),
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::Lit(x, z, sections, seq) => {
@@ -1306,7 +1307,7 @@ impl Streaming {
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.results.recv_timeout(left) {
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, region),
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
Ok(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Ok(Done::Saved(protos)) => self.receive_saved(protos),
Ok(other) => self.deferred.push(other),
@@ -1334,7 +1335,7 @@ impl Streaming {
let started = std::time::Instant::now();
while let Ok(done) = self.results.try_recv() {
match done {
Done::Decorated(x, z, region) => self.receive_decorated(x, z, region),
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
other => self.deferred.push(other),
+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]);
}
}
+1 -31
View File
@@ -1150,7 +1150,7 @@ impl Title {
+ 30.0;
// Toast.xPos: in from the right edge by the visible portion.
let x = w - width * slide.portion;
toast_background(atlas, batch, x, width);
crate::toasts::background(atlas, batch, x, 0.0, width);
batch.text(atlas, &title, x + 18.0, 7.0, rgba(255, 255, 0, 255), false);
batch.text(atlas, &message, x + 18.0, 19.0, u32::MAX, false);
}
@@ -1553,36 +1553,6 @@ impl Title {
}
}
}
/// SystemToast's 32-pixel background keeps the notice icon and borders intact.
fn toast_background(atlas: &HudAtlas, batch: &mut HudBatch, x: f32, width: f32) {
let Some(rect) = atlas.sprites.get("gui/sprites/toast/system").copied() else {
return;
};
let source_x = [0.0, 17.0, rect.w as f32 - 4.0, rect.w as f32];
let target_x = [x, x + 17.0, x + width - 4.0, x + width];
for col in 0..3 {
for (sy, sh, y, h) in [
(0.0, 28.0, 0.0, 28.0),
(rect.h as f32 - 4.0, 4.0, 28.0, 4.0),
] {
let mut piece = rect;
let du = (rect.u1 - rect.u0) / rect.w as f32;
let dv = (rect.v1 - rect.v0) / rect.h as f32;
piece.u0 = rect.u0 + source_x[col] * du;
piece.u1 = rect.u0 + source_x[col + 1] * du;
piece.v0 = rect.v0 + sy * dv;
piece.v1 = rect.v0 + (sy + sh) * dv;
batch.rect_uv(
target_x[col],
y,
target_x[col + 1] - target_x[col],
h,
piece,
u32::MAX,
);
}
}
}
fn centered(atlas: &HudAtlas, batch: &mut HudBatch, text: &str, y: f32, color: u32) {
batch.text(
atlas,
+102
View File
@@ -1,5 +1,6 @@
//! `ToastManager`'s slide, shared by the game's toasts and the title
//! screens' system toasts.
use crate::hud::{HudAtlas, HudBatch};
use std::time::Instant;
/// `ToastManager.ToastInstance`: slides a toast in and out over 600 ms,
@@ -56,3 +57,104 @@ impl Slide {
self.update(origin.elapsed().as_millis() as i64, wants_show)
}
}
/// `SystemToast`: a yellow title over a white message (or the title alone,
/// lower down) on the system background. It stays until its display time
/// has passed since its last change, or until it is hidden.
pub struct SystemToast {
pub slide: Slide,
title: String,
message: Option<String>,
/// `changed`: the next update takes its time as `lastChanged`.
changed: bool,
last_changed: i64,
force_hide: bool,
}
impl SystemToast {
/// `SystemToastId.displayTime`'s default, in milliseconds.
const DISPLAY_TIME: f64 = 5000.0;
pub fn new(title: String, message: Option<String>) -> Self {
Self {
slide: Slide::default(),
title,
message,
changed: false,
last_changed: 0,
force_hide: false,
}
}
/// `reset`: new text, and the display time starts again.
pub fn reset(&mut self, title: String, message: Option<String>) {
self.title = title;
self.message = message;
self.changed = true;
}
pub fn force_hide(&mut self) {
self.force_hide = true;
}
/// `ToastInstance.update` with `SystemToast.update`, the display time
/// scaled by the Notification Time option: the visibility it switched
/// to (the sound to play), and whether it has slid out.
pub fn update(&mut self, multiplier: f64) -> (Option<bool>, bool) {
self.slide.update_now(|fully| {
if std::mem::take(&mut self.changed) {
self.last_changed = fully;
}
!self.force_hide
&& ((fully - self.last_changed) as f64) < Self::DISPLAY_TIME * multiplier
})
}
/// `extractRenderState` at `y`, in from the right edge by the visible
/// portion (`Toast.xPos`).
pub fn draw(&self, atlas: &HudAtlas, batch: &mut HudBatch, y: f32) {
let width = std::iter::once(&self.title)
.chain(&self.message)
.map(|line| HudBatch::text_width(atlas, line))
.fold(0.0, f32::max)
.ceil()
.max(160.0)
+ 30.0;
let x = batch.width - width * self.slide.portion;
background(atlas, batch, x, y, width);
let yellow = crate::hud::rgba(255, 255, 0, 255);
match &self.message {
Some(message) => {
batch.text(atlas, &self.title, x + 18.0, y + 7.0, yellow, false);
batch.text(atlas, message, x + 18.0, y + 19.0, u32::MAX, false);
}
None => {
batch.text(atlas, &self.title, x + 18.0, y + 12.0, yellow, false);
}
}
}
}
/// SystemToast's 32-pixel background keeps the notice icon and borders intact.
pub fn background(atlas: &HudAtlas, batch: &mut HudBatch, x: f32, y: f32, width: f32) {
let Some(rect) = atlas.sprites.get("gui/sprites/toast/system").copied() else {
return;
};
let source_x = [0.0, 17.0, rect.w as f32 - 4.0, rect.w as f32];
let target_x = [x, x + 17.0, x + width - 4.0, x + width];
for col in 0..3 {
for (sy, sh, y, h) in [
(0.0, 28.0, y, 28.0),
(rect.h as f32 - 4.0, 4.0, y + 28.0, 4.0),
] {
let mut piece = rect;
let du = (rect.u1 - rect.u0) / rect.w as f32;
let dv = (rect.v1 - rect.v0) / rect.h as f32;
piece.u0 = rect.u0 + source_x[col] * du;
piece.u1 = rect.u0 + source_x[col + 1] * du;
piece.v0 = rect.v0 + sy * dv;
piece.v1 = rect.v0 + (sy + sh) * dv;
batch.rect_uv(
target_x[col],
y,
target_x[col + 1] - target_x[col],
h,
piece,
u32::MAX,
);
}
}
}
@@ -221,7 +221,9 @@ fn relit_columns_keep_every_mesh_current() {
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(),
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "horizon-mod-registry"
version = "0.1.0"
edition = "2024"
license = "MIT"
rust-version = "1.90"
publish = false
description = "The registry's submission service: GitHub sign-in, the file's checks, staging, and a pull request (docs/api/mod-index.md)"
# Its own workspace, as the telemetry server's: the game's workspace never builds a web server.
[workspace]
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "query", "multipart"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs", "io-util"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1"
# GitHub's API, blocking, on tokio's blocking pool: the launcher's HTTP client.
ureq = { version = "2", default-features = false, features = ["json", "tls"] }
hmac = "0.12"
sha2 = "0.10"
base64 = "0.22"
getrandom = "0.3"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
[profile.release]
strip = true
lto = "thin"
+14
View File
@@ -0,0 +1,14 @@
# Built from the repository root: docker build -f crates/horizon-mod-registry/Dockerfile .
FROM rust:1-slim AS build
WORKDIR /src
# The root manifest only for the shared crate's workspace settings (edition, lints).
COPY Cargo.toml Cargo.toml
COPY crates/horizon-mods crates/horizon-mods
COPY crates/horizon-mod-registry crates/horizon-mod-registry
RUN cargo build --release --locked --manifest-path crates/horizon-mod-registry/Cargo.toml
FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=build /src/crates/horizon-mod-registry/target/release/horizon-mod-registry /usr/local/bin/
USER nobody
CMD ["horizon-mod-registry"]
+44
View File
@@ -0,0 +1,44 @@
# horizon-mod-registry
The service behind the registry's submission form (`playhorizon.dev/mods/submit`). A creator signs in with GitHub and
sends a file with its page; the service runs the file's own checks (`horizon_mods::index::inspect`, the signature, the
entry's fields), uploads it to the `staging` release of
[ludovic111/horizon-mods](https://github.com/ludovic111/horizon-mods) as `<sha256>.<extension>`, and opens a pull
request adding or updating `mods/<id>.toml`. That repository's CI checks it again, with a trial run of a mod in the
sandbox; reviewers decide, and merging publishes. Formats and rules: [docs/api/mod-index.md](../../docs/api/mod-index.md).
It keeps no data: a session is a signed cookie holding the GitHub login, and the user's OAuth token is used once, to
read that login, and dropped. The sign-in asks for no scope.
| Route | |
| --- | --- |
| `GET /api/mods/auth/login` | Starts the GitHub sign-in |
| `GET /api/mods/auth/callback` | GitHub's redirect: sets the session, back to `/mods/submit` |
| `POST /api/mods/auth/logout` | Ends it |
| `GET /api/mods/me` | `{login, csrf}`, or 401 |
| `POST /api/mods/submit` | Multipart: `entry` (JSON: `id kind name summary description authors license source icon key signature version game changelog`) and `file`; `{pull_request}` or `{error}` |
| `GET /healthz` | `ok` |
A submission must come from `REGISTRY_PUBLIC_URL`'s origin with the session's `X-CSRF-Token`. Six per account and sixty
in all per hour. Only an entry's `owners` publish its updates; a key change, an icon in the repository or anything else
is a pull request by hand.
## Running it
```sh
cargo test # against a mock GitHub (tests/submit.rs)
REGISTRY_PUBLIC_URL=http://localhost:4173 REGISTRY_SESSION_KEY=... GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... \
GITHUB_BOT_TOKEN=... REGISTRY_REPO=you/horizon-mods-test cargo run
```
| Variable | |
| --- | --- |
| `REGISTRY_PUBLIC_URL` | The site, `https://playhorizon.dev`: the OAuth callback is `<it>/api/mods/auth/callback` |
| `REGISTRY_SESSION_KEY` | 32 characters or more, random: signs sessions |
| `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | The OAuth app |
| `GITHUB_BOT_TOKEN` | Fine-grained, the registry repository only: Contents and Pull requests read and write |
| `REGISTRY_REPO` | Default `ludovic111/horizon-mods` |
| `PORT` | Default 8080 |
On Railway it is its own service (`railway.json`, built with the `Dockerfile` from the repository root); the website's
Caddy sends `/api/mods/*` to it over Railway's private network.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "crates/horizon-mod-registry/Dockerfile",
"watchPatterns": [
"/crates/horizon-mods/**",
"/crates/horizon-mod-registry/**",
"/Cargo.toml"
]
},
"deploy": {
"healthcheckPath": "/healthz",
"healthcheckTimeout": 60,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 5,
"multiRegionConfig": {
"europe-west4-drams3a": { "numReplicas": 1 }
}
}
}
+276
View File
@@ -0,0 +1,276 @@
//! What the service asks of GitHub: the sign-in (OAuth, no scope: only the
//! public profile), and with the bot's token on the registry repository
//! alone, an entry, the `staging` release, a branch, a file, a pull request.
//! Blocking (ureq): called from tokio's blocking pool.
use base64::{Engine, engine::general_purpose::STANDARD};
use horizon_mods::index::registry::Entry;
use serde_json::{Value, json};
use std::{path::Path, time::Duration};
/// Where GitHub is: the real one, or a test's mock.
#[derive(Clone, Debug)]
pub struct Endpoints {
/// `https://github.com`: OAuth.
pub web: String,
/// `https://api.github.com`.
pub api: String,
/// `https://uploads.github.com`: release assets.
pub uploads: String,
}
impl Default for Endpoints {
fn default() -> Self {
Endpoints {
web: "https://github.com".into(),
api: "https://api.github.com".into(),
uploads: "https://uploads.github.com".into(),
}
}
}
#[derive(Clone)]
pub struct GitHub {
pub at: Endpoints,
/// `owner/name` of the registry repository.
pub repo: String,
pub client_id: String,
pub client_secret: String,
/// Fine-grained, this repository only: contents and pull requests.
pub bot_token: String,
agent: ureq::Agent,
}
/// A failed call: `status` is GitHub's, 0 when it wasn't reached.
#[derive(Debug)]
pub struct Failure {
pub status: u16,
pub message: String,
}
type Result<T> = std::result::Result<T, Failure>;
fn failure(e: ureq::Error) -> Failure {
match e {
ureq::Error::Status(status, response) => Failure {
status,
message: response
.into_string()
.unwrap_or_default()
.chars()
.take(300)
.collect(),
},
other => Failure {
status: 0,
message: other.to_string(),
},
}
}
fn read(response: ureq::Response) -> Result<Value> {
response.into_json().map_err(|e| Failure {
status: 0,
message: e.to_string(),
})
}
impl GitHub {
pub fn new(
at: Endpoints,
repo: String,
client_id: String,
client_secret: String,
bot_token: String,
) -> GitHub {
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(60))
.user_agent("horizon-mod-registry")
.build();
GitHub {
at,
repo,
client_id,
client_secret,
bot_token,
agent,
}
}
/// Where the sign-in starts.
pub fn authorize_url(&self, redirect: &str, state: &str) -> String {
format!(
"{}/login/oauth/authorize?client_id={}&redirect_uri={}&state={state}&allow_signup=true",
self.at.web,
self.client_id,
encode(redirect)
)
}
/// The signed-in account (login, id) for an OAuth `code`. Its token is
/// used once and dropped.
pub fn sign_in(&self, code: &str, redirect: &str) -> Result<(String, u64)> {
let token = self
.agent
.post(&format!("{}/login/oauth/access_token", self.at.web))
.set("Accept", "application/json")
.send_json(json!({
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"redirect_uri": redirect,
}))
.map_err(failure)
.and_then(read)?;
let Some(token) = token["access_token"].as_str() else {
return Err(Failure {
status: 401,
message: "GitHub refused the sign-in".into(),
});
};
let user = self
.agent
.get(&format!("{}/user", self.at.api))
.set("Authorization", &format!("Bearer {token}"))
.set("Accept", "application/vnd.github+json")
.call()
.map_err(failure)
.and_then(read)?;
match (user["login"].as_str(), user["id"].as_u64()) {
(Some(login), Some(id)) => Ok((login.to_owned(), id)),
_ => Err(Failure {
status: 0,
message: "GitHub sent no login".into(),
}),
}
}
fn call(&self, method: &str, path: &str) -> ureq::Request {
self.agent
.request(
method,
&format!("{}/repos/{}{path}", self.at.api, self.repo),
)
.set("Authorization", &format!("Bearer {}", self.bot_token))
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
}
/// `mods/<id>.toml` on main, with its blob sha; None when it isn't there.
pub fn entry(&self, id: &str) -> Result<Option<(Entry, String)>> {
let response = self
.call("GET", &format!("/contents/mods/{id}.toml"))
.query("ref", "main")
.call();
let file = match response {
Ok(r) => read(r)?,
Err(ureq::Error::Status(404, _)) => return Ok(None),
Err(e) => return Err(failure(e)),
};
let bytes = file["content"]
.as_str()
.map(|c| c.replace('\n', ""))
.and_then(|c| STANDARD.decode(c).ok())
.unwrap_or_default();
let entry = toml::from_str(&String::from_utf8_lossy(&bytes)).map_err(|e| Failure {
status: 0,
message: format!("mods/{id}.toml on main: {e}"),
})?;
Ok(Some((entry, file["sha"].as_str().unwrap_or("").to_owned())))
}
/// Uploads `path` as asset `name` of the `staging` prerelease, unless it
/// is already there (the same file submitted again).
pub fn stage(&self, name: &str, path: &Path) -> Result<()> {
let release = self
.call("GET", "/releases/tags/staging")
.call()
.map_err(failure)
.and_then(read)?;
let there = release["assets"]
.as_array()
.is_some_and(|a| a.iter().any(|x| x["name"] == name));
if there {
return Ok(());
}
let id = release["id"].as_u64().unwrap_or(0);
let bytes = std::fs::read(path).map_err(|e| Failure {
status: 0,
message: e.to_string(),
})?;
self.agent
.post(&format!(
"{}/repos/{}/releases/{id}/assets",
self.at.uploads, self.repo
))
.query("name", name)
.set("Authorization", &format!("Bearer {}", self.bot_token))
.set("Accept", "application/vnd.github+json")
.set("Content-Type", "application/octet-stream")
.send_bytes(&bytes)
.map_err(failure)?;
Ok(())
}
/// Makes branch `name` from main; false when it already exists.
pub fn branch(&self, name: &str) -> Result<bool> {
let main = self
.call("GET", "/git/ref/heads/main")
.call()
.map_err(failure)
.and_then(read)?;
let sha = main["object"]["sha"].as_str().unwrap_or("");
match self
.call("POST", "/git/refs")
.send_json(json!({"ref": format!("refs/heads/{name}"), "sha": sha}))
{
Ok(_) => Ok(true),
Err(ureq::Error::Status(422, _)) => Ok(false),
Err(e) => Err(failure(e)),
}
}
/// Writes `path` on `branch` in one commit; `sha` is the blob it replaces.
pub fn put(
&self,
path: &str,
text: &str,
branch: &str,
sha: Option<&str>,
message: &str,
) -> Result<()> {
let mut body = json!({
"message": message,
"content": STANDARD.encode(text),
"branch": branch,
});
if let Some(sha) = sha {
body["sha"] = json!(sha);
}
self.call("PUT", &format!("/contents/{path}"))
.send_json(body)
.map_err(failure)?;
Ok(())
}
/// Opens the pull request; its address.
pub fn pull(&self, title: &str, head: &str, body: &str) -> Result<String> {
let pr = self
.call("POST", "/pulls")
.send_json(json!({"title": title, "head": head, "base": "main", "body": body}))
.map_err(failure)
.and_then(read)?;
Ok(pr["html_url"].as_str().unwrap_or("").to_owned())
}
}
/// Percent-encodes a query value.
fn encode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
+675
View File
@@ -0,0 +1,675 @@
//! `horizon-mod-registry`: the submission form's service at
//! `playhorizon.dev/api/mods/*` (docs/api/mod-index.md). A creator signs in
//! with GitHub, sends a file and its page; the service runs the file's own
//! checks, uploads it to the registry's `staging` release and opens a pull
//! request with the entry. Reviewers decide; merging publishes. It keeps no
//! data: sessions are signed cookies, and GitHub holds everything else.
pub mod github;
use axum::{
Json, Router,
extract::{DefaultBodyLimit, Multipart, Query, State},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Redirect, Response},
routing::{get, post},
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use github::GitHub;
use hmac::{Hmac, Mac};
use horizon_mods::index::{
self, Kind,
registry::{self, Entry, EntryVersion, Sources},
};
use serde::Deserialize;
use serde_json::json;
use sha2::Sha256;
use std::{
collections::{HashMap, VecDeque},
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
/// How long a sign-in lasts.
const SESSION: u64 = 12 * 3600;
/// Submissions per account per hour, and from everyone.
const PER_ACCOUNT: usize = 6;
const PER_HOUR: usize = 60;
/// The page's JSON.
const MAX_FORM: usize = 64 << 10;
pub struct Config {
/// `https://playhorizon.dev`: where the form is, and the only origin it
/// takes submissions from.
pub public_url: String,
/// At least 32 bytes: signs sessions.
pub session_key: Vec<u8>,
pub github: GitHub,
/// Where files wait during a submission.
pub scratch: PathBuf,
}
impl Config {
/// `REGISTRY_PUBLIC_URL`, `REGISTRY_SESSION_KEY`, `REGISTRY_REPO`,
/// `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_BOT_TOKEN`.
pub fn from_env() -> Result<Config, String> {
let var = |name: &str| std::env::var(name).map_err(|_| format!("{name} is not set"));
let key = var("REGISTRY_SESSION_KEY")?;
if key.len() < 32 {
return Err("REGISTRY_SESSION_KEY needs at least 32 characters".into());
}
Ok(Config {
public_url: var("REGISTRY_PUBLIC_URL")?.trim_end_matches('/').to_owned(),
session_key: key.into_bytes(),
github: GitHub::new(
github::Endpoints::default(),
std::env::var("REGISTRY_REPO").unwrap_or_else(|_| "ludovic111/horizon-mods".into()),
var("GITHUB_CLIENT_ID")?,
var("GITHUB_CLIENT_SECRET")?,
var("GITHUB_BOT_TOKEN")?,
),
scratch: std::env::temp_dir().join("horizon-mod-registry"),
})
}
fn callback(&self) -> String {
format!("{}/api/mods/auth/callback", self.public_url)
}
}
#[derive(Clone)]
pub struct App(Arc<Inner>);
struct Inner {
config: Config,
recent: Mutex<HashMap<String, VecDeque<Instant>>>,
}
impl App {
pub fn new(config: Config) -> App {
App(Arc::new(Inner {
config,
recent: Mutex::new(HashMap::new()),
}))
}
}
pub fn router(app: App) -> Router {
Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/api/mods/auth/login", get(login))
.route("/api/mods/auth/callback", get(callback))
.route("/api/mods/auth/logout", post(logout))
.route("/api/mods/me", get(me))
.route(
"/api/mods/submit",
post(submit).layer(DefaultBodyLimit::max(
index::MAX_FILE as usize + MAX_FORM * 2,
)),
)
.with_state(app)
}
/// An error players read in the form.
#[derive(Debug)]
pub struct Refusal(StatusCode, String);
impl Refusal {
fn new(status: StatusCode, message: impl Into<String>) -> Refusal {
Refusal(status, message.into())
}
}
impl IntoResponse for Refusal {
fn into_response(self) -> Response {
(self.0, Json(json!({"error": self.1}))).into_response()
}
}
fn bad(message: impl Into<String>) -> Refusal {
Refusal::new(StatusCode::BAD_REQUEST, message)
}
fn github_down(e: github::Failure) -> Refusal {
eprintln!("horizon-mod-registry: GitHub {}: {}", e.status, e.message);
Refusal::new(
StatusCode::BAD_GATEWAY,
"GitHub didn't answer as expected: try again in a minute",
)
}
// Signed values: `<payload>.<HMAC-SHA256>`, both base64url.
fn sign(key: &[u8], payload: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("any key length");
mac.update(payload.as_bytes());
format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(payload),
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
)
}
fn unsign(key: &[u8], value: &str) -> Option<String> {
let (payload, tag) = value.split_once('.')?;
let payload = String::from_utf8(URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
let mut mac = Hmac::<Sha256>::new_from_slice(key).ok()?;
mac.update(payload.as_bytes());
mac.verify_slice(&URL_SAFE_NO_PAD.decode(tag).ok()?).ok()?;
Some(payload)
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn random_hex() -> String {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("the system's random numbers");
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|c| c.trim().split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_owned())
}
fn set_cookie(name: &str, value: &str, max_age: u64) -> String {
format!("{name}={value}; Path=/api/mods; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age}")
}
/// The signed-in account: (login, id).
fn session(app: &Inner, headers: &HeaderMap) -> Option<(String, u64)> {
let payload = unsign(&app.config.session_key, &cookie(headers, "hm_session")?)?;
let mut parts = payload.split('\n');
let (login, id, until) = (parts.next()?, parts.next()?, parts.next()?);
(until.parse::<u64>().ok()? > now()).then(|| (login.to_owned(), id.parse().unwrap_or(0)))
}
/// The form sends this with each submission: a page on another site can't
/// read it, so it can't submit in a signed-in creator's name.
fn csrf(app: &Inner, headers: &HeaderMap) -> Option<String> {
let session = cookie(headers, "hm_session")?;
let signed = sign(&app.config.session_key, &format!("csrf\n{session}"));
Some(signed.rsplit('.').next()?.chars().take(32).collect())
}
async fn login(State(app): State<App>) -> Response {
let state = random_hex();
let signed = sign(&app.0.config.session_key, &state);
let url = app
.0
.config
.github
.authorize_url(&app.0.config.callback(), &state);
(
[(header::SET_COOKIE, set_cookie("hm_state", &signed, 600))],
Redirect::to(&url),
)
.into_response()
}
#[derive(Deserialize)]
struct Callback {
code: String,
state: String,
}
async fn callback(
State(app): State<App>,
headers: HeaderMap,
Query(q): Query<Callback>,
) -> Result<Response, Refusal> {
let expected = cookie(&headers, "hm_state").and_then(|v| unsign(&app.0.config.session_key, &v));
if expected.as_deref() != Some(q.state.as_str()) {
return Err(Refusal::new(
StatusCode::FORBIDDEN,
"the sign-in expired or didn't start here: try again",
));
}
let inner = app.0.clone();
let (login, id) = tokio::task::spawn_blocking(move || {
inner
.config
.github
.sign_in(&q.code, &inner.config.callback())
})
.await
.map_err(|_| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, "sign-in failed"))?
.map_err(github_down)?;
let value = sign(
&app.0.config.session_key,
&format!("{login}\n{id}\n{}", now() + SESSION),
);
let back = format!("{}/mods/submit", app.0.config.public_url);
let mut response = Redirect::to(&back).into_response();
let headers = response.headers_mut();
headers.append(
header::SET_COOKIE,
set_cookie("hm_session", &value, SESSION).parse().unwrap(),
);
headers.append(
header::SET_COOKIE,
set_cookie("hm_state", "", 0).parse().unwrap(),
);
Ok(response)
}
async fn logout() -> Response {
(
[(header::SET_COOKIE, set_cookie("hm_session", "", 0))],
StatusCode::NO_CONTENT,
)
.into_response()
}
async fn me(
State(app): State<App>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Refusal> {
let (login, _) = session(&app.0, &headers)
.ok_or_else(|| Refusal::new(StatusCode::UNAUTHORIZED, "not signed in"))?;
Ok(Json(
json!({"login": login, "csrf": csrf(&app.0, &headers)}),
))
}
/// The page a submission fills in: a project's fields, and the version's.
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Form {
pub id: String,
pub kind: String,
pub name: String,
pub summary: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub authors: Vec<String>,
pub license: String,
pub source: String,
/// `https://` only; an icon in the repository is a pull request by hand.
#[serde(default)]
pub icon: Option<String>,
/// A new project's minisign public key.
#[serde(default)]
pub key: Option<String>,
/// The `.minisig` text.
#[serde(default)]
pub signature: Option<String>,
pub version: String,
pub game: String,
#[serde(default)]
pub changelog: String,
}
fn take(window: &mut VecDeque<Instant>, max: usize) -> bool {
while window
.front()
.is_some_and(|t| t.elapsed() > Duration::from_secs(3600))
{
window.pop_front();
}
window.len() < max
}
async fn submit(
State(app): State<App>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, Refusal> {
let inner = app.0.clone();
let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok());
if origin != Some(inner.config.public_url.as_str()) {
return Err(Refusal::new(
StatusCode::FORBIDDEN,
"submit from the registry's form",
));
}
let (login, user_id) = session(&inner, &headers)
.ok_or_else(|| Refusal::new(StatusCode::UNAUTHORIZED, "sign in with GitHub first"))?;
let sent = headers.get("x-csrf-token").and_then(|v| v.to_str().ok());
if sent.is_none() || sent.map(str::to_owned) != csrf(&inner, &headers) {
return Err(Refusal::new(
StatusCode::FORBIDDEN,
"reload the form and try again",
));
}
{
let mut recent = inner.recent.lock().unwrap();
let ok = take(recent.entry(String::new()).or_default(), PER_HOUR)
&& take(recent.entry(login.to_lowercase()).or_default(), PER_ACCOUNT);
if !ok {
return Err(Refusal::new(
StatusCode::TOO_MANY_REQUESTS,
"too many submissions this hour: try again later",
));
}
let at = Instant::now();
recent.entry(String::new()).or_default().push_back(at);
recent
.entry(login.to_lowercase())
.or_default()
.push_back(at);
}
let dir = inner.config.scratch.join(random_hex());
std::fs::create_dir_all(&dir)
.map_err(|e| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let result = receive(&mut multipart, &dir).await;
let result = match result {
Ok((form, file)) => {
let inner = inner.clone();
tokio::task::spawn_blocking(move || {
publish(&inner.config, &login, user_id, &form, &file)
})
.await
.unwrap_or_else(|_| {
Err(Refusal::new(
StatusCode::INTERNAL_SERVER_ERROR,
"the check crashed",
))
})
}
Err(e) => Err(e),
};
let _ = std::fs::remove_dir_all(&dir);
result.map(|url| Json(json!({"pull_request": url})))
}
/// Reads the form's two parts: `entry` (JSON) and `file`, streamed to `dir`.
async fn receive(multipart: &mut Multipart, dir: &Path) -> Result<(Form, PathBuf), Refusal> {
use tokio::io::AsyncWriteExt;
let (mut form, mut file) = (None, None);
while let Some(mut field) = multipart
.next_field()
.await
.map_err(|e| bad(e.body_text()))?
{
match field.name() {
Some("entry") => {
let text = field.text().await.map_err(|e| bad(e.body_text()))?;
if text.len() > MAX_FORM {
return Err(bad("the page's text is too long"));
}
form = Some(serde_json::from_str::<Form>(&text).map_err(|e| bad(e.to_string()))?);
}
Some("file") => {
let path = dir.join("upload");
let mut out = tokio::fs::File::create(&path)
.await
.map_err(|e| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut size = 0u64;
while let Some(chunk) = field.chunk().await.map_err(|e| bad(e.body_text()))? {
size += chunk.len() as u64;
if size > index::MAX_FILE {
return Err(bad(format!(
"the file is over {} MiB",
index::MAX_FILE >> 20
)));
}
out.write_all(&chunk).await.map_err(|e| {
Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?;
}
out.flush().await.ok();
file = Some(path);
}
_ => {}
}
}
match (form, file) {
(Some(form), Some(file)) => Ok((form, file)),
_ => Err(bad("send the page and the file")),
}
}
/// `YYYY-MM-DD` of a Unix time (UTC).
fn date(secs: u64) -> String {
// Howard Hinnant's days-to-civil.
let z = (secs / 86400) as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02}")
}
/// The submission, past its checks: the entry written on a new branch and a
/// pull request opened. Returns the pull request's address.
pub fn publish(
config: &Config,
login: &str,
user_id: u64,
form: &Form,
file: &Path,
) -> Result<String, Refusal> {
let gh = &config.github;
let kind = Kind::from(form.kind.clone());
registry::open(&kind).map_err(bad)?;
if !index::valid_id(&form.id) {
return Err(bad("an id is 1 to 64 of a-z, 0-9 and _"));
}
if form
.icon
.as_ref()
.is_some_and(|i| !i.starts_with("https://"))
{
return Err(bad("the icon must be an https:// address"));
}
if !form.source.trim().starts_with("https://") {
return Err(bad("the source must be an https:// address of the code"));
}
if form.name.trim().is_empty() || form.license.trim().is_empty() {
return Err(bad("a project needs a name and a licence"));
}
// The file's own checks first: a wrong file never reaches GitHub.
let facts = index::inspect(&kind, file).map_err(bad)?;
if let Some(id) = facts.id.as_ref().filter(|id| **id != form.id) {
return Err(bad(format!("the file's id is {id:?}, not {:?}", form.id)));
}
if let Some(v) = facts
.version
.as_ref()
.filter(|v| **v != form.version.trim())
{
return Err(bad(format!(
"the file says version {v:?}, not {:?}",
form.version.trim()
)));
}
let sha256 = index::sha256_file(file).map_err(bad)?;
let extension = kind.extension().unwrap_or("zip");
let version = EntryVersion {
version: form.version.trim().to_owned(),
published: date(now()),
game: form.game.trim().to_owned(),
file: format!("{}-{}.{extension}", form.id, form.version.trim()),
sha256: sha256.clone(),
signature: form.signature.clone().filter(|s| !s.trim().is_empty()),
changelog: form.changelog.clone(),
};
let existing = gh.entry(&form.id).map_err(github_down)?;
let is_new = existing.is_none();
let (entry, blob) = match existing {
None => (
Entry {
id: form.id.clone(),
kind: kind.clone(),
name: form.name.trim().to_owned(),
summary: form.summary.trim().to_owned(),
description: form.description.clone(),
authors: form.authors.clone(),
owners: vec![login.to_owned()],
license: form.license.trim().to_owned(),
source: form.source.trim().to_owned(),
icon: form.icon.clone(),
key: form.key.clone().filter(|k| !k.trim().is_empty()),
versions: vec![version.clone()],
},
None,
),
Some((mut entry, blob)) => {
if !entry.owners.iter().any(|o| o.eq_ignore_ascii_case(login)) {
return Err(Refusal::new(
StatusCode::FORBIDDEN,
format!(
"{} is {}'s: only its owners publish its updates",
form.id,
entry.owners.join(", ")
),
));
}
if entry.kind != kind {
return Err(bad(format!("{} is a {}", form.id, entry.kind.name())));
}
if entry.versions.iter().any(|v| v.version == version.version) {
return Err(Refusal::new(
StatusCode::CONFLICT,
format!(
"{} {} is already published: give this file a new version",
form.id, version.version
),
));
}
let key = form.key.clone().filter(|k| !k.trim().is_empty());
if key.is_some() && key != entry.key {
return Err(bad(
"changing a project's key is a pull request by hand, reviewed on its own",
));
}
entry.name = form.name.trim().to_owned();
entry.summary = form.summary.trim().to_owned();
entry.description = form.description.clone();
entry.authors = form.authors.clone();
entry.license = form.license.trim().to_owned();
entry.source = form.source.trim().to_owned();
if form.icon.is_some() {
entry.icon = form.icon.clone();
}
entry.versions.insert(0, version.clone());
(entry, Some(blob))
}
};
// Every check CI runs but the trial run: the file as its kind, its id and
// version, its signature, the entry's fields. CI runs it all again.
let files = file.parent().unwrap_or(Path::new("."));
std::fs::copy(file, files.join(&sha256)).map_err(|e| bad(e.to_string()))?;
let mut checked = entry.clone();
checked.versions.truncate(1);
if checked
.icon
.as_ref()
.is_some_and(|i| !i.starts_with("https://"))
{
checked.icon = None;
}
let project = registry::project(
&checked,
&Sources {
registry: files,
files,
release: index::FILE_HOST,
site: "https://example.invalid/",
generated: String::new(),
},
)
.map_err(|errors| bad(errors.join("; ")))?;
let text = entry.to_toml().map_err(bad)?;
gh.stage(&format!("{sha256}.{extension}"), file)
.map_err(github_down)?;
let branch = format!("submit/{}-{}", form.id, version.version);
if !gh.branch(&branch).map_err(github_down)? {
return Err(Refusal::new(
StatusCode::CONFLICT,
format!(
"{} {} was already submitted: its pull request is open",
form.id, version.version
),
));
}
let what = if is_new { "Add" } else { "Update" };
let message = format!(
"{what} {} {}\n\nSubmitted by @{login} through the form.\n\nCo-authored-by: {login} <{user_id}+{login}@users.noreply.github.com>",
form.id, version.version
);
gh.put(
&format!("mods/{}.toml", form.id),
&text,
&branch,
blob.as_deref(),
&message,
)
.map_err(github_down)?;
let v = &project.versions[0];
let capabilities: Vec<String> = v
.capabilities
.iter()
.map(|c| format!("- may {}", c.describe()))
.collect();
let requires: Vec<String> = v
.requires
.iter()
.map(|r| format!("- needs {} {}", r.id, r.version))
.collect();
let body = format!(
"<!-- submitted-by: {login} -->\nSubmitted by @{login} through [the form]({public}/mods/submit).\n\n\
| | |\n|---|---|\n| Project | `{id}` ({kind}, {new}) |\n| Version | {version} for {game} |\n| File | `{file}`, {size} bytes, SHA-256 `{sha256}` |\n| Signed | {signed} |\n| Source | {source} |\n\n{lists}\n\n\
The checks below run the file's own checks again and, for a mod, a trial run in the sandbox. \
Reviewers: read the source and the checks' report before you approve.",
public = config.public_url,
id = form.id,
kind = kind.name(),
new = if is_new { "new" } else { "update" },
version = v.version,
game = v.game,
file = v.file.name,
size = v.file.size,
signed = if entry.key.is_some() {
"yes, with the project's key"
} else {
"no"
},
source = entry.source,
lists = [capabilities, requires].concat().join("\n"),
);
gh.pull(
&format!("{what} {} {}", form.id, version.version),
&branch,
&body,
)
.map_err(github_down)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dates_and_signatures() {
assert_eq!(date(0), "1970-01-01");
assert_eq!(date(1_792_800_000), "2026-10-24");
let key = b"0123456789abcdef0123456789abcdef";
let signed = sign(key, "alice\n7\n99");
assert_eq!(unsign(key, &signed).as_deref(), Some("alice\n7\n99"));
assert_eq!(unsign(b"another key, another key, another", &signed), None);
let forged = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode("mallory\n8\n99"),
signed.split_once('.').unwrap().1
);
assert_eq!(unsign(key, &forged), None);
}
}
+25
View File
@@ -0,0 +1,25 @@
//! `horizon-mod-registry`: serves on `PORT` (default 8080). Configured by the
//! environment (see `Config::from_env`); keeps no data.
use std::net::SocketAddr;
use horizon_mod_registry::{App, Config, router};
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() {
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(8080);
let config = Config::from_env().unwrap_or_else(|e| panic!("horizon-mod-registry: {e}"));
let repo = config.github.repo.clone();
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port)))
.await
.unwrap_or_else(|e| panic!("port {port}: {e}"));
eprintln!("horizon-mod-registry: listening on {port}, submitting to {repo}");
axum::serve(listener, router(App::new(config)))
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await
.unwrap_or_else(|e| panic!("{e}"));
}
+373
View File
@@ -0,0 +1,373 @@
//! The whole submission against a mock GitHub: sign in, submit the sample
//! signed mod (`crates/horizon-mods/tests/index`), and what lands on GitHub;
//! then what is refused.
use axum::{
Json, Router,
body::{Body, Bytes},
extract::{Path as UrlPath, Query, State},
http::{Request, StatusCode, header},
routing::{get, post},
};
use base64::{Engine, engine::general_purpose::STANDARD};
use horizon_mod_registry::{App, Config, github, router};
use horizon_mods::index::registry::Entry;
use http_body_util::BodyExt;
use serde_json::{Value, json};
use std::{
collections::HashMap,
path::PathBuf,
sync::{Arc, Mutex},
};
use tower::ServiceExt;
const PUBLIC: &str = "https://playhorizon.test";
const HMOD_SHA: &str = "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53";
#[derive(Default)]
struct Mock {
/// Path -> text on main.
main: HashMap<String, String>,
assets: Vec<(String, usize)>,
branches: Vec<String>,
/// (branch, path) -> (text, message, replaced blob).
commits: HashMap<(String, String), (String, String, Option<String>)>,
pulls: Vec<Value>,
/// The account the next sign-in gets.
login: String,
}
type Shared = Arc<Mutex<Mock>>;
async fn mock_github() -> (String, Shared) {
let state: Shared = Arc::default();
state.lock().unwrap().login = "alice".into();
let app = Router::new()
.route(
"/login/oauth/access_token",
post(|Json(body): Json<Value>| async move {
Json(if body["code"] == "good" {
json!({"access_token": "gho_user"})
} else {
json!({"error": "bad_verification_code"})
})
}),
)
.route(
"/user",
get(|State(s): State<Shared>| async move {
Json(json!({"login": s.lock().unwrap().login, "id": 7}))
}),
)
.route(
"/repos/o/r/contents/mods/{file}",
get(
|State(s): State<Shared>, UrlPath(file): UrlPath<String>| async move {
let s = s.lock().unwrap();
match s.main.get(&format!("mods/{file}")) {
Some(text) => Ok(Json(json!({"content": STANDARD.encode(text), "sha": "blob1"}))),
None => Err(StatusCode::NOT_FOUND),
}
},
)
.put(
|State(s): State<Shared>, UrlPath(file): UrlPath<String>, Json(b): Json<Value>| async move {
let text = String::from_utf8(STANDARD.decode(b["content"].as_str().unwrap()).unwrap()).unwrap();
let key = (b["branch"].as_str().unwrap().to_owned(), format!("mods/{file}"));
let value = (text, b["message"].as_str().unwrap().to_owned(), b["sha"].as_str().map(str::to_owned));
s.lock().unwrap().commits.insert(key, value);
Json(json!({}))
},
),
)
.route(
"/repos/o/r/releases/tags/staging",
get(|State(s): State<Shared>| async move {
let names: Vec<Value> = s.lock().unwrap().assets.iter().map(|(n, _)| json!({"name": n})).collect();
Json(json!({"id": 42, "assets": names}))
}),
)
.route(
"/repos/o/r/releases/42/assets",
post(
|State(s): State<Shared>, Query(q): Query<HashMap<String, String>>, body: Bytes| async move {
s.lock().unwrap().assets.push((q["name"].clone(), body.len()));
Json(json!({}))
},
),
)
.route(
"/repos/o/r/git/ref/heads/main",
get(|| async { Json(json!({"object": {"sha": "abc123"}})) }),
)
.route(
"/repos/o/r/git/refs",
post(|State(s): State<Shared>, Json(b): Json<Value>| async move {
assert_eq!(b["sha"], "abc123");
let name = b["ref"].as_str().unwrap().trim_start_matches("refs/heads/").to_owned();
let mut s = s.lock().unwrap();
if s.branches.contains(&name) {
return Err(StatusCode::UNPROCESSABLE_ENTITY);
}
s.branches.push(name);
Ok(Json(json!({})))
}),
)
.route(
"/repos/o/r/pulls",
post(|State(s): State<Shared>, Json(b): Json<Value>| async move {
let mut s = s.lock().unwrap();
s.pulls.push(b);
Json(json!({"html_url": format!("https://github.com/o/r/pull/{}", s.pulls.len())}))
}),
)
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(base, state)
}
fn fixtures() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../horizon-mods/tests/index")
}
struct Client {
app: Router,
cookies: HashMap<String, String>,
}
impl Client {
async fn send(&mut self, request: Request<Body>) -> (StatusCode, Value, Option<String>) {
let response = self.app.clone().oneshot(request).await.unwrap();
let status = response.status();
for c in response.headers().get_all(header::SET_COOKIE) {
let (pair, _) = c.to_str().unwrap().split_once(';').unwrap();
let (k, v) = pair.split_once('=').unwrap();
self.cookies.insert(k.into(), v.into());
}
let location = response
.headers()
.get(header::LOCATION)
.map(|l| l.to_str().unwrap().to_owned());
let bytes = response.into_body().collect().await.unwrap().to_bytes();
(
status,
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
location,
)
}
fn cookie_header(&self) -> String {
self.cookies
.iter()
.filter(|(_, v)| !v.is_empty())
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("; ")
}
fn get(&self, uri: &str) -> Request<Body> {
Request::get(uri)
.header(header::COOKIE, self.cookie_header())
.body(Body::empty())
.unwrap()
}
async fn sign_in(&mut self) -> String {
let (status, _, location) = self.send(self.get("/api/mods/auth/login")).await;
assert_eq!(status, StatusCode::SEE_OTHER);
let location = location.unwrap();
assert!(location.contains(
"redirect_uri=https%3A%2F%2Fplayhorizon.test%2Fapi%2Fmods%2Fauth%2Fcallback"
));
let state = location
.split("state=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap()
.to_owned();
// Another state than the one this browser started with is refused.
let (status, ..) = self
.send(self.get("/api/mods/auth/callback?code=good&state=other"))
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
let (status, _, location) = self
.send(self.get(&format!("/api/mods/auth/callback?code=good&state={state}")))
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
assert_eq!(
location.as_deref(),
Some("https://playhorizon.test/mods/submit")
);
let (status, me, _) = self.send(self.get("/api/mods/me")).await;
assert_eq!(status, StatusCode::OK);
me["csrf"].as_str().unwrap().to_owned()
}
async fn submit(&mut self, csrf: &str, form: &Value, file: &[u8]) -> (StatusCode, Value) {
let boundary = "XhorizonX";
let mut body = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"entry\"\r\n\r\n{form}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"f\"\r\nContent-Type: application/octet-stream\r\n\r\n"
)
.into_bytes();
body.extend_from_slice(file);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
let request = Request::post("/api/mods/submit")
.header(header::COOKIE, self.cookie_header())
.header(header::ORIGIN, PUBLIC)
.header("x-csrf-token", csrf)
.header(
header::CONTENT_TYPE,
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap();
let (status, json, _) = self.send(request).await;
(status, json)
}
}
fn form() -> Value {
let key = std::fs::read_to_string(fixtures().join("test.pub")).unwrap();
json!({
"id": "coords_hud",
"kind": "mod",
"name": "Coordinates HUD",
"summary": "Your coordinates in a corner.",
"authors": ["Alice"],
"license": "MIT",
"source": "https://github.com/alice/coords",
"key": key.lines().last().unwrap(),
"signature": std::fs::read_to_string(fixtures().join("coords_hud-0.1.0.hmod.minisig")).unwrap(),
"version": "0.1.0",
"game": "26.3",
})
}
async fn setup() -> (Client, Shared) {
let (base, mock) = mock_github().await;
let scratch =
std::env::temp_dir().join(format!("horizon-mod-registry-test-{}", std::process::id()));
let config = Config {
public_url: PUBLIC.into(),
session_key: b"a test key of at least thirty-two bytes".to_vec(),
github: github::GitHub::new(
github::Endpoints {
web: base.clone(),
api: base.clone(),
uploads: base,
},
"o/r".into(),
"client".into(),
"secret".into(),
"bot".into(),
),
scratch,
};
let client = Client {
app: router(App::new(config)),
cookies: HashMap::new(),
};
(client, mock)
}
#[tokio::test(flavor = "multi_thread")]
async fn a_submission_becomes_a_pull_request() {
let (mut client, mock) = setup().await;
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
// Not signed in.
let (status, _) = client.submit("x", &form(), &file).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let csrf = client.sign_in().await;
let (status, _) = client.submit("wrong", &form(), &file).await;
assert_eq!(status, StatusCode::FORBIDDEN);
let (status, json) = client.submit(&csrf, &form(), &file).await;
assert_eq!(status, StatusCode::OK, "{json}");
assert_eq!(json["pull_request"], "https://github.com/o/r/pull/1");
{
let m = mock.lock().unwrap();
assert_eq!(m.assets, [(format!("{HMOD_SHA}.hmod"), file.len())]);
assert_eq!(m.branches, ["submit/coords_hud-0.1.0"]);
let (text, message, replaced) = &m.commits[&(
"submit/coords_hud-0.1.0".into(),
"mods/coords_hud.toml".into(),
)];
let entry: Entry = toml::from_str(text).unwrap();
assert_eq!(entry.owners, ["alice"]);
assert_eq!(entry.versions[0].sha256, HMOD_SHA);
assert_eq!(entry.versions[0].file, "coords_hud-0.1.0.hmod");
assert!(entry.versions[0].signature.is_some() && entry.key.is_some());
assert!(message.contains("Co-authored-by: alice <[email protected]>"));
assert_eq!(replaced, &None);
let body = m.pulls[0]["body"].as_str().unwrap();
assert!(body.starts_with("<!-- submitted-by: alice -->"), "{body}");
assert!(body.contains("may draw text on your screen"), "{body}");
}
// The same version again: its branch is there.
let (status, json) = client.submit(&csrf, &form(), &file).await;
assert_eq!(status, StatusCode::CONFLICT, "{json}");
assert_eq!(mock.lock().unwrap().assets.len(), 1, "uploaded once");
}
#[tokio::test(flavor = "multi_thread")]
async fn what_the_form_refuses() {
let (mut client, mock) = setup().await;
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
let csrf = client.sign_in().await;
let refused = |status: StatusCode, json: &Value, part: &str| {
assert!(
json["error"].as_str().unwrap_or("").contains(part),
"{status} {json}"
);
};
// A file that isn't what the page says.
let mut f = form();
f["id"] = json!("other_mod");
let (status, json) = client.submit(&csrf, &f, &file).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
refused(status, &json, "the file's id is \"coords_hud\"");
// A broken signature.
let mut f = form();
f["signature"] = json!(
std::fs::read_to_string(fixtures().join("coords_hud-0.1.0.hmod.minisig"))
.unwrap()
.replace("RUTV", "RUTW")
);
let (status, json) = client.submit(&csrf, &f, &file).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
// Shader packs aren't open.
let mut f = form();
f["kind"] = json!("shader");
let (status, json) = client.submit(&csrf, &f, &file).await;
refused(status, &json, "shader packs open");
// An update by someone who isn't an owner.
let owned = "id = \"coords_hud\"\nkind = \"mod\"\nname = \"C\"\nsummary = \"s\"\nowners = [\"bob\"]\nlicense = \"MIT\"\nsource = \"https://x.test\"\n\n[[versions]]\nversion = \"0.0.1\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"coords_hud-0.0.1.hmod\"\nsha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n";
mock.lock()
.unwrap()
.main
.insert("mods/coords_hud.toml".into(), owned.into());
let (status, json) = client.submit(&csrf, &form(), &file).await;
assert_eq!(status, StatusCode::FORBIDDEN);
refused(status, &json, "bob's");
// From another site.
let mut request = Request::post("/api/mods/submit")
.header(header::COOKIE, client.cookie_header())
.header(header::ORIGIN, "https://evil.test")
.header("x-csrf-token", &csrf)
.body(Body::empty())
.unwrap();
request.headers_mut().insert(
header::CONTENT_TYPE,
"multipart/form-data; boundary=x".parse().unwrap(),
);
let (status, ..) = client.send(request).await;
assert_eq!(status, StatusCode::FORBIDDEN);
let m = mock.lock().unwrap();
assert!(
m.assets.is_empty() && m.pulls.is_empty(),
"nothing reached GitHub"
);
}
+126 -51
View File
@@ -448,60 +448,109 @@ pub fn check_file(
(None, None) => Trust::Unsigned,
_ => return Err("its signature and the project's key don't go together".into()),
};
match project.kind {
Kind::Mod => {
let m = Package::read(path)?.manifest;
crate::Content::read(path, &m)?;
let mut caps = m.capabilities.clone();
caps.sort();
let mut listed = version.capabilities.clone();
listed.sort();
let differs = m.id != project.id
|| m.version != version.version
|| m.api != version.api
|| Some(m.side) != version.side
|| caps != listed;
if differs {
return Err(format!(
"its horizon-mod.toml ({} {}, API {}, {:?}, {}) isn't what the index lists",
m.id,
m.version,
m.api,
m.side,
names(&caps)
));
}
}
Kind::ResourcePack => check_resource_pack(path)?,
Kind::Shader | Kind::Other(_) => {
return Err(format!(
"this Horizon can't install a {} yet",
project.kind.name()
));
}
Kind::Modpack => {
let pack = Modpack::read(path)?;
let m = &pack.manifest;
let mods: Vec<Requirement> = m.mods.iter().map(modpack::PackMod::requirement).collect();
if m.id != project.id
|| m.version != version.version
|| m.format.to_string() != version.api
|| m.game != version.game
|| mods != version.requires
{
return Err(format!(
"its {} isn't what the index lists",
modpack::MANIFEST
));
}
}
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.
fn check_resource_pack(path: &Path) -> Result<(), String> {
/// 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() {
@@ -523,10 +572,35 @@ fn check_resource_pack(path: &Path) -> Result<(), String> {
.map_err(|e| format!("pack.mcmeta: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&meta).map_err(|e| format!("pack.mcmeta: {e}"))?;
if !json["pack"].is_object() {
let pack = &json["pack"];
if !pack.is_object() {
return Err("pack.mcmeta: no \"pack\" object".into());
}
Ok(())
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 {
@@ -534,5 +608,6 @@ fn names(caps: &[Capability]) -> String {
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"));
}
+4 -1
View File
@@ -151,7 +151,10 @@ fn resource_packs_need_a_pack_mcmeta() {
path
};
let meta = r#"{"pack": {"description": "Look", "min_format": 75, "max_format": 75}}"#;
assert_eq!(check_resource_pack(&pack(&[("pack.mcmeta", meta)])), Ok(()));
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();
+102 -34
View File
@@ -36,41 +36,82 @@ fn executable(dir: &Path, name: &str) -> PathBuf {
})
}
fn on_path(name: &str) -> Option<PathBuf> {
std::env::split_paths(&std::env::var_os("PATH")?)
.map(|dir| executable(&dir, name))
.find(|p| p.is_file())
/// `fs::canonicalize`, without the `\\?\` prefix it gives a Windows drive path: the
/// home goes into `JAVA_HOME` and the PATH of preparers, whose lookups don't take it.
fn real_path(path: PathBuf) -> PathBuf {
let real = fs::canonicalize(&path).unwrap_or(path);
if cfg!(windows) {
without_verbatim(real)
} else {
real
}
}
/// The major version `javac -version` reports ("javac 25.0.1" → 25; "javac 1.8.0" → 8).
fn javac_major(javac: &Path) -> Result<u32, String> {
let output = command(javac)
fn without_verbatim(path: PathBuf) -> PathBuf {
match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
Some(rest) if rest.as_bytes().get(1) == Some(&b':') => PathBuf::from(rest),
_ => path,
}
}
/// Resolve compiler symlinks before picking its sibling runtime. The first `java` on the
/// PATH may be an older, runtime-only install; it must not be paired with a newer `javac`.
fn path_jdks(path: &OsStr) -> Vec<PathBuf> {
std::env::split_paths(path)
.map(|dir| executable(&dir, "javac"))
.filter(|javac| javac.is_file())
.map(real_path)
.filter_map(|javac| {
let bin = javac.parent()?;
// macOS's system tools are dispatchers, not a JDK home: JAVA_HOME=/usr
// makes them call themselves. /usr/libexec/java_home finds the actual JDK.
if bin.file_name()? != "bin"
|| (cfg!(target_os = "macos") && bin == Path::new("/usr/bin"))
{
return None;
}
Some(bin.parent()?.to_path_buf())
})
.collect()
}
/// The major version `javac -version` or `java -version` reports, including Java 8's `1.8`.
fn tool_major(program: &Path, name: &str) -> Result<u32, String> {
let output = command(program)
.arg("-version")
.stdin(Stdio::null())
.output()
.map_err(|e| format!("{}: {e}", javac.display()))?;
.map_err(|e| format!("{}: {e}", program.display()))?;
let text = String::from_utf8_lossy(&output.stdout).into_owned()
+ &String::from_utf8_lossy(&output.stderr);
// macOS ships /usr/bin/java and /usr/bin/javac as placeholders.
if text.contains("Unable to locate a Java Runtime") {
return Err(format!(
"{} is macOS's placeholder (no Java installed)",
program.display()
));
}
if !output.status.success() {
return Err(format!(
"{} -version failed ({}): {}",
program.display(),
output.status,
text.trim()
));
}
let marker = if name == "javac" { "javac" } else { "version" };
let version = text
.split_whitespace()
.skip_while(|w| *w != "javac")
.skip_while(|w| *w != marker)
.nth(1)
.ok_or_else(|| {
// macOS ships /usr/bin/javac as a placeholder that only says no Java is installed.
if text.contains("Unable to locate a Java Runtime") {
format!(
"{} is macOS's placeholder (no Java installed)",
javac.display()
)
} else {
format!(
"{} -version printed no version: {}",
javac.display(),
text.trim()
)
}
format!(
"{} -version printed no version: {}",
program.display(),
text.trim()
)
})?;
let mut parts = version.split(['.', '-', '+', '_']);
let mut parts = version.trim_matches('"').split(['.', '-', '+', '_']);
let first: u32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
Ok(if first == 1 {
parts.next().and_then(|p| p.parse().ok()).unwrap_or(0)
@@ -175,11 +216,10 @@ pub fn minecraft_runtimes(runtime: &Path) -> Vec<PathBuf> {
homes
}
/// Finds a JDK with `javac` of at least `major`, the version research-lock.json pins:
/// `JAVA_HOME` when it holds one, else `javac` and `java` on the PATH (as the Python preparer
/// ran them), else, on macOS, the JDK `/usr/libexec/java_home -v <major>` reports, else the JDK
/// homes in `more` (the launcher's pick, Minecraft's own runtimes), else a JDK in the folders
/// installers put them in (`jdk_parents`).
/// Finds a JDK whose compiler and runtime are both at least `major`, the version
/// research-lock.json pins: `JAVA_HOME`, else JDKs on the PATH, else, on macOS, the JDK
/// `/usr/libexec/java_home -v <major>` reports, else the homes in `more` (the launcher's pick,
/// Minecraft's own runtimes), else the folders installers use (`jdk_parents`).
pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
let need = format!(
"Java {major} is needed (a JDK with javac, e.g. Eclipse Temurin {major}). \
@@ -194,8 +234,11 @@ pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
"JAVA_HOME",
));
}
if let (Some(javac), Some(java)) = (on_path("javac"), on_path("java")) {
candidates.push((javac, java, "PATH"));
if let Some(path) = std::env::var_os("PATH") {
for home in path_jdks(&path) {
let bin = home.join("bin");
candidates.push((executable(&bin, "javac"), executable(&bin, "java"), "PATH"));
}
}
if cfg!(target_os = "macos") {
if let Ok(out) = command("/usr/libexec/java_home")
@@ -239,10 +282,19 @@ pub fn find(major: i64, more: &[PathBuf]) -> Result<Jdk, String> {
));
continue;
}
match javac_major(&javac) {
Ok(found) if i64::from(found) >= major => return Ok(Jdk { javac, java }),
Ok(found) => problems.push(format!("{from} has Java {found} ({})", javac.display())),
Err(e) => problems.push(e),
let checked = [(&javac, "javac"), (&java, "java")]
.into_iter()
.try_for_each(|(program, name)| {
let found = tool_major(program, name)?;
if i64::from(found) >= major {
Ok(())
} else {
Err(format!("{name} {found} ({})", program.display()))
}
});
match checked {
Ok(()) => return Ok(Jdk { javac, java }),
Err(e) => problems.push(format!("{from}: {e}")),
}
}
if problems.is_empty() {
@@ -328,6 +380,22 @@ fn run(mut command: Command, log: &fs::File) -> Result<(), String> {
mod tests {
use super::*;
#[test]
fn verbatim_drive_paths_lose_their_prefix() {
let jdk = r"C:\Program Files\Eclipse Adoptium\jdk-25\bin\javac.exe";
assert_eq!(
without_verbatim(PathBuf::from(format!(r"\\?\{jdk}"))),
PathBuf::from(jdk)
);
// A UNC share keeps its verbatim form: stripping it would leave no valid path.
let unc = r"\\?\UNC\server\jdk\bin\javac.exe";
assert_eq!(without_verbatim(PathBuf::from(unc)), PathBuf::from(unc));
assert_eq!(
without_verbatim(PathBuf::from("/usr/lib/jvm/bin/javac")),
PathBuf::from("/usr/lib/jvm/bin/javac")
);
}
#[test]
fn installed_jdks_prefer_the_pinned_major() {
let dir = std::env::temp_dir().join(format!("horizon-prepare-jdks-{}", std::process::id()));
@@ -0,0 +1,133 @@
//! Each scenario runs in its own process so Java discovery sees a controlled environment.
use horizon_prepare::java;
use std::{env, fs, path::Path, process::Command};
const TEST: &str = "selects_a_compatible_compiler_and_runtime";
const CHILD: &str = "HORIZON_JAVA_SELECTION_CHILD";
const ROOT: &str = "HORIZON_JAVA_SELECTION_ROOT";
fn tool(bin: &Path, name: &str) -> std::path::PathBuf {
bin.join(format!("{name}{}", env::consts::EXE_SUFFIX))
}
#[test]
fn selects_a_compatible_compiler_and_runtime() {
if let Ok(scenario) = env::var(CHILD) {
let root = std::path::PathBuf::from(env::var_os(ROOT).unwrap());
if scenario.ends_with("error") {
// Use a future compiler version to keep real installed JDKs out of the result.
let error = java::find(255, &[]).err().expect("accepted an old runtime");
let runtime = if scenario == "legacy-runtime-error" {
8
} else {
21
};
assert!(error.contains(&format!("java {runtime} (")), "{error}");
return;
}
let jdk = java::find(25, &[]).unwrap();
let expected = root.join(if scenario == "target-release" {
"newer"
} else {
"jdk25"
});
for (actual, name) in [(&jdk.javac, "javac"), (&jdk.java, "java")] {
assert_eq!(
fs::canonicalize(actual).unwrap(),
fs::canonicalize(tool(&expected.join("bin"), name)).unwrap()
);
}
assert_eq!(
fs::canonicalize(jdk.home()).unwrap(),
fs::canonicalize(expected).unwrap()
);
let log_path = root.join(format!("{scenario}.log"));
let log = fs::File::create(&log_path).unwrap();
jdk.java(&[], None, &log).unwrap();
assert_eq!(fs::read_to_string(log_path).unwrap().trim(), "java 25.0.1");
return;
}
let root = env::temp_dir().join(format!("horizon-java-selection-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let probe = tool(&root, "probe");
let output = Command::new("rustc")
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support/java_probe.rs"))
.arg("-o")
.arg(&probe)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
for (home, compiler, runtime) in [
("jre21", None, "21.0.1"),
("jdk21", Some("21.0.1"), "21.0.1"),
("jdk25", Some("25.0.1"), "25.0.1"),
("mixed", Some("25.0.1"), "21.0.1"),
("failing", Some("25.0.1"), "25.0.1"),
("newer", Some("26-ea+1"), "25.0.1"),
("future", Some("255.0.1"), "21.0.1"),
("legacy", Some("255.0.1"), "1.8.0_442"),
] {
let bin = root.join(home).join("bin");
fs::create_dir_all(&bin).unwrap();
for (name, version) in [("javac", compiler), ("java", Some(runtime))] {
if let Some(version) = version {
fs::copy(&probe, tool(&bin, name)).unwrap();
fs::write(bin.join(format!("{name}.version")), version).unwrap();
}
}
}
fs::write(root.join("failing/bin/java.exit"), "7").unwrap();
let scenarios = vec![
("older-runtime", None, vec!["jre21", "jdk25"]),
("older-jdk", None, vec!["jdk21", "jdk25"]),
("incompatible-home", Some("mixed"), vec!["jdk25"]),
("failed-runtime", Some("failing"), vec!["jdk25"]),
("target-release", Some("newer"), vec![]),
("runtime-error", Some("future"), vec![]),
("legacy-runtime-error", Some("legacy"), vec![]),
];
#[cfg(unix)]
let scenarios = {
let mut scenarios = scenarios;
let bin = root.join("shims/bin");
fs::create_dir_all(&bin).unwrap();
std::os::unix::fs::symlink(tool(&root.join("jdk25/bin"), "javac"), tool(&bin, "javac"))
.unwrap();
std::os::unix::fs::symlink(tool(&root.join("jre21/bin"), "java"), tool(&bin, "java"))
.unwrap();
scenarios.push(("symlinked-path", None, vec!["shims"]));
scenarios
};
for (scenario, home, path) in scenarios {
let mut child = Command::new(env::current_exe().unwrap());
child
.args(["--exact", TEST, "--nocapture"])
.env(CHILD, scenario)
.env(ROOT, &root)
.env(
"PATH",
env::join_paths(path.iter().map(|home| root.join(home).join("bin"))).unwrap(),
)
.env("HOME", &root)
.env("USERPROFILE", &root)
.env("ProgramFiles", &root)
.env("ProgramW6432", &root)
.env_remove("JAVA_HOME");
if let Some(home) = home {
child.env("JAVA_HOME", root.join(home));
}
let output = child.output().unwrap();
assert!(
output.status.success(),
"{scenario}:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fs::remove_dir_all(root).unwrap();
}
@@ -0,0 +1,21 @@
//! A fake Java tool for process-level JDK selection tests; no installed Java is needed.
use std::{env, fs, process};
fn main() {
let executable = env::current_exe().unwrap();
let bin = executable.parent().unwrap();
let tool = executable.file_stem().unwrap().to_str().unwrap();
let version = fs::read_to_string(bin.join(format!("{tool}.version"))).unwrap();
if env::args().nth(1).as_deref() == Some("-version") {
if tool == "javac" {
println!("javac {version}");
} else {
eprintln!("openjdk version \"{version}\"");
}
} else {
println!("{tool} {version}");
}
if let Ok(status) = fs::read_to_string(bin.join(format!("{tool}.exit"))) {
process::exit(status.parse().unwrap());
}
}
+4
View File
@@ -26,5 +26,9 @@ ipconfig = "0.3.4"
name = "protocol_probe"
path = "examples/probe.rs"
[[example]]
name = "item_wire_probe"
path = "examples/item_wire_probe.rs"
[lints]
workspace = true
@@ -0,0 +1,65 @@
//! The item wire harness's Rust side (tools/test_item_wire_parity.py): reads
//! the oracle's stacks (JSON lines with `label` and `hex`), decodes each with
//! `item::Slot` and prints, per stack, whether every byte was consumed, the
//! patch's component ids (added, then removed), which of them Horizon
//! decoded into its own lossy types (the rest it reads past keeping their
//! bytes, or decodes into ids it writes back unchanged, like trims) and
//! whether encoding the decoded stack gives the same bytes back.
//!
//! cargo run -p horizon-protocol --example item_wire_probe -- vanilla.jsonl rust.jsonl
use horizon_protocol::{
buf::{Codec, Reader, Writer},
item::{Component, Slot},
};
use serde_json::{Value, json};
use std::io::Write;
fn hex(s: &str) -> Option<Vec<u8>> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
.collect()
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
let input = std::fs::read_to_string(args.get(1).ok_or("usage: item_wire_probe IN OUT")?)?;
let mut out = std::io::BufWriter::new(std::fs::File::create(
args.get(2).ok_or("usage: item_wire_probe IN OUT")?,
)?);
for line in input.lines().filter(|l| !l.trim().is_empty()) {
let v: Value = serde_json::from_str(line)?;
let label = v["label"].clone();
let Some(bytes) = v["hex"].as_str().and_then(hex) else {
continue;
};
let mut r = Reader::new(&bytes);
let result = match Slot::read(&mut r) {
Err(e) => json!({"label": label, "error": e.to_string()}),
Ok(stack) => {
let mut w = Writer::new();
Slot::write(&stack, &mut w);
let (added, removed): (Vec<i32>, Vec<i32>) =
stack.as_ref().map_or_else(Default::default, |s| {
(s.added.iter().map(|(k, _)| *k).collect(), s.removed.clone())
});
let typed: Vec<i32> = stack
.iter()
.flat_map(|s| &s.added)
.filter(|(_, c)| !matches!(c, Component::Raw(_) | Component::Trim { .. }))
.map(|(k, _)| *k)
.collect();
json!({
"label": label,
"left": r.remaining(),
"added": added,
"removed": removed,
"typed": typed,
"same_bytes": w.data == bytes,
})
}
};
writeln!(out, "{result}")?;
}
Ok(())
}
@@ -0,0 +1,31 @@
use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::play::StopSound,
};
use std::{error::Error, io::Write};
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<_> = std::env::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 mut reader = Reader::new(&bytes);
let packet = StopSound::read(&mut reader)?;
assert_eq!(reader.remaining(), 0);
let mut writer = Writer::new();
StopSound::write(&packet, &mut writer);
assert_eq!(writer.data, bytes);
writeln!(
out,
"{}",
serde_json::json!({"source":packet.source,"sound":packet.sound})
)?;
}
assert!(StopSound::read(&mut Reader::new(&[1, 11])).is_err());
assert!(StopSound::read(&mut Reader::new(&[3])).is_err());
Ok(())
}
+82 -10
View File
@@ -6,7 +6,7 @@
use crate::{Error, Result, buf::Reader, crypto::Cfb8, frame};
use std::{
io::{Read, Write},
net::{Shutdown, TcpStream, ToSocketAddrs},
net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs},
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicI64, Ordering},
@@ -180,6 +180,13 @@ pub struct ConnWriter {
pub threshold: Option<usize>,
counters: Arc<Counters>,
}
/// `InetAddress.getByName` under Java's default `java.net.preferIPv6Addresses=false`: IPv4
/// addresses first, whatever order the system resolver gives (macOS lists IPv6 first). Vanilla
/// connects to that first address only; the rest stay here as fallbacks.
fn ipv4_first(mut addrs: Vec<SocketAddr>) -> Vec<SocketAddr> {
addrs.sort_by_key(SocketAddr::is_ipv6);
addrs
}
/// Both halves before the split.
pub struct Connection {
pub reader: ConnReader,
@@ -199,19 +206,19 @@ impl Connection {
let addrs = crate::resolver::resolve(host, port)?;
Self::connect_addresses(host, addrs, timeout)
}
fn connect_addresses(
host: &str,
addrs: Vec<std::net::SocketAddr>,
timeout: Duration,
) -> Result<Self> {
let mut last = Error::new(format!("Unknown host {host}"));
for addr in addrs {
fn connect_addresses(host: &str, addrs: Vec<SocketAddr>, timeout: Duration) -> Result<Self> {
// Report the first address's failure: it is the one vanilla tries, and the IPv6
// fallbacks after it often fail only because the client has no IPv6 route.
let mut first = None;
for addr in ipv4_first(addrs) {
match TcpStream::connect_timeout(&addr, timeout) {
Ok(stream) => return Self::from_stream(stream),
Err(e) => last = Error::new(format!("Connection refused: {e}")),
Err(e) => {
first.get_or_insert_with(|| Error::new(format!("Connection refused: {e}")));
}
}
}
Err(last)
Err(first.unwrap_or_else(|| Error::new(format!("Unknown host {host}"))))
}
pub fn from_stream(stream: TcpStream) -> Result<Self> {
stream.set_nodelay(true)?;
@@ -328,6 +335,71 @@ mod tests {
use super::*;
use std::net::TcpListener;
#[test]
fn ipv4_first_keeps_each_familys_order() {
let a = |s: &str| s.parse::<SocketAddr>().unwrap();
let resolved = vec![
a("[2001:db8::1]:25565"),
a("192.0.2.1:25565"),
a("[2001:db8::2]:25565"),
a("10.0.0.1:25565"),
];
assert_eq!(
ipv4_first(resolved),
[
a("192.0.2.1:25565"),
a("10.0.0.1:25565"),
a("[2001:db8::1]:25565"),
a("[2001:db8::2]:25565"),
]
);
}
fn peer(c: &Connection) -> SocketAddr {
match &c.reader.stream {
Stream::Tcp(s) => s.peer_addr().unwrap(),
Stream::Memory(_) => panic!("not a socket"),
}
}
/// Binds `[::1]`, or `None` where the machine has no IPv6 loopback.
fn ipv6_loopback() -> Option<TcpListener> {
let listener = TcpListener::bind("[::1]:0");
if listener.is_err() {
eprintln!("skipped: no IPv6 loopback");
}
listener.ok()
}
#[test]
fn connects_to_ipv4_although_the_resolver_lists_ipv6_first() {
// Both listen, as with a server whose IPv6 address accepts and then resets.
let Some(v6_listener) = ipv6_loopback() else {
return;
};
let v4_listener = TcpListener::bind("127.0.0.1:0").unwrap();
let v6 = v6_listener.local_addr().unwrap();
let v4 = v4_listener.local_addr().unwrap();
let c =
Connection::connect_addresses("test", vec![v6, v4], Duration::from_secs(5)).unwrap();
assert_eq!(peer(&c), v4);
}
#[test]
fn falls_back_to_ipv6_when_ipv4_refuses() {
let Some(v6_listener) = ipv6_loopback() else {
return;
};
let v6 = v6_listener.local_addr().unwrap();
let closed = TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap();
let c = Connection::connect_addresses("test", vec![v6, closed], Duration::from_secs(5))
.unwrap();
assert_eq!(peer(&c), v6);
}
#[test]
fn loopback_with_compression_and_encryption() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+80 -3
View File
@@ -2,11 +2,13 @@
//! item's registry id and a data component patch, plus the hashed form the
//! client sends back in container clicks (HashedStack).
use crate::{
Error, Result,
Result,
buf::{Codec, Reader, Writer},
};
use horizon_world::nbt::Tag;
mod skip;
/// minecraft:data_component_type registry ids (26.3).
pub mod component {
pub const CUSTOM_DATA: i32 = 0;
@@ -35,6 +37,7 @@ pub mod component {
pub const CHARGED_PROJECTILES: i32 = 51;
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
pub const TRIM: i32 = 58;
}
/// A written book's content (`WrittenBookContent.STREAM_CODEC`): the
@@ -78,6 +81,15 @@ pub enum Component {
WrittenBook(WrittenBook),
/// `ChargedProjectiles`: a loaded crossbow's ammunition, as templates.
Stacks(Vec<Stack>),
/// `ArmorTrim` naming its material and pattern by registry id (`trim_material`,
/// `trim_pattern`); a trim with a direct holder stays [`Component::Raw`].
Trim {
material: i32,
pattern: i32,
},
/// A component Horizon reads past without interpreting it: its encoded
/// bytes, written back as they came.
Raw(Vec<u8>),
}
/// `Filterable.streamCodec`: the raw value, then an optional filtered one
@@ -224,11 +236,35 @@ impl Component {
hidden: (0..n).map(|_| r.varint()).collect::<Result<_>>()?,
}
}
other => return Err(Error::new(format!("unsupported item component {other}"))),
// ArmorTrim.STREAM_CODEC: two holders, each a reference's id plus
// one. A direct material or pattern has no registry name: read past.
TRIM => {
let start = r.position();
skip::component(TRIM, r)?;
let bytes = r.since(start);
let mut ids = Reader::new(bytes);
match (ids.varint(), ids.varint()) {
(Ok(material @ 1..), Ok(pattern @ 1..)) if ids.remaining() == 0 => Self::Trim {
material: material - 1,
pattern: pattern - 1,
},
_ => Self::Raw(bytes.to_vec()),
}
}
other => {
let start = r.position();
skip::component(other, r)?;
Self::Raw(r.since(start).to_vec())
}
})
}
fn write(&self, kind: i32, w: &mut Writer) {
match self {
Self::Raw(bytes) => w.bytes(bytes),
Self::Trim { material, pattern } => {
w.varint(material + 1);
w.varint(pattern + 1);
}
Self::Int(v) if kind == component::DYED_COLOR => w.i32(*v),
Self::Int(v) => w.varint(*v),
Self::Unit => {}
@@ -582,7 +618,8 @@ mod tests {
#[test]
fn unknown_components_are_errors() {
let bytes = [1, 5, 1, 0, 99];
// 122 is past DataComponents' last id (cushion_color, 121).
let bytes = [1, 5, 1, 0, 122];
assert!(Slot::read(&mut Reader::new(&bytes)).is_err());
}
@@ -631,4 +668,44 @@ mod tests {
[("textures".to_owned(), "x".to_owned(), None)]
);
}
#[test]
fn components_read_past_keep_their_bytes() {
// Vanilla 26.3's encoding (tools/test_item_wire_parity.py) of a chestplate's
// armour trim (decoded: both holders are references), a banner's patterns
// (one direct), a pot's sherds and a stew's effects: one server's
// inventory, recipe book and advancements had them.
for (hex, kind) in [
("01bd0801003a0502", 58),
("01890b01004a02200e000b6d696e6563726166743a7801790b", 74),
("01880301004c01f60801000001be0c0100000001d30c010000", 76),
("01a408010037020f6412a001", 55),
] {
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
.collect();
let mut r = Reader::new(&bytes);
let stack = Slot::read(&mut r).unwrap();
assert_eq!(r.remaining(), 0, "component {kind}");
let expected = |c: &Component| match kind {
58 => {
*c == Component::Trim {
material: 4,
pattern: 1,
}
}
_ => matches!(c, Component::Raw(_)),
};
assert!(
stack
.as_ref()
.and_then(|s| s.component(kind))
.is_some_and(expected)
);
let mut w = Writer::new();
Slot::write(&stack, &mut w);
assert_eq!(w.data, bytes, "component {kind}");
}
}
}
+427
View File
@@ -0,0 +1,427 @@
//! Reading past the data components Horizon doesn't interpret: each
//! function consumes exactly what its vanilla StreamCodec writes
//! (DataComponents' `networkSynchronized` codecs; a component without one
//! goes as its persistent Codec's NBT, `ByteBufCodecs.fromCodecWithRegistries`).
//! Item data has no length prefix, so a component read short or long makes
//! everything after it unreadable.
use super::{Component, Template};
use crate::{
Error, Result,
buf::{Codec, Reader},
};
type Read = fn(&mut Reader) -> Result<()>;
/// DataComponents' registry order (26.3) for the ids `Component::read`
/// doesn't decode itself.
pub(super) fn component(kind: i32, r: &mut Reader) -> Result<()> {
match kind {
// Unit.STREAM_CODEC: glider, waxed.
34 | 120 => Ok(()),
// Components without a network codec go as their NBT: intangible_projectile,
// map_decorations, debug_stick_state, recipes, lock, container_loot.
22 | 49 | 59 | 68 | 81 | 82 => nbt(r),
// A VarInt (VAR_INT, an idMapper, a registry or holderRegistry id): damage_type,
// enchantable, additional_trade_cost, block_transformer, villager_food, dye,
// ominous_bottle_amplifier, base_color, the mob variants and colours, the pottery
// pattern and cushion_color.
8 | 31 | 42 | 43 | 44 | 46 | 65 | 75 | 88..=108 | 110..=117 | 121 => varint(r),
// FLOAT: minimum_attack_charge, potion_duration_scale.
7 | 54 => f32(r),
// Identifier.STREAM_CODEC: tooltip_style, note_block_sound.
35 | 73 => ident(r),
// UseEffects: canSprint, interactVibrations, speedMultiplier.
5 => {
r.bool()?;
r.bool()?;
f32(r)
}
// AdventureModePredicate: can_place_on, can_break.
14 | 15 => list(r, block_predicate),
16 => list(r, attribute_modifier),
// FoodProperties.DIRECT_STREAM_CODEC: nutrition, saturation, canAlwaysEat.
23 => {
varint(r)?;
f32(r)?;
boolean(r)
}
// Consumable: consumeSeconds, animation, sound, hasConsumeParticles, onConsumeEffects.
24 => {
f32(r)?;
varint(r)?;
sound(r)?;
boolean(r)?;
list(r, consume_effect)
}
// UseRemainder, SulfurCubeContent: one template.
25 | 80 => template(r),
// UseCooldown: seconds, cooldownGroup.
26 => {
f32(r)?;
optional(r, ident)
}
// DamageResistant, Repairable, provides_banner_patterns: a holder set.
27 | 33 | 67 => holder_set(r),
28 => tool(r),
// Weapon: itemDamagePerAttack, disableBlockingForSeconds.
29 => {
varint(r)?;
f32(r)
}
32 => equippable(r),
// DeathProtection: deathEffects.
36 => list(r, consume_effect),
37 => blocks_attacks(r),
// PiercingWeapon: dealsKnockback, dismounts, sound, hitSound.
38 => {
boolean(r)?;
boolean(r)?;
optional(r, sound)?;
optional(r, sound)
}
39 => kinetic_weapon(r),
// SwingAnimation: type, duration.
40 | 41 => {
varint(r)?;
varint(r)
}
// BundleContents: templates.
52 => list(r, template),
53 => potion_contents(r),
// SuspiciousStewEffects: effect, duration.
55 => list(r, |r| {
varint(r)?;
varint(r)
}),
// ArmorTrim: material, then Holder<TrimPattern> (assetId, description, decal).
58 => {
trim_material(r)?;
holder(r, |r| {
ident(r)?;
nbt(r)?;
boolean(r)
})
}
// TypedEntityData (entity_data, block_entity_data): the type id, then a compound.
60 | 62 => {
varint(r)?;
nbt(r)
}
// CustomData: bucket_entity_data.
61 => nbt(r),
// InstrumentComponent: Holder<Instrument> (soundEvent, useDuration, range,
// durabilityDamage, description).
63 => holder(r, |r| {
sound(r)?;
f32(r)?;
f32(r)?;
varint(r)?;
nbt(r)
}),
64 => trim_material(r),
// JukeboxPlayable: Holder<JukeboxSong> (soundEvent, description, lengthInSeconds,
// comparatorOutput).
66 => holder(r, |r| {
sound(r)?;
nbt(r)?;
f32(r)?;
varint(r)
}),
// LodestoneTracker: target (GlobalPos: dimension, BlockPos), tracked.
69 => {
optional(r, |r| {
ident(r)?;
r.i64().map(drop)
})?;
boolean(r)
}
70 => firework_explosion(r),
// Fireworks: flightDuration, explosions.
71 => {
varint(r)?;
list(r, firework_explosion)
}
// BannerPatternLayers: Holder<BannerPattern> (assetId, translationKey), colour.
74 => list(r, |r| {
holder(r, |r| {
ident(r)?;
string(r)
})?;
varint(r)
}),
// PotDecorations: back, left, right, front.
76 => (0..4).try_for_each(|_| optional(r, template)),
// ItemContainerContents: optional templates.
77 => list(r, |r| optional(r, template)),
// BlockItemStateProperties: a string to string map.
78 => list(r, |r| {
string(r)?;
string(r)
}),
// Bees: Occupant (entity type and data, ticksInHive, minTicksInHive).
79 => list(r, |r| {
varint(r)?;
nbt(r)?;
varint(r)?;
varint(r)
}),
83 => sound(r),
// Compostable: layers.
84 => resolvable(r, i32),
// CookingFuel (burnTime, speedMultiplier), BrewingFuel (uses, speedMultiplier).
85 | 86 => {
resolvable(r, i32)?;
resolvable(r, f32)
}
// MobVisibility: targetingEntityTypes, visibility.
87 => {
holder_set(r)?;
f32(r)
}
// Holder<PaintingVariant>: width, height, assetId, title, author.
109 => holder(r, |r| {
varint(r)?;
varint(r)?;
ident(r)?;
optional(r, nbt)?;
optional(r, nbt)
}),
// SignText: four messages, four optional filtered ones, colour, hasGlowingText.
118 | 119 => {
(0..4).try_for_each(|_| nbt(r))?;
optional(r, |r| (0..4).try_for_each(|_| nbt(r)))?;
varint(r)?;
boolean(r)
}
other => Err(Error::new(format!("unsupported item component {other}"))),
}
}
fn boolean(r: &mut Reader) -> Result<()> {
r.bool().map(drop)
}
fn varint(r: &mut Reader) -> Result<()> {
r.varint().map(drop)
}
fn f32(r: &mut Reader) -> Result<()> {
r.f32().map(drop)
}
fn i32(r: &mut Reader) -> Result<()> {
r.i32().map(drop)
}
/// ByteBufCodecs.STRING_UTF8.
fn string(r: &mut Reader) -> Result<()> {
r.string(32767).map(drop)
}
/// Identifier.STREAM_CODEC, ResourceKey.streamCodec.
fn ident(r: &mut Reader) -> Result<()> {
string(r)
}
/// A text component or compound (fromCodecWithRegistries, COMPOUND_TAG).
fn nbt(r: &mut Reader) -> Result<()> {
r.nbt().map(drop)
}
fn template(r: &mut Reader) -> Result<()> {
Template::read(r).map(drop)
}
/// ByteBufCodecs.optional: a flag, then the value.
fn optional(r: &mut Reader, value: Read) -> Result<()> {
if r.bool()? { value(r) } else { Ok(()) }
}
/// ByteBufCodecs.list and map: a VarInt count, then the elements (a map's
/// key and value each). Every element is at least a byte, so the count
/// can't pass what is left.
fn list(r: &mut Reader, element: Read) -> Result<()> {
let n = r.count(r.remaining())?;
(0..n).try_for_each(|_| element(r))
}
/// ByteBufCodecs.holder: 0 and the value itself, or the registry id plus one.
fn holder(r: &mut Reader, direct: Read) -> Result<()> {
if r.varint()? == 0 { direct(r) } else { Ok(()) }
}
/// ByteBufCodecs.holderSet: 0 and a tag id, or the size plus one and the ids.
fn holder_set(r: &mut Reader) -> Result<()> {
match r.varint()? {
0 => ident(r),
n => {
let n = usize::try_from(n - 1).map_err(|_| Error::new("negative holder set size"))?;
if n > r.remaining() {
return Err(Error::new("holder set past the end"));
}
(0..n).try_for_each(|_| varint(r))
}
}
}
/// SoundEvent.STREAM_CODEC: a holder of a location and an optional fixed range.
fn sound(r: &mut Reader) -> Result<()> {
holder(r, |r| {
ident(r)?;
optional(r, f32)
})
}
/// ResolvableInt, ResolvableFloat: Either a constant (true) or a context provider's key.
fn resolvable(r: &mut Reader, constant: Read) -> Result<()> {
if r.bool()? { constant(r) } else { ident(r) }
}
/// TrimMaterial.STREAM_CODEC: a holder of a palette id and a description.
fn trim_material(r: &mut Reader) -> Result<()> {
holder(r, |r| {
ident(r)?;
nbt(r)
})
}
/// MobEffectInstance: the effect id, then its Details.
fn mob_effect(r: &mut Reader) -> Result<()> {
varint(r)?;
effect_details(r)
}
/// MobEffectInstance.Details: amplifier, duration, ambient, showParticles, showIcon, then the
/// hidden effect's Details, recursively.
fn effect_details(r: &mut Reader) -> Result<()> {
varint(r)?;
varint(r)?;
boolean(r)?;
boolean(r)?;
boolean(r)?;
optional(r, effect_details)
}
/// ConsumeEffect.STREAM_CODEC: the consume_effect_type id, then that type's fields.
fn consume_effect(r: &mut Reader) -> Result<()> {
match r.varint()? {
// apply_effects: effects, probability.
0 => {
list(r, mob_effect)?;
f32(r)
}
// remove_effects: a mob effect holder set.
1 => holder_set(r),
// clear_all_effects.
2 => Ok(()),
// teleport_randomly: diameter, directionalParticles.
3 => {
f32(r)?;
boolean(r)
}
// play_sound.
4 => sound(r),
other => Err(Error::new(format!("unknown consume effect type {other}"))),
}
}
/// BlockPredicate: blocks, properties, nbt, then DataComponentMatchers (exact, partial).
fn block_predicate(r: &mut Reader) -> Result<()> {
optional(r, holder_set)?;
// StatePropertiesPredicate: PropertyMatchers, each a name and an
// Either<ExactMatcher (value), RangedMatcher (min, max)>.
optional(r, |r| {
list(r, |r| {
string(r)?;
if r.bool()? {
string(r)
} else {
optional(r, string)?;
optional(r, string)
}
})
})?;
optional(r, nbt)?;
// DataComponentExactPredicate: TypedDataComponents, a component type id and its value.
list(r, |r| {
let kind = r.varint()?;
Component::read(kind, r).map(drop)
})?;
// DataComponentPredicate.STREAM_CODEC: an Either of a predicate type id or a component
// type id, then the predicate as NBT (fromCodecWithRegistries).
list(r, |r| {
boolean(r)?;
varint(r)?;
nbt(r)
})
}
/// ItemAttributeModifiers.Entry: attribute, modifier (id, amount, operation), slot, display.
fn attribute_modifier(r: &mut Reader) -> Result<()> {
varint(r)?;
ident(r)?;
r.f64()?;
varint(r)?;
varint(r)?;
// Display.Type: default, hidden, or override and its text.
match r.varint()? {
0 | 1 => Ok(()),
2 => nbt(r),
other => Err(Error::new(format!(
"unknown attribute modifier display {other}"
))),
}
}
/// Tool: rules (blocks, speed, correctForDrops), defaultMiningSpeed, damagePerBlock,
/// canDestroyBlocksInCreative.
fn tool(r: &mut Reader) -> Result<()> {
list(r, |r| {
holder_set(r)?;
optional(r, f32)?;
optional(r, boolean)
})?;
f32(r)?;
varint(r)?;
boolean(r)
}
/// Equippable: slot, equipSound, assetId, cameraOverlay, allowedEntities, dispensable,
/// swappable, damageOnHurt, equipOnInteract, canBeSheared, shearingSound.
fn equippable(r: &mut Reader) -> Result<()> {
varint(r)?;
sound(r)?;
optional(r, ident)?;
optional(r, ident)?;
optional(r, holder_set)?;
(0..5).try_for_each(|_| boolean(r))?;
sound(r)
}
/// BlocksAttacks: blockDelaySeconds, disableCooldownScale, damageReductions (angle, type,
/// base, factor), itemDamage (threshold, base, factor), bypassedBy, blockSound, disableSound.
fn blocks_attacks(r: &mut Reader) -> Result<()> {
f32(r)?;
f32(r)?;
list(r, |r| {
f32(r)?;
optional(r, holder_set)?;
f32(r)?;
f32(r)
})?;
(0..3).try_for_each(|_| f32(r))?;
optional(r, holder_set)?;
optional(r, sound)?;
optional(r, sound)
}
/// KineticWeapon: contactCooldownTicks, delayTicks, the dismount, knockback and damage
/// conditions (maxDurationTicks, minSpeed, minRelativeSpeed), forwardMovement,
/// damageMultiplier, sound, hitSound.
fn kinetic_weapon(r: &mut Reader) -> Result<()> {
varint(r)?;
varint(r)?;
(0..3).try_for_each(|_| {
optional(r, |r| {
varint(r)?;
f32(r)?;
f32(r)
})
})?;
f32(r)?;
f32(r)?;
optional(r, sound)?;
optional(r, sound)
}
/// PotionContents: potion, customColor, customEffects, customName.
fn potion_contents(r: &mut Reader) -> Result<()> {
optional(r, varint)?;
optional(r, i32)?;
list(r, mob_effect)?;
optional(r, string)
}
/// FireworkExplosion: shape, colors, fadeColors, hasTrail, hasTwinkle.
fn firework_explosion(r: &mut Reader) -> Result<()> {
varint(r)?;
list(r, i32)?;
list(r, i32)?;
boolean(r)?;
boolean(r)
}
+197 -2
View File
@@ -1118,6 +1118,42 @@ packet!(SoundEntity {
pitch: f32 => Float,
seed: i64 => Long,
});
/// ClientboundStopSoundPacket: bit 0 selects a source, bit 1 an event id.
#[derive(Clone, Debug, PartialEq)]
pub struct StopSound {
pub source: Option<i32>,
pub sound: Option<String>,
}
impl Codec<StopSound> for StopSound {
fn read(r: &mut Reader) -> crate::Result<Self> {
let flags = r.u8()?;
let source = if flags & 1 != 0 {
let value = r.varint()?;
if !(0..11).contains(&value) {
return Err(crate::Error::new("invalid sound source"));
}
Some(value)
} else {
None
};
let sound = if flags & 2 != 0 {
Some(r.string(32767)?)
} else {
None
};
Ok(Self { source, sound })
}
fn write(p: &Self, w: &mut Writer) {
w.u8(u8::from(p.source.is_some()) | (u8::from(p.sound.is_some()) << 1));
if let Some(source) = p.source {
w.varint(source);
}
if let Some(sound) = &p.sound {
w.string(sound);
}
}
}
/// One entry of ClientboundCommandsPacket: a literal or an argument node.
#[derive(Clone, Debug, PartialEq)]
pub struct CommandNode {
@@ -1580,6 +1616,125 @@ impl Codec<Vec<(i32, i32, i32)>> for StatList {
}
}
/// `ClientboundBossEventPacket` (`boss_event`).
#[derive(Clone, Debug, PartialEq)]
pub enum BossAction {
Add {
name: Tag,
progress: f32,
/// `BossBarColor` ordinal.
color: i32,
/// `BossBarOverlay` ordinal.
overlay: i32,
darken: bool,
music: bool,
fog: bool,
},
Remove,
UpdateProgress {
progress: f32,
},
UpdateName {
name: Tag,
},
UpdateStyle {
color: i32,
overlay: i32,
},
UpdateProperties {
darken: bool,
music: bool,
fog: bool,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct BossEvent {
pub id: u128,
pub action: BossAction,
}
fn boss_flags(darken: bool, music: bool, fog: bool) -> u8 {
u8::from(darken) | (u8::from(music) << 1) | (u8::from(fog) << 2)
}
fn split_flags(flags: u8) -> (bool, bool, bool) {
(flags & 1 != 0, flags & 2 != 0, flags & 4 != 0)
}
impl Codec<BossEvent> for BossEvent {
fn read(r: &mut Reader) -> Result<BossEvent> {
let id = r.uuid()?;
let action = match r.varint()? {
0 => {
let name = r.nbt()?;
let progress = r.f32()?;
let color = r.varint()?;
let overlay = r.varint()?;
let (darken, music, fog) = split_flags(r.u8()?);
BossAction::Add {
name,
progress,
color,
overlay,
darken,
music,
fog,
}
}
1 => BossAction::Remove,
2 => BossAction::UpdateProgress { progress: r.f32()? },
3 => BossAction::UpdateName { name: r.nbt()? },
4 => BossAction::UpdateStyle {
color: r.varint()?,
overlay: r.varint()?,
},
5 => {
let (darken, music, fog) = split_flags(r.u8()?);
BossAction::UpdateProperties { darken, music, fog }
}
op => return Err(Error::new(format!("unknown boss event operation {op}"))),
};
Ok(BossEvent { id, action })
}
fn write(v: &BossEvent, w: &mut Writer) {
w.uuid(v.id);
match &v.action {
BossAction::Add {
name,
progress,
color,
overlay,
darken,
music,
fog,
} => {
w.varint(0);
w.nbt(name);
w.f32(*progress);
w.varint(*color);
w.varint(*overlay);
w.u8(boss_flags(*darken, *music, *fog));
}
BossAction::Remove => w.varint(1),
BossAction::UpdateProgress { progress } => {
w.varint(2);
w.f32(*progress);
}
BossAction::UpdateName { name } => {
w.varint(3);
w.nbt(name);
}
BossAction::UpdateStyle { color, overlay } => {
w.varint(4);
w.varint(*color);
w.varint(*overlay);
}
BossAction::UpdateProperties { darken, music, fog } => {
w.varint(5);
w.u8(boss_flags(*darken, *music, *fog));
}
}
}
}
packet_enum!(Clientbound, crate::ids::play::clientbound {
BundleDelimiter(Empty) = BUNDLE_DELIMITER,
AddEntity(AddEntity) = ADD_ENTITY,
@@ -1590,6 +1745,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
BlockEntityData(BlockEntityData) = BLOCK_ENTITY_DATA,
BlockEvent(BlockEvent) = BLOCK_EVENT,
BlockUpdate(BlockUpdate) = BLOCK_UPDATE,
BossEvent(BossEvent) = BOSS_EVENT,
ChangeDifficulty(ChangeDifficulty) = CHANGE_DIFFICULTY,
ChunkBatchFinished(ChunkBatchFinished) = CHUNK_BATCH_FINISHED,
ChunkBatchStart(Empty) = CHUNK_BATCH_START,
@@ -1678,6 +1834,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
SetTitlesAnimation(TitlesAnimation) = SET_TITLES_ANIMATION,
Sound(Sound) = SOUND,
SoundEntity(SoundEntity) = SOUND_ENTITY,
StopSound(StopSound) = STOP_SOUND,
StartConfiguration(Empty) = START_CONFIGURATION,
SwingAnimation(SwingAnimation) = SWING_ANIMATION,
SystemChat(SystemChat) = SYSTEM_CHAT,
@@ -2761,10 +2918,11 @@ mod tests {
#[test]
fn unknown_packets_keep_their_payload() {
let p = Clientbound::decode(crate::ids::play::clientbound::BOSS_EVENT, &[1, 2, 3]).unwrap();
let p =
Clientbound::decode(crate::ids::play::clientbound::PLAYER_LOOK_AT, &[1, 2, 3]).unwrap();
assert_eq!(
p,
Clientbound::Unknown(crate::ids::play::clientbound::BOSS_EVENT, vec![1, 2, 3])
Clientbound::Unknown(crate::ids::play::clientbound::PLAYER_LOOK_AT, vec![1, 2, 3])
);
assert_eq!(p.encode()[1..], [1, 2, 3]);
}
@@ -2861,4 +3019,41 @@ mod tests {
w.varint(101);
assert!(BookPages::read(&mut Reader::new(&w.data)).is_err());
}
#[test]
fn boss_event_round_trips() {
let add = Clientbound::BossEvent(BossEvent {
id: 0x0123_4567_89ab_cdef_fedc_ba98_7654_3210,
action: BossAction::Add {
name: Tag::String("Wither".into()),
progress: 0.5,
color: 5,
overlay: 0,
darken: true,
music: false,
fog: true,
},
});
let body = add.encode();
// Packet id 9, then the uuid, operation 0, and the property byte
// (darken 1, fog 4).
assert_eq!(body[0], 9);
assert_eq!(*body.last().unwrap(), 5);
round_trip(add, Clientbound::encode, Clientbound::decode);
round_trip(
Clientbound::BossEvent(BossEvent {
id: 7,
action: BossAction::UpdateProgress { progress: 0.25 },
}),
Clientbound::encode,
Clientbound::decode,
);
round_trip(
Clientbound::BossEvent(BossEvent {
id: 7,
action: BossAction::Remove,
}),
Clientbound::encode,
Clientbound::decode,
);
}
}
+1
View File
@@ -781,6 +781,7 @@ mod tests {
use super::*;
use serde_json::json;
#[cfg(unix)]
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-server-update-{name}-{}",
@@ -168,6 +168,7 @@ mod stored_consume_fixture;
mod stored_effect_attributes_fixture;
mod stored_effect_notifications_fixture;
mod structure_density_fixture;
mod structure_pieces;
mod suffocation_fixture;
mod swimming_fixture;
mod synced_bytes_fixture;
@@ -338,6 +339,7 @@ probes! {
stored_effect_attributes_fixture,
stored_effect_notifications_fixture,
structure_density_fixture,
structure_pieces,
suffocation_fixture,
swimming_fixture,
synced_bytes_fixture,
@@ -0,0 +1,103 @@
//! The structure starts a chunk owns, piece by piece, as a saved start lists them.
//!
//! structure_pieces <client resource dir> <seed> <dimension> <cx,cz>...
//! prints, for every start whose chunk is a target, `start ID CX CZ` and then
//! one `piece TYPE ELEMENT ROTATION X Y Z GROUND_LEVEL_DELTA X0 Y0 Z0 X1 Y1 Z1`
//! line per piece in placement order (jigsaw pieces: the element's template
//! location; other pieces: `-` for element, rotation and position).
use crate::support::common::tag;
use horizon_core::position::ChunkPos;
use horizon_world::{
DATA_VERSION,
nbt::Tag,
registry::BlockRegistry,
worldgen::{
generator::{GeneratorData, OverworldGenerator},
structure::{Rotation, jigsaw::PoolElement, start::Piece},
},
};
use serde_json::Value;
use std::{fs, path::Path};
type Error = Box<dyn std::error::Error + Send + Sync>;
fn registry(resources: &Path) -> Result<&'static BlockRegistry, Error> {
let facts: Value = serde_json::from_str(&fs::read_to_string(resources.join("facts.json"))?)?;
let states = facts["states"].as_array().ok_or("facts states")?;
let registry = BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(states.iter().map(|v| tag(&v[4])).collect()),
),
("defaults", tag(&facts["defaults"])),
]))?;
Ok(Box::leak(Box::new(registry)))
}
fn element(element: &PoolElement) -> String {
match element {
PoolElement::Empty => "empty".into(),
PoolElement::Single { template, .. } => template.location.to_string(),
PoolElement::Feature { feature, .. } => format!("feature:{feature}"),
PoolElement::List { elements, .. } => {
let parts: Vec<_> = elements.iter().map(self::element).collect();
format!("list:{}", parts.join("+"))
}
}
}
fn rotation(rotation: Rotation) -> &'static str {
match rotation {
Rotation::None => "NONE",
Rotation::Clockwise90 => "CLOCKWISE_90",
Rotation::Clockwise180 => "CLOCKWISE_180",
Rotation::Counterclockwise90 => "COUNTERCLOCKWISE_90",
}
}
pub fn main() -> Result<(), Error> {
let mut args = crate::args().skip(1);
let resources = std::path::PathBuf::from(args.next().ok_or("client resource directory")?);
let seed: i64 = args.next().ok_or("seed")?.parse()?;
let dimension = format!("minecraft:{}", args.next().ok_or("dimension")?);
let data = GeneratorData::load(&resources.join("worldgen"), registry(&resources)?)?;
let generator = OverworldGenerator::for_dimension(data, seed, "minecraft:normal", &dimension)?;
let mut worker = generator.worker();
for target in args {
let (x, z) = target.split_once(',').ok_or("target cx,cz")?;
let chunk = ChunkPos::new(x.parse()?, z.parse()?);
for start in worker.structure_starts(chunk) {
if start.chunk != chunk {
continue;
}
println!("start {} {} {}", start.name, chunk.x, chunk.z);
for piece in &start.pieces {
let b = piece.bounding_box();
let placed = match piece {
Piece::Pool(p) => format!(
"{} {} {} {} {} {}",
element(&p.element),
rotation(p.rotation),
p.position[0],
p.position[1],
p.position[2],
p.ground_level_delta
),
Piece::Legacy(_) => "- - - - - -".into(),
};
println!(
"piece {} {placed} {} {} {} {} {} {}",
piece.type_name(),
b.min[0],
b.min[1],
b.min[2],
b.max[0],
b.max[1],
b.max[2]
);
}
}
}
Ok(())
}
@@ -347,14 +347,16 @@ impl StructureResources {
.read_to_end(&mut raw)
.ok()?;
let tag = nbt::read_named(&raw, Limits::templates()).ok()?.0.tag;
Some(Arc::new(StructureTemplate::from_nbt(
Some(StructureTemplate::from_nbt(
&tag,
self.registry,
&self.transforms,
)))
))
});
// StructureTemplateManager.getOrCreate: missing templates are empty.
let loaded = loaded.or_else(|| Some(Arc::new(StructureTemplate::empty())));
let mut loaded = loaded.unwrap_or_else(StructureTemplate::empty);
loaded.location = id.as_str().into();
let loaded = Some(Arc::new(loaded));
self.templates.write().unwrap().insert(id, loaded.clone());
loaded
}
@@ -122,7 +122,11 @@ impl PoolElement {
};
Ok(match kind {
"minecraft:empty_pool_element" => Self::Empty,
"minecraft:single_pool_element" | "minecraft:legacy_single_pool_element" => {
// YUNG's API's element places what a single element places; its
// `name` and `max_count` are the pool entry's (`Pool::limits`).
"minecraft:single_pool_element"
| "minecraft:legacy_single_pool_element"
| "yungsapi:yung_single_element" => {
let location = value["location"]
.as_str()
.ok_or(DecodeError::Field("location"))?;
@@ -336,6 +340,9 @@ pub struct Pool {
pub(super) templates: Vec<Arc<PoolElement>>,
/// The elements with their weights, in file order (`rawTemplates`).
pub(super) raw: Vec<(Arc<PoolElement>, i32)>,
/// Per entry of `raw`: a YUNG element's `name` and `max_count`, the most
/// pieces of that name in one start.
limits: Vec<Option<(Arc<str>, i32)>>,
pub(super) fallback: String,
max_size: OnceLock<i32>,
}
@@ -354,6 +361,16 @@ impl Pool {
shuffle(&mut copy, random);
copy
}
/// YUNG's API's pick: `nextInt` of the entries' total weight, walked in
/// `order` (indices into `raw`); the entry that takes it below zero.
fn weighted(&self, order: &[usize], random: &mut impl RandomSource) -> Option<usize> {
let total: i32 = order.iter().map(|&i| self.raw[i].1).sum();
let mut pick = random.next_int_bound(total).ok()?;
order.iter().position(|&i| {
pick -= self.raw[i].1;
pick < 0
})
}
pub(super) fn max_size(&self) -> i32 {
*self.max_size.get_or_init(|| {
self.templates
@@ -393,7 +410,7 @@ impl Pools {
}
let pool = self.raw.get(name).and_then(|json| {
let value: Value = serde_json::from_str(json).ok()?;
let (mut templates, mut raw) = (Vec::new(), Vec::new());
let (mut templates, mut raw, mut limits) = (Vec::new(), Vec::new(), Vec::new());
for entry in value["elements"].as_array()? {
let element =
match PoolElement::decode(&entry["element"], resources, bindings, None) {
@@ -410,10 +427,22 @@ impl Pools {
templates.push(element.clone());
}
raw.push((element, weight as i32));
let fields = &entry["element"];
limits.push(
match (fields["name"].as_str(), fields["max_count"].as_i64()) {
(Some(name), Some(max))
if fields["element_type"] == "yungsapi:yung_single_element" =>
{
Some((name.into(), max as i32))
}
_ => None,
},
);
}
Some(Arc::new(Pool {
templates,
raw,
limits,
fallback: value["fallback"]
.as_str()
.unwrap_or("minecraft:empty")
@@ -587,6 +616,93 @@ pub trait JigsawContext {
/// couldStructureExistInColumn over inclusive block Y bounds.
fn column_has_valid_biome(&mut self, x: i32, z: i32, min_y: i32, max_y: i32) -> bool;
}
/// How a jigsaw structure draws its pieces.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Draws {
/// JigsawPlacement's.
Vanilla,
/// YUNG's API's `yungsapi:yung_jigsaw` (spec-yungsapi §2.1, §3.1): pools
/// are drawn by weight from their file entries, without replacement, and
/// an element at its `max_count` is skipped.
Yung,
}
/// The elements a jigsaw tries, in order.
enum Candidates {
/// Vanilla: the target pool's weight-expanded elements, then the
/// fallback's, each shuffled up front.
Listed(std::vec::IntoIter<Arc<PoolElement>>),
/// YUNG's: weighted picks from each pool in turn (the fallback's only
/// once the target's are spent), each pool's entries shuffled when it
/// is reached.
Weighted {
pools: Vec<Arc<Pool>>,
current: Option<(Arc<Pool>, Vec<usize>)>,
},
}
impl Candidates {
fn new(
draws: Draws,
target: &Arc<Pool>,
fallback: &Arc<Pool>,
below_max_depth: bool,
random: &mut impl RandomSource,
) -> Self {
let pools = if below_max_depth {
vec![target.clone(), fallback.clone()]
} else {
vec![fallback.clone()]
};
match draws {
Draws::Vanilla => Self::Listed(
pools
.iter()
.flat_map(|pool| pool.shuffled(random))
.collect::<Vec<_>>()
.into_iter(),
),
Draws::Yung => Self::Weighted {
pools: pools.into_iter().rev().collect(),
current: None,
},
}
}
/// The next element to try, with its YUNG name and limit.
fn next(
&mut self,
counts: &HashMap<Arc<str>, i32>,
random: &mut impl RandomSource,
) -> Option<(Arc<PoolElement>, Option<Arc<str>>)> {
match self {
Self::Listed(elements) => elements.next().map(|e| (e, None)),
Self::Weighted { pools, current } => loop {
let (pool, order) = match current {
Some((pool, order)) if !order.is_empty() => (pool, order),
_ => {
let pool = pools.pop()?;
let mut order: Vec<usize> = (0..pool.raw.len()).collect();
shuffle(&mut order, random);
*current = Some((pool, order));
continue;
}
};
let Some(at) = pool.weighted(order, random) else {
order.clear();
continue;
};
let entry = order.remove(at);
let limit = pool.limits[entry].clone();
if let Some((name, max)) = &limit
&& counts.get(name).copied().unwrap_or(0) >= *max
{
continue;
}
return Some((pool.raw[entry].0.clone(), limit.map(|(name, _)| name)));
},
}
}
}
pub struct JigsawConfig<'a> {
pub start_pool: &'a str,
pub start_jigsaw: Option<&'a str>,
@@ -628,6 +744,30 @@ pub fn assemble(
bindings: &FeatureBlockBindings<'_>,
random: &mut impl RandomSource,
position: [i32; 3],
) -> Option<([i32; 3], Vec<PoolPiece>)> {
assemble_drawing(
Draws::Vanilla,
config,
context,
pools,
resources,
bindings,
random,
position,
)
}
/// [`assemble`] with the given draws.
#[allow(clippy::too_many_arguments)]
pub fn assemble_drawing(
draws: Draws,
config: &JigsawConfig<'_>,
context: &mut impl JigsawContext,
pools: &Pools,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
random: &mut impl RandomSource,
position: [i32; 3],
) -> Option<([i32; 3], Vec<PoolPiece>)> {
let alias = |name: &str| {
config
@@ -636,9 +776,24 @@ pub fn assemble(
.cloned()
.unwrap_or_else(|| name.to_owned())
};
let rotation = Rotation::random(random);
let center_pool = pools.get(&alias(config.start_pool), resources, bindings)?;
let element = center_pool.random_template(random);
let (rotation, element, limit) = match draws {
Draws::Vanilla => {
let rotation = Rotation::random(random);
(rotation, center_pool.random_template(random), None)
}
// The entries shuffled, then the rotation, then a weighted pick in
// the shuffled order.
Draws::Yung => {
let mut order: Vec<usize> = (0..center_pool.raw.len()).collect();
shuffle(&mut order, random);
let rotation = Rotation::random(random);
let entry = order[center_pool.weighted(&order, random)?];
let (element, _) = &center_pool.raw[entry];
let limit = center_pool.limits[entry].clone().map(|(name, _)| name);
(rotation, element.clone(), limit)
}
};
if matches!(element.as_ref(), PoolElement::Empty) {
return None;
}
@@ -691,7 +846,10 @@ pub fn assemble(
let low_y = (cy - v).max(context.min_y() + config.padding[0]);
let high_y = (cy + v + 1).min(context.max_y() + 1 - config.padding[1]);
let container = BoundingBox::new([cx - h, low_y, cz - h], [cx + h, high_y - 1, cz + h]);
add_children(
let counts = limit.into_iter().map(|name| (name, 1)).collect();
add_children_drawing(
draws,
counts,
config,
context,
pools,
@@ -719,6 +877,32 @@ pub(super) fn add_children(
random: &mut impl RandomSource,
pieces: &mut Vec<PoolPiece>,
container: BoundingBox,
) {
add_children_drawing(
Draws::Vanilla,
HashMap::new(),
config,
context,
pools,
resources,
bindings,
random,
pieces,
container,
);
}
#[allow(clippy::too_many_arguments)]
fn add_children_drawing(
draws: Draws,
counts: HashMap<Arc<str>, i32>,
config: &JigsawConfig<'_>,
context: &mut impl JigsawContext,
pools: &Pools,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
random: &mut impl RandomSource,
pieces: &mut Vec<PoolPiece>,
container: BoundingBox,
) {
let free = Rc::new(RefCell::new(Free {
container,
@@ -731,6 +915,8 @@ pub(super) fn add_children(
bindings,
pieces,
queue: BTreeMap::new(),
draws,
counts,
};
placer.children(0, free, 0, context, random);
while let Some(state) = placer.next() {
@@ -745,6 +931,9 @@ struct Placer<'a, 'p> {
pieces: &'p mut Vec<PoolPiece>,
/// SequencedPriorityIterator: highest priority first, FIFO within one.
queue: BTreeMap<i32, VecDeque<PieceState>>,
draws: Draws,
/// Pieces placed per YUNG element name.
counts: HashMap<Arc<str>, i32>,
}
impl Placer<'_, '_> {
fn next(&mut self) -> Option<PieceState> {
@@ -816,12 +1005,14 @@ impl Placer<'_, '_> {
} else {
context_free.clone()
};
let mut targets = Vec::new();
if depth != self.config.max_depth {
targets.extend(target_pool.shuffled(random));
}
targets.extend(fallback.shuffled(random));
for target in targets {
let mut candidates = Candidates::new(
self.draws,
&target_pool,
&fallback,
depth != self.config.max_depth,
random,
);
while let Some((target, name)) = candidates.next(&self.counts, random) {
if matches!(target.as_ref(), PoolElement::Empty) {
break;
}
@@ -924,6 +1115,9 @@ impl Placer<'_, '_> {
}],
waterlogging: self.config.waterlogging,
});
if let Some(name) = name {
*self.counts.entry(name).or_default() += 1;
}
if depth < self.config.max_depth {
self.queue
.entry(jigsaw.placement_priority)
@@ -2,7 +2,9 @@
//! references, terrain adaptation and per-chunk piece placement.
use super::{
BoundingBox, StructureResources,
jigsaw::{JigsawConfig, JigsawContext, PlaceFeature, PoolPiece, Pools, Projection, assemble},
jigsaw::{
JigsawConfig, JigsawContext, PlaceFeature, PoolPiece, Pools, Projection, assemble_drawing,
},
legacy::{LegacyPiece, PieceContext},
};
use crate::{
@@ -59,6 +61,8 @@ pub struct JigsawDef {
checks: super::start_checks::StartChecks,
/// Laid out by YUNG's API's assembler (`"horizon:yungsapi"`).
yung: Option<super::yung::Config>,
/// Vanilla's draws, or YUNG's API's for `yungsapi:yung_jigsaw`.
draws: super::jigsaw::Draws,
/// Laid out by Repurposed Structures' and Moog's piece-limited
/// assembler (`"horizon:piece_limited"`).
piece_limited: Option<super::piece_limited::Config>,
@@ -320,8 +324,13 @@ impl StructureRegistry {
.flatten()
.filter_map(|t| Some(t.as_str()?.to_owned())),
);
let draws = match v["type"].as_str() {
Some("yungsapi:yung_jigsaw") => super::jigsaw::Draws::Yung,
_ => super::jigsaw::Draws::Vanilla,
};
let kind = match v["type"].as_str().unwrap_or_default() {
"minecraft:jigsaw" => StructureKind::Jigsaw(JigsawDef {
"minecraft:jigsaw" | "yungsapi:yung_jigsaw" => StructureKind::Jigsaw(JigsawDef {
draws,
start_pool: v["start_pool"].as_str().unwrap_or_default().to_owned(),
start_jigsaw: v["start_jigsaw_name"].as_str().map(str::to_owned),
max_depth: v["size"].as_i64().unwrap_or(0) as i32,
@@ -923,7 +932,8 @@ fn try_generate(
&mut random,
start,
)?,
(None, None) => assemble(
(None, None) => assemble_drawing(
jigsaw.draws,
&config,
context,
&registry.pools,
@@ -5,7 +5,7 @@ use crate::{
nbt::{Compound, Tag},
registry::{BlockRegistry, BlockStateId},
};
use std::sync::OnceLock;
use std::sync::{Arc, OnceLock};
#[derive(Clone, Debug, PartialEq)]
pub struct BlockInfo {
@@ -42,6 +42,9 @@ pub struct EntityInfo {
}
#[derive(Debug)]
pub struct StructureTemplate {
/// The id it was loaded under (`StructureResources::template`); empty
/// for a template read from elsewhere.
pub location: Arc<str>,
pub size: [i32; 3],
palettes: Vec<Palette>,
pub entities: Vec<EntityInfo>,
@@ -129,6 +132,7 @@ impl StructureTemplate {
/// A new, never-loaded template: zero size and no palettes.
pub fn empty() -> Self {
Self {
location: "".into(),
size: [0; 3],
palettes: Vec::new(),
entities: Vec::new(),
@@ -231,6 +235,7 @@ impl StructureTemplate {
}
}
Self {
location: "".into(),
size,
palettes,
entities,
+55
View File
@@ -90,6 +90,61 @@ Unknown keys are refused. `overrides/` may hold only `options.txt`, `horizon-gra
capability `storage`), each at most 64 MiB and 256 MiB in all; anything else refuses the pack. The launcher copies
them into the profile's game folder.
## The registry repository
[ludovic111/horizon-mods](https://github.com/ludovic111/horizon-mods) holds one `mods/<id>.toml` per project, written by
the submission form or by hand, and `revoked.toml`. What an entry can't vouch for is read from its file by the kind's
own check (`index::inspect`): a mod's API, side and capabilities, a modpack's mods, the size. Types and rules:
`horizon_mods::index::registry`.
```toml
id = "coords_hud" # the file's name, and the manifest's id
kind = "mod" # mod | modpack | resourcepack; shader once its format exists
name = "Coordinates HUD"
summary = "One line, at most 160 characters."
description = """Markdown.""" # optional
authors = ["Horizon"]
owners = ["ludovic111"] # GitHub logins who may publish updates
license = "MIT"
source = "https://github.com/..."
icon = "icons/coords_hud.png" # optional: a PNG of at most 256 KiB in the repository, or https://
key = "RWT..." # optional: the minisign public key every version is signed with
[[versions]] # newest first
version = "0.1.0" # letters, digits, '.', '-', '_': the release tag is <id>-<version>
published = "2026-10-24"
game = "26.3"
file = "coords_hud-0.1.0.hmod" # the name players download
sha256 = "e113..." # pins the reviewed file
signature = """untrusted comment: ...""" # when the project has a key
changelog = "" # optional
```
```toml
# revoked.toml
[[revoked]]
id = "bad_mod"
sha256 = "1111..." # optional: without it, every version
reason = "Sent chat messages it didn't declare."
date = "2026-10-24"
```
The submission form's service (`crates/horizon-mod-registry`) writes these entries: it signs the creator in with GitHub, runs the file's checks, uploads the file to the repository's `staging` release as `<sha256>.<extension>` and opens the pull request. Only an entry's `owners` publish updates through it.
Unknown keys are refused. Its CI runs `cargo-horizon-mod` (`crates/cargo-horizon-mod`):
| Command | What it does |
| --- | --- |
| `cargo horizon-mod check <file> [--json]` | A submission's checks: its kind's (above), its signature with `--key` and `--signature`, and for a mod a trial run in the sandbox (40 ticks, 120 frames, a chat line, each command). |
| `cargo horizon-mod registry check <base> <head> --files <dir>` | `check` on each file `head` lists and `base` doesn't: a pull request's new files. |
| `cargo horizon-mod registry list <checkout>` | `<sha256> <id> <version> <file>` per version: the files to download. |
| `cargo horizon-mod registry build <checkout> --files <dir> --out index.json` | Reads every entry and its file (`<dir>/<sha256>`), lists every problem, and writes the index with URLs `<release>/<id>-<version>/<file>`. |
| `cargo horizon-mod registry review <base> <head> --author <login>` | A pull request's changes: a published file never changes and is never removed (take it down in `revoked.toml`), a kind never changes; changes by a non-owner and to owners or keys are pointed out for the reviewers. |
Kinds open to submissions: `mod`, `modpack`, `resourcepack`. `shader` is in the schema and stays closed until
Horizon's own shader pack format exists (Iris and OptiFine packs are for the Modrinth browser); its check will compile
the pack in a headless Horizon. A new kind gets its own check before it opens.
## Changelog
- Schema 1 (2026-10-24): kinds `mod`, `modpack`, `resourcepack`, `shader`; `.hpack` format 1.
+13 -1
View File
@@ -16,7 +16,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
| Example mod | `crates/horizon-mod-sdk/examples/coords-hud` | coordinates, facing and time HUD, `/where`, `/coords` |
| Runtime | `crates/horizon-mods` | wasmtime 36 LTS, component model |
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new / build / run` |
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new / build / run / check`, and the registry's CI (`registry build / review / list`) |
| In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods |
| Launcher mod browser (Modrinth, CurseForge with a key) | [mod-browser.md](mod-browser.md) | resource packs, modpacks |
| Registry index (`index.json`, `.hpack`), signatures | `horizon_mods::index`, [api/mod-index.md](api/mod-index.md) | schema 1; launcher and website next |
@@ -226,6 +226,18 @@ always on, and the game checks its file once a second: rebuild and it restarts
with "Mods reloaded" in chat. Its in-memory state starts over; whatever it
saved with capability `storage` survives the reload.
Before you submit it to the registry, run what its CI runs:
```sh
cargo horizon-mod check target/horizon-mod/my_mod.hmod # --json for the report as JSON
```
It reads the manifest and content, refuses imports the capabilities don't
cover, then plays the mod in the sandbox for 40 ticks, 120 frames, a chat line
and each of its commands: it must start and never trap or overrun. It checks a
`.hpack` and a resource pack `.zip` too, and a signature with `--key <public
key> --signature <file>.minisig`.
### Template layout
```
+11 -5
View File
@@ -32,11 +32,17 @@ stay in the host's dimension. That mode is the escape hatch, so it gets no multi
## One code path for both
The dedicated server and the server thread are the same code. The server thread runs a `Game` with
`options.dedicated` set and a hub without a host player. The window joins it through memory as one
more player. Everything above serves both, and `server_dimension_players` (vanilla clients on Horizon's
dedicated server) and `lan_e2e --server-thread` (Horizon clients on the server thread) check it from
the two sides.
The dedicated server and the server thread are the same code. Each `Game` has an explicit
`ExecutionRole`: `Client`, `IntegratedServer` or `DedicatedServer`, selected before construction in
`Options.execution_role`. Startup, world loading and opening other dimensions use that role. Only
clients mesh chunks and show loading progress; a client can still run its simulation in-frame.
Connecting or disconnecting changes `remote`, independently of the execution role.
The server thread has a hub without a host player. The window joins it through memory as one
more player. It still sets `options.dedicated` for gameplay checks outside the role migration's
startup/world-loading scope. Everything above serves both servers, and `server_dimension_players`
(vanilla clients on Horizon's dedicated server) and `lan_e2e --server-thread` (Horizon clients on the
server thread) check it from the two sides.
## Cost of an extra level
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more