Compare commits

...
Author SHA1 Message Date
Ludovic Marie 731d88a1d8 Merge pull request #1917 from ludovic111/fix/opengl-fallback
🤖 Agent: merged after verify, a real OpenGL run (Mesa GL 4.6: title and a world rendered, no errors) and the backend/shader unit tests. Released at once on Ludovic's request (release-now): a player's Radeon HD 7400M could not start Horizon.
2026-10-25 21:30:01 +01:00
Ludovic MarieandClaude Opus 5.5 903da4f1ab Cargo.lock: wgpu's GL backend dependencies (glow, khronos-egl, glutin_wgl_sys)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:20:02 +01:00
Ludovic MarieandClaude Opus 5.5 54bb3e4fbe Screen effects: one sampler per entry point, so the shader compiles for OpenGL
GLSL combines a texture with its sampler, and naga refuses an entry point that reads one texture
through two (ImageMultipleSamplers): the BLUR override left both branches in fs_screen and
fs_vignette, so creating the screen effect pipelines failed on wgpu's GL backend at startup. Blurred
sprites now have their own entry points (fs_screen_blur, fs_vignette_blur); each pipeline still
samples once per pixel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:19:47 +01:00
Ludovic MarieandClaude Opus 5.5 03a5cbdcd8 Renderer: OpenGL as the last fallback, for GPUs with no Vulkan or DirectX 12 driver
wgpu's GL backend (WGL on Windows, EGL on Linux). Auto tries Vulkan, then DirectX 12 on Windows,
then OpenGL; graphicsApi:opengl (or HORIZON_GRAPHICS_API=opengl) picks it. A Radeon HD 7400M
(TeraScale 2: DirectX 11, OpenGL 4.x) could not start Horizon at all.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:11:47 +01:00
Ludovic Marie b7b4a26164 Merge pull request #1891 from rdbtCVS/fix/renderer-surface-fallback
🤖 Agent: thank you @rdbtCVS! Merged after review (a surface failure now falls through to the next backend, as adapter and device failures do) and this head's verify. Follow-up: an OpenGL fallback for GPUs with neither Vulkan nor DirectX 12 builds on this.
2026-10-25 21:09:37 +01:00
Ludovic Marie 8b4336013a Merge pull request #1864 from socksprox/feat/mod-ui
🤖 Agent: thank you @socksprox! Merged after review (two follow-up commits on your branch: `ui` is opt-in through `experimental = ["ui"]`, and the Settings button now appears when a mod sets its page after loading), tests in game in-frame, on a server-thread window and as a LAN guest, and this head's verify.
2026-10-25 21:07:26 +01:00
Ludovic Marie 1c3be171ce Merge pull request #1867 from ludovic111/fix/prepare-regression
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 20:58:37 +01:00
Ludovic MarieandClaude Opus 5.5 befec1995f ui: a Settings button appears when the mod sets its page later
has_settings was read once, when the mods loaded, so a mod that called
set-settings from a command or a tick never got its Settings button
until a reload. The Mods page's rows now follow each mod call's output.
Also clippy with --features mods: needless borrows of the atlas in
draw_mod_screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:57:10 +01:00
Ludovic MarieandClaude Opus 5.5 05396b4edf ui: experimental, opted into with experimental = ["ui"] in the manifest
docs/api/stability.md: a new API starts experimental, usable only by
opting in with a manifest field naming it. Capability ui now needs
experimental = ["ui"] beside capabilities; without it the manifest is
refused with that line to add. A capability promoted later stays
accepted in the list, so such manifests keep loading.

The ui test guest opts in; stability.md gets ui's row; docs/mods.md
shows the key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:57:10 +01:00
Ludovic Marie 7fd1f3118f Merge pull request #1848 from kacpherek/fix/1501-shulker-nesting
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:32:18 +01:00
Ludovic Marie 4309d96924 Merge pull request #1845 from kacpherek/fix/1297-server-freeze-damage
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:30:27 +01:00
Ludovic Marie 478b8c19c4 Merge pull request #1844 from kacpherek/fix/1520-ride-mobs
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:29:24 +01:00
Ludovic Marie 2af75ba9f6 Merge pull request #1849 from kacpherek/fix/1329-cursor-death
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:27:19 +01:00
Ludovic Marie 89888e9b07 Merge pull request #1847 from kacpherek/fix/1351-pearl-vanish-on-death
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:25:09 +01:00
Ludovic Marie 1110a28521 Merge pull request #1902 from ludovic111/fix/supervisor-lock-test-race
🤖 Agent: merged after verify passed (test-only fix).
2026-10-25 20:23:30 +01:00
Ludovic Marie f6e451162c Merge pull request #1843 from kacpherek/fix/1504-hardcore-spectator
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:21:24 +01:00
Ludovic Marie 6ce6577886 Merge pull request #1841 from kacpherek/fix/1378-team-chat-names
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:19:46 +01:00
Ludovic Marie 8f2aa7f7e9 Merge pull request #1836 from kacpherek/fix/1483-mob-nbt-attributes
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:17:30 +01:00
Ludovic MarieandClaude Opus 5.5 4513af8995 horizon-server: the lock test waits for a fork-held descriptor to close
Tests in the same binary spawn processes (tar, the engine); between fork and exec the child holds a copy
of every open descriptor, the lock's too, so relocking right after drop could see WouldBlock. Seen twice
on CI (#1833, #1895).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:13:36 +01:00
Ludovic Marie 8177a6f450 Merge pull request #1807 from ludovic111/fix/no-nether-portal-in-the-end
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 20:03:49 +01:00
Ludovic Marie f6b882c9da Merge pull request #1802 from ludovic111/fix/mob-flags-and-swings
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 20:01:58 +01:00
Ludovic Marie 9e248a3214 Merge pull request #1797 from ludovic111/fix/tool-use-on-block-ends-click
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 20:00:04 +01:00
Ludovic Marie 781b7b877b Merge pull request #1796 from ludovic111/fix/potion-contents-over-network
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 19:58:08 +01:00
Ludovic Marie 50f4c913a5 Merge pull request #1868 from ludovic111/fix/modrinth-loader-profiles
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 19:55:40 +01:00
Ludovic Marie e555c4ee48 Merge pull request #1831 from kacpherek/fix/1503-guest-chest-comparator
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:43:16 +01:00
Ludovic Marie f5adaec209 Merge pull request #1828 from kacpherek/fix/1335-crafting-table-valid
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:41:29 +01:00
Ludovic Marie 406fbd7793 Merge pull request #1827 from kacpherek/fix/1334-menu-range
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:39:17 +01:00
Ludovic Marie c0567a323c Merge pull request #1804 from kacpherek/pr/surface-rule-bands
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:38:20 +01:00
Ludovic Marie 0713ee5ec0 Merge pull request #1803 from kacpherek/pr/keep-scheduled-ticks
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:36:08 +01:00
Ludovic Marie 4604491fcb Merge pull request #1691 from socksprox/fix/translucent-particles 2026-10-25 19:23:59 +01:00
rdbtCVSandOpenAI Codex b3840adccd Fix renderer backend fallback after surface creation failure
Keep surface creation errors in the backend failure list so Windows Auto can try DirectX 12 after Vulkan fails.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-25 18:08:03 +00:00
Ludovic Marie 6f8cad0020 Merge pull request #1692 from socksprox/fix/streamed-server-pack-zip 2026-10-25 18:45:56 +01:00
Ludovic Marie 1087739079 Merge pull request #1877 from ludovic111/roadmap/decided-2026-10-25
🤖 Agent: merged after verify; Ludovic's yes/no on the 54 candidates from the project's history (roadmap check, 20 roadmap tests, 31 website tests, site builds with 90 items).
2026-10-25 18:17:00 +01:00
Ludovic Marie d8ed068db8 Merge pull request #1781 from levzzz5154/fix/vehicle-passenger-pose
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 18:16:18 +01:00
Ludovic MarieandClaude Sonnet 5.5 2ec2964962 Roadmap: the items Ludovic decided on (55 added, 4 updated, 3 areas)
Adds the ideas he said yes to, as roadmap items with plain and dev text,
and extends ai-companion, new-versions, benchmarks and intel-mac. New
areas: platforms, ai, community.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 18:09:13 +01:00
Ludovic MarieandClaude Opus 5.5 bb414b263c Merge main into fix/vehicle-passenger-pose: keep both lan_e2e checks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:08:13 +01:00
socksproxandDevin f2832f83ef check: cover the ui open/close-screen outputs
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 17:53:13 +01:00
Ludovic MarieandClaude Opus 5.5 b873f30c29 wears_freeze_immune above sync_player_body's doc comment, which it had taken over
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:41:04 +01:00
Ludovic MarieandClaude Opus 5.5 8c54e18587 /ride dismount: the boats' BOARDING_COOLDOWN, not a second 60
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:40:41 +01:00
Ludovic MarieandClaude Opus 5.5 0dfc6d8700 command_exec harness: summoned mobs' saved attribute bases and health (#1483)
A cow summoned with max_health and movement_speed bases, and a zombie
with an attack_damage base and a saved Health, against vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:38:28 +01:00
Ludovic MarieandClaude Opus 5.5 0298d10988 Shulker boxes: one canFitInsideContainerItems for both menus, and a unit test
The client's menu and the server's guest menus now share
menu::fits_inside_container_items, and a resource-backed test checks
that a guest's shulker box menu refuses shulker boxes by pickup,
shift-click and number key while other containers take them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:31:37 +01:00
Ludovic MarieandClaude Opus 5.5 75951a769a Merge #1847 into #1849: LocatorBarClient's respawn and use commands, the harness scaffold
The three harnesses add commands to the same LocatorBarClient lines;
this branch now builds on #1847 (itself on #1843), keeps all three
commands, and test_cursor_death_parity.py runs through
locator.one_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:27:31 +01:00
Ludovic MarieandClaude Opus 5.5 94bc569a7f Merge #1843 into #1847: share LocatorBarClient's respawn command and the harness scaffold
Both add the same respawn command to LocatorBarClient; this branch now
builds on #1843's, and test_pearl_death_parity.py runs through
locator.one_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:27:00 +01:00
Ludovic MarieandClaude Opus 5.5 50727eb5c3 Harnesses: one scaffold for one-player dedicated server scripts
test_hardcore_respawn_parity.py copied test_locator_bar_parity.py's
build and run code; both now share locator.sides, and one-player
scripts run through locator.one_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:26:43 +01:00
Ludovic MarieandClaude Opus 5.5 65d3edfc3a Launcher: find the client jar of a Modrinth App mod loader profile (#1783)
The Modrinth App keeps a Fabric or Quilt profile's vanilla client jar as
`versions/<v>-<loader>/<v>-<loader>.jar`; a player who only played such a
profile has no `versions/<v>/`, and Prepare said Minecraft was not found.
Those jars are now candidates too, checked against the pinned sha1 like the rest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:26:03 +01:00
Ludovic MarieandClaude Opus 5.5 ee74553314 Merge #1827 (container reach) into #1828: share its reach helper
Both PRs add a stillValid check to the same tick functions; this branch
now builds on #1827's lan::guest::within_block_range for the guest's and
the in-frame table checks, and the in-frame check sits beside the
container one in storage.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:25:53 +01:00
Ludovic MarieandClaude Opus 5.5 6d3cd330d1 One isWithinBlockInteractionRange for guests, the in-frame player, stations, lecterns and signs
The reach test was written out four times (stations, lecterns, the
in-frame container check and the guest's); they now share
lan::guest::within_block_range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:25:10 +01:00
Ludovic MarieandClaude Opus 5.5 204859a2cb Launcher: a preparation reusing imported game files still finds the assets
When an update's preparation reused the game files an earlier version imported,
`preparer` handed the chosen folder as it was to the sound copy and the panorama,
fonts, packs and languages step. A Prism Launcher instance or Modrinth App profile
holds no asset store, so the game was silent with a grey title screen, and
Prepare again did the same. It now resolves the folder (linking another
launcher's files into the official layout) either way; with reused imports a
missing one is a log line, not an error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:20:41 +01:00
socksproxandDevin af7a685b5c Mod API: capability ui — mod-owned screens and settings pages
New WIT interface `ui` (text, item and list widgets; open-screen,
close-screen, set-settings) plus a `ui-events` export (select, closed,
settings-opened), in a second world `horizon-ui-mod` so mods built before
it keep loading. The game lays out and draws the widgets with its own
menus' code: a screen the mod owns (Esc/Done to leave) and a Settings
button on the mod's Options > Mods row that opens its page.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 17:09:25 +01:00
Kacpherek 5bfc649edd Drop the cursor stack of a dead player
A stack held on the inventory cursor at death stayed there through the
respawn, even with keep_inventory off. In vanilla the dead player is
removed 20 ticks after dying (or when it respawns sooner), and
Player.remove's inventoryMenu.removed drops what the cursor held where
the player died. Do the same for server players and the in-frame
player, and add a harness that dies with a cursor stack on a dedicated
server and compares with vanilla.
2026-10-25 16:09:09 +01:00
Kacpherek 0c017678eb Keep shulker boxes out of shulker boxes on the server
ShulkerBoxSlot.mayPlace refuses items that cannot fit inside container items, which BlockItem says of every shulker box. The server's menus for guests treated a shulker box's slots like a chest's, so with the server thread a shulker box could be clicked, shift-clicked or swapped into another. Apply the slot rule when the open menu is a shulker box.
2026-10-25 16:05:57 +01:00
Kacpherek 10efca9e6c Discard a dead player's ender pearl when the rule says so
With ender_pearls_vanish_on_death on, a pearl thrown just before death
kept flying and pulled the respawned player back to it. Vanilla's
ThrownEnderpearl.tick discards the pearl instead of ticking it while
its owner is dead. Add a harness that throws a pearl, dies and respawns
on a dedicated server and compares the teleports with vanilla's.
2026-10-25 16:05:52 +01:00
Kacpherek 436c2fefb4 Freeze server-thread players in powder snow
With the server thread, the host's copy of a player never set isInPowderSnow from the blocks along its moves and never ran LivingEntity.aiStep's freezing, so a frozen player took no freezing damage and the client's own damage was overwritten. Record powder snow's FREEZE on the server path and run the freezing countdown and its damage every 40 ticks there. The guest's freeze-immune armor now comes from its worn armor, as for the local player.
2026-10-25 16:01:39 +01:00
Kacpherek f48cfb7966 Let /ride mount and dismount mob riders
/ride now looks up a mob's vehicle as well as the player's, so mobs
can mount mobs, get the already-riding and loop errors, and dismount
to the vehicle's dismount spot. execute on vehicle uses the same
lookup. The command_exec corpus gets a mob riding group.
2026-10-25 16:01:39 +01:00
Kacpherek 0481dbdb81 Make hardcore players spectators when they respawn
A hardcore death offered another survival life. Vanilla's death screen
says Spectate in hardcore, and the server makes the respawned player a
spectator (ServerGamePacketListenerImpl.handleClientCommand). Do the
same on the server and in the in-frame world, and add a harness that
compares a hardcore respawn on a dedicated server with vanilla's.
2026-10-25 16:01:39 +01:00
Kacpherek 13e83e4e63 Read saved attribute bases for every mob
LivingEntity.readAdditionalSaveData applies the attributes list for any
living entity, then sets the health (the max health when none is saved).
Only horses read max_health and movement_speed, so a summoned cow kept its
type defaults. Read max_health, movement_speed and attack_damage for all
mobs before the health; horses still read jump_strength.
2026-10-25 15:51:29 +01:00
Kacpherek 3843a9a4aa Use the team-formatted player name in chat
Chat, /say, /me and /tell now name the sender the way
Player.getDisplayName does: the team prefix, the name and the suffix
in the team colour. On a server the hub gets the scoreboard's teams
each tick; in-frame the existing player_display_name is used.
2026-10-25 15:51:11 +01:00
Kacpherek 467277ef6d Update comparators when a guest changes a chest through its menu
A container click from a server-thread or LAN player wrote the slots but
never called BlockEntity.setChanged, so comparators kept the old output.
Notify the block entity when the click changed the container's slots.
2026-10-25 15:48:51 +01:00
Kacpherek 18ec4145c4 Close crafting tables that are removed or out of reach
CraftingMenu.stillValid needs the block to still be a crafting table and the
player within reach of it. The table's position is now kept with the open
grid, so the menu closes in the tick (giving its grid back) and a stale
click no longer crafts, in single player and for LAN guests.
2026-10-25 15:44:49 +01:00
Kacpherek ed7d88b213 Close container menus when the player moves out of reach
Container.stillValidBlockEntity also checks the player's block interaction
range plus a 4-block buffer. Guest menus now close in the tick once out of
reach and ignore clicks while invalid, and the in-frame single player menu
closes the same way.
2026-10-25 15:40:19 +01:00
Ludovic MarieandClaude Opus 5.5 a2fccf0205 Flint and steel lights nether portals only in the Overworld and the Nether
BaseFireBlock.inPortalDimension: a frame lit in the End stays a fire.
The world code's fire placement already checked it; the flint-and-steel
path (Game::try_light_portal) didn't, so a portal could be lit and used
in the End in every mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:52:17 +01:00
Kacpherek dbafda06ed Surface: specialise the material rule per gradient band and surface side
Each chunk's compiled rule is specialised once for every band of y that
its vertical gradients bound, and for blocks below and at or above the
preliminary surface when the rule asks for it. Conditions those facts
decide are folded away, so a deep stone block evaluates only the rules
that can apply there (ore veins, the underground rules) instead of
walking the whole surface tree. Conditions only read the position and
the world, so the blocks are the same.

Surface stage, one thread, seed 1234: 2.45 -> 2.08 ms per chunk.
2026-10-25 14:40:37 +01:00
Kacpherek e181b17e17 Register new chunks' ticks before post-processing them
postprocess_chunks ran before tick_blocks registered the chunks that
arrived since the last tick, so the fluid and block ticks their marks
schedule went to chunks the scheduler did not know and were dropped.
Vanilla registers a chunk's tick containers before postProcessGeneration.
2026-10-25 14:40:37 +01:00
Kacpherek f56785c2f5 Keep a chunk's scheduled ticks when it unloads before the next tick
attach_save_data took a chunk's ticks only from the tick scheduler, which
registers new chunks once a tick. A chunk loaded and unloaded again in
between was saved with its block and fluid ticks cleared, though its own
saved list still held them.
2026-10-25 14:40:37 +01:00
Ludovic MarieandClaude Opus 5.5 68deac8533 Server clients see mobs' aggression and arm swings
Mob.DATA_MOB_FLAGS_ID now carries the aggressive bit (with its default
0, so it goes back off) and every client mob reads no AI, left-handed
and aggressive from it: a vindicator draws its axe, skeletons their bow,
piglins and illagers their attack poses on the server thread, LAN guests
and dedicated servers. Mobs' swings (LivingEntity.swing) go out as
ClientboundSwingAnimationPacket.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:38:44 +01:00
Ludovic MarieandClaude Opus 5.5 2f1f0150eb mobs diagnostics: say why passenger is set outside the json! macro
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:37:26 +01:00
Ludovic MarieandClaude Opus 5.5 806f7d87f3 Merge origin/main into fix/vehicle-passenger-pose
Keeps main's crystals check and this branch's boat-dismount-pose check in tools/lan_e2e.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:29:29 +01:00
Ludovic MarieandClaude Opus 5.5 df626cf2f3 A tool that changes a block ends the click on a server's client
On a server's client (single player on the server thread, LAN guests), a
shovel making a path, an axe stripping, a hoe tilling or honeycomb waxing
did not end the right click: the client went on to the other hand, so an
off-hand torch was predicted as placed and its hand swung instead of the
tool's (#1289).

Vanilla's client runs `Item.useOn` itself: its SUCCESS swings that hand
and `Minecraft.startUseItem` returns before the off hand. The client now
does the same when its prediction of the tool's use succeeds.

Fixes #1289

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:15:28 +01:00
Ludovic MarieandClaude Opus 5.5 9e21868dfa Potions keep their potion across the connection
A server's client (single player on the server thread, LAN guests, a
dedicated server's players) saw every potion as an "Uncraftable Potion"
in the water colour: the server never wrote `minecraft:potion_contents`
and the client read past it. A potion taken from the creative menu also
reached the server without its potion, so it splashed as water.

PotionContents now goes as vanilla's stream codec writes it: the potion
by its `minecraft:potion` id and the custom colour, with the custom
effects and name kept as their bytes. The server writes it for every
stack it sends, the client reads it into the item's components, and a
creative pick and the server's read of it keep the potion too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:46:45 +01:00
Ludovic MarieandClaude Opus 5.5 be378bb773 Particles: vault connections are translucent, shadows keep their range
- FlyTowardsPositionParticle puts vault_connection on the translucent
  layer: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
- The shadow draw started at world_quads - shadow_quads; with the new
  bucket after it, that drew translucent particles with the shadow
  pipeline and dropped as many shadows.
- One atlas lookup per named-sprite particle and frame: the engine keeps
  each sprite's layer beside its rect instead of hashing the name twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:27:49 +01:00
socksproxandDevin 4032e162ce Draw translucent particles after translucent terrain
Quad particles all went into the entity batch, drawn before translucent
terrain with depth writes on: a stained-glass crumb wrote its depth and
the sorted translucent pass then culled the glass behind it, so breaking
glass in front of glass cut holes through the wall.

Vanilla 26.3 submits quad particles to two phases: opaque layers draw
with the solids and translucent layers draw in an "afterTerrain" phase
right after the translucent section pass (the translucent particle
pipeline still writes depth, so ordering is what matters). Mirror that:
HudAtlas records which sprites have a 0 < alpha < 255 texel
(`SpriteContents.transparency().hasTranslucent`), particles classify
their layer the way `SingleQuadParticle.getLayer` does — the type's
hardcoded layer, or `Layer.bySprite` for terrain, item and block marker
sprites — and the entity batch routes those quads to a sixth bucket the
renderer draws immediately after translucent terrain.

Fixes #1679.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 13:27:49 +01:00
levzzzandCodex 1ec6d6d734 Detach entity passengers when their vehicle is removed
Clear remote passenger mount and pose flags when a vehicle disappears, matching Entity.setRemoved. Add a two-passenger boat destruction check for both single-player paths and LAN observers.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-25 16:15:36 +05:00
Ludovic MarieandClaude Opus 5.5 e7f85e5bfd Zip: test the streamed shapes Java 25's ZipFile opens
ZipFile refuses a 0xFFFFFFFF size without its Zip64 extra field ("invalid
zip64 extra len size"), so the test builds both shapes it accepts: real
sizes, and the sentinel with the extra (the zip crate reads the real size
from it). With every accepted shape carrying a real size, the inflate
limit needs no special case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:44:31 +01:00
Devin 348675c538 Packs: open streamed archives the way Java's ZipFile does
A resource pack written while it streams (Nexo's server behind
resourcepack.gommehd.io, gommehd.net's required pack) leaves every local
header a stub, every uncompressed size at 0xFFFFFFFF and the END record's
entry counts and disk number at 0 and 0xFFFF. Java tolerates all three;
the zip crate's ZipArchive refuses the END record as multi-disk and empty,
so a required server pack failed its reload and the join never got through
(each retry also rebuilt the renderer, once a second).

Count the real central-directory entries and normalize the END record's
disk and count fields when they disagree with it, and bound the inflate
output when an entry's size is the 0xFFFFFFFF sentinel.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 02:52:19 +02:00
78 changed files with 4909 additions and 334 deletions

No files matched your search

+1 -1
View File
@@ -82,7 +82,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
Generated
+90
View File
@@ -1116,12 +1116,44 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
dependencies = [
"khronos_api",
"log",
"xml-rs",
]
[[package]]
name = "glam"
version = "0.30.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
[[package]]
name = "glow"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
dependencies = [
"js-sys",
"slotmap",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "glutin_wgl_sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
dependencies = [
"gl_generator",
]
[[package]]
name = "gpu-allocator"
version = "0.28.0"
@@ -1352,6 +1384,13 @@ dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "horizon-mods-test-ui-guest"
version = "0.1.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1805,6 +1844,23 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
"libloading",
"pkg-config",
]
[[package]]
name = "khronos_api"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "lazy_static"
version = "1.5.1"
@@ -3142,6 +3198,15 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.16.2"
@@ -4267,6 +4332,7 @@ dependencies = [
"smallvec",
"thiserror 2.0.21",
"wgpu-core-deps-apple",
"wgpu-core-deps-emscripten",
"wgpu-core-deps-windows-linux-android",
"wgpu-hal",
"wgpu-naga-bridge",
@@ -4282,6 +4348,15 @@ dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-emscripten"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-windows-linux-android"
version = "30.0.1"
@@ -4304,13 +4379,18 @@ dependencies = [
"bytemuck",
"cfg-if",
"cfg_aliases",
"glow",
"glutin_wgl_sys",
"gpu-allocator",
"hashbrown 0.17.1",
"js-sys",
"khronos-egl",
"libc",
"libloading",
"log",
"naga",
"naga-types",
"ndk-sys",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -4330,10 +4410,14 @@ dependencies = [
"smallvec",
"static_assertions",
"thiserror 2.0.21",
"wasm-bindgen",
"wayland-sys",
"web-sys",
"wgpu-naga-bridge",
"wgpu-types",
"windows 0.62.2",
"windows-core 0.62.2",
"windows-result 0.4.1",
]
[[package]]
@@ -5074,6 +5158,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xml-rs"
version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
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-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
resolver = "2"
[workspace.package]
+619 -13
View File
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs.
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included.
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
@@ -80,6 +80,16 @@ Servers that show dialogs, such as the register and login screens of AuthMe, wil
</details>
#### Pre-generate an area with /pregen
A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore.
<details><summary>Technical</summary>
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/ludovic111/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
</details>
### Mods and API
#### Horizon's mod registry
@@ -94,6 +104,40 @@ More: [docs/mods.md](docs/mods.md)
</details>
#### Mods that run on the server
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
<details><summary>Technical</summary>
API 0.3, experimental first ([#1833](https://github.com/ludovic111/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
</details>
#### Screens, input and drawing for mods
Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.
<details><summary>Technical</summary>
Capability `ui` is in review ([#1864](https://github.com/ludovic111/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
More: [docs/mods.md](docs/mods.md)
</details>
#### Tools for people who make mods
Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.
<details><summary>Technical</summary>
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
</details>
### Gameplay parity
#### Single player as a server: the last differences
@@ -130,6 +174,16 @@ More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](http
</details>
#### Horizon's own shader format and kit
Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them.
<details><summary>Technical</summary>
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/ludovic111/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/ludovic111/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/ludovic111/horizon/issues/1861)), the sky stage ([#1863](https://github.com/ludovic111/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/ludovic111/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
</details>
### Horizon extras
#### Discord Rich Presence
@@ -160,11 +214,65 @@ A review of the whole tree: dead code and stubs removed ([#1432](https://github.
#### Fresh benchmarks against vanilla
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, on the website's Benchmarks page.
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, with a history that shows what each release changed.
<details><summary>Technical</summary>
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
</details>
#### Faster world creation and entry on small CPUs
Creating a world and joining it should be quick even on a four-thread laptop, where a fresh world at 32 chunks can make you wait minutes for the first terrain.
<details><summary>Technical</summary>
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/ludovic111/horizon/issues/1760) and [#1778](https://github.com/ludovic111/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
</details>
#### Servers that hold 20 TPS with 50 to 100 players
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
<details><summary>Technical</summary>
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
</details>
#### Low-end computers: Steam Deck, old Macs, 8 GB
Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of memory, with a battery mode that saves power on laptops.
<details><summary>Technical</summary>
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
</details>
#### Native upscaling
Draw the game at a lower resolution and sharpen it to your screen's size, in the way of FSR or DLSS, to gain frames on weaker graphics cards. An optional graphics setting.
<details><summary>Technical</summary>
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/ludovic111/horizon/issues/1403).
</details>
#### A batch of targeted optimisations
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
<details><summary>Technical</summary>
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/ludovic111/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
More: [docs/profiling.md](docs/profiling.md)
</details>
@@ -180,6 +288,126 @@ The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-
</details>
#### A Convert to Rust button for what isn't rewritten yet
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
<details><summary>Technical</summary>
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
</details>
#### Mods with code: machines, magic, mobs
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
<details><summary>Technical</summary>
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/ludovic111/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/ludovic111/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
</details>
#### Data packs in the mod browser
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
<details><summary>Technical</summary>
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
</details>
### Servers and multiplayer
#### Prepare a version without Java
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
<details><summary>Technical</summary>
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/ludovic111/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
</details>
#### Server console, bans and a full admin API
Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin API and an MCP server for AI agents.
<details><summary>Technical</summary>
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
</details>
#### Hosting polish: Windows service, safe updates
Smoother hosting: a real Windows service, a backup before every update, backups that keep the world and each player's state, and a changed view distance or message without a restart.
<details><summary>Technical</summary>
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/ludovic111/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
</details>
#### Guided hosting with Tailscale and an invite button
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
<details><summary>Technical</summary>
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
More: [docs/hosting.md](docs/hosting.md)
</details>
#### An official Docker image for servers
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
<details><summary>Technical</summary>
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
More: [docs/server.md](docs/server.md)
</details>
### Mods and API
#### Mods that read and change the world
Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing.
<details><summary>Technical</summary>
After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. [docs/api/mods.md](docs/api/mods.md) ('Not there yet').
More: [docs/modpacks.md](docs/modpacks.md)
</details>
#### Machines and blocks with inventories (API 0.4)
Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology mods of big modpacks.
<details><summary>Technical</summary>
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/ludovic111/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
</details>
#### Lua and other languages for mods
Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.
<details><summary>Technical</summary>
Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. [docs/api/mods.md](docs/api/mods.md) ('Ways in').
</details>
### Gameplay parity
#### Pistons push players and mobs
@@ -192,6 +420,50 @@ Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (hon
</details>
#### One single-player path: remove the in-frame world
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
<details><summary>Technical</summary>
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
</details>
#### Villages and combat, exactly as in vanilla
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
<details><summary>Technical</summary>
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/ludovic111/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
</details>
#### Choose the audio output device
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
<details><summary>Technical</summary>
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/ludovic111/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
</details>
### Rendering
#### Well-known shader looks, rewritten for Horizon
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
<details><summary>Technical</summary>
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
More: [docs/shaders.md](docs/shaders.md)
</details>
### Launcher
#### Microsoft sign-in (coming soon)
@@ -206,6 +478,18 @@ More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
</details>
#### A visible beta label
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
<details><summary>Technical</summary>
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
</details>
### Platforms
#### Horizon on Intel Macs
Builds of the game and the launcher for Macs with an Intel processor.
@@ -216,29 +500,351 @@ x86_64 game and launcher builds, picked per processor ([#1171](https://github.co
</details>
## Later
#### Wayland and Hyprland polish
### Gameplay parity
#### Following new Minecraft versions
When Mojang releases a new version of the game, Horizon follows it, and every system is checked against the new version again.
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
<details><summary>Technical</summary>
The port pipeline detects a release, diffs its classes against 26.3 and writes a port report ([docs/version-ports.md](docs/version-ports.md)); the harnesses then run against the new version.
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/ludovic111/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/ludovic111/horizon/issues/1007), closed). Checked on a real Wayland session.
</details>
### AI and agents
#### A versioned agent-control protocol
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
<details><summary>Technical</summary>
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
More: [docs/api/stability.md](docs/api/stability.md)
</details>
#### Port mods with your own AI agent
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
<details><summary>Technical</summary>
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
</details>
### Community
#### Horizon on X (coming soon)
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
<details><summary>Technical</summary>
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
</details>
### Code and tools
#### Pipeline protection and a GitHub App for agents
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
<details><summary>Technical</summary>
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/ludovic111/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
</details>
#### Signed updates everywhere, installers included
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
<details><summary>Technical</summary>
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/ludovic111/horizon/issues/1011), [#1014](https://github.com/ludovic111/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
</details>
## Later
### Performance
#### Exact far terrain from real chunks
Far terrain drawn from the world you really built and explored, caves and builds included, not only a preview from the seed.
<details><summary>Technical</summary>
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/ludovic111/horizon/issues/718).
</details>
### Modpacks
#### Older Fabric and Forge mods
Recognise mods built for versions before 26.1 in the mod browser, so they can be checked and carried over like newer ones.
<details><summary>Technical</summary>
A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars ([docs/mod-browser.md](docs/mod-browser.md)).
</details>
#### A modpack's own resource and shader packs
Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
<details><summary>Technical</summary>
Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
</details>
#### Whole modpacks, such as ATM10, rewritten
Rewriting a complete big modpack for Horizon so it runs far better than the original. Not a priority now: the work above comes first.
<details><summary>Technical</summary>
The project's first goal for modpacks: support them ten times better than vanilla and port any of them. It was set aside in favour of compatibility through the browser. What exists: ATM10's world generates and 42 of its 496 mods are carried at least in part ([docs/modpacks.md](docs/modpacks.md)). It depends on 'Mods with code' and the native mod API.
</details>
#### Running Java mods that aren't rewritten
Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not started: the Convert button and native rewrites come first.
<details><summary>Technical</summary>
Earlier research ([#527](https://github.com/ludovic111/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
</details>
### Servers and multiplayer
#### Ely.by and other sign-in providers
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
<details><summary>Technical</summary>
Issue [#1270](https://github.com/ludovic111/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
</details>
#### Online-mode checks for Play with Friends
Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.
<details><summary>Technical</summary>
Play with Friends joins over an encrypted tunnel with an invite code ([docs/play-with-friends.md](docs/play-with-friends.md), 'Identity'). Online-mode checks against Mojang's session servers can be added on top of the same tunnel; they need Microsoft sign-in first, which is coming soon. Play with Friends stays self-hosted between players, with no relay run by the project.
</details>
### Mods and API
#### Orbital Laser on the normal game
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
<details><summary>Technical</summary>
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
</details>
#### Native versions of popular client mods
Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.
<details><summary>Technical</summary>
Done as extras: a mod list and menu, a minimap and a zoom ([docs/extras.md](docs/extras.md)). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.
More: [docs/mods.md](docs/mods.md)
</details>
#### Create-style physics as a native mod
Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the base game.
<details><summary>Technical</summary>
Issue [#805](https://github.com/ludovic111/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
</details>
#### Proximity voice chat as a native mod
Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod.
<details><summary>Technical</summary>
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
</details>
### Gameplay parity
#### Every Minecraft version, prepared automatically
Every past and future Minecraft version prepared for Horizon automatically. When Mojang releases one, Horizon follows it, and every system is checked against it again.
<details><summary>Technical</summary>
The port pipeline ([docs/version-ports.md](docs/version-ports.md), `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.
</details>
#### Controllers: tested on real pads, and in the launcher
Make controller support solid: tested with real PlayStation and Xbox pads and a Steam Deck, and usable in the launcher too.
<details><summary>Technical</summary>
The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.
</details>
#### The full debug screen
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
<details><summary>Technical</summary>
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/ludovic111/horizon/issues/562) asked for the F3 editor).
</details>
### Rendering
#### A light default shader pack
A small built-in look (tone mapping, colour grade, a cheap vignette) that costs almost nothing, for players who want a nicer picture without a heavy pack.
<details><summary>Technical</summary>
An opt-in, composite-only pack on the native shader API, under about 0.3 ms at 1080p, shown on the Shader Packs page. Pack option sliders and profiles, and a Failed row with the full error, belong with it.
More: [docs/shaders.md](docs/shaders.md)
</details>
#### Optional ray tracing
An optional ray-tracing mode for graphics cards that support it (RTX and recent AMD): shadows, water reflections and global lighting on top of the vanilla look.
<details><summary>Technical</summary>
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/ludovic111/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
</details>
#### HDR monitors
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
<details><summary>Technical</summary>
Issue [#1441](https://github.com/ludovic111/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
</details>
#### MSAA, anisotropic filtering, exclusive fullscreen
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
<details><summary>Technical</summary>
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.
</details>
### Launcher
#### Lean installs that use less disk
Smaller installs: Horizon keeps one shared copy of the prepared game files and removes what only the preparation needed.
<details><summary>Technical</summary>
A shared resource store keyed by the preparer's digest, no prepare intermediates left behind, the current and the previous version kept, and an automatic clean-up. Not started.
</details>
### Platforms
#### Horizon on Linux ARM64
Game and launcher builds for 64-bit ARM Linux computers.
<details><summary>Technical</summary>
Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
</details>
#### OpenGL for old computers
A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL.
<details><summary>Technical</summary>
Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like the DirectX 12 fallback that already ships.
More: [docs/graphics.md](docs/graphics.md)
</details>
#### Android, iOS and the browser
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
<details><summary>Technical</summary>
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/ludovic111/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/ludovic111/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/ludovic111/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
</details>
### Horizon extras
#### An optional AI teammate
#### A complete video studio inside the game
An AI teammate that plays beside you and that you can talk to. It is off unless you turn it on, and the No AI switch hides it completely.
Record, replay and edit videos without leaving the game: record in a keypress, replay a session, cut and finish the video. One of Horizon's original goals.
<details><summary>Technical</summary>
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)), and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). Local models first; it ships only once the safety evaluation passes.
Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket ([docs/agent-control.md](docs/agent-control.md)) already lets tools drive the game for scripted shots.
</details>
### AI and agents
#### An optional AI teammate
An optional AI teammate you can talk to by voice. It plays beside you, helps or finishes the game with you, runs on your computer first, and exists only if you turn it on. The No AI switch hides it.
<details><summary>Technical</summary>
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
</details>
### Community
#### A video series about Horizon
A series of videos about how Horizon is made and what is new, with each upload announced on Discord.
<details><summary>Technical</summary>
Kira already shows new uploads in a read-only Discord channel (done). The series follows each stage of the project: a script, edits, titles and a thumbnail per video.
</details>
+2
View File
@@ -225,6 +225,8 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
});
}
match failed(&mods) {
+5
View File
@@ -68,6 +68,11 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] }
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[target.'cfg(windows)'.build-dependencies]
# The icon resource in the .exe (build.rs).
winresource = "0.1"
@@ -49,7 +49,7 @@ pub fn main() -> Result<()> {
}
}
}
let mut engine = ParticleEngine::new(root, &HashMap::new());
let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let case: Value = serde_json::from_str(&line?)?;
@@ -152,7 +152,7 @@ pub fn main() -> Result<()> {
world(&assets, &facts, false, true)?,
world(&assets, &facts, true, true)?,
];
let mut engine = ParticleEngine::new(root, &HashMap::new());
let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
for (ty, item) in [
(ParticleType::ItemSlime, "item_slime_ball"),
(ParticleType::ItemSnowball, "item_snowball"),
+18 -8
View File
@@ -617,9 +617,23 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.head_pitch = pitch;
m.on_ground = flag(tag, "OnGround");
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
if let Some(h) = float(tag.get("Health")) {
m.health = h as f32;
// LivingEntity.readAdditionalSaveData: the saved attribute bases, then
// the health, the max health when there is none.
if let Some(Tag::List(attrs)) = tag.get("attributes") {
for a in attrs {
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
continue;
};
let v = f64::from_bits(*bits);
match id.as_str() {
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
_ => {}
}
}
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
@@ -735,12 +749,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
continue;
};
let v = f64::from_bits(*bits);
match id.as_str() {
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:jump_strength" => m.extra.vector[0] = v,
_ => {}
if id == "minecraft:jump_strength" {
m.extra.vector[0] = f64::from_bits(*bits);
}
}
}
+59 -13
View File
@@ -482,6 +482,9 @@ enum Screen {
CommandBlock,
/// WinScreen: the end poem and the credits (game/win.rs).
Win,
/// A `ui` mod's own screen, or its settings page from Options > Mods
/// (game/mods.rs).
Mod,
}
struct Mining {
pos: [i32; 3],
@@ -1168,6 +1171,8 @@ struct Game<'a> {
carried: Option<ItemStack>,
craft_grid: Vec<Option<ItemStack>>,
craft_size: usize,
/// The crafting table the 3x3 grid belongs to (`CraftingMenu.access`).
craft_table: Option<[i32; 3]>,
/// A quick craft drag over inventory slots: (button, game slots so far).
inv_drag: Option<(i32, Vec<usize>)>,
/// `AbstractContainerScreen`'s quick craft and skip-release state.
@@ -1837,6 +1842,19 @@ impl Game<'_> {
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
})
}
/// `LivingEntity.canFreeze`'s armor half: any worn piece in
/// `freeze_immune_wearables`.
pub(crate) fn wears_freeze_immune(&self) -> bool {
let immune = self
.res
.items()
.item_tags
.get("minecraft:freeze_immune_wearables");
self.armor
.iter()
.flatten()
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)))
}
/// What the window decides for its player's body every tick, in frame and
/// on a server alike: the look (Player.travel steers sprint-swimming by
/// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an
@@ -1863,16 +1881,7 @@ impl Game<'_> {
let leather_boots = self.armor[3]
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
let immune = self
.res
.items()
.item_tags
.get("minecraft:freeze_immune_wearables");
let freeze_immune = self
.armor
.iter()
.flatten()
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
let freeze_immune = self.wears_freeze_immune();
let Some(player) = self.player.as_mut() else {
return;
};
@@ -2173,6 +2182,11 @@ impl Game<'_> {
// Player.drop: the drop statistics.
self.stats.add("dropped", &thrown.item, thrown.count as i32);
self.stats.custom("drop", 1);
self.throw_from_eyes(thrown, eye);
}
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
/// along the view direction.
fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3]) {
let (yaw, pitch) = (
f64::from(self.yaw).to_radians(),
f64::from(self.pitch).to_radians(),
@@ -2916,6 +2930,7 @@ impl Game<'_> {
}
if &*name == "minecraft:crafting_table" {
self.craft_size = 3;
self.craft_table = Some(pos);
} else if &*name == "minecraft:ender_chest" {
self.open_ender_chest(key);
self.container_opener_event(key, true);
@@ -4335,7 +4350,8 @@ impl Game<'_> {
// Out of the world.
let _ = player.set_health(0.0, false);
}
if player.health.dead_or_dying() && self.screen != Screen::Dead {
let dead = player.health.dead_or_dying();
if dead && self.screen != Screen::Dead {
// Inventory.dropAll: every stack thrown in a random direction.
let feet = [player.position.x, player.position.y, player.position.z];
let at = [feet[0], feet[1] + 1.32, feet[2]];
@@ -4369,6 +4385,18 @@ impl Game<'_> {
}
self.screen = Screen::Dead;
self.capture(false);
self.ui.death_time = 0;
} else if dead {
// LivingEntity.tickDeath: 20 ticks after dying the player is
// removed, and Player.remove's inventoryMenu.removed drops what
// the cursor held.
self.ui.death_time += 1;
if self.ui.death_time >= 20
&& let Some(stack) = self.carried.take()
&& let Some(eye) = self.eye_position()
{
self.throw_from_eyes(stack, eye);
}
}
self.tick_world(running)
}
@@ -5047,6 +5075,7 @@ impl Game<'_> {
self.draw_creative(&mut batch);
}
Screen::Chat => self.draw_chat_screen(&mut batch),
Screen::Mod => self.draw_mod_screen(&mut batch),
Screen::Playing | Screen::Options | Screen::Win => {}
}
self.draw_toasts(&mut batch);
@@ -5425,6 +5454,7 @@ impl Game<'_> {
}
}
self.craft_size = 2;
self.craft_table = None;
if let Some(key) = self.container.take() {
self.container_opener_event(key, false);
self.post_block_game_event(
@@ -7879,8 +7909,12 @@ impl Game<'_> {
(0..width).all(|i| frame([left[0] + d[0] * i, left[1] + height, left[2] + d[2] * i]));
((3..=21).contains(&height) && top_ok).then_some((left, width, height))
}
/// BaseFireBlock.onPlace: lighting fire inside a frame creates a portal.
/// BaseFireBlock.onPlace: lighting fire inside a frame creates a portal,
/// only in the Overworld and the Nether (`inPortalDimension`).
fn try_light_portal(&mut self, at: [i32; 3]) -> Result<bool> {
if !matches!(self.dim, Dim::Overworld | Dim::Nether) {
return Ok(false);
}
for x_axis in [true, false] {
if let Some((origin, width, height)) = self.portal_shape(at, x_axis) {
let d = if x_axis { [1, 0, 0] } else { [0, 0, 1] };
@@ -10363,6 +10397,7 @@ impl Game<'_> {
Screen::CommandBlock => self.close_command_block(),
Screen::Chat => self.close_chat(),
Screen::Win => self.close_win_screen()?,
Screen::Mod => self.close_mod_screen(true),
},
KeyCode::Digit1
| KeyCode::Digit2
@@ -11009,6 +11044,9 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::Options && d != 0.0 {
self.options_scroll(d);
}
if self.screen == Screen::Mod && d != 0.0 {
self.mod_screen_scroll(d);
}
if self.screen == Screen::Creative && d != 0.0 {
self.creative_screen.wheel(f64::from(d), &self.pack.names);
}
@@ -11231,6 +11269,9 @@ impl ApplicationHandler for Game<'_> {
.map_or([1, 1], |r| [r.config.width, r.config.height]);
self.stats_click(size);
}
Screen::Mod if pressed && button == MouseButton::Left => {
self.mod_screen_click()
}
_ => {}
}
}
@@ -11709,7 +11750,11 @@ impl<'a> Game<'a> {
let inventory = vec![None; 36];
let now = Instant::now();
let look = options.look;
let particles = horizon_client::particles::ParticleEngine::new(&root, &gui.atlas.sprites);
let particles = horizon_client::particles::ParticleEngine::new(
&root,
&gui.atlas.sprites,
&gui.atlas.translucent,
);
Ok(Game {
res: res.clone(),
rest_loader: None,
@@ -11746,6 +11791,7 @@ impl<'a> Game<'a> {
carried: None,
craft_grid: vec![None; 9],
craft_size: 2,
craft_table: None,
inv_drag: None,
tweaks: Default::default(),
inv_clicks: Default::default(),
+7 -2
View File
@@ -416,6 +416,7 @@ impl Game<'_> {
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
@@ -1599,8 +1600,12 @@ impl Game<'_> {
if self.screen != Screen::Dead {
return Err("respawn needs the death screen".into());
}
let label =
horizon_client::text::Text::translate("deathScreen.respawn", &[]).plain();
let key = if self.level.hardcore {
"deathScreen.spectate"
} else {
"deathScreen.respawn"
};
let label = horizon_client::text::Text::translate(key, &[]).plain();
let w = widgets::find(&widgets::last(), &label)?;
if !w.enabled {
// DeathScreen enables its buttons after 20 ticks.
@@ -15,6 +15,8 @@ use horizon_world::registry::BlockStateId;
impl Game<'_> {
pub(super) fn tick_block_entities(&mut self) -> Result<()> {
self.tick_mount_menu();
self.tick_craft_table();
self.tick_container_reach();
self.load_entity_types();
if !self.world.block_entities.has_states() {
let states = self.ticking_states();
@@ -13,7 +13,7 @@ use horizon_client::{
use horizon_core::geometry::{Aabb, Vec3};
/// `Entity.boardingCooldown` once a mob leaves its boat.
const BOARDING_COOLDOWN: i32 = 60;
pub(super) const BOARDING_COOLDOWN: i32 = 60;
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
/// feet are.
const PLAYER_RIDING_OFFSET: f64 = 0.6;
+3 -1
View File
@@ -99,6 +99,8 @@ pub struct UiState {
/// Death screen cause and when the player died (buttons wait 20 ticks).
pub death_text: Option<Text>,
pub died_at: Option<std::time::Instant>,
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
pub death_time: u32,
}
impl Game<'_> {
@@ -586,7 +588,7 @@ impl Game<'_> {
} else {
let line = Text::translate(
"chat.type.text",
&[Text::literal(PLAYER_NAME), Text::literal(message)],
&[self.player_display_name(), Text::literal(message)],
);
let focused = self.screen == Screen::Chat;
let heard = self.mods.running().then(|| line.plain());
+4 -4
View File
@@ -214,7 +214,7 @@ impl Game<'_> {
}
/// Player.getDisplayName: the name (team-formatted), suggesting a
/// `/tell`, showing the player on hover, inserting the name.
fn player_display_name(&self) -> Text {
pub(super) fn player_display_name(&self) -> Text {
let name = self.acting_player_name().to_owned();
let Some(uuid) = self.lan_acting_uuid else {
return self.target_name(Target::Player);
@@ -2809,7 +2809,7 @@ impl Game<'_> {
let name = match self.command_source.map(|s| s.entity) {
Some(Some(t)) => self.target_name(t),
Some(None) => Text::literal("Server"),
None => Text::literal(self.acting_player_name()),
None => self.player_display_name(),
};
let line = match id {
"say" => Text::translate("chat.type.announcement", &[name, message]),
@@ -2819,14 +2819,14 @@ impl Game<'_> {
// Whispering to yourself: the incoming and outgoing lines.
let out = Text::translate(
"commands.message.display.outgoing",
&[Text::literal(self.acting_player_name()), message.clone()],
&[self.player_display_name(), message.clone()],
)
.with_color(text::GRAY)
.italic();
self.system_message(out);
Text::translate(
"commands.message.display.incoming",
&[Text::literal(self.acting_player_name()), message],
&[self.player_display_name(), message],
)
.with_color(text::GRAY)
.italic()
@@ -606,10 +606,7 @@ impl Game<'_> {
.collect(),
_ => Vec::new(),
},
Some("vehicle") => match entity {
Target::Player => self.riding.map(Target::Mob).into_iter().collect(),
_ => Vec::new(),
},
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
Some("controller") | Some("passengers") => match entity {
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
vec![Target::Player]
@@ -1074,15 +1074,15 @@ impl Game<'_> {
};
let name = self.target_name(target);
if id == "ride.dismount" {
let vehicle = match target {
Target::Player => self.riding.map(Target::Mob),
_ => None,
};
let Some(v) = vehicle else {
let Some(v) = self.ride_vehicle(target) else {
return Err(err("commands.ride.not_riding", &[&name.plain()]));
};
let vehicle_name = self.target_name(v);
self.dismount_mount();
match target {
Target::Player => self.dismount_mount(),
Target::Mob(rider) => self.dismount_mob(rider),
_ => {}
}
self.feedback(Text::translate(
"commands.ride.dismount.success",
&[name, vehicle_name],
@@ -1094,11 +1094,8 @@ impl Game<'_> {
return Ok(0);
};
let vehicle_name = self.target_name(vehicle);
if target == Target::Player && self.riding.is_some() {
let current = self
.riding
.map(Target::Mob)
.map_or_else(Text::empty, |v| self.target_name(v));
if let Some(current) = self.ride_vehicle(target) {
let current = self.target_name(current);
return Err(err(
"commands.ride.already_riding",
&[&name.plain(), &current.plain()],
@@ -1107,22 +1104,42 @@ impl Game<'_> {
if vehicle == Target::Player {
return Err(err("commands.ride.mount.failure.cant_ride_players", &[]));
}
if target == vehicle {
return Err(err("commands.ride.mount.failure.loop", &[]));
// getSelfAndPassengers: the vehicle is the target or rides it.
let mut up = Some(vehicle);
while let Some(v) = up {
if v == target {
return Err(err("commands.ride.mount.failure.loop", &[]));
}
up = self.ride_vehicle(v);
}
// The player can ride mobs that carry a rider.
// Players and mobs can ride mobs.
let Target::Mob(mob_id) = vehicle else {
return Err(err(
"commands.ride.mount.failure.generic",
&[&name.plain(), &vehicle_name.plain()],
));
};
if target != Target::Player || !self.mobs.iter().any(|m| m.id == mob_id && m.alive()) {
let rider = match target {
Target::Mob(id) => Some(id),
_ => None,
};
if !(target == Target::Player || rider.is_some())
|| !self.mobs.iter().any(|m| m.id == mob_id && m.alive())
{
return Err(err(
"commands.ride.mount.failure.generic",
&[&name.plain(), &vehicle_name.plain()],
));
}
if let Some(rider) = rider {
// Entity.startRiding.
self.mount_mob(rider, mob_id);
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
return Ok(1);
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) {
m.extra.flag = false;
m.extra.counter = 0;
@@ -1138,6 +1155,47 @@ impl Game<'_> {
Ok(1)
}
/// Entity.getVehicle: the player's mount, or a mob's.
pub(super) fn ride_vehicle(&self, t: Target) -> Option<Target> {
let id = match t {
Target::Player => return self.riding.map(Target::Mob),
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id)?.extra.mount?,
_ => return None,
};
Some(if self.boats.iter().any(|b| b.id == id) {
Target::Boat(id)
} else {
Target::Mob(id)
})
}
/// LivingEntity.stopRiding for a mob: off its vehicle
/// (Entity.removePassenger), then to where the vehicle lets it out
/// (LivingEntity.dismountVehicle).
fn dismount_mob(&mut self, rider: u32) {
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
return;
};
let Some(v) = self.mobs[i].extra.mount.take() else {
return;
};
let (width, yaw) = (f64::from(self.mobs[i].width()), self.mobs[i].yaw);
let at = if let Some(b) = self.boats.iter().position(|b| b.id == v) {
self.boat_dismount_target(b, width, yaw)
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == v) {
m.extra.rider = None;
// Entity.getDismountLocationForPassenger: on top of the vehicle.
let p = m.position;
horizon_core::geometry::Vec3::new(p.x, p.y + f64::from(m.height()), p.z)
} else {
self.mobs[i].position
};
let m = &mut self.mobs[i];
m.extra.passenger = false;
m.extra.boarding_cooldown = super::super::boat_passengers::BOARDING_COOLDOWN;
m.position = at;
m.previous = at;
}
// ---- /spreadplayers -----------------------------------------------------------------------
pub(super) fn cmd_spreadplayers(&mut self, ctx: &Context) -> R<i32> {
@@ -799,6 +799,7 @@ impl Game<'_> {
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
}
}
+25 -2
View File
@@ -321,7 +321,7 @@ impl Game<'_> {
.iter()
.filter(|m| m.distance_sqr(center) <= radius * radius)
.map(|m| {
serde_json::json!({
let mut state = serde_json::json!({
"id": m.id,
"kind": m.kind.id,
"pos": [m.position.x, m.position.y, m.position.z],
@@ -370,7 +370,11 @@ impl Game<'_> {
"patrol_target": s.patrol_target,
"vehicle": m.extra.mount,
})),
})
});
// Drawn seated (Entity.isPassenger); set apart because the
// json! above is at the macro recursion limit.
state["passenger"] = m.extra.passenger.into();
state
})
.collect();
serde_json::json!({ "count": self.mobs.len(), "near": list, "projectiles": self.creatures.projectiles.len() })
@@ -1694,7 +1698,26 @@ impl Game<'_> {
targets: targets_guests,
};
let guest_entities = self.lan_guest_entities();
// ThrownEnderpearl.tick: a dead player's pearl is discarded in
// place of its tick while ender_pearls_vanish_on_death is on.
let vanish = self.ui.rules.bool("ender_pearls_vanish_on_death");
let host_dead = self.player_view().is_some_and(|p| !p.alive);
let dead_guests: Vec<u32> = self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
.guests
.iter()
.filter(|g| g.dead)
.map(|g| g.conn)
.collect()
});
projectiles.retain_mut(|p| {
if vanish
&& matches!(p.kind, ProjectileKind::EnderPearl)
&& p.owner == Some(Who::Player)
&& p.guest.map_or(host_dead, |c| dead_guests.contains(&c))
{
return false;
}
let mob_shot = !matches!(p.owner, Some(Who::Player));
// A player's arrows, snowballs and eggs meet every player; a
// guest's flies from that guest (Projectile.getOwner), not
+19 -5
View File
@@ -55,8 +55,8 @@ pub(super) struct LanHost {
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
/// Blocks broken this tick (their sound is a level event for the guests).
pub broken: Vec<[f64; 3]>,
/// Hurt and death state of the mobs guests see.
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool)>>,
/// Hurt time, death and swing time (while swinging) of the mobs guests see.
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
pub mob_players: HashMap<u32, u32>,
/// An integrated server's owner (game/integrated.rs).
@@ -168,11 +168,15 @@ fn mob_meta(
meta.push((9, serializer::FLOAT, Value::Float(health)));
}
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2.
let flags = i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1;
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what
// draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised
// weapon).
let flags =
i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1 | i8::from(m.ai.aggressive) << 2;
if flags != 0 {
meta.push((15, serializer::BYTE, Value::Byte(flags)));
}
defaults.push((15, serializer::BYTE, Value::Byte(0)));
// AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's).
if let Some(index) = baby_index(kind) {
if m.is_baby() {
@@ -1193,7 +1197,17 @@ impl Game<'_> {
if dead && !old.is_some_and(|o| o.1) {
host.hub.entity_event(m.id as i32, 3, at);
}
seen.insert(m.id, (m.hurt_time, dead));
// LivingEntity.swing on a server (a melee hit, a thrown potion):
// ClientboundSwingAnimationPacket for the players around. A swing
// started since the last flush, or restarted (its time went back).
let swing = m.ai.swinging.then_some(m.ai.swing_time);
if let Some(time) = swing
&& !dead
&& old.and_then(|o| o.2).is_none_or(|before| time < before)
{
host.hub.swing(m.id as i32, 0, at, None);
}
seen.insert(m.id, (m.hurt_time, dead, swing));
}
host.mob_state.insert(self.dim, seen);
}
+91 -5
View File
@@ -117,6 +117,7 @@ pub(super) struct GuestSwap {
host_command_block: Option<super::command_blocks::CommandBlockScreen>,
host_mount_menu: super::mount_menu::MountMenu,
craft_size: usize,
craft_table: Option<[i32; 3]>,
craft_grid: Vec<Option<ItemStack>>,
container: Option<(i32, i32, i32)>,
mining: Option<Mining>,
@@ -307,6 +308,7 @@ impl Game<'_> {
// The host's crafting grid stays theirs; a crafting table the guest
// opens shows as the 3x3 size.
let craft_size = std::mem::replace(&mut self.craft_size, 2);
let craft_table = self.craft_table.take();
let craft_grid = std::mem::replace(&mut self.craft_grid, vec![None; 9]);
let container = self.container.take();
let mining = std::mem::replace(&mut self.mining, play.mining.take());
@@ -390,6 +392,7 @@ impl Game<'_> {
host_command_block,
host_mount_menu,
craft_size,
craft_table,
craft_grid,
container,
mining,
@@ -445,6 +448,7 @@ impl Game<'_> {
host_command_block,
host_mount_menu,
craft_size,
craft_table,
craft_grid,
container,
mining,
@@ -495,7 +499,10 @@ impl Game<'_> {
// What the action opened: handed to the guest instead of the host.
let opened = self.container.take();
let opened_table = std::mem::replace(&mut self.craft_size, craft_size) == 3;
let guest_table = std::mem::replace(&mut self.craft_table, craft_table);
let opened_table = (std::mem::replace(&mut self.craft_size, craft_size) == 3)
.then_some(guest_table)
.flatten();
self.craft_grid = craft_grid;
let opened_station = std::mem::replace(&mut self.station, host_station);
let bed = std::mem::replace(&mut self.sleeping, sleeping);
@@ -671,8 +678,8 @@ impl Game<'_> {
if let Some(key) = guest_lectern {
self.lan_open_lectern(host, conn, key);
}
if opened_table {
self.lan_open_crafting(host, conn);
if let Some(pos) = opened_table {
self.lan_open_crafting(host, conn, pos);
}
if let Some(mob) = guest_mount {
self.lan_open_mount(host, conn, mob);
@@ -812,6 +819,31 @@ impl Game<'_> {
}
}
/// `ServerPlayer.tick`'s `stillValid`: a guest's block container closes
/// once the guest is out of reach of its block (a removed block closes it
/// in the hub's `sync_containers`).
fn lan_containers_tick(&mut self, host: &mut LanHost) {
let far: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g))
.filter(|g| {
g.menu.as_ref().is_some_and(|m| {
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
})
})
.map(|g| g.conn)
.collect();
for conn in far {
self.guest_container_closed(host, conn);
host.hub.close_container(conn);
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
self.guest_carried_back(host, gi);
}
}
}
/// A packet from a guest that the hub does not handle itself.
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
@@ -1075,7 +1107,20 @@ impl Game<'_> {
}
S::ContainerClick(p) => {
let rules = ItemRules(self.res.items());
let slots = host.hub.guests[gi]
.menu
.as_ref()
.filter(|m| m.window == p.container)
.map(|m| m.slots);
let before = slots.and_then(|s| s.read(&self.world, &host.hub.guests[gi]));
let drops = host.hub.container_click(conn, &p, &mut self.world, &rules);
// A click that changed the container's slots: BlockEntity.setChanged,
// so comparators read it again (a double chest's other half follows).
if let Some(Slots::Block(key) | Slots::Double(key, _)) = slots
&& slots.and_then(|s| s.read(&self.world, &host.hub.guests[gi])) != before
{
self.container_changed(key);
}
self.drop_guest_items(host, gi, drops);
}
S::ContainerClose(p) => {
@@ -1231,14 +1276,39 @@ impl Game<'_> {
}
}
}
if let Some(Component::Potion {
potion: Some(id), ..
}) = s.component(component::POTION_CONTENTS)
&& let Some(potion) = host.hub.registry_name("minecraft:potion", *id)
{
out = out.with_potion(potion);
}
Some(out)
}
/// `LivingEntity.tickDeath`: 20 ticks after dying the player is removed,
/// and `Player.remove`'s `inventoryMenu.removed` drops what the cursor
/// held (`dropOrPlaceInInventory` for a removed player). Respawning
/// sooner removes it too.
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
let g = &mut host.hub.guests[gi];
g.death_time += 1;
if g.death_time >= 20 {
self.guest_drop_carried(host, gi);
}
}
fn guest_drop_carried(&mut self, host: &mut LanHost, gi: usize) {
if let Some(c) = host.hub.guests[gi].carried.take() {
self.drop_guest_items(host, gi, vec![c]);
}
}
/// `AbstractContainerMenu.removed`: what a closing player held on the
/// cursor goes back into their inventory (`placeItemBackInInventory`,
/// dropped only if it doesn't fit), and their client's cursor empties
/// with the inventory it is sent.
fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
pub(super) fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
if let Some(c) = host.hub.guests[gi].carried.take() {
self.lan_place_back(host, gi, vec![c]);
host.hub.send_inventory(gi);
@@ -1573,6 +1643,8 @@ impl Game<'_> {
if !host.hub.guests[i].dead {
return;
}
// PlayerList.respawn removes the dead player (Player.remove).
self.guest_drop_carried(host, i);
// PlayerList.respawn: a server's player comes back in its respawn
// point's level: the Overworld, where the world spawn and beds are,
// or another one's anchor or forced point, which its block resolves
@@ -1613,6 +1685,7 @@ impl Game<'_> {
}
let g = &mut host.hub.guests[i];
g.dead = false;
g.death_time = 0;
g.health = 20.0;
g.food = 20;
g.saturation = 5.0;
@@ -1649,6 +1722,11 @@ impl Game<'_> {
border: &self.border,
};
host.hub.respawn(i, spawn, &ctx);
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
// respawned player only watches.
if self.level.hardcore {
host.hub.set_game_mode(conn, SPECTATOR);
}
if let Some(config) = elsewhere {
self.respawn_trip(conn, config);
}
@@ -1680,6 +1758,8 @@ impl Game<'_> {
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
self.lan_merchant_tick(host);
self.lan_stations_tick(host);
self.lan_tables_tick(host);
self.lan_containers_tick(host);
self.tick_lan_mount_menus(host);
self.announce_guest_advancements(host);
let conns: Vec<u32> = host
@@ -1694,7 +1774,11 @@ impl Game<'_> {
continue;
};
self.guest_sleep_tick(host, gi);
if host.hub.guests[gi].dead || self.on_server_trip(conn) {
if host.hub.guests[gi].dead {
self.guest_tick_death(host, gi);
continue;
}
if self.on_server_trip(conn) {
continue;
}
let play = host.play.entry(conn).or_default();
@@ -1942,6 +2026,7 @@ impl Game<'_> {
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
let freeze_immune = game.wears_freeze_immune();
let Some(player) = game.player.as_mut() else {
return (Vec::new(), None);
};
@@ -2005,6 +2090,7 @@ impl Game<'_> {
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
// lit campfires and grown sweet berry bushes hurt (#1305).
w.player.shift_key_down = sneaking;
w.player.freeze_immune = freeze_immune;
let _ = w.server_block_damage(start);
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
+55 -14
View File
@@ -19,8 +19,8 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
pub(super) struct GuestCrafting {
/// The inventory menu; its grid lives in the hub's guest.
inventory: Option<Box<Menu>>,
/// The open crafting table: its window, state id and menu.
table: Option<(i32, i32, Box<Menu>)>,
/// The open crafting table: its window, state id, menu and block.
table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
/// container menu keeps the client in sync.
@@ -383,17 +383,17 @@ impl Game<'_> {
host.hub.send_inventory(gi);
placed
} else {
let Some((window, state, mut menu)) = host
let Some((window, state, mut menu, pos)) = host
.play
.get_mut(&conn)
.and_then(|play| play.crafting.table.take_if(|(w, _, _)| *w == p.container))
.and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == p.container))
else {
return;
};
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
menu.place_recipe(env, &id, p.use_max, creative, None)
});
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu));
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
self.lan_send_table_content(host, conn);
placed
};
@@ -403,7 +403,7 @@ impl Game<'_> {
}
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32) {
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
self.lan_crafting_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
// The container it replaces loses its opener.
@@ -413,7 +413,7 @@ impl Game<'_> {
return;
};
let menu = Box::new(Menu::new(Kind::Crafting));
host.play.entry(conn).or_default().crafting.table = Some((window, 0, menu));
host.play.entry(conn).or_default().crafting.table = Some((window, 0, menu, pos));
self.lan_send_table_content(host, conn);
}
@@ -427,31 +427,72 @@ impl Game<'_> {
let Some(play) = host.play.get_mut(&conn) else {
return false;
};
let Some((window, state, mut menu)) = play
let Some((window, state, mut menu, pos)) = play
.crafting
.table
.take_if(|(window, _, _)| *window == p.container)
.take_if(|(window, ..)| *window == p.container)
else {
return false;
};
// A table that is no longer valid takes no clicks (handleContainerClick).
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& !host.hub.guests[gi].spectator()
&& self.lan_table_valid(&host.hub.guests[gi], pos)
{
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
});
}
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu));
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
self.lan_send_table_content(host, conn);
true
}
/// `CraftingMenu.stillValid`: the block is still a crafting table and the
/// guest is within reach of it.
fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
let name = &self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]))
.name;
&**name == "minecraft:crafting_table" && g.within_block_range(pos, 4.0)
}
/// `ServerPlayer.tick`'s `stillValid`: a guest's crafting table closes
/// once it is gone or out of reach, giving its grid back.
pub(super) fn lan_tables_tick(&mut self, host: &mut LanHost) {
let invalid: Vec<(u32, i32)> = host
.play
.iter()
.filter_map(|(c, p)| p.crafting.table.as_ref().map(|t| (*c, t.0, t.3)))
.filter(|&(c, _, pos)| {
host.hub
.guests
.iter()
.find(|g| g.conn == c)
.is_some_and(|g| host.hub.here(g) && !self.lan_table_valid(g, pos))
})
.map(|(c, window, _)| (c, window))
.collect();
for (conn, window) in invalid {
self.lan_crafting_closed(host, conn, Some(window));
host.hub.send_to(
conn,
&C::ContainerClose(play::ContainerClose { container: window }),
);
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
self.guest_carried_back(host, gi);
}
}
}
/// The whole crafting table menu for the client (`sendAllDataToRemote`).
fn lan_send_table_content(&mut self, host: &mut LanHost, conn: u32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let Some((window, state, menu)) = host
let Some((window, state, menu, _)) = host
.play
.get_mut(&conn)
.and_then(|p| p.crafting.table.as_mut())
@@ -499,9 +540,9 @@ impl Game<'_> {
} else {
match crafting
.table
.take_if(|(w, _, _)| window.is_none_or(|window| *w == window))
.take_if(|(w, ..)| window.is_none_or(|window| *w == window))
{
Some((_, _, menu)) => (1..10).filter_map(|s| menu.slot(s).cloned()).collect(),
Some((_, _, menu, _)) => (1..10).filter_map(|s| menu.slot(s).cloned()).collect(),
None => return false,
}
};
@@ -527,7 +568,7 @@ impl Game<'_> {
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
g.craft_result = None;
if let Some(play) = play {
if let Some((_, _, menu)) = play.crafting.table {
if let Some((_, _, menu, _)) = play.crafting.table {
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
}
// A workstation's inputs too.
+2 -26
View File
@@ -588,7 +588,7 @@ impl Game<'_> {
let [x, y, z] = gs.pos;
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
let block = StationKind::from_block(name) == Some(gs.kind);
if !(block && within_reach(g, gs.pos)) {
if !(block && g.within_block_range(gs.pos, 4.0)) {
let window = gs.window;
self.lan_station_closed(host, conn, Some(window));
host.hub.send_to(
@@ -631,16 +631,7 @@ impl Game<'_> {
Some(BlockEntity::Lectern(l)) if l.book.is_some()
);
// Container.stillValidBlockEntity: the interaction range and its buffer.
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
let pos = [key.0, key.1, key.2];
let near = (0..3)
.map(|k| {
let lo = f64::from(pos[k]);
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
})
.sum::<f64>()
< reach * reach;
let near = g.within_block_range([key.0, key.1, key.2], 4.0);
if has_book && near {
self.lan_send_lectern(host, conn, false);
} else {
@@ -660,18 +651,3 @@ pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
.unwrap_or_default()
}
/// `Player.isWithinBlockInteractionRange(pos, 4)`: the guest's eyes within
/// its block interaction range (4.5, creative 5) and a 4-block buffer of the
/// block's box.
pub(super) fn within_reach(g: &horizon_client::lan::guest::Guest, pos: [i32; 3]) -> bool {
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
(0..3)
.map(|k| {
let lo = f64::from(pos[k]);
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
})
.sum::<f64>()
< reach * reach
}
+341 -1
View File
@@ -20,10 +20,13 @@ use {
horizon_client::{
extras::Corner,
extras_hud,
items::ItemStack,
mod_list::Installed,
options_ui::Page,
text::{self, Text},
ui,
},
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status},
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
std::time::Instant,
};
@@ -39,6 +42,23 @@ pub(super) struct ModsState {
/// The chat's command tree while a running mod owns commands.
#[cfg(feature = "mods")]
tree: Option<Dispatcher>,
/// A `ui` mod's screen or settings page while it is up.
#[cfg(feature = "mods")]
screen: Option<ModScreen>,
}
/// A `ui` mod's own screen, or its settings page from Options > Mods: the
/// widgets the mod pushed, laid out and drawn with the menus' own widgets.
#[cfg(feature = "mods")]
struct ModScreen {
/// The running mod's index in `mods.mods.entries()`.
owner: usize,
/// Opened from Options > Mods: Done and Esc return there.
settings: bool,
/// What the mod's screen replaced (Playing, Chat, ...), to restore.
under: Screen,
/// One scroll per `list` widget, in widget order.
scrolls: Vec<ui::Scroll>,
}
impl ModsState {
/// The chat's command tree: vanilla's, plus the running mods' commands.
@@ -166,6 +186,11 @@ impl Game<'_> {
}
pub(super) fn mods_chat(&mut self, _line: &str) {}
pub(super) fn draw_mod_panels(&self, _batch: &mut HudBatch, _corners: &mut Corners) {}
pub(super) fn open_mod_screen(&mut self, _owner: usize, _settings: bool) {}
pub(super) fn close_mod_screen(&mut self, _tell: bool) {}
pub(super) fn draw_mod_screen(&mut self, _batch: &mut HudBatch) {}
pub(super) fn mod_screen_click(&mut self) {}
pub(super) fn mod_screen_scroll(&mut self, _delta: f32) {}
}
#[cfg(feature = "mods")]
@@ -187,6 +212,8 @@ impl Game<'_> {
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
let mods = Mods::load(&paths, on, vanilla_command);
self.mods.mods = mods;
// Entries were replaced wholesale: a screen's owner index is stale.
self.mods.screen = None;
self.list_mods();
self.sync_mod_commands();
self.show_mod_output();
@@ -212,6 +239,7 @@ impl Game<'_> {
description: String::new(),
problem,
dev: false,
has_settings: false,
};
};
// What it may do first: the row cuts long descriptions.
@@ -227,6 +255,7 @@ impl Game<'_> {
description: format!("{may} {}", m.description).trim().to_owned(),
problem,
dev: dev.contains(&e.path),
has_settings: e.has_ui_settings(),
}
})
.collect();
@@ -343,10 +372,22 @@ impl Game<'_> {
Output::Feedback { text, error: false } => Text::literal(text),
Output::Feedback { text, error: true } => Text::literal(text).with_color(text::RED),
Output::Stopped { name, reason } => {
// Its screen goes with it: the entry's run is gone, so
// `mod_ui_view` finds no widgets and the screen closes.
self.list_mods();
self.sync_mod_commands();
Text::literal(format!("Mod {name} stopped: {reason}")).with_color(text::RED)
}
Output::OpenScreen { owner } => {
self.open_mod_screen(owner, false);
continue;
}
Output::CloseScreen { owner } => {
if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) {
self.close_mod_screen(false);
}
continue;
}
};
// Not through system_message: a mod must not hear its own lines.
let focused = self.screen == Screen::Chat;
@@ -354,6 +395,12 @@ impl Game<'_> {
.chat
.add(&self.gui.atlas, line, None, self.ticks, focused);
}
// A mod may set or clear its settings page in any call, not only
// while loading: its row's Settings button follows.
let rows = self.settings.mods.installed.iter_mut();
for (row, e) in rows.zip(self.mods.mods.entries()) {
row.has_settings = e.has_ui_settings();
}
}
/// The mods' HUD panels, stacked with the extras in their corners.
pub(super) fn draw_mod_panels(&self, batch: &mut HudBatch, corners: &mut Corners) {
@@ -376,4 +423,297 @@ impl Game<'_> {
extras_hud::draw_lines(batch, &self.gui.atlas, corners, corner, &lines);
}
}
/// A `ui` screen's title and widgets as the mod last pushed them; None
/// when the surface is gone (the mod stopped or closed it).
fn mod_ui_view(&self) -> Option<(String, Vec<UiWidget>)> {
let ms = self.mods.screen.as_ref()?;
if ms.settings {
let widgets = self.mods.mods.settings_widgets(ms.owner);
if widgets.is_empty() {
return None;
}
let name = self
.mods
.mods
.entries()
.get(ms.owner)
.and_then(|e| e.manifest.as_ref())
.map_or("Mod", |m| m.name.as_str());
Some((name.to_string(), widgets.to_vec()))
} else {
let s: &UiScreen = self.mods.mods.screen(ms.owner)?;
Some((s.title.clone(), s.widgets.clone()))
}
}
/// The layout the mod screen's widgets get at `h`: (widget, top,
/// height), the column being `COLUMN` wide down the middle.
fn mod_ui_layout(&self, h: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> {
let mut out = Vec::with_capacity(widgets.len());
let mut y = MOD_UI_TOP;
for w in widgets {
let wh = match w {
UiWidget::Text(_) => 12.0,
UiWidget::Item(_) => 20.0,
UiWidget::List(l) => (l.rows.len().min(6) as f32 * 12.0) + 8.0,
};
// Nothing may cover Done; a crowded mod loses what won't fit.
if y + wh > h - 34.0 {
break;
}
out.push((y, wh));
y += wh + 6.0;
}
out
}
/// A mod pushes its screen (`ui.open-screen`), or Options > Mods opens
/// its settings page (`settings`, the mod's `installed` index).
pub(super) fn open_mod_screen(&mut self, owner: usize, settings: bool) {
if self.title.is_some() {
return;
}
if settings {
self.mods.mods.ui_settings_opened(owner);
if self.mods.mods.settings_widgets(owner).is_empty() {
self.show_mod_output();
return;
}
}
self.mods.screen = Some(ModScreen {
owner,
settings,
under: self.screen,
scrolls: Vec::new(),
});
self.screen = Screen::Mod;
if self.player.is_some() {
self.capture(false);
}
self.show_mod_output();
}
/// Leaves the mod screen: back to Options for a settings page, to what
/// it covered for a mod's own. `tell` is Esc/Done, heard as `closed`.
pub(super) fn close_mod_screen(&mut self, tell: bool) {
let Some(ms) = self.mods.screen.take() else {
return;
};
if ms.settings {
self.open_options_at(self.options_from_title, Page::Mods);
} else {
self.screen = ms.under;
if self.screen == Screen::Playing && self.player.is_some() {
self.capture(true);
}
}
if tell {
self.mods.mods.ui_closed(ms.owner);
self.show_mod_output();
}
}
/// The mod's screen or settings page: its title over widgets stacked
/// down the middle, a Done button, Esc to leave (vanilla's menus).
pub(super) fn draw_mod_screen(&mut self, batch: &mut HudBatch) {
if self.mods.screen.is_none() {
self.screen = Screen::Playing;
return;
}
let Some((title, widgets)) = self.mod_ui_view() else {
self.mods.screen = None;
self.screen = Screen::Playing;
return;
};
// Item icons draw in a second pass: `draw_item` needs `&mut self`,
// so `atlas` may not stay borrowed across it.
let mut items: Vec<(String, f32, f32)> = Vec::new();
let atlas = &self.gui.atlas;
let in_world = self.player.is_some();
ui::menu_background(batch, atlas, 0.0, 0.0, batch.width, batch.height, in_world);
ui::centered_text(
batch,
atlas,
&Text::literal(title),
0.0,
15.0,
batch.width,
u32::MAX,
);
let cx = (batch.width / 2.0).floor();
let x = cx - MOD_UI_COLUMN / 2.0;
let layout = self.mod_ui_layout(batch.height, &widgets);
let mut list_i = 0usize;
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
match w {
UiWidget::Text(t) => {
let color = if t.color == 0 {
u32::MAX
} else {
t.color | 0xFF00_0000
};
ui::centered_text(
batch,
atlas,
&Text::literal(t.text.clone()),
x,
wy + 1.0,
MOD_UI_COLUMN,
color,
);
}
UiWidget::Item(i) => {
if self.res.items().get(&i.item).is_some() {
items.push((i.item.clone(), x + 2.0, wy + 2.0));
} else {
batch.text(atlas, "?", x + 6.0, wy + 6.0, u32::MAX, true);
}
let name = if i.name.is_empty() {
i.item.rsplit(':').next().unwrap_or(&i.item).to_string()
} else {
i.name.clone()
};
batch.text_styled(
atlas,
&Text::literal(name),
x + 22.0,
wy + 6.0,
u32::MAX,
true,
);
}
UiWidget::List(l) => {
let scroll = {
let ms = self.mods.screen.as_mut().expect("checked");
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
ms.scrolls[list_i]
};
list_i += 1;
let rows_top = wy + 4.0;
let rows_bottom = wy + wh - 4.0;
batch.fill(atlas, x, wy, MOD_UI_COLUMN, wh, 0x8000_0000);
for (b, yy) in [(x, wy), (x, wy + wh - 1.0)] {
batch.fill(atlas, b, yy, MOD_UI_COLUMN, 1.0, 0xFF50_5050);
}
for (xx, yy) in [(x, wy), (x + MOD_UI_COLUMN - 1.0, wy)] {
batch.fill(atlas, xx, yy, 1.0, wh, 0xFF50_5050);
}
let content = l.rows.len() as f32 * 12.0;
let first = (scroll.amount / 12.0) as usize;
let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1)
.min(l.rows.len());
for (vi, ri) in (first..last).enumerate() {
let ry = rows_top + vi as f32 * 12.0 - (scroll.amount % 12.0);
if ui::hovered(self.cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) {
batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF);
}
if l.selected == Some(ri as u32) {
batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF);
}
batch.text_styled(
atlas,
&Text::literal(l.rows[ri].clone()),
x + 6.0,
ry + 2.0,
u32::MAX,
true,
);
}
ui::scrollbar(
batch,
atlas,
x + MOD_UI_COLUMN - 5.0,
rows_top,
rows_bottom,
content,
scroll.amount,
);
}
}
}
let hover = ui::hovered(self.cursor, cx - 100.0, batch.height - 27.0, 200.0, 20.0);
ui::button(
batch,
atlas,
cx - 100.0,
batch.height - 27.0,
200.0,
20.0,
&Text::literal("Done"),
true,
hover,
);
for (id, ix, iy) in items {
let stack = ItemStack::new(id.as_str(), 1);
self.draw_item(batch, &stack, ix, iy);
}
}
/// A click on the mod screen: Done leaves it, a `list` row is the mod's
/// `ui-events::select`.
pub(super) fn mod_screen_click(&mut self) {
let (w, h) = self.gui_size();
let cursor = self.cursor;
if ui::hovered(cursor, (w / 2.0).floor() - 100.0, h - 27.0, 200.0, 20.0) {
self.audio.play_button_click();
self.close_mod_screen(true);
return;
}
let Some((_, widgets)) = self.mod_ui_view() else {
return;
};
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
let layout = self.mod_ui_layout(h, &widgets);
let mut list_i = 0usize;
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
let UiWidget::List(l) = w else { continue };
let scroll = self
.mods
.screen
.as_ref()
.and_then(|ms| ms.scrolls.get(list_i).copied())
.unwrap_or_default();
list_i += 1;
let row = ((cursor.1 - (wy + 4.0) + scroll.amount) / 12.0) as i64;
if ui::hovered(cursor, x + 1.0, wy + 1.0, MOD_UI_COLUMN - 7.0, wh - 2.0)
&& row >= 0
&& (row as usize) < l.rows.len()
{
self.audio.play_button_click();
self.mods.mods.ui_select(
self.mods.screen.as_ref().map_or(0, |ms| ms.owner),
l.id,
row as u32,
);
self.show_mod_output();
return;
}
}
}
/// The wheel over a `list` scrolls it.
pub(super) fn mod_screen_scroll(&mut self, delta: f32) {
let (w, h) = self.gui_size();
let cursor = self.cursor;
let Some((_, widgets)) = self.mod_ui_view() else {
return;
};
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
let layout = self.mod_ui_layout(h, &widgets);
let mut list_i = 0usize;
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
let UiWidget::List(l) = w else { continue };
let over = ui::hovered(cursor, x, wy, MOD_UI_COLUMN, wh);
let Some(ms) = self.mods.screen.as_mut() else {
return;
};
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
if over {
let content = l.rows.len() as f32 * 12.0;
ms.scrolls[list_i].wheel(delta, 12.0, wy + 4.0, wy + wh - 4.0, content);
return;
}
list_i += 1;
}
}
}
#[cfg(feature = "mods")]
const MOD_UI_TOP: f32 = 40.0;
#[cfg(feature = "mods")]
const MOD_UI_COLUMN: f32 = 200.0;
@@ -237,6 +237,7 @@ impl Game<'_> {
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::ModSettings(i) => self.open_mod_screen(i, true),
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
}
}
+3 -4
View File
@@ -76,15 +76,14 @@ impl Game<'_> {
let pos = ChunkPos::new(chunk.x, chunk.z);
let time = self.world.game_time;
let mut extras: ChunkExtras = chunk.saved.as_deref().cloned().unwrap_or_default();
// A chunk the scheduler hasn't registered yet (one loaded and
// unloaded again before the next tick) still holds its saved ticks:
// `register_chunk_ticks` empties that list once it takes them.
if let Some(t) = self.block_ticks.blocks.chunk(pos) {
extras.block_ticks = t.pack(time);
} else {
extras.block_ticks.clear();
}
if let Some(t) = self.block_ticks.fluids.chunk(pos) {
extras.fluid_ticks = t.pack(time);
} else {
extras.fluid_ticks.clear();
}
extras.last_update = time;
// TheEndGatewayBlockEntity saves its Age (the server copy's).
@@ -27,6 +27,11 @@ impl Game<'_> {
if ready.is_empty() {
return Ok(());
}
// The ticks the marks schedule go to the chunks' containers, which a
// chunk that arrived since the last tick doesn't have yet
// (`LevelChunk.registerTickContainerInLevel` comes before
// `postProcessGeneration`).
self.sync_tick_chunks();
for key in ready {
let Some(chunk) = self.world.chunks.get_mut(&key) else {
continue;
+42 -16
View File
@@ -700,6 +700,16 @@ impl Game<'_> {
("mob_factor", Tag::Float(v[5].to_bits())),
]),
Component::Profile(p) => profile_tag(p),
// PotionContents' NBT: the potion by name and the custom colour.
Component::Potion { potion, color, .. } => {
let potion = potion.and_then(|id| r.builtin.get("minecraft:potion")?.name(id));
Tag::compound(
potion
.map(|name| ("potion", Tag::String(name.into())))
.into_iter()
.chain(color.map(|c| ("custom_color", Tag::Int(c)))),
)
}
// ItemStackTemplate.CODEC: id, count and components.
Component::Stacks(stacks) => Tag::List(
stacks
@@ -761,6 +771,20 @@ impl Game<'_> {
fn to_network(&self, s: &Option<ItemStack>) -> Option<Stack> {
let s = s.as_ref()?;
let mut stack = Stack::new(self.item_id(&s.item), s.count as i32);
// A creative pick keeps its potion (PotionContents by its builtin id).
if let Some(id) = s.potion().and_then(|p| {
let potions = self.remote.as_ref()?.builtin.get("minecraft:potion")?;
potions.id(&horizon_world::datafix::dynamic::namespaced(p))
}) {
stack.added.push((
horizon_protocol::item::component::POTION_CONTENTS,
horizon_protocol::item::Component::Potion {
potion: Some(id),
color: None,
rest: Vec::new(),
},
));
}
if s.damage > 0 {
stack.added.push((
horizon_protocol::item::component::DAMAGE,
@@ -1576,10 +1600,10 @@ impl Game<'_> {
self.turn_remote_projectile(id, rotation);
}
fn remove_entity(&mut self, id: i32) {
// The vehicle the player rode is gone (Entity.removeVehicle).
if self.riding == Some(id as u32) {
self.riding = None;
}
// Entity.setRemoved detaches every passenger, even when no empty
// SetPassengers packet preceded the vehicle's removal. Otherwise
// mobs and other players keep their passenger pose after it is gone.
self.remote_set_passengers(id, &[]);
self.mobs.retain(|m| m.id != id as u32);
self.orbs.retain(|o| o.id != id as u32);
self.dropped.retain(|d| d.id != id as u32);
@@ -2046,15 +2070,6 @@ impl Game<'_> {
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
{
for (index, _, value) in &p.values {
if let (15, Value::Byte(flags)) = (index, value) {
m.no_ai = flags & 1 != 0;
}
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
@@ -4061,8 +4076,14 @@ impl Game<'_> {
}
if reacts {
self.predict_use(&changes, seq);
} else if let Some(stack) = held.as_ref() {
self.predict_item_on_block(stack, &hit);
} else if let Some(stack) = held.as_ref()
&& self.predict_item_on_block(stack, &hit)
{
// Item.useOn succeeded here (InteractionResult.SUCCESS):
// this hand swings and the click ends, so the other hand
// (a shield, a torch) is never tried.
self.with_hand(hand, Game::swing_arm);
return true;
}
if held_block {
let placed = if reacts {
@@ -4092,7 +4113,8 @@ impl Game<'_> {
/// `playSound(player, ...)`: the server sends them to the others only):
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
/// block (stripping, scraping, removing wax, tilling, making a path).
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) {
/// Whether the item's use succeeded, which ends the click.
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) -> bool {
let at = [
f64::from(hit.pos[0]) + 0.5,
f64::from(hit.pos[1]) + 0.5,
@@ -4111,11 +4133,15 @@ impl Game<'_> {
self.audio
.play("minecraft:item.honeycomb.wax_on", Some(at), 1.0, 1.0);
}
waxes
} else if let Some((_, sound, _, _, particle)) =
self.block_transform(stack, hit.pos, hit.face)
{
self.transform_particle(&particle, hit.pos);
self.audio.play(&sound, Some(at), 1.0, 1.0);
true
} else {
false
}
}
@@ -23,6 +23,14 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
// animation follow it.
(9, Value::Float(health)) => m.health = *health,
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4.
// Mob.isAggressive draws the illagers' and skeletons' attack
// poses and the vindicator's axe.
(15, Value::Byte(flags)) => {
m.no_ai = flags & 1 != 0;
m.extra.left_handed = flags & 2 != 0;
m.ai.aggressive = flags & 4 != 0;
}
(i, Value::Bool(b)) if Some(i) == baby => m.extra.age = if *b { -1 } else { 0 },
_ => {}
}
@@ -178,6 +186,19 @@ mod tests {
assert_eq!(m.fire_ticks, 0);
}
#[test]
fn mob_flags_turn_aggression_on_and_off() {
let Some(mut m) = mob("minecraft:vindicator") else {
return;
};
apply(&mut m, &[(15, s::BYTE, Value::Byte(4 | 2))]);
assert!(m.ai.aggressive && m.extra.left_handed && !m.no_ai);
assert!(horizon_client::mobs::illager::shows_item(&m));
apply(&mut m, &[(15, s::BYTE, Value::Byte(0))]);
assert!(!m.ai.aggressive && !m.extra.left_handed);
assert!(!horizon_client::mobs::illager::shows_item(&m));
}
#[test]
fn a_villager_takes_its_type_profession_and_level() {
let Some(mut m) = mob("minecraft:villager") else {
@@ -332,6 +332,7 @@ impl Game<'_> {
return;
}
self.load_scoreboard();
host.hub.teams.clone_from(&self.score.board.teams);
let view = horizon_client::lan::scoreboard::View::of(&self.score.board);
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
if g.scoreboard != view {
+24 -6
View File
@@ -2,6 +2,7 @@
use super::{Game, Screen};
use horizon_client::{
Result,
command::args::GameMode,
hud::{HudBatch, rgba},
text::{self, Text},
ui,
@@ -17,7 +18,8 @@ impl Screen {
| Self::Stats
| Self::Advancements
| Self::WorldOptions
| Self::Friends => true,
| Self::Friends
| Self::Mod => true,
Self::Playing
| Self::GameModeSwitcher
| Self::Chat
@@ -81,9 +83,15 @@ fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
(x0 + 72.0, y, "gui/sprites/pause_menu/player_reporting"),
]
}
fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
/// DeathScreen.init: a hardcore player can only spectate.
fn death_buttons(w: f32, h: f32, hardcore: bool) -> [(f32, f32, &'static str, u8); 2] {
let respawn = if hardcore {
"deathScreen.spectate"
} else {
"deathScreen.respawn"
};
[
(w / 2.0 - 100.0, h / 4.0 + 72.0, "deathScreen.respawn", 0),
(w / 2.0 - 100.0, h / 4.0 + 72.0, respawn, 0),
(
w / 2.0 - 100.0,
h / 4.0 + 96.0,
@@ -223,7 +231,12 @@ impl Game<'_> {
),
);
}
let title = Text::translate("deathScreen.title", &[]);
let title = if self.level.hardcore {
"deathScreen.title.hardcore"
} else {
"deathScreen.title"
};
let title = Text::translate(title, &[]);
let tw = atlas.font.width_text(&title);
ui::text_scaled(
batch,
@@ -258,7 +271,7 @@ impl Game<'_> {
.ui
.died_at
.is_none_or(|t| t.elapsed().as_millis() >= 1000);
for (x, y, key, _) in death_buttons(w, h) {
for (x, y, key, _) in death_buttons(w, h, self.level.hardcore) {
let hover = active && ui::hovered(self.cursor, x, y, 200.0, 20.0);
ui::button(
batch,
@@ -286,7 +299,7 @@ impl Game<'_> {
.as_ref()
.map_or([1, 1], |r| [r.config.width, r.config.height]);
let b = HudBatch::new(size);
let hit = death_buttons(b.width, b.height)
let hit = death_buttons(b.width, b.height, self.level.hardcore)
.into_iter()
.find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0))
.map(|d| d.3);
@@ -330,6 +343,11 @@ impl Game<'_> {
Some(1) => self.close_world()?,
_ => {}
}
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
// respawned player only watches.
if hit == Some(0) && self.remote.is_none() && self.level.hardcore {
self.set_mode(GameMode::Spectator);
}
if hit.is_some() {
self.audio.play_button_click();
}
+1 -1
View File
@@ -78,7 +78,7 @@ impl Game<'_> {
let Some(g) = host.hub.guest(conn) else {
return;
};
let near = super::lan_stations::within_reach(g, p.pos);
let near = g.within_block_range(p.pos, 4.0);
let Some(play) = host.play.get_mut(&conn) else {
return;
};
@@ -383,6 +383,8 @@ impl Loading {
self.menu.win = Some((screen, Instant::now()));
}
}
// Mods' settings pages open only in a running game.
Event::ModSettings(_) => {}
// The startup menu runs before the game opens a control socket.
Event::ButtonClick | Event::CopyAgentDetails => {}
}
+46
View File
@@ -127,6 +127,52 @@ impl Game<'_> {
None => vec![own],
}
}
/// `Container.stillValidBlockEntity`: the open block container closes once
/// the player's eyes are out of reach of any of its blocks (a 4-block buffer).
pub(super) fn tick_container_reach(&mut self) {
// A server's menu ends when the server closes it.
if self.remote.is_some() {
return;
}
let Some(eye) = self.eye_position() else {
return;
};
if self
.container
.is_none_or(|k| k.1 == super::minecarts::CART_KEY_Y)
{
return;
}
let range = self.reach() + 4.0;
let near = self.container_parts().iter().all(|&((x, y, z), _)| {
horizon_client::lan::guest::within_block_range(eye, range, [x, y, z])
});
if !near {
self.close_container();
self.screen = super::Screen::Playing;
self.capture(true);
}
}
/// `CraftingMenu.stillValid`: the open crafting table closes once its
/// block is no longer a crafting table or the player's eyes are out of
/// reach (a 4-block buffer).
pub(super) fn tick_craft_table(&mut self) {
// A server's menu ends when the server closes it.
if self.remote.is_some() || self.craft_size != 3 {
return;
}
let (Some(pos), Some(eye)) = (self.craft_table, self.eye_position()) else {
return;
};
let state = self.world.block(pos[0], pos[1], pos[2]);
let table = &*self.res.facts().get(state).name == "minecraft:crafting_table";
let range = self.reach() + 4.0;
if !(table && horizon_client::lan::guest::within_block_range(eye, range, pos)) {
self.close_container();
self.screen = super::Screen::Playing;
self.capture(true);
}
}
/// The number of slots of the open container.
pub(super) fn container_size(&self) -> usize {
self.container_parts().iter().map(|p| p.1).sum()
+87 -47
View File
@@ -35,7 +35,7 @@ fn entity_shader() -> String {
mod entity_batch;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::{SHADOW_OVERLAY, WATER_MASK_OVERLAY};
pub use entity_batch::{SHADOW_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
pub fn outline_width(width: u32) -> f32 {
@@ -683,25 +683,31 @@ fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
}
/// The backends to open a device on, each tried in turn until one gives a
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
/// `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends
/// itself. Only Windows has two: Auto tries Vulkan, then DirectX 12 when
/// Vulkan has no adapter (no Vulkan driver) or no device. A native crash in
/// the driver can't be caught in the game; the launcher switches the setting
/// to DirectX 12 after one at the renderer's start (#746).
fn backend_order(api: GraphicsApi, windows: bool, wgpu_backend: bool) -> Vec<wgpu::Backends> {
if !windows || wgpu_backend {
return vec![wgpu::Backends::default()];
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
/// which picks the backends itself. Auto tries Vulkan, then DirectX 12 on Windows,
/// then OpenGL (Windows and Linux) for GPUs with neither driver, each when the one
/// before has no surface, adapter or device. A native crash in the driver can't be
/// caught in the game; the launcher switches the setting to DirectX 12 after one at
/// the renderer's start (#746). macOS has Metal only.
fn backend_order(api: GraphicsApi, os: &str, wgpu_backend: bool) -> Vec<wgpu::Backends> {
use wgpu::Backends;
if os == "macos" || wgpu_backend {
return vec![Backends::default()];
}
let windows = os == "windows";
match api {
GraphicsApi::Auto => vec![wgpu::Backends::VULKAN, wgpu::Backends::DX12],
GraphicsApi::Vulkan => vec![wgpu::Backends::VULKAN],
GraphicsApi::Dx12 => vec![wgpu::Backends::DX12],
GraphicsApi::Auto if windows => vec![Backends::VULKAN, Backends::DX12, Backends::GL],
GraphicsApi::Auto => vec![Backends::VULKAN, Backends::GL],
GraphicsApi::Dx12 if windows => vec![Backends::DX12],
GraphicsApi::Vulkan | GraphicsApi::Dx12 => vec![Backends::VULKAN],
GraphicsApi::OpenGl => vec![Backends::GL],
}
}
fn backends_name(backends: wgpu::Backends) -> &'static str {
match backends {
wgpu::Backends::VULKAN => "Vulkan",
wgpu::Backends::DX12 => "DirectX 12",
wgpu::Backends::GL => "OpenGL",
_ => "graphics",
}
}
@@ -780,7 +786,7 @@ async fn open_device(
) -> Result<Opened> {
let order = backend_order(
api.with_env(),
cfg!(windows),
std::env::consts::OS,
std::env::var_os("WGPU_BACKEND").is_some(),
);
let mut failures = Vec::new();
@@ -793,7 +799,13 @@ async fn open_device(
let surface = if offscreen {
None
} else {
Some(instance.create_surface(window.clone())?)
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => {
failures.push(format!("{name} surface: {e}"));
continue;
}
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
@@ -863,14 +875,14 @@ async fn open_device(
/// Vulkan is loaded at run time (the game links no graphics library), so a
/// missing driver shows here rather than as a missing-DLL dialog.
#[cfg(target_os = "windows")]
const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan or DirectX 12 found: Horizon needs a GPU \
driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \
const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan, DirectX 12 or OpenGL found: Horizon needs \
a GPU driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \
start the game again";
#[cfg(target_os = "macos")]
const NO_GPU_DRIVER: &str = "No Metal graphics device found";
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs the Vulkan loader \
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's)";
const NO_GPU_DRIVER: &str = "No Vulkan or OpenGL graphics driver found: Horizon needs the Vulkan loader \
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's), or OpenGL through EGL";
/// Logs a step of the renderer's start, for a native crash in the graphics
/// driver: it leaves no panic or crash report, so the log's last line is the
/// only sign of where the game stopped. The log file is written unbuffered;
@@ -2057,16 +2069,15 @@ impl Renderer {
// One per blend and `.png.mcmeta` sampling (screen_effects::SAMPLE_*):
// the shader is specialised, so a pixel pays for none of the others.
let screen_pipelines: [wgpu::RenderPipeline; 8] = std::array::from_fn(|i| {
let (entry, blend) = if i & 4 == 0 {
("fs_screen", alpha_blend)
} else {
("fs_vignette", vignette_blend)
let blur = i as u32 & crate::screen_effects::SAMPLE_BLUR != 0;
let (entry, blend) = match (i & 4 == 0, blur) {
(true, false) => ("fs_screen", alpha_blend),
(true, true) => ("fs_screen_blur", alpha_blend),
(false, false) => ("fs_vignette", vignette_blend),
(false, true) => ("fs_vignette_blur", vignette_blend),
};
let on = |bit: u32| f64::from(u8::from(i as u32 & bit != 0));
let constants = [
("BLUR", on(crate::screen_effects::SAMPLE_BLUR)),
("CLAMP", on(crate::screen_effects::SAMPLE_CLAMP)),
];
let clamp = i as u32 & crate::screen_effects::SAMPLE_CLAMP != 0;
let constants = [("CLAMP", f64::from(u8::from(clamp)))];
let compilation_options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
@@ -3439,6 +3450,7 @@ impl Renderer {
translucent_crumbling_quads,
water_mask_quads,
shadow_quads,
translucent_particle_quads,
] = self.entity_batch.update(
frame.entities,
frame.additive,
@@ -3450,7 +3462,8 @@ impl Renderer {
+ crumbling_quads
+ translucent_crumbling_quads
+ water_mask_quads
+ shadow_quads;
+ shadow_quads
+ translucent_particle_quads;
let cloud_quads = if let Some(input) = &frame.clouds {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
@@ -3849,7 +3862,7 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..shadow_quads * 6,
((world_quads - shadow_quads) * 4) as i32,
((world_quads - translucent_particle_quads - shadow_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -3917,6 +3930,22 @@ impl Renderer {
);
}
}
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
// translucent particle layer draws after translucent terrain —
// the quads write depth, so before it they would hide the glass
// or water behind them.
if translucent_particle_quads > 0 {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_particle_quads * 6,
((world_quads - translucent_particle_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
// LevelRenderer.executeClassicTransparency: translucent targets'
// outlines follow terrain, so its blending cannot wash them out.
if outline_edges > 0 && frame.outline_after_terrain {
@@ -4423,14 +4452,23 @@ mod tests {
normal: [0; 4],
}; 4]
};
let entities: Vec<_> = [(0, 0), (14, 9), (4, 1), (11, 2), (7, 3), (4, 4), (12, 7)]
.into_iter()
.flat_map(|(layer, id)| quad(layer, id))
.collect();
let entities: Vec<_> = [
(0, 0),
(14, 9),
(4, 1),
(11, 2),
(15, 10),
(7, 3),
(4, 4),
(12, 7),
]
.into_iter()
.flat_map(|(layer, id)| quad(layer, id))
.collect();
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 0, 1]);
assert_eq!(counts, [4, 1, 1, 0, 1, 1]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -4438,29 +4476,31 @@ mod tests {
.iter()
.map(|q| q[0].light)
.collect::<Vec<_>>(),
[0, 1, 3, 4, 2, 7, 9, 5, 6, 8]
[0, 1, 3, 4, 2, 7, 9, 10, 5, 6, 8]
);
}
#[test]
fn windows_tries_vulkan_then_directx_12() {
fn auto_tries_vulkan_then_directx_12_then_opengl() {
use super::{GraphicsApi, backend_order};
use wgpu::Backends;
let order = |api, os| backend_order(api, os, false);
assert_eq!(
backend_order(GraphicsApi::Auto, true, false),
[Backends::VULKAN, Backends::DX12]
order(GraphicsApi::Auto, "windows"),
[Backends::VULKAN, Backends::DX12, Backends::GL]
);
assert_eq!(order(GraphicsApi::Vulkan, "windows"), [Backends::VULKAN]);
assert_eq!(order(GraphicsApi::Dx12, "windows"), [Backends::DX12]);
assert_eq!(order(GraphicsApi::OpenGl, "windows"), [Backends::GL]);
assert_eq!(
backend_order(GraphicsApi::Vulkan, true, false),
[Backends::VULKAN]
order(GraphicsApi::Auto, "linux"),
[Backends::VULKAN, Backends::GL]
);
assert_eq!(
backend_order(GraphicsApi::Dx12, true, false),
[Backends::DX12]
);
// WGPU_BACKEND picks the backends itself; other systems have one.
assert_eq!(order(GraphicsApi::Dx12, "linux"), [Backends::VULKAN]);
assert_eq!(order(GraphicsApi::OpenGl, "linux"), [Backends::GL]);
// WGPU_BACKEND picks the backends itself; macOS has Metal only.
for api in GraphicsApi::ALL {
assert_eq!(backend_order(api, true, true), [Backends::default()]);
assert_eq!(backend_order(api, false, false), [Backends::default()]);
assert_eq!(backend_order(api, "windows", true), [Backends::default()]);
assert_eq!(order(api, "macos"), [Backends::default()]);
}
}
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
+10 -5
View File
@@ -3,13 +3,16 @@ use super::Vertex;
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
/// the boat water mask (13, `RenderPipelines.WATER_MASK`) and entity shadows
/// (14, `RenderPipelines.ENTITY_SHADOW`).
const MATERIALS: usize = 5;
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), entity shadows
/// (14, `RenderPipelines.ENTITY_SHADOW`) and translucent-layer particles (15,
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`).
const MATERIALS: usize = 6;
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
pub const WATER_MASK_OVERLAY: u32 = 13;
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
pub const SHADOW_OVERLAY: u32 = 14;
/// The overlay of translucent-layer particles (`SingleQuadParticle.Layer.translucent`).
pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
#[derive(Default)]
pub(super) struct EntityBatch {
@@ -41,6 +44,7 @@ impl EntityBatch {
12 => 2,
WATER_MASK_OVERLAY => 3,
SHADOW_OVERLAY => 4,
TRANSLUCENT_PARTICLE_OVERLAY => 5,
_ => 0,
};
self.buckets[material][counts[material]] = index;
@@ -92,13 +96,14 @@ mod tests {
) -> (Vec<Vertex>, [u32; MATERIALS]) {
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
let mut counts = [0; MATERIALS];
for (i, layer) in [0, 11, 12, 13, 14].into_iter().enumerate() {
for (i, layer) in [0, 11, 12, 13, 14, 15].into_iter().enumerate() {
for quad in entities.as_chunks::<4>().0 {
let material = match quad[0].layer {
11 => 11,
12 => 12,
13 => 13,
14 => 14,
15 => 15,
_ => 0,
};
if material == layer {
@@ -118,7 +123,7 @@ mod tests {
pos: [index as f32, 2.0, 3.0],
uv: [index as f32, 5.0],
layer: if mixed {
[7, 11, 14, 12, 4, 13, 0][index / 4 % 7]
[7, 11, 14, 12, 4, 13, 15, 0][index / 4 % 8]
} else {
0
},
+12 -3
View File
@@ -98,19 +98,27 @@ impl PresentMode {
/// macOS). Applies at the next start.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GraphicsApi {
/// Vulkan, and DirectX 12 when Vulkan has no adapter or device.
/// Vulkan, then DirectX 12 (Windows), then OpenGL when neither has a surface, adapter or device.
#[default]
Auto,
Vulkan,
Dx12,
/// OpenGL, for GPUs with no Vulkan or DirectX 12 driver (Windows and Linux).
OpenGl,
}
impl GraphicsApi {
pub const ALL: [GraphicsApi; 3] = [GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::Dx12];
pub const ALL: [GraphicsApi; 4] = [
GraphicsApi::Auto,
GraphicsApi::Vulkan,
GraphicsApi::Dx12,
GraphicsApi::OpenGl,
];
pub fn name(self) -> &'static str {
match self {
GraphicsApi::Auto => "auto",
GraphicsApi::Vulkan => "vulkan",
GraphicsApi::Dx12 => "dx12",
GraphicsApi::OpenGl => "opengl",
}
}
pub fn label(self) -> &'static str {
@@ -118,6 +126,7 @@ impl GraphicsApi {
GraphicsApi::Auto => "Auto",
GraphicsApi::Vulkan => "Vulkan",
GraphicsApi::Dx12 => "DirectX 12",
GraphicsApi::OpenGl => "OpenGL",
}
}
/// The API a name stands for, ignoring case; `None` for anything else.
@@ -126,7 +135,7 @@ impl GraphicsApi {
.into_iter()
.find(|api| api.name().eq_ignore_ascii_case(s.trim()))
}
/// `HORIZON_GRAPHICS_API` (auto, vulkan or dx12) if set, else the setting.
/// `HORIZON_GRAPHICS_API` (auto, vulkan, dx12 or opengl) if set, else the setting.
pub fn with_env(self) -> Self {
std::env::var("HORIZON_GRAPHICS_API")
.ok()
+14 -1
View File
@@ -3,7 +3,7 @@
//! GUI scale is applied when drawing).
use crate::Result;
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
};
@@ -58,6 +58,9 @@ pub struct HudAtlas {
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
/// which puts their particles on vanilla's translucent layer.
pub translucent: HashSet<String>,
/// Sprites whose `.png.mcmeta` sets `blur` or `clamp`; the others
/// sample as vanilla's defaults (nearest, repeat).
pub flags: HashMap<String, TextureFlags>,
@@ -462,7 +465,16 @@ impl HudAtlas {
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
let mut pixels = vec![0u8; (width * height * 4) as usize];
let mut sprites = HashMap::new();
let mut translucent = HashSet::new();
for ((name, w, h, data), (px, py)) in images.iter().zip(placements) {
if data
.as_chunks::<4>()
.0
.iter()
.any(|p| p[3] != 0 && p[3] != 255)
{
translucent.insert(name.clone());
}
for row in 0..*h {
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
let dst = (((py + row) * width + px) * 4) as usize;
@@ -527,6 +539,7 @@ impl HudAtlas {
width,
height,
sprites,
translucent,
flags,
fire_animations,
font,
@@ -343,7 +343,21 @@ impl Hub {
if !window_ok || self.guests[i].spectator() {
return Vec::new();
}
// A menu that is no longer valid takes no clicks.
if let Some(m) = self.guests[i].menu.as_ref().filter(|_| in_menu)
&& !m.slots.still_valid(world, &self.guests[i], m.pos)
{
return Vec::new();
}
let source = self.guests[i].menu.as_ref().map(|m| m.slots);
let shulker_box = self
.sync
.builtin
.get("minecraft:menu")
.and_then(|m| m.id("minecraft:shulker_box"));
let shulker = in_menu
&& shulker_box.is_some()
&& self.guests[i].menu.as_ref().map(|m| m.kind) == shulker_box;
let mut drag = if in_menu {
self.guests[i].menu.as_mut().and_then(|m| m.drag.take())
} else {
@@ -357,6 +371,7 @@ impl Hub {
let mut view = View {
guest,
container: items.as_deref_mut(),
shulker,
};
let (drops, changed) = menu::click(
&mut view,
+60
View File
@@ -223,6 +223,8 @@ pub struct Guest {
/// Whether the guest was on the ground before the first of `moves`.
pub moves_from_ground: bool,
pub dead: bool,
/// `LivingEntity.deathTime`: ticks since the guest died.
pub death_time: u32,
/// The join packets were sent (PlayerList.placeNewPlayer ran).
pub placed: bool,
/// The level the player is in (`ServerPlayer.level().dimension()`): the
@@ -349,6 +351,7 @@ impl Guest {
moves: Vec::new(),
moves_from_ground: false,
dead: false,
death_time: 0,
placed: false,
dimension: "minecraft:overworld".into(),
menu: None,
@@ -404,6 +407,17 @@ impl Guest {
pub fn eye_height(&self) -> f64 {
if self.sneaking { 1.27 } else { 1.62 }
}
/// `Player.isWithinBlockInteractionRange(pos, buffer)` for the guest:
/// its block interaction range is 4.5, 5 in creative.
pub fn within_block_range(&self, pos: [i32; 3], buffer: f64) -> bool {
let eye = [
self.position[0],
self.position[1] + self.eye_height(),
self.position[2],
];
let range = if self.creative() { 5.0 } else { 4.5 };
within_block_range(eye, range + buffer, pos)
}
/// The inventory menu's 46 slots (ServerPlayer.inventoryMenu): 0 crafting
/// result, 1..=4 crafting grid, 5..=8 armor, 9..=35 main, 36..=44 hotbar,
/// 45 off hand.
@@ -521,6 +535,22 @@ fn network_stack(
horizon_protocol::item::Component::Int(i32::from(mode)),
));
}
// PotionContents: the potion by its `minecraft:potion` id.
if let Some(id) = (d.potion.as_deref()).and_then(|potion| {
synced_id(
"minecraft:potion",
&horizon_world::datafix::dynamic::namespaced(potion),
)
}) {
stack.added.push((
horizon_protocol::item::component::POTION_CONTENTS,
horizon_protocol::item::Component::Potion {
potion: Some(id),
color: None,
rest: Vec::new(),
},
));
}
// A loaded crossbow's ammunition.
if !d.charged.is_empty() {
let charged = d
@@ -568,11 +598,41 @@ fn network_stack(
Some(stack)
}
/// `Player.isWithinBlockInteractionRange`: `eye` closer than `range` to the
/// box of the block at `pos` (`AABB.distanceToSqr`).
pub fn within_block_range(eye: [f64; 3], range: f64, pos: [i32; 3]) -> bool {
(0..3)
.map(|k| {
let lo = f64::from(pos[k]);
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
})
.sum::<f64>()
< range * range
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_protocol::item::{Component, component};
#[test]
fn potions_go_as_potion_registry_ids() {
let synced_id = |registry: &str, name: &str| match (registry, name) {
("minecraft:potion", "minecraft:swiftness") => Some(14),
_ => None,
};
let potion = ItemStack::new("minecraft:splash_potion", 1).with_potion("swiftness");
let stack = stack_to_network(|_| 7, synced_id, &Some(potion)).unwrap();
assert_eq!(
stack.component(component::POTION_CONTENTS),
Some(&Component::Potion {
potion: Some(14),
color: None,
rest: Vec::new(),
})
);
}
#[test]
fn trims_go_as_synced_registry_references() {
let mut chestplate = ItemStack::new("minecraft:diamond_chestplate", 1);
+22
View File
@@ -149,6 +149,9 @@ pub struct Hub {
pub singleplayer_owner: Option<u128>,
/// Who rides what, set by the game before each tick.
pub riding: super::entity::Riding,
/// The scoreboard's teams, set by the game each tick, for players'
/// display names.
pub teams: Vec<crate::scoreboard::Team>,
last_clock: i64,
last_rate: f32,
ticking: (f32, bool),
@@ -206,6 +209,24 @@ pub fn display_name(uuid: u128, name: &str) -> Tag {
])
}
impl Hub {
/// Player.getDisplayName: the name formatted for the player's team
/// (PlayerTeam.formatNameForTeam), suggesting `/tell NAME `, showing the
/// player on hover and inserting the name.
pub fn player_display_name(&self, uuid: u128, name: &str) -> Tag {
let Some(team) = self.teams.iter().find(|t| t.players.contains(name)) else {
return display_name(uuid, name);
};
let mut c = team.formatted_name_comp(&crate::scoreboard::literal(name));
c["click_event"] =
serde_json::json!({"action": "suggest_command", "command": format!("/tell {name} ")});
c["hover_event"] = serde_json::json!({"action": "show_entity", "id": "minecraft:player",
"uuid": horizon_protocol::crypto::uuid_string(uuid), "name": name});
c["insertion"] = name.into();
crate::scoreboard::component_tag(&c)
}
}
/// A yellow translation of player names (`multiplayer.player.joined`).
fn yellow_names(key: &str, names: Vec<Tag>) -> Tag {
Tag::compound([
@@ -315,6 +336,7 @@ impl Hub {
entity_types: Default::default(),
entities: Default::default(),
entity_out: Vec::new(),
teams: Vec::new(),
}
}
+6 -6
View File
@@ -2,7 +2,7 @@
//! handle it: chat sessions validated against the services' keys, signed
//! messages checked link by link, the acknowledged window, and messages
//! relayed with each connection's numbering and signature cache.
use super::{HostEvent, Hub, display_name};
use super::{HostEvent, Hub};
use horizon_protocol::{
packets::play::{self, Clientbound as C},
secure_chat::{self, Body, Chain, ChatSession, DecodeError, ProfileKey, Signature, Unpacked},
@@ -214,7 +214,7 @@ impl Hub {
}
let bound = play::ChatTypeBound {
chat_type: self.chat_type,
name: display_name(uuid, &name),
name: self.player_display_name(uuid, &name),
target: None,
};
let line = format!("<{name}> {}", message.content);
@@ -427,7 +427,7 @@ impl Hub {
let (uuid, name) = (self.guests[i].uuid, self.guests[i].name.clone());
let bound = play::ChatTypeBound {
chat_type: self.chat_type_id(chat_type),
name: display_name(uuid, &name),
name: self.player_display_name(uuid, &name),
target: None,
};
let line = match chat_type {
@@ -467,15 +467,15 @@ impl Hub {
self.chat_type_id("msg_command_incoming"),
);
for &t in targets {
let target = display_name(self.guests[t].uuid, &self.guests[t].name);
let target = self.player_display_name(self.guests[t].uuid, &self.guests[t].name);
let out = play::ChatTypeBound {
chat_type: outgoing,
name: display_name(uuid, &name),
name: self.player_display_name(uuid, &name),
target: Some(target),
};
let inc = play::ChatTypeBound {
chat_type: incoming,
name: display_name(uuid, &name),
name: self.player_display_name(uuid, &name),
target: None,
};
match &message {
+75 -13
View File
@@ -64,6 +64,19 @@ impl Slots {
.iter()
.all(|k| world.block_entities.contains_key(k))
}
/// `Container.stillValid` for the guest: the block entities are there
/// and the guest is within reach of each (`stillValidBlockEntity`). The
/// ender chest's block is `at` (`PlayerEnderChestContainer.activeChest`).
pub fn still_valid(self, world: &World, guest: &Guest, at: Option<(i32, i32, i32)>) -> bool {
let blocks = match self {
Slots::Ender => at.into_iter().collect(),
_ => self.halves(),
};
self.valid(world)
&& blocks
.iter()
.all(|k| guest.within_block_range([k.0, k.1, k.2], 4.0))
}
/// Container slot `n`.
pub fn get(self, world: &World, guest: &Guest, mut n: usize) -> Option<ItemStack> {
if self == Slots::Ender {
@@ -111,6 +124,8 @@ pub struct View<'a> {
pub guest: &'a mut Guest,
/// The container's slots, or None for the inventory menu.
pub container: Option<&'a mut [Option<ItemStack>]>,
/// The container is a shulker box (`ShulkerBoxSlot`).
pub shulker: bool,
}
impl View<'_> {
@@ -153,6 +168,12 @@ impl View<'_> {
if self.container.is_none() && (5..=8).contains(&i) {
return rules.armor_index(&stack.item) == Some(i - 5);
}
if self.shulker
&& self.container.as_ref().is_some_and(|c| i < c.len())
&& !crate::menu::fits_inside_container_items(stack)
{
return false;
}
self.enabled(i)
}
fn slot_max(&self, i: usize, stack: &ItemStack, rules: &dyn Rules) -> u32 {
@@ -665,6 +686,24 @@ mod tests {
)
}
/// The prepared resources' items, or None (the test is skipped) without them.
fn rules() -> Option<ItemRules> {
let root = std::env::var_os("HORIZON_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("items.json").exists() {
eprintln!("skipping resource-backed menu check: {}", root.display());
return None;
}
let registry = horizon_world::level::data::load_registry(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, &registry).unwrap();
Some(ItemRules(Items::load(&root, &registry, &facts).unwrap()))
}
fn contents(stack: &Option<ItemStack>) -> Vec<(String, u32)> {
let inside = stack.as_ref().and_then(|s| s.data());
inside.map_or(Vec::new(), |d| {
@@ -680,20 +719,9 @@ mod tests {
/// slot; a stack clicked onto a bundle goes in, as far as it fits.
#[test]
fn guest_chest_clicks_fill_and_empty_bundles() {
let root = std::env::var_os("HORIZON_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("items.json").exists() {
eprintln!("skipping resource-backed bundle check: {}", root.display());
let Some(rules) = rules() else {
return;
}
let registry = horizon_world::level::data::load_registry(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, &registry).unwrap();
let rules = ItemRules(Items::load(&root, &registry, &facts).unwrap());
};
let mut g = guest();
let mut chest = vec![
Some(ItemStack::new("minecraft:stone", 48)),
@@ -704,6 +732,7 @@ mod tests {
let mut view = View {
guest: &mut g,
container: Some(&mut chest),
shulker: false,
};
view.guest.carried = Some(ItemStack::new("minecraft:bundle", 1));
// Left click with the bundle: the stone goes in.
@@ -736,4 +765,37 @@ mod tests {
);
assert_eq!(contents(&view.get(2)), []);
}
#[test]
fn guest_shulker_box_menu_refuses_shulker_boxes() {
let Some(rules) = rules() else {
return;
};
for shulker in [true, false] {
let mut g = guest();
let mut container = vec![None; 27];
let mut drag = None;
let mut view = View {
guest: &mut g,
container: Some(&mut container),
shulker,
};
// Carried onto an empty slot, shift-clicked from the hotbar
// (menu slot 27 + 27) and swapped in with the number key 1.
view.guest.carried = Some(ItemStack::new("minecraft:red_shulker_box", 1));
click(&mut view, &mut drag, 0, 0, input::PICKUP, &rules);
assert_eq!(view.get(0).is_none(), shulker);
view.guest.carried = None;
view.guest.inventory[0] = Some(ItemStack::new("minecraft:shulker_box", 1));
click(&mut view, &mut drag, 54, 0, input::QUICK_MOVE, &rules);
assert_eq!(view.guest.inventory[0].is_some(), shulker);
view.guest.inventory[0] = Some(ItemStack::new("minecraft:blue_shulker_box", 1));
click(&mut view, &mut drag, 5, 0, input::SWAP, &rules);
assert_eq!(view.get(5).is_none(), shulker);
// Anything else goes in.
view.guest.carried = Some(ItemStack::new("minecraft:diamond", 1));
click(&mut view, &mut drag, 9, 0, input::PICKUP, &rules);
assert!(view.get(9).is_some());
}
}
}
+7 -6
View File
@@ -247,6 +247,12 @@ pub fn beacon_required_levels(effect: Option<&str>) -> i32 {
.map_or(i32::MAX, |i| i as i32 + 1)
}
/// `Item.canFitInsideContainerItems` (`ShulkerBoxSlot.mayPlace`): shulker
/// boxes cannot nest.
pub fn fits_inside_container_items(stack: &ItemStack) -> bool {
!stack.item.ends_with("shulker_box")
}
/// The containers a menu spans.
const PLAYER: usize = 0;
const MAIN: usize = 1;
@@ -1210,11 +1216,6 @@ impl Menu {
env.items.get(&stack.item).is_some_and(|i| i.fuel.is_some())
}
/// `Item.canFitInsideContainerItems`: shulker boxes cannot nest.
fn fits_inside(stack: &ItemStack) -> bool {
!(stack.item.ends_with("shulker_box"))
}
// ---- Slot ----
fn may_place(&self, env: &Env, s: usize, stack: &ItemStack) -> bool {
@@ -1247,7 +1248,7 @@ impl Menu {
.as_deref()
.is_some_and(|m| i.equip_allowed.as_ref().is_none_or(|a| a.contains(m)))
}),
Role::Shulker => Self::fits_inside(stack),
Role::Shulker => fits_inside_container_items(stack),
Role::Fuel => Self::is_fuel(env, stack) || Self::is_bucket(stack),
Role::Output | Role::CraftResult | Role::Inert => false,
Role::CrafterIn => self.data.get(self.slots[s].index) != Some(&1),
+4
View File
@@ -26,6 +26,9 @@ pub struct Installed {
pub problem: Option<String>,
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
pub dev: bool,
/// The running mod set a page of widgets (capability `ui`): its row
/// gains a Settings button.
pub has_settings: bool,
}
impl ModList {
@@ -96,6 +99,7 @@ mod tests {
description: String::new(),
problem: None,
dev,
has_settings: false,
};
let mut list = ModList {
installed: vec![installed("a", false), installed("b", true)],
+21 -4
View File
@@ -1083,11 +1083,26 @@ impl MotionWorld<'_, '_, '_> {
}
/// What `applyEffectsFromBlocks` does to a server's player along the
/// moves its client sent since `from` (in its history): the damage of
/// magma, cactus, lit campfires and grown sweet berry bushes. Its client
/// moves it, so the slowing and the rest are the client's.
/// magma, cactus, lit campfires and grown sweet berry bushes, and powder
/// snow's FREEZE, then `LivingEntity.aiStep`'s freezing and its damage.
/// Its client moves it, so the slowing and the rest are the client's.
pub fn server_block_damage(&mut self, from: Vec3) -> Result<()> {
self.player.old_position = from;
self.effects_from_blocks(true)
// Entity.baseTick
self.player.was_in_powder_snow = self.player.in_powder_snow;
self.player.in_powder_snow = false;
self.effects_from_blocks(true)?;
// LivingEntity.aiStep "freezing" (server).
if !self.player.in_powder_snow || !self.player.can_freeze() {
self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0);
}
if self.player.health.tick_count % 40 == 0
&& self.player.is_fully_frozen()
&& self.player.can_freeze()
{
self.freeze_damage()?;
}
Ok(())
}
fn effects_from_blocks(&mut self, damage_only: bool) -> Result<()> {
use horizon_world::entity_move::MovementRecord;
@@ -1191,7 +1206,7 @@ impl MotionWorld<'_, '_, '_> {
groups += 1;
last_step = step;
}
if groups == 1 {
if groups == 1 && !damage_only {
// A later group finds the fire already out.
self.player.powder_snow_entered.push(p);
}
@@ -1316,6 +1331,8 @@ impl MotionWorld<'_, '_, '_> {
"minecraft:sweet_berry_bush" if damage_only => {
self.sweet_berry_bush_damage(state)?;
}
// PowderSnowBlock.entityInside's FREEZE.
"minecraft:powder_snow" if damage_only => entered.push((step, p)),
_ => {}
}
if damage_only {
+31 -9
View File
@@ -893,6 +893,9 @@ enum Act {
ResetPadKey(&'static str),
ResetPadKeys,
ToggleMod(usize),
/// The installed mod's own settings page (capability `ui`), shown as a
/// screen: `Event::ModSettings` has the game open it.
ModSettings(usize),
OpenModFolder,
/// Credits & Attribution's Credits button (WinScreen without the poem).
Credits,
@@ -944,6 +947,9 @@ pub enum Event {
Packs(Vec<String>),
OpenPackFolder,
OpenModFolder,
/// Open the mod's own settings page (capability `ui`): the installed
/// mod's index in `mods.installed`.
ModSettings(usize),
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
Credits,
/// Copy how an agent connects (the AI Agent page).
@@ -1503,15 +1509,26 @@ impl OptionsUi {
} else if installed.dev {
label = label.append(" (Dev)");
}
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i));
Row::Buttons(
if installed.dev || installed.id.is_empty() {
inactive(widget)
} else {
widget
},
None,
)
let dead = installed.dev || installed.id.is_empty();
// A running mod with a settings page (capability
// `ui`) gets its own button beside the toggle.
if installed.has_settings {
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
let settings = button(
0.0,
0.0,
150.0,
Text::literal("Settings"),
Act::ModSettings(i),
);
Row::Buttons(
if dead { inactive(toggle) } else { toggle },
Some(settings),
)
} else {
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i));
Row::Buttons(if dead { inactive(widget) } else { widget }, None)
}
})
.collect()
}
@@ -2498,6 +2515,10 @@ impl OptionsUi {
s.mods.toggle(i);
events.push(Event::Changed);
}
Act::ModSettings(i) => {
events.push(Event::ButtonClick);
events.push(Event::ModSettings(i));
}
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
@@ -2988,6 +3009,7 @@ mod tests {
description: "May show HUD panels.".into(),
problem: id.is_empty().then(|| "Invalid package".into()),
dev,
has_settings: false,
});
}
let (widgets, ..) = ui.layout(&s, size.0, size.1);
+63 -12
View File
@@ -18,7 +18,10 @@ use horizon_core::{
random::{LegacyRandom, RandomSource},
};
use horizon_world::block_collision::BlockCollisionRegistry;
use std::{collections::HashMap, path::Path};
use std::{
collections::{HashMap, HashSet},
path::Path,
};
macro_rules! particle_types {
($($variant:ident = $name:literal),* $(,)?) => {
@@ -196,6 +199,35 @@ impl ParticleType {
| Self::OminousSpawning
)
}
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
/// hardcodes the translucent layer, drawn after translucent terrain.
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
/// sprite set on the engine.
pub fn translucent_layer(self) -> bool {
match self {
// CampfireSmokeParticle.
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
// PlayerCloudParticle.
| Self::Cloud | Self::Sneeze
// SpellParticle.
| Self::Effect | Self::InstantEffect | Self::EntityEffect
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
// EmissiveRisingParticle.
| Self::Soul | Self::SculkSoul
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
| Self::Vibration | Self::Firefly | Self::Flash
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
// SparkParticle (firework), SquidInkParticle, TotemParticle.
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
| Self::TotemOfUndying
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
| Self::VaultConnection => true,
_ => false,
}
}
}
/// `ParticleOptions` with the parameters of the option classes used here.
@@ -1147,7 +1179,9 @@ pub struct ParticleEngine {
pub requested: Option<Vec<(ParticleOptions, [f64; 3], [f64; 3])>>,
names: Vec<String>,
rects: Vec<Option<Rect>>,
atlas: HashMap<String, Rect>,
/// Each named sprite, and whether `SingleQuadParticle.Layer.bySprite` puts
/// it on the translucent layer (a texel with 0 < alpha < 255).
atlas: HashMap<String, (Rect, bool)>,
sets: HashMap<ParticleType, u32>,
set_sprites: Vec<Vec<u32>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
@@ -1167,7 +1201,7 @@ pub struct ParticleEngine {
/// `ParticleGroup.MAX_PARTICLES` (per render group; one group here).
const MAX_PARTICLES: usize = 16384;
impl ParticleEngine {
pub fn new(root: &Path, atlas: &HashMap<String, Rect>) -> Self {
pub fn new(root: &Path, atlas: &HashMap<String, Rect>, translucent: &HashSet<String>) -> Self {
let mut engine = Self {
particles: Vec::new(),
pending: Vec::new(),
@@ -1178,7 +1212,10 @@ impl ParticleEngine {
requested: None,
names: Vec::new(),
rects: Vec::new(),
atlas: atlas.clone(),
atlas: atlas
.iter()
.map(|(name, &rect)| (name.clone(), (rect, translucent.contains(name))))
.collect(),
sets: HashMap::new(),
set_sprites: Vec::new(),
emitters: Vec::new(),
@@ -1220,7 +1257,7 @@ impl ParticleEngine {
return i as u32;
}
self.names.push(name.to_owned());
self.rects.push(self.atlas.get(name).copied());
self.rects.push(self.atlas.get(name).map(|&(rect, _)| rect));
(self.names.len() - 1) as u32
}
pub fn len(&self) -> usize {
@@ -3760,9 +3797,18 @@ impl ParticleEngine {
{
continue;
}
let rect = match &p.sprite {
Sprite::Index(i) => self.rects.get(*i as usize).copied().flatten(),
Sprite::Named(name) => self.atlas.get(name).copied(),
// `SubmitNodeCollection.submitQuadParticleGroup`: the translucent
// layer draws after translucent terrain, so a crumb of stained
// glass cannot hide the glass behind it (`getLayer`).
let (rect, layer) = match &p.sprite {
Sprite::Index(i) => (
self.rects.get(*i as usize).copied().flatten(),
p.ty.translucent_layer(),
),
Sprite::Named(name) => match self.atlas.get(name) {
Some(&(rect, translucent)) => (Some(rect), translucent),
None => (None, false),
},
};
let Some(r) = rect else { continue };
let a = f64::from(partial);
@@ -3867,7 +3913,11 @@ impl ParticleEngine {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: 0,
layer: if layer {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
0
},
color,
light,
});
@@ -3891,7 +3941,7 @@ mod tests {
#[test]
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(0, 0, 0);
// Destination offset (-15, 7, 15); the unused top byte is ignored.
engine.block_teleportation([4, 12, -5], 0xff01_0f1fu32 as i32, [16, 8, 16], 0);
@@ -3916,7 +3966,7 @@ mod tests {
#[test]
fn consume_teleport_unpacks_offsets_within_127() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(0, 0, 0);
// TeleportRandomlyConsumeEffect: BlockUtil.clampedPackDifferenceInPosition
// of the move (-7, -3, 9) with radii 127, from (4, 12, -5) to (-3, 9, 4).
@@ -3941,7 +3991,8 @@ mod tests {
(2, 0, 99_413_408_083_437),
] {
for distant in [false, true] {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
let mut engine =
ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(0, 0, 0);
if distant {
engine.camera = [1000.0; 3];
+22 -12
View File
@@ -8,10 +8,12 @@
// atlas, wrapped as repeat reads it (hud.rs), so the hardware's bilinear
// sample is GL_LINEAR with repeat; clamp-to-edge keeps the sample point half
// a texel inside. Each sampling has its own pipeline (gfx.rs), so a pixel
// costs what it did with nearest and repeat only: one sample.
// costs what it did with nearest and repeat only: one sample. Each entry
// point reads `atlas` through one sampler only: GLSL (wgpu's OpenGL backend)
// combines a texture with its sampler, and an override would not remove the
// other sampler's branch.
@group(0) @binding(2) var nearest_sampler: sampler;
@group(0) @binding(4) var linear_sampler: sampler;
override BLUR: bool = false;
override CLAMP: bool = false;
struct Out {
@builtin(position) clip: vec4<f32>,
@@ -32,28 +34,36 @@ struct Out {
out.edges = vec4(rect.xy + half, rect.zw - half);
return out;
}
fn sample(in: Out) -> vec4<f32> {
// The sprite's atlas coordinates of this pixel.
fn atlas_uv(in: Out) -> vec2<f32> {
var f = in.uv;
if !CLAMP {
// Repeat: one period of the sprite.
f = fract(f);
}
var uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
let uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
if CLAMP {
uv = clamp(uv, in.edges.xy, in.edges.zw);
return clamp(uv, in.edges.xy, in.edges.zw);
}
if BLUR {
return textureSampleLevel(atlas, linear_sampler, uv, 0.0) * in.color;
}
return textureSampleLevel(atlas, nearest_sampler, uv, 0.0) * in.color;
return uv;
}
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
let c = sample(in);
fn screen(c: vec4<f32>) -> vec4<f32> {
if c.a <= 0.0 { discard; }
return c;
}
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
return screen(textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color);
}
@fragment fn fs_screen_blur(in: Out) -> @location(0) vec4<f32> {
return screen(textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color);
}
// RenderPipelines.VIGNETTE blends ZERO, ONE_MINUS_SRC_COLOR: output the
// texture colour; the blend state darkens the frame by it.
@fragment fn fs_vignette(in: Out) -> @location(0) vec4<f32> {
return vec4(sample(in).rgb, 1.0);
let c = textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color;
return vec4(c.rgb, 1.0);
}
@fragment fn fs_vignette_blur(in: Out) -> @location(0) vec4<f32> {
let c = textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color;
return vec4(c.rgb, 1.0);
}
+112 -1
View File
@@ -23,7 +23,8 @@ impl Zip {
pub fn open(path: &Path) -> io::Result<Self> {
Self::from_bytes(fs::read(path)?)
}
pub fn from_bytes(data: Vec<u8>) -> io::Result<Self> {
pub fn from_bytes(mut data: Vec<u8>) -> io::Result<Self> {
normalize_end(&mut data);
let archive = ZipArchive::new(Cursor::new(Arc::from(data))).map_err(invalid)?;
let entries = (0..archive.len())
.filter_map(|i| Some((archive.name_for_index(i)?.replace('\\', "/"), i)))
@@ -59,6 +60,59 @@ impl Zip {
}
}
/// The 22 fixed bytes of the end-of-central-directory record.
const END_HEADER: usize = 22;
/// Java's `ZipFile` ignores the END record's disk numbers and file counts,
/// reading the central directory from `cenpos` to `cenpos + cenlen` whatever
/// they say; the `zip` crate insists the record agrees with the directory.
/// Packs written while streaming (Nexo serves them for gommehd.net, for one)
/// leave every file's local header a stub and the END counts and disk number
/// at 0 and 0xFFFF: archives Java opens but the crate refuses. Count the real
/// directory entries and normalize the record so it does not look multi-disk
/// or empty.
fn normalize_end(data: &mut [u8]) {
if data.len() < END_HEADER {
return;
}
let u16_at = |data: &[u8], at: usize| u16::from_le_bytes(data[at..at + 2].try_into().unwrap());
let u32_at = |data: &[u8], at: usize| u32::from_le_bytes(data[at..at + 4].try_into().unwrap());
// The END record's comment can hide the signature: scan back through the
// last 64 KiB, like every zip reader.
let start = data.len().saturating_sub(END_HEADER + u16::MAX as usize);
let mut end = None;
for pos in (start..=data.len().saturating_sub(END_HEADER)).rev() {
if data[pos..pos + 4] == [0x50, 0x4b, 0x05, 0x06]
&& pos + END_HEADER + u16_at(data, pos + 20) as usize <= data.len()
{
end = Some(pos);
break;
}
}
let Some(end) = end else { return };
// The directory's real position and length, as Java derives them; the
// recorded offset is relative to the archive's start.
let cenlen = u32_at(data, end + 12) as usize;
let cenpos = end.saturating_sub(cenlen);
let mut count = 0u16;
let mut p = cenpos;
while p + 46 <= end && data[p..p + 4] == [0x50, 0x4b, 0x01, 0x02] {
count = count.saturating_add(1);
p += 46
+ u16_at(data, p + 28) as usize
+ u16_at(data, p + 30) as usize
+ u16_at(data, p + 32) as usize;
}
let disk = u16_at(data, end + 4);
let with_directory = u16_at(data, end + 6);
if disk == with_directory && u16_at(data, end + 8) == count && u16_at(data, end + 10) == count {
return;
}
data[end + 4..end + 10].fill(0);
data[end + 8..end + 10].copy_from_slice(&count.to_le_bytes());
data[end + 10..end + 12].copy_from_slice(&count.to_le_bytes());
}
#[cfg(test)]
mod tests {
use super::*;
@@ -207,4 +261,61 @@ mod tests {
cut.truncate(cut.len() / 2);
assert!(Zip::from_bytes(cut).is_err());
}
/// A pack as a streaming writer serves it (Nexo/gommehd.net): every
/// local header is a 30-byte stub and the END record's disk number and
/// counts are wrong. Java 25's `ZipFile` opens it, with each size either
/// in the directory or as 0xFFFFFFFF plus its Zip64 extra field (without
/// that field it refuses the archive: "invalid zip64 extra len size").
#[test]
fn reads_a_streamed_archive() {
for zip64 in [false, true] {
let files: [(&str, &[u8]); 2] = [
("pack.mcmeta", b"{\"pack\":{}}"),
("a/b.txt", b"streamed entry"),
];
let mut out = Vec::new();
let mut central = Vec::new();
for (name, body) in files {
let data = miniz_oxide::deflate::compress_to_vec(body, 6);
let offset = out.len() as u32;
out.extend(0x0403_4b50u32.to_le_bytes());
out.extend([0; 26]);
out.extend(&data);
let extra: Vec<u8> = if zip64 {
[1u16, 8]
.iter()
.flat_map(|v| v.to_le_bytes())
.chain((body.len() as u64).to_le_bytes())
.collect()
} else {
Vec::new()
};
central.extend(0x0201_4b50u32.to_le_bytes());
central.extend([20, 0, 20, 0, 0, 0]);
central.extend(8u16.to_le_bytes());
central.extend([0; 8]);
central.extend((data.len() as u32).to_le_bytes());
central.extend(if zip64 { u32::MAX } else { body.len() as u32 }.to_le_bytes());
central.extend((name.len() as u16).to_le_bytes());
central.extend((extra.len() as u16).to_le_bytes());
central.extend([0; 10]);
central.extend(offset.to_le_bytes());
central.extend(name.as_bytes());
central.extend(extra);
}
let start = out.len() as u32;
out.extend(&central);
out.extend(0x0605_4b50u32.to_le_bytes());
out.extend(u16::MAX.to_le_bytes());
out.extend([0; 6]);
out.extend((central.len() as u32).to_le_bytes());
out.extend(start.to_le_bytes());
out.extend([0; 2]);
let zip = Zip::from_bytes(out).unwrap();
assert_eq!(zip.names().collect::<Vec<_>>(), ["a/b.txt", "pack.mcmeta"]);
assert_eq!(zip.read("pack.mcmeta").unwrap(), b"{\"pack\":{}}");
assert_eq!(zip.read("a/b.txt").unwrap(), b"streamed entry");
}
}
}
+32 -4
View File
@@ -21,18 +21,24 @@
//! ```
//!
//! Every interface but `log` is a capability: list the ones the mod uses in
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`).
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `ui`).
//!
//! Widgets (capability `ui`): a mod shows a screen it owns with
//! [`ui::open_screen`] and a page in Options > Mods with
//! [`ui::set_settings`]; what the player does with them comes back as
//! [`Mod::ui_select`], [`Mod::ui_closed`] and [`Mod::ui_settings_opened`].
wit_bindgen::generate!({
path: "wit",
world: "horizon-mod",
world: "horizon-ui-mod",
pub_export_macro: true,
export_macro_name: "__export_horizon_mod",
default_bindings_module: "horizon_mod_sdk",
});
pub use exports::horizon::mods::events::{Guest as __Guest, Registration};
pub use horizon::mods::{chat, game, hud, log, storage};
pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest;
pub use horizon::mods::{chat, game, hud, log, storage, ui};
/// The game's calls into a mod. Only the hooks [`Registration`] asks for
/// are called; the others may stay as their defaults.
@@ -51,6 +57,14 @@ pub trait Mod: Sized + 'static {
}
/// A chat line the player received, when [`Registration::chat`] is set.
fn chat(&mut self, _text: &str) {}
/// A row of one of the mod's `list` widgets was picked (capability
/// `ui`): the widget's `id` and the row.
fn ui_select(&mut self, _id: u32, _row: u32) {}
/// The player left the mod's screen (Esc or Done; capability `ui`).
fn ui_closed(&mut self) {}
/// The player opened the mod's settings page (capability `ui`): call
/// [`ui::set_settings`] for fresh widgets.
fn ui_settings_opened(&mut self) {}
}
impl Registration {
@@ -135,13 +149,27 @@ macro_rules! export_mod {
with(|m| $crate::Mod::chat(m, &text))
}
}
impl $crate::__UiEventsGuest for Export {
fn select(id: u32, row: u32) {
with(|m| $crate::Mod::ui_select(m, id, row))
}
fn closed() {
with(|m| $crate::Mod::ui_closed(m))
}
fn settings_opened() {
with(|m| $crate::Mod::ui_settings_opened(m))
}
}
// Only WebAssembly has the component's exports: a host build (cargo test or
// clippy on the workspace) checks the mod without them, which macOS
// couldn't link (its exported-symbols list cuts names at the `#`).
#[cfg(target_arch = "wasm32")]
$crate::__export_horizon_mod!(Export);
#[cfg(not(target_arch = "wasm32"))]
let _ = <Export as $crate::__Guest>::init;
let _ = (
<Export as $crate::__Guest>::init,
<Export as $crate::__UiEventsGuest>::select,
);
};
};
}
+92
View File
@@ -86,6 +86,83 @@ interface storage {
delete: func(key: string);
}
/// Capability `ui`: widgets the game draws with its own menus' widgets, for
/// a screen the mod owns and a settings page in Options > Mods. Layout and
/// drawing are the game's: a mod says what its widgets hold, never where
/// they sit or how they look, and the game clips everything it is handed.
interface ui {
/// A line of text.
record text-widget {
/// The mod's own name for the widget; `ui-events` calls echo it.
id: u32,
/// Up to 256 characters.
text: string,
/// 0xRRGGBB; 0 is the menus' white.
color: u32,
}
/// An item's icon and a name beside it, as recipe viewers show them.
record item-widget {
/// The mod's own name for the widget.
id: u32,
/// `minecraft:diamond`, `<mod>:<path>`; an unknown id draws a "?".
item: string,
/// The name shown beside the icon (empty: the item's own).
name: string,
}
/// Rows the player picks one of, as an options page lists choices: a
/// pick is `ui-events::select`, `selected` is the row to mark now.
record list-widget {
/// The mod's own name for the widget; `ui-events::select` echoes it.
id: u32,
/// The rows' texts, at most 512, each up to 256 characters.
rows: list<string>,
/// The row to mark as the current choice, if any.
selected: option<u32>,
}
/// A thing on a mod's screen or settings page.
variant widget {
/// Text the player reads.
text(text-widget),
/// An item's icon and name.
item(item-widget),
/// Rows the player picks from.
%list(list-widget),
}
/// A screen the mod owns: a title over widgets stacked down the middle,
/// the Done button the game adds, and Esc to leave it.
record screen {
/// Its title, up to 256 characters.
title: string,
/// Its widgets, in order, at most 64.
widgets: list<widget>,
}
/// Show `screen` over the game; a second call while it is open replaces
/// it. Only the last call of one hook counts.
open-screen: func(screen: screen);
/// Close the mod's screen when it is open (the mod hears no `closed`
/// for this: it was its own doing).
close-screen: func();
/// The mod's page in Options > Mods, reached from a settings button on
/// its row: the widgets it shows, an empty list for no page. The player
/// opening the page is `ui-events::settings-opened`: push fresh widgets
/// then; later calls update the page while it shows.
set-settings: func(widgets: list<widget>);
}
/// What the game calls on a mod that shows widgets (capability `ui`):
/// what the player did with them.
interface ui-events {
/// A row of one of the mod's `list` widgets was picked: the widget's
/// `id` and the row, on the screen or the settings page that showed it.
select: func(id: u32, row: u32);
/// The player left the mod's screen (Esc or Done); not for a screen the
/// mod closed itself or one that went away with the mod.
closed: func();
/// The player opened the mod's settings page in Options > Mods: call
/// `ui::set-settings` now to fill it with fresh widgets.
settings-opened: func();
}
/// What the game calls. Only the hooks `init` asks for are ever called.
interface events {
record registration {
@@ -119,3 +196,18 @@ world horizon-mod {
import storage;
export events;
}
/// A mod with widgets (capability `ui`): the SDK builds every mod against
/// this world. Mods built for `horizon-mod` alone (no `ui-events` export)
/// still load and simply hear nothing of their widgets.
world horizon-ui-mod {
include horizon-mod;
import ui;
export ui-events;
}
/// The game's side of `horizon-ui-mod` beyond `horizon-mod`.
world ui-host {
import ui;
export ui-events;
}
+46 -1
View File
@@ -49,6 +49,12 @@ pub struct Manifest {
/// that are on may share one (`content::clash`).
#[serde(default)]
pub namespaces: Vec<String>,
/// The experimental capabilities the mod opts into (`["ui"]`): each one
/// it lists in `capabilities` must be named here too, since it may change
/// in any release (docs/api/stability.md). One no longer experimental is
/// accepted here, so the manifest keeps loading once it is promoted.
#[serde(default)]
pub experimental: Vec<Capability>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
@@ -75,14 +81,18 @@ pub enum Capability {
/// New blocks and items from the mod's `data/` (API 0.2): no WIT
/// interface, the game reads them before the world loads.
Registries,
/// A screen the mod owns and a settings page in Options > Mods, drawn
/// with the menus' own widgets (interface `ui`, calls `ui-events`).
Ui,
}
impl Capability {
pub const ALL: [Capability; 5] = [
pub const ALL: [Capability; 6] = [
Capability::GameRead,
Capability::Chat,
Capability::Hud,
Capability::Storage,
Capability::Registries,
Capability::Ui,
];
/// The WIT interface it grants, in `horizon:mods`.
pub fn interface(self) -> &'static str {
@@ -92,8 +102,14 @@ impl Capability {
Capability::Hud => "hud",
Capability::Storage => "storage",
Capability::Registries => "registries",
Capability::Ui => "ui",
}
}
/// May change in any release: a mod uses it only with
/// `experimental = ["<name>"]` in its manifest.
pub fn is_experimental(self) -> bool {
matches!(self, Capability::Ui)
}
/// What the player reads before turning the mod on: "May <this>".
pub fn describe(self) -> &'static str {
match self {
@@ -102,6 +118,7 @@ impl Capability {
Capability::Hud => "draw text on your screen",
Capability::Storage => "save its own settings to disk",
Capability::Registries => "add blocks and items to your worlds",
Capability::Ui => "show its own screens and a settings page",
}
}
/// The capability a component import needs, `Ok(None)` for one every
@@ -132,6 +149,7 @@ impl fmt::Display for Capability {
Capability::Hud => "hud",
Capability::Storage => "storage",
Capability::Registries => "registries",
Capability::Ui => "ui",
};
f.write_str(name)
}
@@ -162,6 +180,16 @@ impl Manifest {
"{MANIFEST}: capability registries needs api = \"0.2\""
));
}
if let Some(c) = m
.capabilities
.iter()
.find(|c| c.is_experimental() && !m.experimental.contains(c))
{
return Err(format!(
"{MANIFEST}: capability {c} is experimental and may change in any release: \
add experimental = [\"{c}\"] to use it"
));
}
if !m.namespaces.is_empty() && !m.capabilities.contains(&Capability::Registries) {
return Err(format!(
"{MANIFEST}: namespaces are for content, which needs capability registries"
@@ -414,4 +442,21 @@ capabilities = ["game-read", "hud"]
assert_eq!(p.wasm.as_deref(), Some(&b"\0asm"[..]));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn experimental_capabilities_need_the_opt_in() {
let base = "id = \"m\"\nname = \"M\"\nversion = \"1\"\napi = \"0.1\"\n";
let err = Manifest::parse(&format!("{base}capabilities = [\"ui\"]\n")).unwrap_err();
assert!(err.contains("experimental = [\"ui\"]"), "{err}");
let m = Manifest::parse(&format!(
"{base}capabilities = [\"ui\"]\nexperimental = [\"ui\"]\n"
))
.unwrap();
assert_eq!(m.experimental, [Capability::Ui]);
// A capability that is (or became) stable is fine in the list.
Manifest::parse(&format!(
"{base}capabilities = [\"hud\"]\nexperimental = [\"hud\"]\n"
))
.unwrap();
}
}
+186 -14
View File
@@ -16,6 +16,15 @@ mod bindings {
world: "horizon-mod",
});
}
/// Capability `ui`'s world beyond `horizon-mod` (docs/mods.md): a second
/// world, so mods built before it (no `ui-events` export) still instantiate
/// as `horizon-mod` and simply hear nothing of their widgets.
mod ui_bindings {
wasmtime::component::bindgen!({
path: "../horizon-mod-sdk/wit",
world: "ui-host",
});
}
pub use bindings::horizon::mods::{
game::{Clock, Player},
hud::{Corner, Line},
@@ -25,6 +34,12 @@ use bindings::{
exports::horizon::mods::events::Registration,
horizon::mods::{chat, game, hud, log as mod_log, storage},
};
/// The widget model a `ui` screen or settings page shows (WIT `interface
/// ui`), for the game to draw with its menus' own widgets.
pub use ui_bindings::horizon::mods::ui::{
ItemWidget, ListWidget, Screen as UiScreen, TextWidget, Widget as UiWidget,
};
use ui_bindings::{UiHost, horizon::mods::ui};
/// A mod's linear memory cap.
const MEMORY: usize = 64 << 20;
@@ -66,6 +81,11 @@ pub enum Output {
Feedback { text: String, error: bool },
/// The mod was turned off: it trapped, overran or failed to start.
Stopped { name: String, reason: String },
/// `ui.open-screen`: the mod's entry in `entries()`; what it shows is
/// [`Mods::screen`].
OpenScreen { owner: usize },
/// `ui.close-screen`: the mod closed its own screen (no `closed` back).
CloseScreen { owner: usize },
}
/// A mod's text panel, drawn every frame until it changes.
@@ -95,19 +115,44 @@ pub struct Entry {
run: Option<Running>,
modified: Option<SystemTime>,
}
impl Entry {
/// The running mod has a settings page (capability `ui` set widgets
/// with `ui.set-settings`): its row in Options > Mods gains a button.
pub fn has_ui_settings(&self) -> bool {
self.run
.as_ref()
.is_some_and(|r| !r.store.data().ui.settings.is_empty())
}
}
struct Running {
store: Store<State>,
exports: HorizonMod,
/// The mod's `ui-events` export; none for a mod built before `ui`.
ui: Option<UiHost>,
hooks: Registration,
}
/// What capability `ui` pushed since the hooks last ran.
#[derive(Default)]
struct UiState {
/// The screen `ui.open-screen` last pushed, until `ui.close-screen`.
screen: Option<UiScreen>,
/// The widgets `ui.set-settings` last pushed (the mod's page in
/// Options > Mods).
settings: Vec<UiWidget>,
/// The calls since the last hook ended, for [`Output`].
open: bool,
close: bool,
}
struct State {
name: String,
id: String,
snapshot: Arc<Snapshot>,
out: Vec<Output>,
panel: Option<Panel>,
ui: UiState,
/// Capability `storage`'s folder; made on first `set`.
data_dir: PathBuf,
/// Bytes `storage::set` may still write before this hook call ends
@@ -184,6 +229,8 @@ impl Mods {
let mut linker = Linker::new(&engine);
HorizonMod::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
.map_err(|e| e.to_string())?;
UiHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
.map_err(|e| e.to_string())?;
self.engine = Some((engine, linker));
}
Ok(self.engine.as_ref().expect("made above"))
@@ -242,6 +289,7 @@ impl Mods {
snapshot,
out: Vec::new(),
panel: None,
ui: UiState::default(),
data_dir,
storage_budget: STORAGE_WRITE_BUDGET,
limits: StoreLimitsBuilder::new()
@@ -252,8 +300,13 @@ impl Mods {
let mut store = Store::new(engine, state);
store.limiter(|s| &mut s.limits);
store.set_fuel(FUEL_INIT).map_err(|e| e.to_string())?;
let exports =
HorizonMod::instantiate(&mut store, &component, linker).map_err(|e| reason(&e))?;
let instance = linker
.instantiate(&mut store, &component)
.map_err(|e| reason(&e))?;
let exports = HorizonMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
// `ui-events` is a mod built since `ui`'s; older mods have no export
// and stay callable through `events` alone.
let ui = UiHost::new(&mut store, &instance).ok();
let mut hooks = exports
.horizon_mods_events()
.call_init(&mut store)
@@ -269,6 +322,7 @@ impl Mods {
Ok(Some(Running {
store,
exports,
ui,
hooks,
}))
}
@@ -290,7 +344,7 @@ impl Mods {
&mut self,
fuel: u64,
wants: impl Fn(&Registration) -> bool,
mut f: impl FnMut(&HorizonMod, &mut Store<State>) -> wasmtime::Result<()>,
mut f: impl FnMut(&mut Running) -> wasmtime::Result<()>,
) {
for i in 0..self.entries.len() {
if self.entries[i]
@@ -308,20 +362,28 @@ impl Mods {
&mut self,
i: usize,
fuel: u64,
f: impl FnOnce(&HorizonMod, &mut Store<State>) -> wasmtime::Result<R>,
f: impl FnOnce(&mut Running) -> wasmtime::Result<R>,
) -> Option<R> {
let entry = &mut self.entries[i];
let run = entry.run.as_mut()?;
run.store.data_mut().snapshot = self.snapshot.clone();
run.store.data_mut().storage_budget = STORAGE_WRITE_BUDGET;
let started = Instant::now();
let result = run
.store
.set_fuel(fuel)
.and_then(|()| f(&run.exports, &mut run.store));
let result = run.store.set_fuel(fuel).and_then(|()| f(run));
// An average over about the last 64 calls.
entry.cost = (entry.cost * 63 + started.elapsed()) / 64;
self.out.append(&mut run.store.data_mut().out);
let data = run.store.data_mut();
let (open, close) = (
std::mem::take(&mut data.ui.open),
std::mem::take(&mut data.ui.close),
);
self.out.append(&mut data.out);
if open {
self.out.push(Output::OpenScreen { owner: i });
}
if close {
self.out.push(Output::CloseScreen { owner: i });
}
match result {
Ok(r) => Some(r),
Err(e) => {
@@ -344,7 +406,11 @@ impl Mods {
self.each(
FUEL_TICK,
|h| h.tick,
|m, s| m.horizon_mods_events().call_tick(s, tick),
|run| {
run.exports
.horizon_mods_events()
.call_tick(&mut run.store, tick)
},
);
}
/// A rendered frame, `seconds` after the last.
@@ -352,7 +418,11 @@ impl Mods {
self.each(
FUEL_FRAME,
|h| h.frame,
|m, s| m.horizon_mods_events().call_frame(s, seconds),
|run| {
run.exports
.horizon_mods_events()
.call_frame(&mut run.store, seconds)
},
);
}
/// A chat line the player received, as plain text.
@@ -360,7 +430,11 @@ impl Mods {
self.each(
FUEL_EVENT,
|h| h.chat,
|m, s| m.horizon_mods_events().call_chat(s, text),
|run| {
run.exports
.horizon_mods_events()
.call_chat(&mut run.store, text)
},
);
}
/// Whether a running mod owns `/name`.
@@ -381,8 +455,10 @@ impl Mods {
let Some(i) = self.command_owner(name) else {
return false;
};
let result = self.call(i, FUEL_EVENT, |m, s| {
m.horizon_mods_events().call_command(s, name, args)
let result = self.call(i, FUEL_EVENT, |run| {
run.exports
.horizon_mods_events()
.call_command(&mut run.store, name, args)
});
match result {
Some(Ok(Some(text))) => self.out.push(Output::Feedback { text, error: false }),
@@ -405,6 +481,60 @@ impl Mods {
.iter()
.filter_map(|e| e.run.as_ref()?.store.data().panel.as_ref())
}
/// The screen mod `i` last pushed with `ui.open-screen`, while it is up.
pub fn screen(&self, i: usize) -> Option<&UiScreen> {
self.entries
.get(i)?
.run
.as_ref()?
.store
.data()
.ui
.screen
.as_ref()
}
/// The widgets `ui.set-settings` pushed: mod `i`'s page in
/// Options > Mods. Empty while it has none.
pub fn settings_widgets(&self, i: usize) -> &[UiWidget] {
self.entries
.get(i)
.and_then(|e| e.run.as_ref())
.map_or(&[], |r| &r.store.data().ui.settings)
}
/// A `list` widget's row was picked on a surface of mod `i`.
pub fn ui_select(&mut self, i: usize, id: u32, row: u32) {
self.ui_event(i, |ui, s| {
ui.horizon_mods_ui_events().call_select(s, id, row)
});
}
/// The player left mod `i`'s screen (Esc or Done).
pub fn ui_closed(&mut self, i: usize) {
self.ui_event(i, |ui, s| ui.horizon_mods_ui_events().call_closed(s));
}
/// The player opened mod `i`'s settings page.
pub fn ui_settings_opened(&mut self, i: usize) {
self.ui_event(i, |ui, s| {
ui.horizon_mods_ui_events().call_settings_opened(s)
});
}
/// A `ui-events` call on mod `i`; no export, no call (mods built
/// before `ui` have none).
fn ui_event(
&mut self,
i: usize,
f: impl FnOnce(&UiHost, &mut Store<State>) -> wasmtime::Result<()>,
) {
let has_ui = self
.entries
.get(i)
.and_then(|e| e.run.as_ref())
.is_some_and(|r| r.ui.is_some());
if has_ui {
self.call(i, FUEL_EVENT, |run| {
f(run.ui.as_ref().expect("checked above"), &mut run.store)
});
}
}
/// What mods asked to show since the last call.
pub fn drain(&mut self) -> Vec<Output> {
std::mem::take(&mut self.out)
@@ -476,6 +606,33 @@ fn clip(text: String) -> String {
}
}
/// Widgets on a `ui` screen or settings page, and rows on one `list`.
const WIDGETS: usize = 64;
const LIST_ROWS: usize = 512;
fn clip_widgets(widgets: Vec<UiWidget>) -> Vec<UiWidget> {
widgets
.into_iter()
.take(WIDGETS)
.map(|w| match w {
UiWidget::Text(mut t) => {
t.text = clip(t.text);
UiWidget::Text(t)
}
UiWidget::Item(mut i) => {
i.item = clip(i.item);
i.name = clip(i.name);
UiWidget::Item(i)
}
UiWidget::List(mut l) => {
l.rows = l.rows.into_iter().take(LIST_ROWS).map(clip).collect();
l.selected = l.selected.filter(|&s| (s as usize) < l.rows.len());
UiWidget::List(l)
}
})
.collect()
}
impl mod_log::Host for State {
fn write(&mut self, level: mod_log::Level, message: String) {
let level = match level {
@@ -527,6 +684,21 @@ impl hud::Host for State {
self.panel = None;
}
}
impl ui::Host for State {
fn open_screen(&mut self, mut screen: ui::Screen) {
screen.title = clip(screen.title);
screen.widgets = clip_widgets(screen.widgets);
self.ui.screen = Some(screen);
self.ui.open = true;
}
fn close_screen(&mut self) {
self.ui.screen = None;
self.ui.close = true;
}
fn set_settings(&mut self, widgets: Vec<ui::Widget>) {
self.ui.settings = clip_widgets(widgets);
}
}
impl storage::Host for State {
fn get(&mut self, key: String) -> Option<Vec<u8>> {
storage_key_ok(&key)
+50
View File
@@ -17,6 +17,14 @@ version = "0.1.0"
api = "0.1"
capabilities = ["chat", "storage"]
"#;
const UI_MANIFEST: &str = r#"
id = "test_ui_guest"
name = "Test UI Guest"
version = "0.1.0"
api = "0.1"
capabilities = ["chat", "ui"]
experimental = ["ui"]
"#;
/// Builds both mods once and writes their `.hmod` files; returns the folder.
fn mods_dir() -> &'static Path {
@@ -32,6 +40,8 @@ fn mods_dir() -> &'static Path {
"coords-hud",
"-p",
"horizon-mods-test-guest",
"-p",
"horizon-mods-test-ui-guest",
"--target-dir",
])
.arg(&target)
@@ -48,6 +58,7 @@ fn mods_dir() -> &'static Path {
let mods = [
("coords_hud", std::fs::read_to_string(example).unwrap()),
("horizon_mods_test_guest", GUEST_MANIFEST.to_string()),
("horizon_mods_test_ui_guest", UI_MANIFEST.to_string()),
];
for (name, manifest) in mods {
let core = std::fs::read(built.join(format!("{name}.wasm"))).unwrap();
@@ -366,6 +377,45 @@ fn hook_overhead() {
);
}
#[test]
fn ui_screens_settings_and_events() {
let mut mods = load("horizon_mods_test_ui_guest.hmod");
assert_eq!(mods.entries()[0].status, Status::Running);
assert!(!mods.entries()[0].has_ui_settings());
// `/ui_screen` pushes the screen; the host clips nothing of this one.
assert!(mods.command("ui_screen"));
assert_eq!(mods.drain(), [Output::OpenScreen { owner: 0 }]);
let screen = mods.screen(0).expect("a pushed screen");
assert_eq!(screen.title, "Guest's screen");
assert_eq!(screen.widgets.len(), 3);
// What the player did comes back through `ui-events`.
mods.ui_select(0, 7, 2);
mods.ui_closed(0);
assert_eq!(
mods.drain(),
[
Output::Chat {
name: "Test UI Guest".into(),
text: "selected 7:2".into(),
},
Output::Chat {
name: "Test UI Guest".into(),
text: "closed".into(),
},
]
);
// The settings page: empty until the mod fills it, refreshed on open.
assert!(mods.command("ui_settings"));
assert_eq!(mods.settings_widgets(0).len(), 3);
assert!(mods.entries()[0].has_ui_settings());
mods.ui_settings_opened(0);
assert_eq!(mods.settings_widgets(0).len(), 1);
// `/ui_close` closes the mod's own screen.
assert!(mods.command("ui_close"));
assert!(mods.drain().contains(&Output::CloseScreen { owner: 0 }));
assert!(mods.screen(0).is_none());
}
#[test]
fn content_only_mods_load_without_an_engine() {
let manifest = r#"
@@ -0,0 +1,16 @@
[package]
name = "horizon-mods-test-ui-guest"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
description = "A mod that shows widgets, for horizon-mods' sandbox tests"
[lib]
crate-type = ["cdylib"]
[dependencies]
horizon-mod-sdk = { path = "../../../horizon-mod-sdk" }
[lints]
workspace = true
@@ -0,0 +1,66 @@
//! A mod for the runtime's `ui` tests: `/ui_screen` opens a screen with one
//! of each widget, `/ui_settings` sets a settings page, `/ui_close` closes
//! the screen; the `ui-events` calls answer in chat so the host sees them.
use horizon_mod_sdk::{
Mod, Registration, chat,
ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget},
};
struct Guest;
fn widgets() -> Vec<Widget> {
vec![
Widget::Text(TextWidget {
id: 1,
text: "Pick a row".into(),
color: 0,
}),
Widget::Item(ItemWidget {
id: 2,
item: "minecraft:diamond".into(),
name: String::new(),
}),
Widget::List(ListWidget {
id: 7,
rows: vec!["first".into(), "second".into(), "third".into()],
selected: Some(1),
}),
]
}
impl Mod for Guest {
fn init() -> Result<(Self, Registration), String> {
let hooks = ["ui_screen", "ui_settings", "ui_close"]
.into_iter()
.fold(Registration::new(), Registration::command);
Ok((Guest, hooks))
}
fn command(&mut self, name: &str, _args: &str) -> Result<Option<String>, String> {
match name {
"ui_screen" => ui::open_screen(&Screen {
title: "Guest's screen".into(),
widgets: widgets(),
}),
"ui_settings" => ui::set_settings(&widgets()),
"ui_close" => ui::close_screen(),
_ => {}
}
Ok(None)
}
fn ui_select(&mut self, id: u32, row: u32) {
chat::show(&format!("selected {id}:{row}"));
}
fn ui_closed(&mut self) {
chat::show("closed");
}
fn ui_settings_opened(&mut self) {
ui::set_settings(&[Widget::Text(TextWidget {
id: 1,
text: "fresh".into(),
color: 0x33CC33,
})]);
chat::show("settings opened");
}
}
horizon_mod_sdk::export_mod!(Guest);
+90
View File
@@ -35,6 +35,7 @@ pub mod component {
pub const MAP_ID: i32 = 48;
pub const MAP_POST_PROCESSING: i32 = 50;
pub const CHARGED_PROJECTILES: i32 = 51;
pub const POTION_CONTENTS: i32 = 53;
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
pub const TRIM: i32 = 58;
@@ -65,6 +66,13 @@ pub enum Component {
Lore(Vec<Tag>),
Enchantments(Vec<(i32, i32)>),
AttackRange([f32; 6]),
/// `PotionContents`: the potion's `minecraft:potion` id and the custom
/// colour; the custom effects and name stay their encoded bytes.
Potion {
potion: Option<i32>,
color: Option<i32>,
rest: Vec<u8>,
},
Profile(Profile),
CustomModelData {
floats: Vec<f32>,
@@ -162,6 +170,22 @@ impl Component {
.collect::<Result<_>>()?,
)
}
POTION_CONTENTS => {
let potion = if r.bool()? { Some(r.varint()?) } else { None };
let color = if r.bool()? { Some(r.i32()?) } else { None };
let start = r.position();
skip::potion_effects_and_name(r)?;
// No custom effects and no name: an empty tail.
let rest = match r.since(start) {
[0, 0] => Vec::new(),
bytes => bytes.to_vec(),
};
Self::Potion {
potion,
color,
rest,
}
}
ATTACK_RANGE => {
let mut values = [0.0f32; 6];
for v in &mut values {
@@ -283,6 +307,27 @@ impl Component {
}
}
Self::AttackRange(values) => values.iter().for_each(|v| w.f32(*v)),
Self::Potion {
potion,
color,
rest,
} => {
w.bool(potion.is_some());
if let Some(id) = potion {
w.varint(*id);
}
w.bool(color.is_some());
if let Some(color) = color {
w.i32(*color);
}
// An empty tail is no custom effects and no name.
if rest.is_empty() {
w.varint(0);
w.bool(false);
} else {
w.bytes(rest);
}
}
Self::Profile(p) => {
w.u8(0);
w.bool(p.name.is_some());
@@ -532,6 +577,51 @@ mod tests {
assert_eq!(r.remaining(), 0);
}
#[test]
fn potion_contents_round_trip() {
let mut arrow = Stack::new(7, 3);
arrow.added.push((
component::POTION_CONTENTS,
Component::Potion {
potion: Some(31),
color: None,
rest: Vec::new(),
},
));
round::<Option<Stack>, Slot>(Some(arrow));
// A custom colour, one custom effect (speed II for 600 ticks, shown,
// no hidden effect) and a custom name survive as they came.
let mut w = Writer::new();
w.bool(false);
w.bool(true);
w.i32(0x00FF_8800);
w.varint(1);
for v in [1, 1, 600] {
w.varint(v);
}
for b in [false, true, true, false] {
w.bool(b);
}
w.bool(true);
w.string("Tea");
let mut r = Reader::new(&w.data);
let read = Component::read(component::POTION_CONTENTS, &mut r).unwrap();
assert_eq!(r.remaining(), 0);
let Component::Potion {
potion,
color,
rest,
} = &read
else {
panic!("{read:?}");
};
assert_eq!((*potion, *color), (None, Some(0x00FF_8800)));
assert!(!rest.is_empty());
let mut again = Writer::new();
read.write(component::POTION_CONTENTS, &mut again);
assert_eq!(again.data, w.data);
}
#[test]
fn stacks_round_trip() {
round::<Option<Stack>, Slot>(None);
+3 -5
View File
@@ -87,7 +87,6 @@ pub(super) fn component(kind: i32, r: &mut Reader) -> Result<()> {
}
// BundleContents: templates.
52 => list(r, template),
53 => potion_contents(r),
// SuspiciousStewEffects: effect, duration.
55 => list(r, |r| {
varint(r)?;
@@ -410,10 +409,9 @@ fn kinetic_weapon(r: &mut Reader) -> Result<()> {
optional(r, sound)?;
optional(r, sound)
}
/// PotionContents: potion, customColor, customEffects, customName.
fn potion_contents(r: &mut Reader) -> Result<()> {
optional(r, varint)?;
optional(r, i32)?;
/// PotionContents' customEffects and customName (`Component::read` reads
/// the potion and colour before them).
pub(super) fn potion_effects_and_name(r: &mut Reader) -> Result<()> {
list(r, mob_effect)?;
optional(r, string)
}
+11 -1
View File
@@ -849,7 +849,17 @@ mod tests {
let first = lock(&dir).unwrap();
assert!(lock(&dir).unwrap_err().contains("already running"));
drop(first);
lock(&dir).unwrap();
// A process another test spawns holds a copy of the lock's descriptor between its fork and exec:
// the lock comes back once that child has exec'd.
let mut again = lock(&dir);
for _ in 0..50 {
if again.is_ok() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
again = lock(&dir);
}
again.unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -23,6 +23,7 @@ use horizon_core::{
pub use library::MaterialRuleLibrary;
pub use noise::MaterialNoiseSources;
use rule::CompiledRule;
pub use rule::SurfaceVariants;
pub use rule::{MaterialRule, MaterialRuleError};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -299,7 +300,81 @@ enum Kind<R: RandomSource> {
},
Not(Box<Compiled<R>>),
}
/// What a specialised rule may assume about every position it is applied to
/// (`CompiledRule::specialize`): its y lies in `low..=high`, and whether it is
/// at or above the column's preliminary surface level, when known.
#[derive(Clone, Copy, Debug)]
pub struct Facts {
pub low: i32,
pub high: i32,
pub above_surface: Option<bool>,
}
impl<R: RandomSource> Clone for Kind<R>
where
R::Positional: Clone,
{
fn clone(&self) -> Self {
match self {
Self::Simple(condition) => Self::Simple(condition.clone()),
Self::Biome(names) => Self::Biome(names.clone()),
Self::Constant(value) => Self::Constant(*value),
Self::Noise { sampler, min, max } => Self::Noise {
sampler: *sampler,
min: *min,
max: *max,
},
Self::Gradient { low, high, random } => Self::Gradient {
low: *low,
high: *high,
random: random.clone(),
},
Self::Not(child) => Self::Not(child.clone()),
}
}
}
impl<R: RandomSource> Clone for Compiled<R>
where
R::Positional: Clone,
{
fn clone(&self) -> Self {
Self {
kind: self.kind.clone(),
revision: self.revision,
result: self.result,
}
}
}
impl<R: RandomSource> Compiled<R> {
/// The value `facts` decide for this condition at every position they
/// cover. Conditions read the position and the world, never change them,
/// so one decided in advance gives what testing it would.
fn decided(&self, facts: Facts) -> Option<bool> {
match &self.kind {
Kind::Constant(value) => Some(*value),
Kind::Simple(MaterialCondition::AbovePreliminarySurface) => facts.above_surface,
Kind::Gradient { low, high, .. } => {
if facts.high <= *low {
Some(true)
} else if facts.low >= *high {
Some(false)
} else {
None
}
}
Kind::Not(child) => child.decided(facts).map(|value| !value),
_ => None,
}
}
/// The vertical gradients' bounds, and whether the preliminary surface
/// is asked for.
fn inputs(&self, gradients: &mut Vec<(i32, i32)>, surface: &mut bool) {
match &self.kind {
Kind::Gradient { low, high, .. } => gradients.push((*low, *high)),
Kind::Simple(MaterialCondition::AbovePreliminarySurface) => *surface = true,
Kind::Not(child) => child.inputs(gradients, surface),
_ => {}
}
}
fn constant_result(&self) -> Option<bool> {
match &self.kind {
Kind::Constant(value) => Some(*value),
@@ -1,5 +1,7 @@
//! Compiled material rule composition, sharing the condition worker's context.
use super::{Compiled, Context, MaterialCondition, MaterialConditions, MaterialSurfaceQueries};
use super::{
Compiled, Context, Facts, MaterialCondition, MaterialConditions, MaterialSurfaceQueries,
};
use crate::{registry::BlockStateId, worldgen::DecodeError};
use horizon_core::{
density::DensitySampler,
@@ -209,6 +211,161 @@ impl<R: RandomSource> CompiledRule<R> {
}
}
}
impl<R: RandomSource> CompiledRule<R>
where
R::Positional: Clone,
{
/// This rule where `facts` hold: conditions they decide are folded away,
/// with what can no longer produce a block, and a sequence stops at its
/// first child that always produces one. For every position the facts
/// cover it gives the block the whole rule gives.
fn specialize(&self, facts: Facts) -> Self {
match self {
Self::Empty => Self::Empty,
Self::Block(block) => Self::Block(*block),
Self::Bandlands => Self::Bandlands,
Self::OreVein {
blocks,
raw_ore_chance,
densities,
random,
} => Self::OreVein {
blocks: *blocks,
raw_ore_chance: *raw_ore_chance,
densities: *densities,
random: random.clone(),
},
Self::Condition { condition, rule } => match condition.decided(facts) {
Some(false) => Self::Empty,
Some(true) => rule.specialize(facts),
None => match rule.specialize(facts) {
Self::Empty => Self::Empty,
rule => Self::Condition {
condition: condition.clone(),
rule: Box::new(rule),
},
},
},
Self::Sequence(children) => {
let mut result = Vec::new();
for child in children {
let child = child.specialize(facts);
if matches!(child, Self::Empty) {
continue;
}
let last = child.always_produces();
result.push(child);
if last {
break;
}
}
match result.len() {
0 => Self::Empty,
1 => result.pop().unwrap(),
_ => Self::Sequence(result),
}
}
}
}
/// Whether applying this rule never gives `None` (only an error).
fn always_produces(&self) -> bool {
match self {
Self::Block(_) | Self::Bandlands => true,
Self::Sequence(children) => children.iter().any(Self::always_produces),
Self::Empty | Self::OreVein { .. } | Self::Condition { .. } => false,
}
}
fn inputs(&self, gradients: &mut Vec<(i32, i32)>, surface: &mut bool) {
match self {
Self::Condition { condition, rule } => {
condition.inputs(gradients, surface);
rule.inputs(gradients, surface);
}
Self::Sequence(children) => {
for child in children {
child.inputs(gradients, surface);
}
}
_ => {}
}
}
}
/// A rule specialised (`CompiledRule::specialize`) for each band of y between
/// its vertical gradients' bounds, and below and at or above the preliminary
/// surface when the rule asks for it: most blocks of a column, deep stone,
/// then evaluate only the few rules that can apply there.
pub struct SurfaceVariants {
/// The first y of each band after the first, ascending.
starts: Vec<i32>,
/// The rule index per band (two per band, below then above the surface,
/// when `surface`).
rules: Vec<usize>,
pub surface: bool,
}
impl SurfaceVariants {
/// The rule for a block at `y`, `above` the preliminary surface (ignored
/// unless `surface`).
#[inline]
pub fn rule(&self, y: i32, above: bool) -> usize {
let band = self.starts.partition_point(|&start| start <= y);
if self.surface {
self.rules[band * 2 + usize::from(above)]
} else {
self.rules[band]
}
}
}
impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S>
where
R::Positional: Clone,
{
/// Add the specialised variants of rule `index` (`SurfaceVariants`).
pub fn surface_variants(&mut self, index: usize) -> Result<SurfaceVariants, MaterialRuleError> {
let rule = self
.rules
.get(index)
.ok_or(MaterialRuleError::UnknownRule)?;
let (mut gradients, mut surface) = (Vec::new(), false);
rule.inputs(&mut gradients, &mut surface);
// A gradient is true at and below `low`, random between, false at
// and above `high`: it is decided within each band these bound.
let mut starts: Vec<i32> = gradients
.iter()
.flat_map(|&(low, high)| [low.saturating_add(1), high])
.collect();
starts.sort_unstable();
starts.dedup();
let mut variants = Vec::new();
for band in 0..=starts.len() {
let low = if band == 0 {
i32::MIN
} else {
starts[band - 1]
};
let high = starts.get(band).map_or(i32::MAX, |&next| next - 1);
let sides: &[Option<bool>] = if surface {
&[Some(false), Some(true)]
} else {
&[None]
};
for &above_surface in sides {
let facts = Facts {
low,
high,
above_surface,
};
variants.push(self.rules[index].specialize(facts));
}
}
let first = self.rules.len();
self.rules.extend(variants);
Ok(SurfaceVariants {
starts,
rules: (first..self.rules.len()).collect(),
surface,
})
}
}
impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S> {
/// Compile all children in order, even after an unconditional block rule.
/// Failed compilation never adds a partial rule to this worker.
@@ -196,7 +196,10 @@ pub fn build_surface<R: RandomSource, S: MaterialSurfaceQueries, B: TerrainBlock
rule: MaterialRule,
inputs: SurfaceInputs<'_, R, S>,
extensions: &MaterialExtensions<R::Positional>,
) -> Result<(), SurfaceError> {
) -> Result<(), SurfaceError>
where
R::Positional: Clone,
{
let min = chunk.min_y();
let max = min + chunk.height() - 1;
if inputs.write_min_y < min
@@ -240,9 +243,13 @@ fn build_columns<R: RandomSource, S: MaterialSurfaceQueries, B: TerrainBlockStat
extensions: &MaterialExtensions<R::Positional>,
write_min_y: i32,
write_max_y: i32,
) -> Result<(), SurfaceError> {
) -> Result<(), SurfaceError>
where
R::Positional: Clone,
{
let min = chunk.min_y();
let rule = worker.add_rule(rule)?;
let variants = worker.surface_variants(rule)?;
let cx = chunk.position().x.wrapping_shl(4);
let cz = chunk.position().z.wrapping_shl(4);
for lx in 0..16 {
@@ -321,6 +328,8 @@ fn build_columns<R: RandomSource, S: MaterialSurfaceQueries, B: TerrainBlockStat
above = above.wrapping_add(1);
worker.update_y(above, y.wrapping_sub(bottom).wrapping_add(1), water, y);
if y >= write_min_y && y <= write_max_y {
let above = variants.surface && y >= worker.minimum_surface_level()?;
let rule = variants.rule(y, above);
if let Some(state) = worker.try_apply(rule, BlockPos::new(x, y, z))? {
column.set_block(y, state)?;
}
+88
View File
@@ -12,12 +12,27 @@ The contract between Horizon and its mods: the WIT package `horizon:[email protected]`,
| `hud` | [`hud`](#hud) | draw text on your screen |
| `storage` | [`storage`](#storage) | save its own settings to disk |
| `registries` | data files (`assets/`, `data/`), no interface | add blocks and items to your worlds |
| `ui` | [`ui`](#ui) | show its own screens and a settings page |
## World `horizon-mod`
- Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage`
- Exports (what the mod provides): `events`
## World `horizon-ui-mod`
A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets.
- Imports (what the game offers): `ui`, `log`, `game`, `chat`, `hud`, `storage`
- Exports (what the mod provides): `ui-events`, `events`
## World `ui-host`
The game's side of `horizon-ui-mod` beyond `horizon-mod`.
- Imports (what the game offers): `ui`
- Exports (what the mod provides): `ui-events`
## `log`
Access: always granted.
@@ -117,6 +132,79 @@ Capability `storage`: a per-mod folder of small key/value files, quota'd (docs/m
| `set` | `func(key: string, value: list<u8>) -> result<_, string>` | Errs on a malformed key or over quota (bytes for the whole mod). |
| `delete` | `func(key: string)` | No error if `key` was already absent. |
## `ui`
Access: capability `ui`.
Capability `ui`: widgets the game draws with its own menus' widgets, for a screen the mod owns and a settings page in Options > Mods. Layout and drawing are the game's: a mod says what its widgets hold, never where they sit or how they look, and the game clips everything it is handed.
### record `text-widget`
A line of text.
| Field | Type | |
| --- | --- | --- |
| `id` | `u32` | The mod's own name for the widget; `ui-events` calls echo it. |
| `text` | `string` | Up to 256 characters. |
| `color` | `u32` | 0xRRGGBB; 0 is the menus' white. |
### record `item-widget`
An item's icon and a name beside it, as recipe viewers show them.
| Field | Type | |
| --- | --- | --- |
| `id` | `u32` | The mod's own name for the widget. |
| `item` | `string` | `minecraft:diamond`, `<mod>:<path>`; an unknown id draws a "?". |
| `name` | `string` | The name shown beside the icon (empty: the item's own). |
### record `list-widget`
Rows the player picks one of, as an options page lists choices: a pick is `ui-events::select`, `selected` is the row to mark now.
| Field | Type | |
| --- | --- | --- |
| `id` | `u32` | The mod's own name for the widget; `ui-events::select` echoes it. |
| `rows` | `list<string>` | The rows' texts, at most 512, each up to 256 characters. |
| `selected` | `option<u32>` | The row to mark as the current choice, if any. |
### variant `widget`
A thing on a mod's screen or settings page.
| Case | Payload | |
| --- | --- | --- |
| `text` | `text-widget` | Text the player reads. |
| `item` | `item-widget` | An item's icon and name. |
| `list` | `list-widget` | Rows the player picks from. |
### record `screen`
A screen the mod owns: a title over widgets stacked down the middle, the Done button the game adds, and Esc to leave it.
| Field | Type | |
| --- | --- | --- |
| `title` | `string` | Its title, up to 256 characters. |
| `widgets` | `list<widget>` | Its widgets, in order, at most 64. |
| Function | Signature | What it does |
| --- | --- | --- |
| `open-screen` | `func(screen: screen)` | Show `screen` over the game; a second call while it is open replaces it. Only the last call of one hook counts. |
| `close-screen` | `func()` | Close the mod's screen when it is open (the mod hears no `closed` for this: it was its own doing). |
| `set-settings` | `func(widgets: list<widget>)` | The mod's page in Options > Mods, reached from a settings button on its row: the widgets it shows, an empty list for no page. The player opening the page is `ui-events::settings-opened`: push fresh widgets then; later calls update the page while it shows. |
## `ui-events`
Access: exported by the mod: the game calls it.
What the game calls on a mod that shows widgets (capability `ui`): what the player did with them.
| Function | Signature | What it does |
| --- | --- | --- |
| `select` | `func(id: u32, row: u32)` | A row of one of the mod's `list` widgets was picked: the widget's `id` and the row, on the screen or the settings page that showed it. |
| `closed` | `func()` | The player left the mod's screen (Esc or Done); not for a screen the mod closed itself or one that went away with the mod. |
| `settings-opened` | `func()` | The player opened the mod's settings page in Options > Mods: call `ui::set-settings` now to fill it with fresh widgets. |
## `events`
Access: exported by the mod: the game calls it.
+1
View File
@@ -87,6 +87,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
| Part | Level | Version |
| --- | --- | --- |
| Mod API: `log`, `game`, `chat`, `hud`, `storage`, `events` | preview | 0.1.0 |
| Mod API: `ui`, `ui-events` (a mod's screens and settings page) | experimental | manifest `experimental = ["ui"]` |
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
| Server mods (events, world, server commands) | not started | manifest `api = "0.3"`, experimental first |
| Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | |
+6 -3
View File
@@ -12,7 +12,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
| Part | Where | State |
|---|---|---|
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage; tick, frame, chat and command hooks |
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, ui; tick, frame, chat and command hooks |
| 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 |
@@ -86,6 +86,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
| `chat` | `chat` | `show(text)`: a line only the local player sees, after the mod's name (8 per call) |
| `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters |
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |
| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` |
| Hook (export) | When |
|---|---|
@@ -112,7 +113,6 @@ loading. Ordered by what the first native ports need:
| `target` | the crosshair's block or entity, with its state and NBT summary | Jade/WTHIT-style tooltips |
| `inventory-read` | the player's inventory, held item, open container | AppleSkin, recipe viewers |
| `recipes` | the recipe registry and lookups | JEI/EMI-style viewers |
| `ui` | widgets: text, item icons, lists, a screen the mod owns, a settings page in Options > Mods | built from vanilla's widgets (`ui`) so it matches the menus |
| `world-read` | blocks and biomes around the player, within the render distance | minimaps, schematics |
| `render` | world-space lines, boxes and ghost blocks in a per-frame budget | Litematica-style placement, waypoints |
| `registries` | new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the `.hmod`'s `assets/` and `data/` | server side; see [Multiplayer](#multiplayer) |
@@ -285,10 +285,13 @@ authors = ["..."]
api = "0.1" # the API the SDK targets
side = "client" # client | server | both
capabilities = ["game-read", "hud", "chat"]
experimental = [] # experimental capabilities it uses, e.g. ["ui"]
```
Unknown keys are refused, so a typo is an error, not a silently ignored
permission.
permission. An experimental capability (today `ui`) may change in any release
([stability](api/stability.md)): a mod uses it only by naming it in
`experimental` as well, so nobody depends on it by accident.
### Content mods (API 0.2)
+88 -9
View File
@@ -1103,6 +1103,34 @@ fn with_java(cmd: &mut Command, home: &Path) {
}
}
/// The Minecraft folder a preparation whose game files are imported already reads: the sounds,
/// the title panorama, the fonts and the languages come from its asset store. It is the one
/// `resolve` found, linked into the official layout for another launcher's files; without one, the
/// chosen folder, and the preparation goes without them, saying so. Before, such a preparation
/// read the chosen folder as it was, which a Prism Launcher or Modrinth App install does not
/// keep its assets in: the game was silent and its title screen grey.
fn assets_folder(
resolved: Result<sources::Resolved, String>,
chosen: &Path,
version: &str,
log: &dyn Fn(String),
) -> PathBuf {
match resolved {
Ok(sources::Resolved::Folder(dir)) => dir,
Ok(sources::Resolved::NotFound(_)) => {
log(format!(
"Minecraft {version}'s assets were not found in any launcher folder: the game \
will be silent and the title screen plain"
));
chosen.to_path_buf()
}
Err(e) => {
log(format!("Minecraft {version}'s assets: {e}"));
chosen.to_path_buf()
}
}
}
/// Checks what the preparation needs and picks how to run it. PLAY.txt describes the same
/// steps: the player's own launcher install, the server jar from that version's own json, and
/// Java (and Python, for versions before horizon-prepare). Also returns the Minecraft folder
@@ -1117,9 +1145,8 @@ pub fn preparer(
let dir = version_dir(tag);
let reused = horizon_prepare::artifacts::reuse_imported(&dir, &|line| log(line.to_string()))?;
let chosen = minecraft.to_path_buf();
let minecraft = if reused {
minecraft.to_path_buf()
} else {
// Resolved even when the game files are reused: the assets still come from the install.
let minecraft = {
let pins = read_json(&dir.join("research-lock.json"))
.and_then(|lock| sources::Pins::read(&lock))
.ok_or("The version's research-lock.json doesn't pin the Minecraft files")?;
@@ -1139,12 +1166,17 @@ pub fn preparer(
Ok(body)
};
let linked = data_dir().join("linked-minecraft");
match sources::resolve(minecraft, &pins, &known, &linked, &fetch, log)? {
sources::Resolved::Folder(dir) => dir,
sources::Resolved::NotFound(checked) => {
let not_found = sources::not_found(&version, &checked, minecraft);
download_for_owner(&pins, &own, &fetch, log).map_err(|e| not_found + &e)?;
own
let resolved = sources::resolve(minecraft, &pins, &known, &linked, &fetch, log);
if reused {
assets_folder(resolved, &chosen, &version, log)
} else {
match resolved? {
sources::Resolved::Folder(dir) => dir,
sources::Resolved::NotFound(checked) => {
let not_found = sources::not_found(&version, &checked, minecraft);
download_for_owner(&pins, &own, &fetch, log).map_err(|e| not_found + &e)?;
own
}
}
}
};
@@ -2353,6 +2385,53 @@ mod tests {
use super::*;
use std::cmp::Ordering::*;
/// A preparation that reuses the imported game files still reads the assets from the
/// launcher that has them: a Prism Launcher instance's are in the launcher root, linked into
/// the official layout (jan.sonki on Discord: silent, grey title screen since 0.2.48).
#[test]
fn reused_imports_take_the_assets_from_the_launcher_that_has_them() {
let dir =
std::env::temp_dir().join(format!("horizon-reused-assets-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let client = b"fake client jar";
let manifest = br#"{"id": "26.3"}"#;
let write = |path: &str, data: &[u8]| {
let path = dir.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, data).unwrap();
};
let prism = "PrismLauncher";
write(
&format!("{prism}/libraries/com/mojang/minecraft/26.3/minecraft-26.3-client.jar"),
client,
);
write(&format!("{prism}/assets/indexes/34.json"), b"{}");
let instance = dir.join(prism).join("instances/26.3/minecraft");
fs::create_dir_all(&instance).unwrap();
let pins = sources::Pins::read(&serde_json::json!({ "minecraft": {
"version": "26.3",
"client_sha1": sources::sha1_hex(client),
"manifest_sha1": sources::sha1_hex(manifest),
}}))
.unwrap();
let linked = dir.join("linked");
let fetch = |_: &str| Ok(manifest.to_vec());
let lines = std::cell::RefCell::new(Vec::new());
let log = |line: String| lines.borrow_mut().push(line);
let resolved = sources::resolve(&instance, &pins, &[], &linked, &fetch, &log);
let folder = assets_folder(resolved, &instance, "26.3", &log);
assert_eq!(folder, linked);
assert!(folder.join("assets/indexes/34.json").is_file());
// Nothing found: the chosen folder, and a line saying what the game will lack.
let empty = dir.join("empty");
fs::create_dir_all(&empty).unwrap();
let resolved = sources::resolve(&empty, &pins, &[], &linked, &fetch, &log);
assert_eq!(assets_folder(resolved, &empty, "26.3", &log), empty);
assert!(lines.borrow().last().unwrap().contains("will be silent"));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn resources_without_sounds_are_reported() {
let dir = std::env::temp_dir().join(format!("horizon-no-sounds-{}", std::process::id()));
+18
View File
@@ -191,6 +191,24 @@ fn client_jars(base: &Path, version: &str) -> Vec<String> {
jars.push(jar);
}
}
jars.extend(loader_jars(base, version));
jars
}
/// The client jars the Modrinth App keeps for a mod loader's profile: `versions/<v>-<loader>/`,
/// the vanilla jar under the loader's name, when only a Fabric or Quilt profile of the version
/// was ever played (#1783). The jar is checked against the pin like the others.
fn loader_jars(base: &Path, version: &str) -> Vec<String> {
let prefix = format!("{version}-");
let mut jars: Vec<String> = fs::read_dir(base.join("versions"))
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| name.starts_with(&prefix))
.map(|name| format!("versions/{name}/{name}.jar"))
.collect();
jars.sort();
jars
}
+23
View File
@@ -289,6 +289,29 @@ fn the_modrinth_app_atlauncher_and_gdlauncher_are_found() {
}
}
/// A Modrinth App that only ever played a Fabric profile of 26.3 keeps the vanilla client jar
/// under the loader's name (#1783); a profile of another version beside it is not taken.
#[test]
fn a_modrinth_loader_profile_has_the_client_jar() {
let s = Scratch::new("modrinth-fabric");
s.write(
"ModrinthApp/meta/versions/26.3.1-0.18.1/26.3.1-0.18.1.jar",
b"other",
);
let jar = "versions/26.3-0.18.1/26.3-0.18.1.jar";
let dir = s.launcher("ModrinthApp", "meta", jar);
s.write(
"ModrinthApp/meta/versions/26.3-0.18.1/26.3-0.18.1.json",
br#"{"id":"26.3-0.18.1","inheritsFrom":"26.3"}"#,
);
let profile = dir.join("profiles/Fabric 26.3");
fs::create_dir_all(&profile).unwrap();
let linked = s.0.join("linked");
let got = resolve_with(&profile, &[], &linked, &Mojang::new(MANIFEST)).unwrap();
assert_eq!(got, linked);
assert_linked(&linked, &dir.join("meta"), jar);
}
#[test]
fn an_instance_folder_may_be_chosen() {
let s = Scratch::new("instance");
@@ -84,7 +84,12 @@ import net.minecraft.world.phys.Vec3;
* still), {@code sync NAME} (runs {@code time query gametime}, whose reply is
* not recorded, and prints {@code synced NAME} once it is back: the server has
* handled everything this player sent before, and everything the server sent
* this player before the reply has been recorded), {@code quit NAME}, {@code
* this player before the reply has been recorded), {@code respawn NAME} (the
* death screen's button, ServerboundClientCommandPacket PERFORM_RESPAWN),
* {@code use NAME PITCH} (the main hand's item used looking that way,
* ServerboundUseItemPacket), {@code pickup NAME SLOT} (a left click on that
* slot of the player's inventory menu, claiming nothing), {@code quit NAME},
* {@code
* players on|off} (whether each player also records the other players added
* to and removed from its level, and its own respawns into a dimension),
* {@code chat on|off} (whether the system chat each player receives is
@@ -130,6 +135,12 @@ public final class LocatorBarClient {
players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundPlayerAbilitiesPacket(abilities));
}
case "still" -> players.get(w[1]).still();
case "respawn" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundClientCommandPacket(
net.minecraft.network.protocol.game.ServerboundClientCommandPacket.Action.PERFORM_RESPAWN));
case "use" -> players.get(w[1]).use(Float.parseFloat(w[2]));
case "pickup" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
0, 0, Short.parseShort(w[2]), (byte) 0, net.minecraft.world.inventory.ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
case "sync" -> {
try {
players.get(w[1]).sync();
@@ -250,6 +261,13 @@ public final class LocatorBarClient {
}
}
private int sequence;
void use(float pitch) throws Exception {
send(new net.minecraft.network.protocol.game.ServerboundUseItemPacket(
net.minecraft.world.InteractionHand.MAIN_HAND, ++sequence, position.yRot(), pitch));
}
void still() throws Exception {
PositionMoveRotation at = position;
// A tick of its own: the server takes one position per client tick (receivedPositionThisTick).
+723 -16
View File
@@ -49,10 +49,22 @@ name = "Rendering"
id = "launcher"
name = "Launcher"
[[area]]
id = "platforms"
name = "Platforms"
[[area]]
id = "extras"
name = "Horizon extras"
[[area]]
id = "ai"
name = "AI and agents"
[[area]]
id = "community"
name = "Community"
[[area]]
id = "project"
name = "Code and tools"
@@ -206,6 +218,75 @@ dev = """A review of the whole tree: dead code and stubs removed (#1432, #1658),
feature to its files and harnesses."""
links = ["#1432", "#1658", "#1453", "#1454", "#1448", "#1707", "docs/architecture.md"]
[[item]]
id = "native-shaders"
title = "Horizon's own shader format and kit"
status = "now"
area = "rendering"
plain = """Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it \
runs, and the registry will list them."""
dev = """`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, \
options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the \
example pack Golden Hour. Merged: the check every pack passes (#1820) and the tool, templates and guide \
(#1822). In review: the post stage running in the game with hot reload (#1861), the sky stage (#1863) and \
registry checks that use the game's own check (#1855). Left: the material, lighting and shadow stages, a \
last-good fallback on reload, a GPU-time overlay per stage and budgets (docs/shaders.md, \
docs/api/shaders.md). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from \
scratch'."""
links = ["#1820", "#1822", "#1861", "#1863", "#1855", "docs/shaders.md", "docs/api/shaders.md"]
[[item]]
id = "server-mods"
title = "Mods that run on the server"
status = "now"
area = "mods"
plain = """Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react \
to players joining, chat and blocks being broken or placed, and that add commands."""
dev = """API 0.3, experimental first (#1833, in review): the same sandboxed component runs on the server thread and \
the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or \
left), game rules and server commands with typed arguments. The world, item and entity interfaces come after \
(see 'Mods that read and change the world'). docs/api/mods.md, docs/mods.md."""
links = ["#1833", "docs/api/mods.md", "docs/mods.md"]
[[item]]
id = "mod-screens"
title = "Screens, input and drawing for mods"
status = "now"
area = "mods"
plain = """Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger \
mods, such as the Orbital Laser, can run on the normal game."""
dev = """Capability `ui` is in review (#1864): mod-owned screens and a settings page under Options > Mods, built from \
vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a \
budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance \
test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game')."""
links = ["#1864", "docs/mods.md"]
[[item]]
id = "modder-tools"
title = "Tools for people who make mods"
status = "now"
area = "mods"
plain = """Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and \
clear promises about what keeps working from one version to the next."""
dev = """`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot (#1816) and, for server \
mods, scripted server events (#1837); the content template and the guides are merged (#1859, #1810, \
docs/api/stability.md). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a \
frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference \
entry is removed without a new major version, and the SDK on crates.io once the API is stable."""
links = ["#1816", "#1837", "docs/api/stability.md", "website/docs/test-a-mod.md"]
[[item]]
id = "pregen"
title = "Pre-generate an area with /pregen"
status = "now"
area = "servers"
plain = """A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when \
they explore."""
dev = """Generates, lights and saves an area before anyone walks there (#1805, a contributor's pull request, in \
review). Useful on servers, and it moves the generation cost away from the first visit; the generation \
speed-ups are under 'Steady frames at long render distances'."""
links = ["#1805"]
# ---------------------------------------------------------------------------------------------------- Next
[[item]]
@@ -239,11 +320,14 @@ id = "benchmarks"
title = "Fresh benchmarks against vanilla"
status = "next"
area = "performance"
plain = """New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way \
every time, on the website's Benchmarks page."""
plain = """New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every \
time, with a history that shows what each release changed."""
dev = """Redo the comparison after the current performance work (#1073); reliability, coverage and presentation \
(#1346); a scene must count as loaded only once its first chunk arrives (#1749). Players' reports come in through \
`--benchmark` and `horizon-launcher data benchmark` (docs/benchmark.md)."""
(#1346); a scene must count as loaded only once its first chunk arrives (#1749). Next: a performance history \
across releases (an automatic comparison of each release with the last, on a timeline), more report fields \
(server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on \
battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and \
`horizon-launcher data benchmark` (docs/benchmark.md)."""
links = ["#1073", "#1346", "#1749", "docs/benchmark.md"]
[[item]]
@@ -262,36 +346,659 @@ links = ["#1496", "#1609", "#1608", "#1704", "#1758"]
id = "intel-mac"
title = "Horizon on Intel Macs"
status = "next"
area = "launcher"
plain = """Builds of the game and the launcher for Macs with an Intel processor."""
area = "platforms"
plain = "Builds of the game and the launcher for Macs with an Intel processor."
dev = """x86_64 game and launcher builds, picked per processor (#1171). Before it ships it needs a run on a real \
Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: #1161, #1792."""
links = ["#1171", "#1161", "#1792"]
[[item]]
id = "world-entry"
title = "Faster world creation and entry on small CPUs"
status = "next"
area = "performance"
plain = """Creating a world and joining it should be quick even on a four-thread laptop, where a fresh world at 32 \
chunks can make you wait minutes for the first terrain."""
dev = """Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on \
structure starts, and leave one core free for the rest of the game as vanilla does (#1760 and #1778, in \
review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the \
first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. \
Measured with `tools/perf_bench.py` (docs/benchmark.md, docs/profiling.md)."""
links = ["docs/benchmark.md", "docs/profiling.md"]
[[item]]
id = "server-tick"
title = "Servers that hold 20 TPS with 50 to 100 players"
status = "next"
area = "performance"
plain = """Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops \
to about 10 at 100."""
dev = """Measured against vanilla on equal work (docs/server.md, Scale): Horizon is ahead with few players and when \
exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity \
changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks \
and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark \
(`horizon-bots`) is the proof."""
links = ["docs/server.md"]
[[item]]
id = "low-end-mode"
title = "Low-end computers: Steam Deck, old Macs, 8 GB"
status = "next"
area = "performance"
plain = """Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of \
memory, with a battery mode that saves power on laptops."""
dev = """Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and \
animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory \
use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported \
on the Benchmarks page."""
links = ["docs/benchmark.md", "website/docs/performance.md"]
[[item]]
id = "upscaling"
title = "Native upscaling"
status = "next"
area = "performance"
plain = """Draw the game at a lower resolution and sharpen it to your screen's size, in the way of FSR or DLSS, to gain \
frames on weaker graphics cards. An optional graphics setting."""
dev = """A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. \
Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue \
#1403."""
links = ["#1403"]
[[item]]
id = "targeted-optimisations"
title = "A batch of targeted optimisations"
status = "next"
area = "performance"
plain = """Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster \
structure placement, high-rate mice and very large texture packs."""
dev = """Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is \
near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw \
shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial \
chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas \
packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy \
scenes (#1461). Profile first, keep what pays, show before and after numbers."""
links = ["#1461", "docs/profiling.md"]
[[item]]
id = "convert-to-rust"
title = "A Convert to Rust button for what isn't rewritten yet"
status = "next"
area = "modpacks"
plain = """When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be \
converted, so you don't have to wait for the rewrite."""
dev = """Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item \
definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API \
allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. \
The browser says what works before the install. Java code itself does not run (docs/modpacks.md, \
docs/mod-browser.md)."""
links = ["docs/modpacks.md", "docs/mod-browser.md"]
[[item]]
id = "code-mods"
title = "Mods with code: machines, magic, mobs"
status = "next"
area = "modpacks"
plain = """Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are \
most of what a big modpack is made of."""
dev = """333 of the 496 mods in a big pack (ATM10) contain code. Issue #1854 ranks the API gaps (ticking block \
entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the \
recommended path and a furnace ported tick for tick as proof. The first API step is a draft (#1189, see \
'Machines and blocks with inventories'). The path isn't started yet (docs/modpacks.md)."""
links = ["#1854", "docs/modpacks.md"]
[[item]]
id = "datapack-browser"
title = "Data packs in the mod browser"
status = "next"
area = "modpacks"
plain = """Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and \
resource packs."""
dev = """A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the \
same checks and 'works in Horizon' notes as the other kinds (docs/mod-browser.md)."""
links = ["docs/mod-browser.md"]
[[item]]
id = "prepare-without-java"
title = "Prepare a version without Java"
status = "next"
area = "servers"
plain = """Set up Horizon, a server first, without Java on your computer: published tables are combined with your own \
copy of the game."""
dev = """Today `horizon-prepare` runs Mojang's exporters with Java. Issue #1750 proposes publishing the computed \
version tables once from CI and assembling the rest locally from the player's own jar and assets, provided \
the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A \
server-only prepare from `server.jar` alone is the first step (docs/server.md)."""
links = ["#1750", "docs/server.md"]
[[item]]
id = "server-admin"
title = "Server console, bans and a full admin API"
status = "next"
area = "servers"
plain = """Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin \
API and an MCP server for AI agents."""
dev = """`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API \
additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics \
endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, \
server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC \
over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon \
server. docs/server.md, docs/api/server.md."""
links = ["docs/server.md", "docs/api/server.md"]
[[item]]
id = "hosting-polish"
title = "Hosting polish: Windows service, safe updates"
status = "next"
area = "servers"
plain = """Smoother hosting: a real Windows service, a backup before every update, backups that keep the world and each \
player's state, and a changed view distance or message without a restart."""
dev = """A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config \
for view distance and the message of the day (needs engine support); backups that keep world and player \
state (#1808, a contributor's draft, in review). docs/hosting.md, docs/server.md."""
links = ["#1808", "docs/hosting.md"]
[[item]]
id = "tailscale-hosting"
title = "Guided hosting with Tailscale and an invite button"
status = "next"
area = "servers"
plain = """In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to \
open ports on a router."""
dev = """On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that \
explains and checks each step, and an invite button that hands friends what they need to join over the \
tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over \
Tailscale is already described in docs/server.md."""
links = ["docs/hosting.md", "docs/server.md"]
[[item]]
id = "docker-image"
title = "An official Docker image for servers"
status = "next"
area = "servers"
plain = """An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game \
files mounted in."""
dev = """The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian \
bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and \
friends answering the first-start questions), and anyone can build it (docs/hosting.md, section 9). Left: \
publish the image to a public registry from the release workflow, tagged per release, so it follows the same \
checks, and update the docs and the launcher. The image never holds Mojang's files."""
links = ["docs/hosting.md", "docs/server.md"]
[[item]]
id = "one-single-player-path"
title = "One single-player path: remove the in-frame world"
status = "next"
area = "gameplay"
plain = """Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer \
bugs. The older in-frame mode goes once everything passes without it."""
dev = """`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the \
window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity \
harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' \
and docs/server-levels.md."""
links = ["docs/server-levels.md"]
[[item]]
id = "villages-combat-parity"
title = "Villages and combat, exactly as in vanilla"
status = "next"
area = "gameplay"
plain = """Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their \
homes, mace smashes and knockback."""
dev = """Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on \
dedicated servers, flower offers (#1639 is one report). Combat: the mobs' `attack_knockback` attribute, mace \
smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against \
vanilla by a harness (docs/rust-parity.md)."""
links = ["#1639", "docs/rust-parity.md"]
[[item]]
id = "audio-device"
title = "Choose the audio output device"
status = "next"
area = "gameplay"
plain = "Pick the speakers or headphones Horizon plays through, in Music & Sounds."
dev = """An output device list in Music & Sounds, falling back to the system default. #1035 started it: a player on \
an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed."""
links = ["#1035"]
[[item]]
id = "mod-world-items"
title = "Mods that read and change the world"
status = "next"
area = "mods"
plain = """Mods that can see and change blocks, read inventories and items, and find and affect entities, all through \
the server so every player sees the same thing."""
dev = """After server mods: read blocks, states and biomes in loaded chunks and change them through the server's \
block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, \
open container and item stacks with components; entity queries near a position, a safe subset of entity data \
and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot \
paths. docs/api/mods.md ('Not there yet')."""
links = ["docs/api/mods.md", "docs/modpacks.md"]
[[item]]
id = "machines-api"
title = "Machines and blocks with inventories (API 0.4)"
status = "next"
area = "mods"
plain = """Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology \
mods of big modpacks."""
dev = """Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, \
energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed \
client and server channel and typed config beside them. A draft (#1189) proves a furnace ported tick for \
tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges \
(docs/modpacks.md, docs/api/stability.md)."""
links = ["#1189", "docs/modpacks.md"]
[[item]]
id = "mod-languages"
title = "Lua and other languages for mods"
status = "next"
area = "mods"
plain = """Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass \
the tests. Every language runs in the same sandbox."""
dev = """Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest \
names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest \
can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or \
TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a \
template, an example and a CI test. docs/api/mods.md ('Ways in')."""
links = ["docs/api/mods.md"]
[[item]]
id = "shader-pack-examples"
title = "Well-known shader looks, rewritten for Horizon"
status = "next"
area = "rendering"
plain = """Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose \
licence is open source and compatible with ours are rewritten, never anything else."""
dev = """The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and \
recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the \
rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. \
Today only the Golden Hour example exists (`crates/horizon-shader-api/packs/golden_hour`)."""
links = ["docs/shaders.md", "crates/horizon-shader-api/packs/golden_hour"]
[[item]]
id = "beta-label"
title = "A visible beta label"
status = "next"
area = "launcher"
plain = """A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built."""
dev = """Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the \
version number (docs/versioning.md)."""
links = ["docs/versioning.md"]
[[item]]
id = "wayland-polish"
title = "Wayland and Hyprland polish"
status = "next"
area = "platforms"
plain = """Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a \
restart and the like."""
dev = """Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it (#1866). Earlier \
reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay (#1007, closed). \
Checked on a real Wayland session."""
links = ["#1866"]
[[item]]
id = "agent-control-protocol"
title = "A versioned agent-control protocol"
status = "next"
area = "ai"
plain = """A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an \
event stream so tools keep working as Horizon changes."""
dev = """`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; \
reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; \
`subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. \
Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: docs/agent-control.md, \
docs/api/agent-control.md."""
links = ["docs/agent-control.md", "docs/api/agent-control.md", "docs/api/stability.md"]
[[item]]
id = "own-agents-porting"
title = "Port mods with your own AI agent"
status = "next"
area = "ai"
plain = """You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives \
you the tools, but provides no hosted agents."""
dev = """A player runs their own agent locally with `horizon-ctl` and the MCP servers (docs/agent-control.md), `cargo \
horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide \
(website/docs/agents.md, docs/modpacks.md) and runs no agents, accounts or keys for players. The No AI \
switch turns all of it off."""
links = ["website/docs/agents.md", "docs/modpacks.md", "docs/agent-control.md"]
[[item]]
id = "x-account"
title = "Horizon on X (coming soon)"
status = "next"
area = "community"
plain = "Coming soon: an official Horizon account on X, used to announce releases and the weekly summary."
dev = """Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as \
soon as the account's credentials exist (roadmap/README.md, 'The journal'). Waiting for the account itself."""
links = ["roadmap/README.md"]
[[item]]
id = "agents-app"
title = "Pipeline protection and a GitHub App for agents"
status = "next"
area = "project"
plain = """Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller \
identity of their own instead of a personal account."""
dev = """`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the \
installers (#1015); the branch protection that makes it count waits for the `horizon-agent` GitHub App, \
which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or \
members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on \
(docs/security.md)."""
links = ["docs/security.md"]
[[item]]
id = "signed-installers"
title = "Signed updates everywhere, installers included"
status = "next"
area = "project"
plain = """Every download Horizon makes is checked against the project's signature, including the first install with \
install.sh and install.ps1."""
dev = """Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned \
(#1011, #1014). Left: the first install through `install.sh` and `install.ps1` still trusts only the \
checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship \
(docs/security.md)."""
links = ["docs/security.md"]
# ---------------------------------------------------------------------------------------------------- Later
[[item]]
id = "ai-companion"
title = "An optional AI teammate"
status = "later"
area = "extras"
plain = """An AI teammate that plays beside you and that you can talk to. It is off unless you turn it on, and the \
No AI switch hides it completely."""
dev = """Draft pull requests: the companion (#1068), its safety modes and their evaluation (#1069), and a route \
that finishes the game (#1070). Local models first; it ships only once the safety evaluation passes."""
area = "ai"
plain = """An optional AI teammate you can talk to by voice. It plays beside you, helps or finishes the game with you, \
runs on your computer first, and exists only if you turn it on. The No AI switch hides it."""
dev = """Draft pull requests: the companion (#1068), its safety modes and their evaluation (#1069) and a route that \
finishes the game (#1070). In the drafts: a companion that follows, fights, mines, crafts, sleeps and \
respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, \
helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to \
All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys \
optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably \
yet, voices for more languages, and the safety evaluation, which must pass before it ships."""
links = ["#1068", "#1069", "#1070"]
[[item]]
id = "new-versions"
title = "Following new Minecraft versions"
title = "Every Minecraft version, prepared automatically"
status = "later"
area = "gameplay"
plain = """When Mojang releases a new version of the game, Horizon follows it, and every system is checked against \
the new version again."""
dev = """The port pipeline detects a release, diffs its classes against 26.3 and writes a port report \
(docs/version-ports.md); the harnesses then run against the new version."""
plain = """Every past and future Minecraft version prepared for Horizon automatically. When Mojang releases one, \
Horizon follows it, and every system is checked against it again."""
dev = """The port pipeline (docs/version-ports.md, `tools/version_port.py`) detects a release, prepares it, diffs its \
classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and \
writes a port report that becomes issues. The goal is as little human work as possible for any version, past \
or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't \
installed yet. Two versions in one build are out of scope."""
links = ["docs/version-ports.md"]
[[item]]
id = "exact-far-terrain"
title = "Exact far terrain from real chunks"
status = "later"
area = "performance"
plain = """Far terrain drawn from the world you really built and explored, caves and builds included, not only a \
preview from the seed."""
dev = """Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas \
nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the \
far-terrain levels of detail already shipped; the open work on them is #718."""
links = ["#718"]
[[item]]
id = "older-jars"
title = "Older Fabric and Forge mods"
status = "later"
area = "modpacks"
plain = """Recognise mods built for versions before 26.1 in the mod browser, so they can be checked and carried over \
like newer ones."""
dev = """A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids \
with dashes (`fabric-api`); the port step then treats them like newer jars (docs/mod-browser.md)."""
links = ["docs/mod-browser.md"]
[[item]]
id = "modpack-own-packs"
title = "A modpack's own resource and shader packs"
status = "later"
area = "modpacks"
plain = """Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use \
them."""
dev = """Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it \
are left out. Resource packs can be carried as they are; shader packs go through the converter \
(docs/modpacks.md)."""
links = ["docs/modpacks.md"]
[[item]]
id = "whole-modpacks"
title = "Whole modpacks, such as ATM10, rewritten"
status = "later"
area = "modpacks"
plain = """Rewriting a complete big modpack for Horizon so it runs far better than the original. Not a priority now: \
the work above comes first."""
dev = """The project's first goal for modpacks: support them ten times better than vanilla and port any of them. It \
was set aside in favour of compatibility through the browser. What exists: ATM10's world generates and 42 of \
its 496 mods are carried at least in part (docs/modpacks.md). It depends on 'Mods with code' and the native \
mod API."""
links = ["docs/modpacks.md"]
[[item]]
id = "java-mod-bridge"
title = "Running Java mods that aren't rewritten"
status = "later"
area = "modpacks"
plain = """Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not \
started: the Convert button and native rewrites come first."""
dev = """Earlier research (#527, closed) found that Java mods can't simply run, because they call Minecraft's own \
classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. \
Whatever is chosen has to run inside the same sandbox and capability checks as native mods."""
links = ["#527"]
[[item]]
id = "alt-accounts"
title = "Ely.by and other sign-in providers"
status = "later"
area = "servers"
plain = """Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft."""
dev = """Issue #1270 asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It \
builds on the Microsoft sign-in (docs/microsoft-account.md)."""
links = ["#1270", "docs/microsoft-account.md"]
[[item]]
id = "friends-online-mode"
title = "Online-mode checks for Play with Friends"
status = "later"
area = "servers"
plain = """Friends who join through an invite code could prove who they are with their Microsoft account, as on \
online-mode servers."""
dev = """Play with Friends joins over an encrypted tunnel with an invite code (docs/play-with-friends.md, \
'Identity'). Online-mode checks against Mojang's session servers can be added on top of the same tunnel; \
they need Microsoft sign-in first, which is coming soon. Play with Friends stays self-hosted between \
players, with no relay run by the project."""
links = ["docs/play-with-friends.md"]
[[item]]
id = "orbital-laser"
title = "Orbital Laser on the normal game"
status = "later"
area = "mods"
plain = """The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the \
registry as the showcase of the mod API."""
dev = """A pull request on https://github.com/Stink-O/orbital-laser (MIT) once the input, ui, render and world \
capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its \
author's fork. Tested in the game, and listed in the registry."""
links = ["https://github.com/Stink-O/orbital-laser"]
[[item]]
id = "client-mod-natives"
title = "Native versions of popular client mods"
status = "later"
area = "mods"
plain = """Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world \
map and schematic overlays."""
dev = """Done as extras: a mod list and menu, a minimap and a zoom (docs/extras.md). Left: block info tooltips, a \
recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a \
schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` \
in the mod API, or they go into the game directly."""
links = ["docs/extras.md", "docs/mods.md"]
[[item]]
id = "create-physics"
title = "Create-style physics as a native mod"
status = "later"
area = "mods"
plain = """Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the \
base game."""
dev = """Issue #805 (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. \
It needs server mods, entities and rendering first."""
links = ["#805"]
[[item]]
id = "proximity-voice"
title = "Proximity voice chat as a native mod"
status = "later"
area = "mods"
plain = """Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod."""
dev = """Needs server mods, mod networking and an audio interface in the mod API before anything can be built \
(docs/api/mods.md)."""
links = ["docs/api/mods.md"]
[[item]]
id = "light-default-pack"
title = "A light default shader pack"
status = "later"
area = "rendering"
plain = """A small built-in look (tone mapping, colour grade, a cheap vignette) that costs almost nothing, for players \
who want a nicer picture without a heavy pack."""
dev = """An opt-in, composite-only pack on the native shader API, under about 0.3 ms at 1080p, shown on the Shader \
Packs page. Pack option sliders and profiles, and a Failed row with the full error, belong with it."""
links = ["docs/shaders.md"]
[[item]]
id = "ray-tracing"
title = "Optional ray tracing"
status = "later"
area = "rendering"
plain = """An optional ray-tracing mode for graphics cards that support it (RTX and recent AMD): shadows, water \
reflections and global lighting on top of the vanilla look."""
dev = """A spike with ray-traced sun shadows, off by default, is a draft (#1180). Reflections, global illumination, \
Apple M3 and newer if it is cheap, and a performance report per card come after."""
links = ["#1180"]
[[item]]
id = "hdr"
title = "HDR monitors"
status = "later"
area = "rendering"
plain = "Output for HDR monitors: brighter highlights and richer colour where your screen supports it."
dev = """Issue #1441. Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on \
Windows, macOS and Linux."""
links = ["#1441"]
[[item]]
id = "msaa-fullscreen"
title = "MSAA, anisotropic filtering, exclusive fullscreen"
status = "later"
area = "rendering"
plain = """More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. \
Today Horizon has FXAA and borderless fullscreen."""
dev = """Not implemented (docs/graphics.md). Anisotropic filtering needs linear magnification in wgpu, which blurs \
close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen \
where the backend supports it."""
links = ["docs/graphics.md"]
[[item]]
id = "video-studio"
title = "A complete video studio inside the game"
status = "later"
area = "extras"
plain = """Record, replay and edit videos without leaving the game: record in a keypress, replay a session, cut and \
finish the video. One of Horizon's original goals."""
dev = """Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. \
Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it \
exists yet; the agent-control socket (docs/agent-control.md) already lets tools drive the game for scripted \
shots."""
links = ["docs/agent-control.md"]
[[item]]
id = "controllers"
title = "Controllers: tested on real pads, and in the launcher"
status = "later"
area = "gameplay"
plain = """Make controller support solid: tested with real PlayStation and Xbox pads and a Steam Deck, and usable in \
the launcher too."""
dev = """The game side is merged (docs/controllers.md): pads read with gilrs, menus, rumble, glyphs, remapping and \
DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck \
(DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus \
navigation in the GPUI launcher."""
links = ["docs/controllers.md"]
[[item]]
id = "debug-screen"
title = "The full debug screen"
status = "later"
area = "gameplay"
plain = """Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump."""
dev = """Today F3 shows position, chunk, light, biome and FPS (docs/native-client.md). Vanilla also has F3+F6 debug \
options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump (#562 asked \
for the F3 editor)."""
links = ["#562", "docs/native-client.md"]
[[item]]
id = "lean-installs"
title = "Lean installs that use less disk"
status = "later"
area = "launcher"
plain = """Smaller installs: Horizon keeps one shared copy of the prepared game files and removes what only the \
preparation needed."""
dev = """A shared resource store keyed by the preparer's digest, no prepare intermediates left behind, the current \
and the previous version kept, and an automatic clean-up. Not started."""
[[item]]
id = "linux-arm64"
title = "Horizon on Linux ARM64"
status = "later"
area = "platforms"
plain = "Game and launcher builds for 64-bit ARM Linux computers."
dev = """Issue #1709: aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM \
machine with its Vulkan driver. The server build already drops audio and controllers \
(`--no-default-features`)."""
links = ["#1709"]
[[item]]
id = "opengl-fallback"
title = "OpenGL for old computers"
status = "later"
area = "platforms"
plain = "A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL."
dev = """Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL \
backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like \
the DirectX 12 fallback that already ships."""
links = ["docs/graphics.md"]
[[item]]
id = "phones-and-browser"
title = "Android, iOS and the browser"
status = "later"
area = "platforms"
plain = """Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the \
first pieces."""
dev = """No official build. Android was first closed as not planned, with touch controls, a mobile interface, \
packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a \
WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` \
compiling on iOS (#1813) and helper-thread limits on Android (#1751). Preparing without Java (#1750) matters \
here too. Horizon ships none of Mojang's files on any platform."""
links = ["#1813", "#1751"]
[[item]]
id = "video-series"
title = "A video series about Horizon"
status = "later"
area = "community"
plain = """A series of videos about how Horizon is made and what is new, with each upload announced on Discord."""
dev = """Kira already shows new uploads in a read-only Discord channel (done). The series follows each stage of the \
project: a script, edits, titles and a thumbnail per video."""
# ---------------------------------------------------------------------------------------------------- Help wanted
[[item]]
+39
View File
@@ -291,6 +291,19 @@ def entities():
cmd('/data get entity @e[tag=root,limit=1] Passengers[0].Health'),
cmd('/data get entity @e[tag=root,limit=1] Passengers[0].id'),
cmd('/kill @e[tag=root]'),
# Saved attribute bases are read for every mob, then Health or the max
# health when there is none (LivingEntity.readAdditionalSaveData, #1483).
cmd('/summon minecraft:cow 4.5 210 8.5 {Tags:["attr"],NoAI:1b,NoGravity:1b,'
'attributes:[{id:"minecraft:max_health",base:40.0d},{id:"minecraft:movement_speed",base:0.5d}]}'),
cmd('/data get entity @e[tag=attr,limit=1] Health'),
cmd('/attribute @e[tag=attr,limit=1] minecraft:max_health base get'),
cmd('/attribute @e[tag=attr,limit=1] minecraft:movement_speed base get'),
cmd('/kill @e[tag=attr]'),
cmd('/summon minecraft:zombie 4.5 210 8.5 {Tags:["attr"],NoAI:1b,NoGravity:1b,Health:7.0f,'
'attributes:[{id:"minecraft:attack_damage",base:9.0d}]}'),
cmd('/data get entity @e[tag=attr,limit=1] Health'),
cmd('/attribute @e[tag=attr,limit=1] minecraft:attack_damage base get'),
cmd('/kill @e[tag=attr]'),
*setup('/kill @e[type=minecraft:armor_stand]'),
{'op': 'tick', 'n': 25},
cmd('/execute if entity @e[tag=h]'),
@@ -327,6 +340,31 @@ def riding_teleport():
'/tp @s 8.5 230 8.5 0 0')
def mob_riding():
"""Mobs mount and dismount mobs with /ride, NBT passengers included (#1520)."""
rider, vehicle = '@e[tag=ride_r,limit=1]', '@e[tag=ride_v,limit=1]'
return [
*setup('/summon minecraft:cow 4.5 220 12.5 {NoAI:1b,NoGravity:1b,Tags:["ride_v"]}',
'/summon minecraft:pig 6.5 220 12.5 {NoAI:1b,NoGravity:1b,Tags:["ride_r"]}'),
cmd(f'/ride {rider} dismount'),
cmd(f'/ride {rider} mount @s'),
cmd(f'/ride {rider} mount {rider}'),
cmd(f'/ride {rider} mount {vehicle}'),
cmd(f'/ride {rider} mount {vehicle}'),
cmd(f'/ride {vehicle} mount {rider}'),
cmd('/execute as @e[tag=ride_r] on vehicle run say carrying'),
cmd(f'/ride {rider} dismount'),
cmd(f'/data get entity {rider} Pos'),
cmd('/execute as @e[tag=ride_r] on vehicle run say still carrying'),
*setup('/kill @e[tag=ride_r]', '/kill @e[tag=ride_v]'),
*setup('/summon minecraft:cow 4.5 220 12.5 {NoAI:1b,NoGravity:1b,Tags:["ride_v"],'
'Passengers:[{id:"minecraft:pig",NoAI:1b,NoGravity:1b,Tags:["ride_r"]}]}'),
cmd(f'/ride {rider} dismount'),
cmd(f'/data get entity {rider} Pos'),
*setup('/kill @e[tag=ride_r]', '/kill @e[tag=ride_v]'),
]
def scores():
return [
cmd('/scoreboard objectives add harness dummy'),
@@ -1017,6 +1055,7 @@ def groups():
('blocks and border', world_blocks()),
('entities', entities()),
('riding teleport', riding_teleport()),
('mob riding', mob_riding()),
('scores, teams and bossbars', scores()),
('execute', execute()),
('levels', levels()),
+50
View File
@@ -6252,6 +6252,56 @@ CHECKS += ['crystals']
CHECK_FNS.update(crystals=check_crystals)
def check_boat_dismount_pose(host, guest, out):
"""Removing a boat detaches its mobs on the server and every observing client."""
x, y, z = map(math.floor, pos_of(host))
bx, bz = x + 3.5, z + 3.5
tag = 'boat_dismount_pose'
host('command', command='gamemode creative')
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 8} {y - 1} {z + 8} minecraft:stone')
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 8} {y + 4} {z + 8} minecraft:air')
host('command', command=f'summon minecraft:oak_boat {bx} {y} {bz} {{Tags:["{tag}"]}}')
for _ in range(2):
host('command', command=f'summon minecraft:husk {bx} {y} {bz} {{NoAI:1b,Tags:["{tag}"]}}')
host.wait(5)
try:
host.wait(20)
boat = next(o for o in host('status')['objects']
if o['kind'].endswith('oak_boat') and math.dist(o['position'], [bx, y, bz]) < 2)
riders = [m['id'] for m in host.raw('mobs', 64)['near']
if m['kind'] == 'minecraft:husk' and m['mount'] == boat['id']]
assert len(riders) == 2, f'the boat did not collect both husks: {riders}'
for game in (host, guest):
game.wait(10)
for rider in riders:
m = mob_seen(game, rider)
assert m and m['mount'] == boat['id'] and m['passenger'], f'{game.name} did not see seated husk {rider}: {m}'
game.raw('look-at', bx, y + 1, bz)
game.shot(out, 'boat-passengers-seated')
host('command', command=f'tp @s {bx - 3} {y} {bz} 0 0')
host.wait(5)
# Aim at the near rim, below the riders, so their boxes cannot
# intercept the creative player's one hit that destroys the boat.
host.raw('look-at', bx - 0.55, y + 0.1, bz)
host.raw('mouse', 'left', 'click')
seen = {}
for game in (host, guest):
game.wait(20)
assert all(o['id'] != boat['id'] for o in game('status')['objects']), f'{game.name} still sees the boat'
seen[game.name] = [mob_seen(game, rider) for rider in riders]
game.raw('look-at', bx, y + 1, bz)
game.shot(out, 'boat-passengers-dismounted')
for m in seen[game.name]:
assert m and m['mount'] is None and not m['passenger'], f'{game.name} still draws a removed boat\'s passenger: {m}'
return {name: [{k: m[k] for k in ('id', 'mount', 'passenger')} for m in mobs] for name, mobs in seen.items()}
finally:
host('command', command=f'kill @e[tag={tag}]')
CHECKS += ['boat-dismount-pose']
CHECK_FNS['boat-dismount-pose'] = check_boat_dismount_pose
def check_sprint_fov(host, guest, out):
"""Releasing Ctrl must preserve a sprint until W is released, then reset FOV.
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""What a player holds on the cursor when they die, on a dedicated server, against vanilla's, seen by a vanilla client.
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world
vanilla generated first, with Alice an operator in survival and keep_inventory off. LocatorBarClient (a player made
of vanilla's protocol code) is given seven diamonds, picks them up onto its inventory menu's cursor (a PICKUP click)
ten blocks from the spawn, runs /kill on itself, waits on the death screen and respawns (PERFORM_RESPAWN). Twenty
ticks after dying vanilla removes the dead player (LivingEntity.tickDeath), and Player.remove's
inventoryMenu.removed drops the cursor's stack where it died. Alice then asks whether an item lies around the death
site; the reply, and the inventory slots, respawns and teleports she is sent from the death on, are compared
exactly.
"""
import sys
try:
from . import test_locator_bar_parity as locator
except ImportError:
import test_locator_bar_parity as locator
NAME = 'cursor-death-parity'
# server_dir and generate_world write these server.properties.
locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival')
SCRIPT = [
('setup', ['cmd Alice gamerule keep_inventory false', 'cmd Alice clear @s',
'cmd Alice give @s minecraft:diamond 7', 'cmd Alice tp @s 10.5 -60 0.5']),
# Hotbar slot 0 is the inventory menu's slot 36.
('pickup', ['pickup Alice 36']),
('death', ['cmd Alice kill @s']),
('respawn', ['respawn Alice']),
('check', ['chat on', 'cmd Alice execute if entity @e[type=minecraft:item,x=10.5,y=-60,z=0.5,distance=..4]']),
]
def shown(lines):
"""Alice's slots, respawns, teleports and chat from the death on."""
return [line for line in lines if len(parts := line.split(' ', 3)) >= 3
and parts[1] in ('step=death', 'step=respawn', 'step=check')
and parts[2] in ('slot', 'inventory', 'respawn', 'teleport', 'chat')]
def check(vanilla, _):
if not any(l.startswith('Alice step=check chat') for l in vanilla):
return 'vanilla answered no check command'
return None
def main():
lines = locator.one_player(NAME, SCRIPT, shown, check, 'A player dying with a stack on the inventory menu cursor '
'(keep_inventory off) and respawning on a dedicated server: the stack is dropped '
'where it died, not kept.')
print(f'cursor death: {len(lines)} lines identical')
if __name__ == '__main__':
try:
main()
except AssertionError as error:
sys.exit(f'{NAME}: {error}')
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Respawning in a hardcore world on a dedicated server, against vanilla's, seen by a vanilla client.
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one hardcore
flat world vanilla generated first, with Alice an operator in survival. LocatorBarClient (a player made of
vanilla's protocol code) joins, runs /kill on itself and presses the death screen's button
(ServerboundClientCommandPacket PERFORM_RESPAWN). Vanilla's ServerGamePacketListenerImpl.handleClientCommand
respawns the player, then makes it a spectator because the server is hardcore. The game mode changes and respawns
Alice is sent in the death and respawn steps are compared exactly.
"""
import sys
try:
from . import test_locator_bar_parity as locator
except ImportError:
import test_locator_bar_parity as locator
NAME = 'hardcore-respawn-parity'
# server_dir and generate_world write these server.properties.
locator.PROPERTIES = dict(locator.PROPERTIES, hardcore='true', gamemode='survival')
SCRIPT = [
('death', ['cmd Alice kill @s']),
('respawn', ['respawn Alice']),
]
def shown(lines):
"""Alice's game mode changes and respawns in the death and respawn steps."""
return [line for line in lines if len(parts := line.split(' ', 3)) >= 3
and parts[1] in ('step=death', 'step=respawn') and parts[2] in ('gamemode', 'respawn')]
def check(vanilla, _):
if 'Alice step=respawn gamemode 3' not in vanilla:
return 'vanilla did not make the respawned player a spectator'
return None
def main():
lines = locator.one_player(NAME, SCRIPT, shown, check, 'A player dying and respawning in a hardcore world on a '
'dedicated server: the respawn and the game mode it is sent (spectator).')
print(f'hardcore respawn: {len(lines)} lines identical')
if __name__ == '__main__':
try:
main()
except AssertionError as error:
sys.exit(f'{NAME}: {error}')
+72 -4
View File
@@ -205,12 +205,13 @@ def run_side(label, server_command, client_command, build, world):
return out.read_text(encoding='utf-8').splitlines()
def main():
def sides(name):
"""The vanilla and Horizon dedicated servers and the LocatorBarClient that joins them, built for harness
`name`: (lock, build directory, vanilla server, Horizon server, client) commands."""
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / NAME
build = lab.LOCAL / name
build.mkdir(parents=True, exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
(build / 'report.json').unlink(missing_ok=True)
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java'
classes = build / 'classes'
shutil.rmtree(classes, ignore_errors=True)
@@ -228,7 +229,74 @@ def main():
vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui']
client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient']
return lock, build, vanilla_server, horizon_server, client
def one_player(name, script, shown, check, scope, step_seconds=4.0):
"""A harness of one player, Alice: on each server she joins, then runs `script`, a list of (step, client
actions) `step_seconds` apart. The lines `shown(transcript)` keeps must match vanilla's exactly, and
`check(vanilla's shown lines, vanilla's transcript)` returns why vanilla's run is no proof (the script did
not run), or None."""
lock, build, vanilla_server, horizon_server, client = sides(name)
def run(label, server_command, world):
directory = build / label
port = server_dir(directory, world)
out = build / f'{label}.txt'
out.unlink(missing_ok=True)
server = Server(server_command, directory, label)
try:
server.wait_for(r'Done \(')
with (build / f'{label}-client.log').open('w') as log:
process = subprocess.Popen([*map(str, client), '127.0.0.1', str(port), str(out)], cwd=lab.ROOT,
stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, text=True)
def tell(line):
process.stdin.write(line + '\n')
process.stdin.flush()
try:
for line in ['players on', 'state on', 'step joins', 'join Alice']:
tell(line)
server.wait_for(r'Alice joined the game', timeout=120)
time.sleep(step_seconds)
for step, actions in script:
tell(f'step {step}')
for action in actions:
tell(action)
time.sleep(step_seconds)
tell('end')
process.wait(timeout=60)
finally:
if process.poll() is None:
process.kill()
finally:
server.stop()
if not out.exists():
raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)')
return out.read_text(encoding='utf-8').splitlines()
world = generate_world(vanilla_server, build)
vanilla_all = run('vanilla', vanilla_server, world)
horizon_all = run('horizon', horizon_server, world)
errors = [l for l in vanilla_all + horizon_all if ' error ' in f' {l} ' or 'disconnected' in l]
vanilla, horizon = shown(vanilla_all), shown(horizon_all)
if errors or vanilla != horizon:
import difflib
diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm=''))
raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n'
+ '\n'.join(errors[:10]) + '\n' + diff[:6000])
if (why := check(vanilla, vanilla_all)) is not None:
raise AssertionError(f'{why}: the script did not run (see {build})')
lab.write_json(build / 'report.json', dict(
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), scope=scope))
return vanilla
def main():
lock, build, vanilla_server, horizon_server, client = sides(NAME)
report = build / 'report.json'
world = generate_world(vanilla_server, build)
vanilla = run_side('vanilla', vanilla_server, client, build, world)
horizon = run_side('horizon', horizon_server, client, build, world)
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""An ender pearl in flight when its thrower dies, on a dedicated server, against vanilla's, seen by a vanilla client.
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world
vanilla generated first, with Alice an operator in survival. LocatorBarClient (a player made of vanilla's protocol
code) throws an ender pearl straight up (ServerboundUseItemPacket), runs /kill on itself while the pearl flies,
presses the death screen's button (PERFORM_RESPAWN) and waits for the pearl to come down, with the rule
ender_pearls_vanish_on_death true: ThrownEnderpearl.tick discards a dead owner's pearl, so the respawned player is
never teleported. The teleports, game mode changes and respawns Alice is sent from the throw on are compared
exactly.
"""
import sys
try:
from . import test_locator_bar_parity as locator
except ImportError:
import test_locator_bar_parity as locator
NAME = 'pearl-death-parity'
# server_dir and generate_world write these server.properties.
locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival')
SCRIPT = [
('setup', ['cmd Alice gamerule ender_pearls_vanish_on_death true', 'cmd Alice give @s minecraft:ender_pearl']),
# Straight up: it comes down about four seconds later, near where it was thrown.
('throw', ['use Alice -90', 'cmd Alice kill @s']),
('respawn', ['respawn Alice']),
('landed', []),
]
def shown(lines):
"""Alice's teleports, game mode changes and respawns from the throw on."""
steps = {f'step={step}' for step, _ in SCRIPT if step != 'setup'}
return [line for line in lines if len(parts := line.split(' ', 3)) >= 3 and parts[1] in steps
and parts[2] in ('teleport', 'gamemode', 'respawn')]
def check(vanilla, vanilla_all):
killed = any(l.startswith('Alice step=throw killed') for l in vanilla_all)
if not killed or 'Alice step=respawn respawn minecraft:overworld' not in vanilla:
return 'vanilla did not kill and respawn Alice'
return None
def main():
lines = locator.one_player(NAME, SCRIPT, shown, check, 'An ender pearl in flight when its thrower dies and '
'respawns on a dedicated server, with ender_pearls_vanish_on_death true: the pearl '
'vanishes and never teleports the player.')
print(f'pearl death: {len(lines)} lines identical')
if __name__ == '__main__':
try:
main()
except AssertionError as error:
sys.exit(f'{NAME}: {error}')
+654 -9
View File
@@ -59,10 +59,22 @@
"id": "launcher",
"name": "Launcher"
},
{
"id": "platforms",
"name": "Platforms"
},
{
"id": "extras",
"name": "Horizon extras"
},
{
"id": "ai",
"name": "AI and agents"
},
{
"id": "community",
"name": "Community"
},
{
"id": "project",
"name": "Code and tools"
@@ -255,6 +267,73 @@
],
"ongoing": true
},
{
"id": "native-shaders",
"title": "Horizon's own shader format and kit",
"status": "now",
"area": "rendering",
"plain": "Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them.",
"dev": "`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes (#1820) and the tool, templates and guide (#1822). In review: the post stage running in the game with hot reload (#1861), the sky stage (#1863) and registry checks that use the game's own check (#1855). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets (docs/shaders.md, docs/api/shaders.md). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.",
"links": [
"#1820",
"#1822",
"#1861",
"#1863",
"#1855",
"docs/shaders.md",
"docs/api/shaders.md"
]
},
{
"id": "server-mods",
"title": "Mods that run on the server",
"status": "now",
"area": "mods",
"plain": "Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.",
"dev": "API 0.3, experimental first (#1833, in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). docs/api/mods.md, docs/mods.md.",
"links": [
"#1833",
"docs/api/mods.md",
"docs/mods.md"
]
},
{
"id": "mod-screens",
"title": "Screens, input and drawing for mods",
"status": "now",
"area": "mods",
"plain": "Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.",
"dev": "Capability `ui` is in review (#1864): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').",
"links": [
"#1864",
"docs/mods.md"
]
},
{
"id": "modder-tools",
"title": "Tools for people who make mods",
"status": "now",
"area": "mods",
"plain": "Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.",
"dev": "`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot (#1816) and, for server mods, scripted server events (#1837); the content template and the guides are merged (#1859, #1810, docs/api/stability.md). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.",
"links": [
"#1816",
"#1837",
"docs/api/stability.md",
"website/docs/test-a-mod.md"
]
},
{
"id": "pregen",
"title": "Pre-generate an area with /pregen",
"status": "now",
"area": "servers",
"plain": "A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore.",
"dev": "Generates, lights and saves an area before anyone walks there (#1805, a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.",
"links": [
"#1805"
]
},
{
"id": "microsoft-sign-in",
"title": "Microsoft sign-in (coming soon)",
@@ -286,8 +365,8 @@
"title": "Fresh benchmarks against vanilla",
"status": "next",
"area": "performance",
"plain": "New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, on the website's Benchmarks page.",
"dev": "Redo the comparison after the current performance work (#1073); reliability, coverage and presentation (#1346); a scene must count as loaded only once its first chunk arrives (#1749). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` (docs/benchmark.md).",
"plain": "New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, with a history that shows what each release changed.",
"dev": "Redo the comparison after the current performance work (#1073); reliability, coverage and presentation (#1346); a scene must count as loaded only once its first chunk arrives (#1749). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` (docs/benchmark.md).",
"links": [
"#1073",
"#1346",
@@ -314,7 +393,7 @@
"id": "intel-mac",
"title": "Horizon on Intel Macs",
"status": "next",
"area": "launcher",
"area": "platforms",
"plain": "Builds of the game and the launcher for Macs with an Intel processor.",
"dev": "x86_64 game and launcher builds, picked per processor (#1171). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: #1161, #1792.",
"links": [
@@ -323,13 +402,328 @@
"#1792"
]
},
{
"id": "world-entry",
"title": "Faster world creation and entry on small CPUs",
"status": "next",
"area": "performance",
"plain": "Creating a world and joining it should be quick even on a four-thread laptop, where a fresh world at 32 chunks can make you wait minutes for the first terrain.",
"dev": "Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does (#1760 and #1778, in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` (docs/benchmark.md, docs/profiling.md).",
"links": [
"docs/benchmark.md",
"docs/profiling.md"
]
},
{
"id": "server-tick",
"title": "Servers that hold 20 TPS with 50 to 100 players",
"status": "next",
"area": "performance",
"plain": "Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.",
"dev": "Measured against vanilla on equal work (docs/server.md, Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.",
"links": [
"docs/server.md"
]
},
{
"id": "low-end-mode",
"title": "Low-end computers: Steam Deck, old Macs, 8 GB",
"status": "next",
"area": "performance",
"plain": "Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of memory, with a battery mode that saves power on laptops.",
"dev": "Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.",
"links": [
"docs/benchmark.md",
"website/docs/performance.md"
]
},
{
"id": "upscaling",
"title": "Native upscaling",
"status": "next",
"area": "performance",
"plain": "Draw the game at a lower resolution and sharpen it to your screen's size, in the way of FSR or DLSS, to gain frames on weaker graphics cards. An optional graphics setting.",
"dev": "A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue #1403.",
"links": [
"#1403"
]
},
{
"id": "targeted-optimisations",
"title": "A batch of targeted optimisations",
"status": "next",
"area": "performance",
"plain": "Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.",
"dev": "Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes (#1461). Profile first, keep what pays, show before and after numbers.",
"links": [
"#1461",
"docs/profiling.md"
]
},
{
"id": "convert-to-rust",
"title": "A Convert to Rust button for what isn't rewritten yet",
"status": "next",
"area": "modpacks",
"plain": "When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.",
"dev": "Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run (docs/modpacks.md, docs/mod-browser.md).",
"links": [
"docs/modpacks.md",
"docs/mod-browser.md"
]
},
{
"id": "code-mods",
"title": "Mods with code: machines, magic, mobs",
"status": "next",
"area": "modpacks",
"plain": "Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.",
"dev": "333 of the 496 mods in a big pack (ATM10) contain code. Issue #1854 ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft (#1189, see 'Machines and blocks with inventories'). The path isn't started yet (docs/modpacks.md).",
"links": [
"#1854",
"docs/modpacks.md"
]
},
{
"id": "datapack-browser",
"title": "Data packs in the mod browser",
"status": "next",
"area": "modpacks",
"plain": "Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.",
"dev": "A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds (docs/mod-browser.md).",
"links": [
"docs/mod-browser.md"
]
},
{
"id": "prepare-without-java",
"title": "Prepare a version without Java",
"status": "next",
"area": "servers",
"plain": "Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.",
"dev": "Today `horizon-prepare` runs Mojang's exporters with Java. Issue #1750 proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step (docs/server.md).",
"links": [
"#1750",
"docs/server.md"
]
},
{
"id": "server-admin",
"title": "Server console, bans and a full admin API",
"status": "next",
"area": "servers",
"plain": "Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin API and an MCP server for AI agents.",
"dev": "`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. docs/server.md, docs/api/server.md.",
"links": [
"docs/server.md",
"docs/api/server.md"
]
},
{
"id": "hosting-polish",
"title": "Hosting polish: Windows service, safe updates",
"status": "next",
"area": "servers",
"plain": "Smoother hosting: a real Windows service, a backup before every update, backups that keep the world and each player's state, and a changed view distance or message without a restart.",
"dev": "A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state (#1808, a contributor's draft, in review). docs/hosting.md, docs/server.md.",
"links": [
"#1808",
"docs/hosting.md"
]
},
{
"id": "tailscale-hosting",
"title": "Guided hosting with Tailscale and an invite button",
"status": "next",
"area": "servers",
"plain": "In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.",
"dev": "On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in docs/server.md.",
"links": [
"docs/hosting.md",
"docs/server.md"
]
},
{
"id": "docker-image",
"title": "An official Docker image for servers",
"status": "next",
"area": "servers",
"plain": "An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.",
"dev": "The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it (docs/hosting.md, section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.",
"links": [
"docs/hosting.md",
"docs/server.md"
]
},
{
"id": "one-single-player-path",
"title": "One single-player path: remove the in-frame world",
"status": "next",
"area": "gameplay",
"plain": "Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.",
"dev": "`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and docs/server-levels.md.",
"links": [
"docs/server-levels.md"
]
},
{
"id": "villages-combat-parity",
"title": "Villages and combat, exactly as in vanilla",
"status": "next",
"area": "gameplay",
"plain": "Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.",
"dev": "Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers (#1639 is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness (docs/rust-parity.md).",
"links": [
"#1639",
"docs/rust-parity.md"
]
},
{
"id": "audio-device",
"title": "Choose the audio output device",
"status": "next",
"area": "gameplay",
"plain": "Pick the speakers or headphones Horizon plays through, in Music & Sounds.",
"dev": "An output device list in Music & Sounds, falling back to the system default. #1035 started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.",
"links": [
"#1035"
]
},
{
"id": "mod-world-items",
"title": "Mods that read and change the world",
"status": "next",
"area": "mods",
"plain": "Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing.",
"dev": "After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. docs/api/mods.md ('Not there yet').",
"links": [
"docs/api/mods.md",
"docs/modpacks.md"
]
},
{
"id": "machines-api",
"title": "Machines and blocks with inventories (API 0.4)",
"status": "next",
"area": "mods",
"plain": "Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology mods of big modpacks.",
"dev": "Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft (#1189) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges (docs/modpacks.md, docs/api/stability.md).",
"links": [
"#1189",
"docs/modpacks.md"
]
},
{
"id": "mod-languages",
"title": "Lua and other languages for mods",
"status": "next",
"area": "mods",
"plain": "Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.",
"dev": "Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. docs/api/mods.md ('Ways in').",
"links": [
"docs/api/mods.md"
]
},
{
"id": "shader-pack-examples",
"title": "Well-known shader looks, rewritten for Horizon",
"status": "next",
"area": "rendering",
"plain": "Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.",
"dev": "The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`crates/horizon-shader-api/packs/golden_hour`).",
"links": [
"docs/shaders.md",
"crates/horizon-shader-api/packs/golden_hour"
]
},
{
"id": "beta-label",
"title": "A visible beta label",
"status": "next",
"area": "launcher",
"plain": "A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.",
"dev": "Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number (docs/versioning.md).",
"links": [
"docs/versioning.md"
]
},
{
"id": "wayland-polish",
"title": "Wayland and Hyprland polish",
"status": "next",
"area": "platforms",
"plain": "Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.",
"dev": "Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it (#1866). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay (#1007, closed). Checked on a real Wayland session.",
"links": [
"#1866"
]
},
{
"id": "agent-control-protocol",
"title": "A versioned agent-control protocol",
"status": "next",
"area": "ai",
"plain": "A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.",
"dev": "`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: docs/agent-control.md, docs/api/agent-control.md.",
"links": [
"docs/agent-control.md",
"docs/api/agent-control.md",
"docs/api/stability.md"
]
},
{
"id": "own-agents-porting",
"title": "Port mods with your own AI agent",
"status": "next",
"area": "ai",
"plain": "You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.",
"dev": "A player runs their own agent locally with `horizon-ctl` and the MCP servers (docs/agent-control.md), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (website/docs/agents.md, docs/modpacks.md) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.",
"links": [
"website/docs/agents.md",
"docs/modpacks.md",
"docs/agent-control.md"
]
},
{
"id": "x-account",
"title": "Horizon on X (coming soon)",
"status": "next",
"area": "community",
"plain": "Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.",
"dev": "Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist (roadmap/README.md, 'The journal'). Waiting for the account itself.",
"links": [
"roadmap/README.md"
]
},
{
"id": "agents-app",
"title": "Pipeline protection and a GitHub App for agents",
"status": "next",
"area": "project",
"plain": "Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.",
"dev": "`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers (#1015); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on (docs/security.md).",
"links": [
"docs/security.md"
]
},
{
"id": "signed-installers",
"title": "Signed updates everywhere, installers included",
"status": "next",
"area": "project",
"plain": "Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.",
"dev": "Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned (#1011, #1014). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship (docs/security.md).",
"links": [
"docs/security.md"
]
},
{
"id": "ai-companion",
"title": "An optional AI teammate",
"status": "later",
"area": "extras",
"plain": "An AI teammate that plays beside you and that you can talk to. It is off unless you turn it on, and the No AI switch hides it completely.",
"dev": "Draft pull requests: the companion (#1068), its safety modes and their evaluation (#1069), and a route that finishes the game (#1070). Local models first; it ships only once the safety evaluation passes.",
"area": "ai",
"plain": "An optional AI teammate you can talk to by voice. It plays beside you, helps or finishes the game with you, runs on your computer first, and exists only if you turn it on. The No AI switch hides it.",
"dev": "Draft pull requests: the companion (#1068), its safety modes and their evaluation (#1069) and a route that finishes the game (#1070). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.",
"links": [
"#1068",
"#1069",
@@ -338,15 +732,266 @@
},
{
"id": "new-versions",
"title": "Following new Minecraft versions",
"title": "Every Minecraft version, prepared automatically",
"status": "later",
"area": "gameplay",
"plain": "When Mojang releases a new version of the game, Horizon follows it, and every system is checked against the new version again.",
"dev": "The port pipeline detects a release, diffs its classes against 26.3 and writes a port report (docs/version-ports.md); the harnesses then run against the new version.",
"plain": "Every past and future Minecraft version prepared for Horizon automatically. When Mojang releases one, Horizon follows it, and every system is checked against it again.",
"dev": "The port pipeline (docs/version-ports.md, `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.",
"links": [
"docs/version-ports.md"
]
},
{
"id": "exact-far-terrain",
"title": "Exact far terrain from real chunks",
"status": "later",
"area": "performance",
"plain": "Far terrain drawn from the world you really built and explored, caves and builds included, not only a preview from the seed.",
"dev": "Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is #718.",
"links": [
"#718"
]
},
{
"id": "older-jars",
"title": "Older Fabric and Forge mods",
"status": "later",
"area": "modpacks",
"plain": "Recognise mods built for versions before 26.1 in the mod browser, so they can be checked and carried over like newer ones.",
"dev": "A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars (docs/mod-browser.md).",
"links": [
"docs/mod-browser.md"
]
},
{
"id": "modpack-own-packs",
"title": "A modpack's own resource and shader packs",
"status": "later",
"area": "modpacks",
"plain": "Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.",
"dev": "Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter (docs/modpacks.md).",
"links": [
"docs/modpacks.md"
]
},
{
"id": "whole-modpacks",
"title": "Whole modpacks, such as ATM10, rewritten",
"status": "later",
"area": "modpacks",
"plain": "Rewriting a complete big modpack for Horizon so it runs far better than the original. Not a priority now: the work above comes first.",
"dev": "The project's first goal for modpacks: support them ten times better than vanilla and port any of them. It was set aside in favour of compatibility through the browser. What exists: ATM10's world generates and 42 of its 496 mods are carried at least in part (docs/modpacks.md). It depends on 'Mods with code' and the native mod API.",
"links": [
"docs/modpacks.md"
]
},
{
"id": "java-mod-bridge",
"title": "Running Java mods that aren't rewritten",
"status": "later",
"area": "modpacks",
"plain": "Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not started: the Convert button and native rewrites come first.",
"dev": "Earlier research (#527, closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.",
"links": [
"#527"
]
},
{
"id": "alt-accounts",
"title": "Ely.by and other sign-in providers",
"status": "later",
"area": "servers",
"plain": "Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.",
"dev": "Issue #1270 asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in (docs/microsoft-account.md).",
"links": [
"#1270",
"docs/microsoft-account.md"
]
},
{
"id": "friends-online-mode",
"title": "Online-mode checks for Play with Friends",
"status": "later",
"area": "servers",
"plain": "Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.",
"dev": "Play with Friends joins over an encrypted tunnel with an invite code (docs/play-with-friends.md, 'Identity'). Online-mode checks against Mojang's session servers can be added on top of the same tunnel; they need Microsoft sign-in first, which is coming soon. Play with Friends stays self-hosted between players, with no relay run by the project.",
"links": [
"docs/play-with-friends.md"
]
},
{
"id": "orbital-laser",
"title": "Orbital Laser on the normal game",
"status": "later",
"area": "mods",
"plain": "The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.",
"dev": "A pull request on https://github.com/Stink-O/orbital-laser (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.",
"links": [
"https://github.com/Stink-O/orbital-laser"
]
},
{
"id": "client-mod-natives",
"title": "Native versions of popular client mods",
"status": "later",
"area": "mods",
"plain": "Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.",
"dev": "Done as extras: a mod list and menu, a minimap and a zoom (docs/extras.md). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.",
"links": [
"docs/extras.md",
"docs/mods.md"
]
},
{
"id": "create-physics",
"title": "Create-style physics as a native mod",
"status": "later",
"area": "mods",
"plain": "Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the base game.",
"dev": "Issue #805 (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.",
"links": [
"#805"
]
},
{
"id": "proximity-voice",
"title": "Proximity voice chat as a native mod",
"status": "later",
"area": "mods",
"plain": "Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod.",
"dev": "Needs server mods, mod networking and an audio interface in the mod API before anything can be built (docs/api/mods.md).",
"links": [
"docs/api/mods.md"
]
},
{
"id": "light-default-pack",
"title": "A light default shader pack",
"status": "later",
"area": "rendering",
"plain": "A small built-in look (tone mapping, colour grade, a cheap vignette) that costs almost nothing, for players who want a nicer picture without a heavy pack.",
"dev": "An opt-in, composite-only pack on the native shader API, under about 0.3 ms at 1080p, shown on the Shader Packs page. Pack option sliders and profiles, and a Failed row with the full error, belong with it.",
"links": [
"docs/shaders.md"
]
},
{
"id": "ray-tracing",
"title": "Optional ray tracing",
"status": "later",
"area": "rendering",
"plain": "An optional ray-tracing mode for graphics cards that support it (RTX and recent AMD): shadows, water reflections and global lighting on top of the vanilla look.",
"dev": "A spike with ray-traced sun shadows, off by default, is a draft (#1180). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.",
"links": [
"#1180"
]
},
{
"id": "hdr",
"title": "HDR monitors",
"status": "later",
"area": "rendering",
"plain": "Output for HDR monitors: brighter highlights and richer colour where your screen supports it.",
"dev": "Issue #1441. Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.",
"links": [
"#1441"
]
},
{
"id": "msaa-fullscreen",
"title": "MSAA, anisotropic filtering, exclusive fullscreen",
"status": "later",
"area": "rendering",
"plain": "More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.",
"dev": "Not implemented (docs/graphics.md). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.",
"links": [
"docs/graphics.md"
]
},
{
"id": "video-studio",
"title": "A complete video studio inside the game",
"status": "later",
"area": "extras",
"plain": "Record, replay and edit videos without leaving the game: record in a keypress, replay a session, cut and finish the video. One of Horizon's original goals.",
"dev": "Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket (docs/agent-control.md) already lets tools drive the game for scripted shots.",
"links": [
"docs/agent-control.md"
]
},
{
"id": "controllers",
"title": "Controllers: tested on real pads, and in the launcher",
"status": "later",
"area": "gameplay",
"plain": "Make controller support solid: tested with real PlayStation and Xbox pads and a Steam Deck, and usable in the launcher too.",
"dev": "The game side is merged (docs/controllers.md): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.",
"links": [
"docs/controllers.md"
]
},
{
"id": "debug-screen",
"title": "The full debug screen",
"status": "later",
"area": "gameplay",
"plain": "Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.",
"dev": "Today F3 shows position, chunk, light, biome and FPS (docs/native-client.md). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump (#562 asked for the F3 editor).",
"links": [
"#562",
"docs/native-client.md"
]
},
{
"id": "lean-installs",
"title": "Lean installs that use less disk",
"status": "later",
"area": "launcher",
"plain": "Smaller installs: Horizon keeps one shared copy of the prepared game files and removes what only the preparation needed.",
"dev": "A shared resource store keyed by the preparer's digest, no prepare intermediates left behind, the current and the previous version kept, and an automatic clean-up. Not started."
},
{
"id": "linux-arm64",
"title": "Horizon on Linux ARM64",
"status": "later",
"area": "platforms",
"plain": "Game and launcher builds for 64-bit ARM Linux computers.",
"dev": "Issue #1709: aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).",
"links": [
"#1709"
]
},
{
"id": "opengl-fallback",
"title": "OpenGL for old computers",
"status": "later",
"area": "platforms",
"plain": "A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL.",
"dev": "Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like the DirectX 12 fallback that already ships.",
"links": [
"docs/graphics.md"
]
},
{
"id": "phones-and-browser",
"title": "Android, iOS and the browser",
"status": "later",
"area": "platforms",
"plain": "Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.",
"dev": "No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS (#1813) and helper-thread limits on Android (#1751). Preparing without Java (#1750) matters here too. Horizon ships none of Mojang's files on any platform.",
"links": [
"#1813",
"#1751"
]
},
{
"id": "video-series",
"title": "A video series about Horizon",
"status": "later",
"area": "community",
"plain": "A series of videos about how Horizon is made and what is new, with each upload announced on Discord.",
"dev": "Kira already shows new uploads in a read-only Discord channel (done). The series follows each stage of the project: a script, edits, titles and a thumbnail per video."
},
{
"id": "benchmark-your-pc",
"title": "Benchmark your computer",