Compare commits

...
Author SHA1 Message Date
levzzz ddb317c771 Merge branch 'pr-5909-new' into mod-api-rework 2026-11-01 20:40:22 +05:00
[email protected]andClaude Opus 5.5 aef97f1e43 Merge upstream/main into staging/unified-hmod
Upstream added --player-dir handling to the startup code in main.rs. With the
library split that code lives in entry.rs, so the change is applied there and
main.rs stays the thin launcher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:35:47 +01:00
levzzz 3f3bdfa663 Merge branch 'pr-5909' into mod-api-rework 2026-11-01 20:24:21 +05:00
Ludovic Marie 9610099341 Merge pull request #6041 from horizon-rust/perf/lazy-loot-tables
🤖 Agent: measured on playhorizon (alternated, THP off): idle anonymous memory −7.2 MB on every start, Done and 20-bot MSPT unchanged (5 rounds). pr_ready ✅ (410 tests + 21 content root); clippy -D warnings and fmt clean on the Mac.
2026-11-01 16:24:04 +01:00
levzzzandClaude Opus 5.5 5482b975fb Merge hr/main into the mod API rework (#5446)
main.rs moved to entry.rs on this branch; carry main's --player-dir
log/crash folder handling over to entry.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:18:53 +05:00
[email protected]andClaude Opus 5.5 18a02fe574 Merge native/api-mods (#5446) into staging/unified-hmod
Combined staging branch: the game library split (#5446) and the single mod
file format (#5909) together, as agreed to land in one release. No conflicts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:17:21 +01:00
Ludovic Marie 6f54a627d6 Merge pull request #4947 from horizon-rust/server-asks/lod-client
🤖 Agent: main merged after #4945/#4946 landed: main's new wanted_tiles queue now skips tiles waiting out their retry, as this PR's loop did. verify green on a6d37d7; tools/server_lod_e2e.py passed on Linux at the previous head (source server, capped at 16; none when unset).
2026-11-01 16:11:02 +01:00
Ludovic Marie 97873acc8b Merge pull request #5723 from TobiasLogic/shaderpacks/pack-vignette
🤖 Agent: Review by Ludovic's AI agent (Claude). A pack's vignette=false hides the game's vignette; with no pack nothing changes. I merged main and reworded a comment that named another project. Verify green.
2026-11-01 16:10:56 +01:00
Ludovic Marie 5bdb8885e6 Merge pull request #5701 from TobiasLogic/shaderpacks/translation-fixes-2
🤖 Agent: Review by Ludovic's AI agent (Claude). GLSL translation fixes, each with a test (GLSL 4.60 §4.1.9, §4.2.2), packs-only code. I merged main in; verify green on this head.
2026-11-01 16:10:51 +01:00
Ludovic Marie 6f30860d03 Merge pull request #5693 from Joansitoh/fix-3172-pack-uniforms
🤖 Agent: main merged again (the two uniforms go after main's isRightHanded … hasCeiling, looked up by name); verify green on 67ebb02. Thanks @Joansitoh.
2026-11-01 16:04:52 +01:00
Ludovic Marie bcba31c931 Merge pull request #5513 from Solenopsisbot/accounts/player-dir
🤖 Agent: levzzz5154's five review points fixed on the branch (crash telemetry stays on --home's consent, servers ignore the flag, limits documented, splitscreen guests on the game folder's mods intended, tests for shared_dir_of and servers_file_of); main merged; verify green on 9d56f33. Without --player-dir nothing changes. Thanks @Solenopsisbot.
2026-11-01 16:04:47 +01:00
Ludovic Marie 6ff6183171 Merge pull request #5820 from Detimes/feat-player-respawn-hook
🤖 Agent: Review by Ludovic's AI agent (Claude). levzzz5154's two points are fixed (the hook runs through as_guest, after placement), the docs conflict with main is resolved, and the API fragment carries neoforge/side. Verify green.
2026-11-01 16:04:43 +01:00
Ludovic Marie 995dc2cef0 Merge pull request #5997 from oskal675-design/fix/shulker-box-player-lift
🤖 Agent: Opening shulker lids push the local player on the server-thread client as ShulkerBoxBlockEntity.tick does on both sides; lan_e2e shulker_lift check; verify green.
2026-11-01 16:04:38 +01:00
[email protected]andClaude Opus 5.5 8421d34846 Merge upstream/main into native/api-mods
Upstream added `pub mod lod_net;` to the old lib.rs module list, which this PR
moved to client_lib.rs: add it there. Upstream's new horizon-optifine
dependency is used by mobs/cem.rs, a reusable-client module, so it goes in
horizon-client-lib's manifest instead of horizon-client's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:03:03 +01:00
Ludovic Marie 486c22db0d Merge pull request #5646 from JustKarkat/test/experience-orb-stuck-case
🤖 Agent: #5601 has merged, so this test-only follow-up leaves draft: main merged (corpus union, every function checked against its source), verify green on 938f032. Thanks @JustKarkat.
2026-11-01 15:57:10 +01:00
Ludovic MarieandClaude Opus 5.5 a6d37d7f68 Merge main into #4947: failed tiles still wait out their retry in main's wanted_tiles queue
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:53:58 +01:00
Ludovic Marie f03d1b80a0 Merge pull request #5894 from Teiwazik/shader/block-texture
🤖 Agent: shader API series stacked on one union (each head holds the earlier ones); pr_ready --quick green on the last head 47b5a77 (2574 tests), shader-api tests + clippy on each earlier head; offscreen in-game run with a pack using all four features, no validation error.
2026-11-01 15:52:34 +01:00
Ludovic Marie 0817971f73 Merge pull request #6005 from kacpherek/perf/opaque-terrain-pass
🤖 Agent: Opaque terrain pass without discard: two solid passes on Metal (Apple's tiled GPUs, measured faster on M1 Pro), one pass elsewhere as before (fix pushed here); the far-tile opaque marking is fixed everywhere.
2026-11-01 15:52:27 +01:00
Ludovic Marie f860ccf026 Merge pull request #6012 from Sylviromi/datapack-command-reload
🤖 Agent: /datapack enable and disable reload the server's data as ReloadCommand.reloadPacks does (reload_world_tables, as /reload); four lines.
2026-11-01 15:52:19 +01:00
Ludovic Marie 737a34b70c Merge pull request #6010 from Xmilon/native/mouse-button-events
🤖 Agent: Raw mouse button pre/post hooks (fragments): no-ops without a mod, synthetic clicks skipped; test runs the real dispatch order.
2026-11-01 15:52:15 +01:00
Ludovic Marie 795b56ee09 Merge pull request #6000 from Xmilon/native/compute-fog-color
🤖 Agent: native_compute_fog_color hook (fragment): no-op without a mod, finite 0..=1 RGB only; test runs the real dispatch.
2026-11-01 15:52:11 +01:00
Ludovic Marie f113b76de6 Merge pull request #6006 from xFedeT/chore/port-map-stale
🤖 Agent: Port map only: 27 missing rows point at the code that ports them; skip-release.
2026-11-01 15:52:04 +01:00
Ludovic Marie cb356bf994 Merge pull request #5993 from HrafnaValkyrja/fix/boat-underwater
🤖 Agent: A boat used under water lands at the eye and is refused as in vanilla's BoatItem.use (ClipContext.Fluid.ANY on a full fluid cell); unit test, verify green.
2026-11-01 15:51:50 +01:00
Ludovic Marie 6f1501d309 Merge pull request #4946 from horizon-rust/server-asks/lod-server
🤖 Agent: far terrain 2/3 (the server samples tiles, off by default). #4945 and main merged, verify green on 6ee16d8; rdbtCVS approved, kacpherek's points fixed in the code; the stack's e2e passes on Linux.
2026-11-01 15:51:43 +01:00
Ludovic Marie de2577f9ff Merge pull request #5888 from Joansitoh/fix/5698-hide-break-particles
🤖 Agent: Extras: hide block-break particles, off by default; merged main in (extras.rs beside uiSoundReserve and splitscreen); verify green.
2026-11-01 15:51:35 +01:00
Ludovic Marie c6a691be44 Merge pull request #4945 from horizon-rust/server-asks/lod-wire
🤖 Agent: far terrain 1/3 (wire format, off by default). Main merged, verify green on 1f6b07c; rdbtCVS approved; the stack's e2e passes (tools/server_lod_e2e.py on Linux: server-sampled LOD capped at the server's 16 chunks, none when unset).
2026-11-01 15:51:30 +01:00
Ludovic Marie 7b97de798d Merge pull request #5868 from oskal675-design/fix/gliding-explosion-impulse
🤖 Agent: requeued: the parity failure was the runners' (a dead .horizon-prepare.lock link on runner horizon at playhorizon and horizon-2 at the Mac mini, now removed); the run is rerunning on the same head.
2026-11-01 15:51:25 +01:00
Ludovic Marie 9513756c20 Merge pull request #5944 from HrafnaValkyrja/fix/wide-mobs-suffocate
🤖 Agent: Wide mobs (hoglins…) suffocate in walls with vanilla's isInWall box; reviewed, verify green.
2026-11-01 15:51:20 +01:00
Ludovic Marie f9ccf49446 Merge pull request #5883 from Joansitoh/fix/5567-tnt-passengers
🤖 Agent: Primed TNT keeps its Passengers through save and load as vanilla's Entity.saveWithoutId; reviewed, verify green.
2026-11-01 15:51:13 +01:00
Ludovic MarieandClaude Opus 5.5 67ebb0257e Merge main into #5693: the two uniforms after main's isRightHanded … hasCeiling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:47:07 +01:00
Ludovic Marie c05ef84a56 Merge pull request #5598 from JulesB40/ccr-b42808eb-bs7uli
🤖 Agent: security-reviewed by Ludovic's AI agent (Claude): the secrets file is now encrypted at rest (ChaCha20-Poly1305, key from the computer's machine-id), mode 0600, atomic replace, no token in logs or errors, the owner gate unchanged. 58 account tests pass on Linux (playhorizon); verify and launcher green on 0de577b. Thanks @JulesB40.
2026-11-01 15:37:33 +01:00
Ludovic Marie 5b9ffaaec6 Merge pull request #5992 from Xmilon/native/detached-camera-distance
🤖 Agent: native_camera_distance hook: a no-op fragment hook until a mod fills it, finite 0..=1024 overrides only, collision clipping kept; unit tests cover it.
2026-11-01 15:37:29 +01:00
Ludovic Marie f4d7883389 Merge pull request #5854 from Sylviromi/screen-render-post-prompt
🤖 Agent: Review by Ludovic's AI agent (Claude). The hook skips the screen the server pack prompt hides, as hud_layers draws Playing then; arismoko's in-game check confirms. I merged main; verify green.
2026-11-01 15:37:24 +01:00
Ludovic Marie 3bcd9c3372 Merge pull request #5795 from Detimes/docs-5785-player-projectiles
🤖 Agent: Review by Ludovic's AI agent (Claude). Docs only: bow PvP between players works since 5763854b0 (lan_e2e arrows, #5785's dedicated-server check). I merged main; verify green.
2026-11-01 15:37:19 +01:00
Ludovic Marie bb1a6be3fe Merge pull request #5789 from kacpherek/fix/dragon-every-dimension
🤖 Agent: Review by Ludovic's AI agent (Claude). EnderDragonRenderer has no dimension check; dragon_geometry returns at once with no dragon, and the dragon is cleared on a dimension change. I merged main; verify green.
2026-11-01 15:37:12 +01:00
Ludovic Marie 9d15888d8e Merge pull request #5778 from abit-foggy/fix/lod-anisotropic-streaks
🤖 Agent: Review by Ludovic's AI agent (Claude). Far tiles sample an explicit mip, only on tile faces (UVs past a block); block faces keep sprite_grad. A few ALU ops per far-tile fragment. I merged main; verify green.
2026-11-01 15:37:07 +01:00
Ludovic Marie 97f84d9beb Merge pull request #5984 from NerfPhaselock/perf/translucent-arena
🤖 Agent: Translucent batching 1/3: off by default (only horizon-graphics.txt turns it on), so players see no change; with it off the frame adds one bool check (follow_translucent_layout) and a branch per draw run, uploads and compaction keep their old paths. Measured gains in the body are for the opt-in path.
2026-11-01 15:37:03 +01:00
Ludovic Marie d1a804f466 Merge pull request #5741 from Stink-O/native/mod-screens
🤖 Agent: Review by Ludovic's AI agent (Claude). levzzz5154's four points are addressed (overlay gate, client-only guard, gamepad note, #5617 merged in); API fragment. I merged main (merges with #5731 cleanly); verify green.
2026-11-01 15:36:58 +01:00
Ludovic Marie 57d83bfa07 Merge pull request #5910 from Sylviromi/recipe-filters
🤖 Agent: Data-pack and content-mod recipe removal/replacement by filter, load time only; reviewed, verify green.
2026-11-01 15:36:52 +01:00
Ludovic Marie 6c0bf45fd8 Merge pull request #5945 from HrafnaValkyrja/fix/spellcaster-hand-particles
🤖 Agent: Evoker and illusioner spell hand particles as vanilla's SpellcasterIllager.tick; reviewed, verify green.
2026-11-01 15:36:46 +01:00
Ludovic Marie cdda320c95 Merge pull request #5914 from HrafnaValkyrja/fix/plate-trident-rocket-hook
🤖 Agent: Tridents, rockets and fishing hooks press plates and trip tripwires (entity-inside checks as vanilla); reviewed, verify green.
2026-11-01 15:36:40 +01:00
Ludovic Marie 0d4edd409c Merge pull request #5988 from dill-lk/main
🤖 Agent: parity.py treats a remote OSError as a failed fetch instead of crashing; Python only, tests pass, verify green.
2026-11-01 15:36:36 +01:00
Ludovic MarieandClaude Opus 5.5 887f6e9750 Opaque pass: two solid passes on Metal only
The split saves Apple's tiled GPUs their hidden surface removal; elsewhere
the second pass doubled the solid draw calls (one per section without
multi-draw) and the vertex work for no gain that was measured. Other
backends keep the single fs_solid pipeline, the flat one included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:27:30 +01:00
Ludovic MarieandClaude Opus 5.5 28aa93145d Format the opaque pass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:23:51 +01:00
Ludovic Marie 15cc1e4d8e Merge remote-tracking branch 'origin/main' into pk1/6005 2026-11-01 15:22:57 +01:00
Ludovic Marie e6eed3e11f Merge pull request #5982 from TiggerTakkyTTT/fix/server-management-difficulty
🤖 Agent: Management protocol serversettings/difficulty get/set as MinecraftServerSettingsServiceImpl, saved to server.properties and server.toml; the management harness covers it.
2026-11-01 15:22:25 +01:00
Ludovic Marie c58aeb2c52 Merge pull request #5973 from oskal675-design/fix/boat-paddles-on-dismount
🤖 Agent: Boat paddle flags reset to false (AbstractBoat.DATA_ID_PADDLE_*) when a stroke stops or the rider dismounts; new harness boat_paddle_reset compares against a vanilla server.
2026-11-01 15:22:18 +01:00
Ludovic Marie e937f37995 Merge pull request #5972 from HrafnaValkyrja/fix/splash-hits-thrower
🤖 Agent: Thrown potions hit players with vanilla's pick margin and leftOwner rule (Projectile.canHitEntity / checkLeftOwner); costs nothing when no potion is in flight; unit test covers the thrower case.
2026-11-01 15:22:02 +01:00
Ludovic MarieandClaude Opus 5.5 9d56f3371a --player-dir: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:21:59 +01:00
Ludovic Marie 2ac1a8e83b Merge pull request #5958 from doodlyyousuf/fix/5912-painting-place-swing
🤖 Agent: Placing a painting swings the hand as vanilla's HangingEntityItem.useOn (InteractionResult.SUCCESS) does; tests pass, parity runs on the head.
2026-11-01 15:21:56 +01:00
Ludovic Marie 4b2196179a Merge pull request #5659 from some-du6e/optifine/emf-geometry
🤖 Agent: OptiFine series step verified (licence gate, gating to packs that ship the files, per-mob cost measured, verify green on 64d0b1a4e). Thanks @some-du6e.
2026-11-01 15:21:51 +01:00
Ludovic Marie 34cc13febe Merge pull request #5647 from some-du6e/optifine/emf-expr
🤖 Agent: OptiFine series step verified (licence gate, gating to packs that ship the files, per-mob cost measured, verify green on b88a1aad1). Thanks @some-du6e.
2026-11-01 15:21:45 +01:00
[email protected]andCodex 0190d098d6 feat(mods): raw mouse button pre and post events
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:17:44 +01:00
Ludovic Marie 71b1ed56fe Merge pull request #5622 from some-du6e/optifine/emf-jem
🤖 Agent: OptiFine series step verified (licence gate, gating to packs that ship the files, per-mob cost measured, verify green on d454a1fcb). Thanks @some-du6e.
2026-11-01 15:15:47 +01:00
Ludovic Marie ffb93f3791 Merge pull request #5986 from gigamans442/fix/custom-uniform-terminator
🤖 Agent: Shader packs: a custom variable/uniform expression may end with one ';' (trimmed), extra statements and empty expressions still rejected; unit tests.
2026-11-01 15:15:42 +01:00
Ludovic Marie 8d724b6150 Merge pull request #5980 from gigamans442/fix/shader-image-option-macros
🤖 Agent: Shader packs: custom image dimensions expand pack option macros (default and chosen), the option table is still built once; literals unchanged, unknown names still rejected; unit tests.
2026-11-01 15:15:38 +01:00
Ludovic Marie a61d6c8a5f Merge pull request #5974 from gigamans442/fix/shader-option-declared-default
🤖 Agent: Shader options: a declared default missing from its authored range stays selectable (inserted first), empty ranges still create no option; unit tests.
2026-11-01 15:15:33 +01:00
Ludovic Marie 7785e9c909 Merge pull request #5968 from gigamans442/fix/compute-dispatch-splats
🤖 Agent: Shader packs: a one-literal vector constant (ivec3(1)) in workGroups/workGroupsRender fills every component (GLSL 4.60 §5.4.2); negative or nested expressions still rejected; regressions fail before the fix.
2026-11-01 15:15:29 +01:00
Ludovic Marie de41830167 Merge pull request #5966 from TobiasLogic/shaderpacks/dimension-uniforms
🤖 Agent: Shader packs: isRightHanded, is_on_ground and the dimension's bedrockLevel/heightLimit/logicalHeightLimit/hasSkylight/hasCeiling get real values (black-box probe of the uniform values, no source read); set per frame only while a pack is on.
2026-11-01 15:15:24 +01:00
Ludovic Marie 75b1798f1d Merge pull request #5964 from TobiasLogic/shaderpacks/fog-frag-coord
🤖 Agent: Shader packs: gl_FogFragCoord becomes a float varying (hz_FogFragCoord) like gl_FrontColor; translator unit test; GLSL 1.20 spec, no other project's code. horizon-shaders only, verify green.
2026-11-01 15:15:19 +01:00
Ludovic Marie 8b11051f3b Merge pull request #5895 from Vaspyyy/feat/native-mob-submit
🤖 Agent: Client hook native_mob_submit as an API fragment; I fixed arismoko's two review points (walk state from mobs::anim::state: no walk while riding/dead, babies x3; hook asked before the sulfur cube's block). Verify green on 62e9cf2ba; the hook is a no-op stub without mods.
2026-11-01 15:15:13 +01:00
Federico TaglianiandClaude Sonnet 5.5 c80a4f57d5 Port map: 27 rows that said missing are in the code
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 15:15:10 +01:00
levzzz 24ef0d341d Merge pull request #5976 from Xmilon/native/viewport-fov
feat(mods): computed world field-of-view hook
2026-11-01 19:11:28 +05:00
[email protected]andCodex e0d42f6719 feat(mods): adjust computed world fog color
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:10:03 +01:00
Ludovic Marie 4aa6854a88 Merge pull request #5742 from some-du6e/fix/lod-near-detail
🤖 Agent: Reviewed lod.rs (seam stand-in rule in select and evaluate, coarse backdrop in the wanted queue, load order only). fmt/clippy/lod tests green in verify on this head. Thanks @some-du6e.
2026-11-01 15:08:52 +01:00
Ludovic Marie 081172f935 Merge pull request #5902 from Sylviromi/data-reloaded
🤖 Agent: Requeued after merging main (conflict beside native_data_pack_files, both hooks kept, 3 files +24 vs main). Server hook native_server_data_loaded(reload) as an API fragment; empty stub, no cost.
2026-11-01 15:08:46 +01:00
Ludovic MarieandClaude Opus 5.5 dc105082c2 Merge main into fix/5698-hide-break-particles (beside uiSoundReserve)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:07:30 +01:00
Ludovic Marie 4b87c54096 Merge pull request #5653 from Joansitoh/fix-3333-mojang-republish
CI: daily check for a republished Mojang version JSON
2026-11-01 15:07:10 +01:00
Ludovic MarieandClaude Opus 5.5 a498176bd7 Merge main into #5741
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:06:49 +01:00
HrafnaValkyrjaandClaude Opus 5.5 dd3e33c0ec Boats can't be placed from underwater
A fluid hit always snapped to the top of the water cell, so a boat landed
above a submerged player. In a full water cell the hit now stays on the
ray, as vanilla's VoxelShape.clip has it, so the boat collides with the
player and nothing is placed.

Fixes #5967

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 09:05:19 -05:00
Ludovic Marie 885e287a2e Merge pull request #5897 from Vaspyyy/feat/native-biome-colors
🤖 Agent: Client hook native_biome_colors + native_refresh_biome_colors as an API fragment: runs only when a mod asks for a refresh (rebuilds sections like a Biome Blend change), nothing per frame; Biomes::with_colors unit-tested.
2026-11-01 15:03:56 +01:00
Ludovic Marie 3995900d53 Merge pull request #5904 from xFedeT/fix/nightly-parity-regressions
🤖 Agent: Harness diagnostics only: a JVM oracle that dies without an exception reports its exit/signal and last output (chunk_generation, datafix); no change to what is compared.
2026-11-01 15:03:49 +01:00
Ludovic Marie 89e1f987ec Merge pull request #5933 from Joansitoh/fix/border-wall-section
🤖 Agent: World border wall invalidated when the camera changes SectionPos (LevelRenderer.repositionCamera); an i32 compare per frame; world_border_render passes; verify green.
2026-11-01 15:03:44 +01:00
Ludovic Marie b7b413178f Merge pull request #5921 from Joansitoh/fix/lead-rope-rot-old
🤖 Agent: One line: rope_rot_old (yRotO/xRotO) set at the start of the server-client tick, as the local tick does, so the held lead end stops easing toward yaw 0; verify green.
2026-11-01 15:03:38 +01:00
Ludovic Marie 7979de85a9 Merge pull request #5878 from Joansitoh/fix/5719-font-region-full
🤖 Agent: A full dynamic glyph region no longer caches the missing glyph forever: overflow is retried after begin_frame clears the region (one bool check per frame); font unit tests cover overflow and no-provider chars; verify green.
2026-11-01 15:03:30 +01:00
Ludovic Marie 2d681d7794 Merge pull request #5874 from Joansitoh/fix/5642-osk-pointer-mode
🤖 Agent: Pointer menu mode: the on-screen keyboard auto-opens over chat/signs and A on a text field opens it, as focus mode does; pure helpers unit-tested; pad UI only, verify green.
2026-11-01 15:03:18 +01:00
Ludovic Marie 2dba644e25 Merge pull request #5884 from Vaspyyy/fix/5850-advancement-toast-color
🤖 Agent: Toast label colour packed with hud::rgba (yellow 0xFFFF00 / pink 0xFF88FF as AdvancementToast), unit test on the packed bytes; client UI only, verify green.
2026-11-01 15:03:13 +01:00
Ludovic Marie 938f0323ac Merge main into #5646
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-01 15:03:10 +01:00
Ludovic Marie f6ad13d707 Merge pull request #5887 from kacpherek/perf/gpu-pass-timings
🤖 Agent: queued with the depth_pass fix (130acf3d3). Thanks @kacpherek.
2026-11-01 15:03:08 +01:00
Ludovic Marie c9e839b403 Merge pull request #5857 from Lostpold88/fix/bow-arrow-physics
🤖 Agent: queued with my fix (9ec5db50d, see above): the remote arrow's flight rotation as vanilla's `AbstractArrow.tick` has it, without the two hunks that went against 26.3. Thanks @Lostpold88.
2026-11-01 15:03:03 +01:00
Ludovic Marie 86f6b049f1 Merge pull request #5685 from Stink-O/sound-channels-for-the-listener
🤖 Agent: verify was green before main's splitscreen extra conflicted. Main is merged in (40704247c), and the extra stays off by default. Queued for merge, thanks @Stink-O.
2026-11-01 15:02:52 +01:00
Ludovic MarieandClaude Opus 5.5 39b41a8d85 Merge main into #5820; the respawn hook fires once the player is placed, as them
Resolves docs/native-mods.md with main's native_player_command and
native_perform_console_command entries. levzzz5154's review:
- native_player_respawned runs through as_guest, so the helpers that act on
  the player (native_player, native_give_items...) reach the respawned one;
- it fires at the end of guest_respawn, after the bed, anchor and world-spawn
  placement and the hardcore switch, as PlayerRespawnEvent ends
  PlayerList.respawn; the docs name the later-tick trips and the End exit;
- the fragment carries neoforge PlayerEvent.PlayerRespawnEvent, side server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:02:26 +01:00
oskal675-designandCodex GPT-6 12b31598e2 Apply shulker box lid pushes on the client
Keep the client collision snapshot in sync with the animated lid and apply its existing swept-volume push, including while a container menu is open. Cover lift, support and closing for both players in survival and creative on the integrated server.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 19:01:21 +05:00
Ludovic MarieandClaude Opus 5.5 693e8b0892 Merge main into data-reloaded (beside native_data_pack_files)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:00:23 +01:00
Ludovic MarieandClaude Opus 5.5 e80ee4d10a Shader packs: the vignette comment names the property, not another project
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:59:31 +01:00
Ludovic MarieandClaude Opus 5.5 e4b8313747 Merge main into #5723
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:59:30 +01:00
[email protected]andCodex 8dc3e7fbc5 feat(mods): adjust detached camera distance before collision clipping
Co-Authored-By: Codex <[email protected]>
2026-11-01 14:58:33 +01:00
Ludovic MarieandClaude Opus 5.5 502ffb18e0 Merge main into fix/5698-hide-break-particles (hideBlockParticles beside main's splitscreen)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:55:14 +01:00
Ludovic MarieandClaude Opus 5.5 dbe150799f --player-dir: crash telemetry keeps the home's consent, servers ignore it; tests and documented limits
levzzz5154's review: only logs/ and crash-reports/ follow --player-dir (crash
telemetry stays on --home, whose consent it reads); a dedicated server never
takes it. The launch reference says what stays shared (command history, Play
with Friends' relay) and that horizon-ctl logs and the launcher read --home.
Tests: shared_dir_of, servers_file_of.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:54:20 +01:00
Ludovic MarieandClaude Opus 5.5 d139c570fa Merge main into #5701
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:53:45 +01:00
SylviromiandClaude Opus 5.5 0db0837641 /datapack enable and disable reload the server's data, as /reload does
Vanilla's DataPackCommand ends in ReloadCommand.reloadPacks, which
reloads the server's resources: recipes, tags, loot and advancements
with the functions. Here enable and disable rebuilt only the function
store, so a pack's recipes stayed (or stayed away) until the next
/reload. They now reload the content tables too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:53:30 +03:00
gigamans442andCodex GPT-6 4078411d22 Keep compute dispatch splats limited to scalar literals
Reject unsupported negative and nested scalar expressions rather than dropping their operators when expanding a vector. Add a regression for the overbroad expansion.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 08:53:05 -05:00
Ludovic Marie de25a4d0a6 Merge main into #5513 2026-11-01 14:52:41 +01:00
Kacpherek 701e3ca790 Draw far tile faces over opaque texels in the opaque pass
Far tiles marked only leaves as opaque, so every stone, sand or ice face
of the far terrain went through the cutout pass and its discard. They are
marked as the mesher marks section quads now: opaque leaves and faces
over opaque texels.
2026-11-01 14:52:32 +01:00
Ludovic Marie 5106f5300e Merge pull request #5700 from calclb/fix/4689-tnt-igniter
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:52:17 +01:00
Ludovic Marie ebf11e2430 Merge pull request #5697 from ClickingMaster/fix/trident-pickup-animation
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:51:46 +01:00
Ludovic Marie 7799e903f6 Merge pull request #5695 from ASOwnerYT/fix/enderman-boat
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:51:14 +01:00
Ludovic MarieandClaude Opus 5.5 130acf3d3d Native shadow map: the depth pass takes the pass timing (main split begin into depth_pass)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:50:32 +01:00
Ludovic Marie 373a59f4e9 Merge pull request #5694 from spidercraft/fsr-entity-motion
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:50:27 +01:00
[email protected]andCodex 5c8fbc0c3e feat(mods): expose computed world field of view
Co-Authored-By: Codex <[email protected]>
2026-11-01 14:49:50 +01:00
Ludovic Marie 4789f92dd3 Merge pull request #5686 from Stink-O/faster-fill
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:49:10 +01:00
Ludovic Marie d6e5c5a974 Merge pull request #5667 from JustKarkat/fix/phantom-glass-panes
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:48:28 +01:00
gigamans442andCodex GPT-6 380c15e6d3 Accept one trailing custom-expression terminator
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 08:47:39 -05:00
gigamans442andCodex GPT-6 9d0e0a0be5 Expand shader options in custom image dimensions
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 08:47:38 -05:00
gigamans442andCodex GPT-6 3c7da2b819 Keep declared shader defaults in selectable values
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 08:47:38 -05:00
Ludovic Marie fe6d441ac7 Merge pull request #5650 from TiggerTakkyTTT/fix/dragon-death-animation
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:47:35 +01:00
Ludovic MarieandClaude Opus 5.5 2ec0f2bdc1 Merge main into #5778
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:47:34 +01:00
HrafnaValkyrjaandClaude Opus 5.5 7fd48091ae Thrown potions hit players, and their thrower after leaving them
A splash, lingering or XP bottle stopped at blocks and mobs only, so it
fell through any player. The step now meets players too. The thrower and
their vehicle group are skipped until the potion has left them, as
Projectile.canHitEntity and checkLeftOwner do.

Fixes #5876

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:46:49 -05:00
Ludovic MarieandClaude Opus 5.5 40704247c7 Merge main into #5685: uiSoundReserve beside main's splitscreen extra
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:46:28 +01:00
Ludovic MarieandClaude Opus 5.5 865f808792 Merge main into #5854
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:45:26 +01:00
oskal675-designandCodex GPT-6.1 326532f859 Reset boat paddle metadata when rowing stops
Send the default false values when paddle entries disappear from tracked metadata, including dismounting mid-stroke. Compare 18 transitions with actual vanilla servers.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 18:44:30 +05:00
gigamans442andCodex GPT-6 f73179c3f1 Fix scalar vector constants in shader compute dispatch
Expand a scalar vector constructor to its declared component count when reading compute dispatch metadata. Cover absolute and relative dispatch, explicit component lists and undeclared defaults.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 08:43:51 -05:00
Ludovic MarieandClaude Opus 5.5 598fcb7b86 Mojang republish check: hourly, not daily (rdbtCVS: a day of broken prepares is too long)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:43:39 +01:00
Ludovic MarieandClaude Opus 5.5 dd96a77905 Merge main into #5789
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:43:14 +01:00
Ludovic MarieandClaude Opus 5.5 62e9cf2ba9 native_mob_submit: vanilla's walk state, and asked before the sulfur cube's block
- NativeMobRender takes the walk cycle, head turn and age from mobs::anim::state,
  the state the vanilla models read: no walking while riding or dead, a baby's
  legs three times as far (LivingEntityRenderer.extractRenderState,
  WalkAnimationState.positionScale).
- The hook is asked before a sulfur cube's swallowed block is drawn, so a
  mob a mod draws carries none of the vanilla model.

Review points from arismoko's review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:43:02 +01:00
Ludovic MarieandClaude Opus 5.5 8c1478525e Merge main into #5795
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:42:01 +01:00
NerfPhaselockandClaude Opus 5.5 f0b0890d6a Renderer: a Horizon pack's [shadow] keeps translucent quads in place
A pack of Horizon's own with [shadow] draws from the shadow camera's lists
(d36550e82), which makes frames flat where the device can, and flat draws
read translucent quads in the section arena. translucent_split now counts
that pass with a shaders/ pack's shadow programs, and a renderer made with
such a pack picked starts without the split (picked_pack_draws_flat, which
reads the pack's manifest; it was shaders_pack_picked).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:41:50 -05:00
[email protected]andCodex 9e18079acc fix(parity): migrate broad saved client target selections too
Co-Authored-By: Codex <[email protected]>
2026-11-01 14:41:20 +01:00
Ludovic Marie 81ae4a7d8f Merge pull request #5783 from horizon-rust/sweep-g/launcher-native-builds
🤖 Agent: reviewed by Ludovic's AI agent (Claude): rdbtCVS's #5595 finished (main merged, "Built on {base}" in all 10 launcher languages); remove_version refuses a running build; verify and launcher checks green on e918209, merges cleanly with main. Thanks @rdbtCVS.
2026-11-01 14:41:09 +01:00
Ludovic MarieandClaude Opus 5.5 0de577b509 Launcher secrets file: encrypted at rest (ChaCha20-Poly1305, a key of the computer's)
The file kept the Microsoft refresh token and the Minecraft access token in
plain JSON. It is now sealed with a key derived from /etc/machine-id
(/etc/hostid on FreeBSD), so no token is on disk in plain text and a copy
taken off the computer can't be read. A file that doesn't open is refused
without quoting it and never written over, as a broken one was.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:41:02 +01:00
Ludovic Marie aeadf92f84 Merge main into #5598 2026-11-01 14:39:50 +01:00
Ludovic Marie d02dd55ae3 Merge pull request #5946 from hello23113/fix/windows-cpu-name
🤖 Agent: Windows F3/crash-report CPU name from the registry's ProcessorNameString with the old fallback; read once (OnceLock) so no per-frame cost; the launcher starts the game with CREATE_NO_WINDOW so reg.exe opens no console. Parser unit-tested.
2026-11-01 14:39:20 +01:00
Ludovic Marie 805d85cec0 Merge pull request #5877 from Joansitoh/fix/5673-mcp-protocol-version
🤖 Agent: MCP initialize now negotiates the protocol version (echo only a supported one), same 12-line helper in horizon-ctl, horizon-server and the launcher, with unit tests; verify and launcher CI green.
2026-11-01 14:39:16 +01:00
Ludovic Marie e9351841b5 Merge pull request #5880 from levzzz5154/docs/content-mods-refresh
🤖 Agent: Docs only (mods.md, modpacks.md, template comment, manifest doc comment); checked the facts against the content loader (sounds key names a vanilla block, menu/entity_type kinds, mismatch refusal). The failing 'check' job is horizon-mod-registry's lock on main, unrelated.
2026-11-01 14:39:12 +01:00
TobiasLogicandClaude Opus 5.5 b80d1a0851 Shader packs: isRightHanded, is_on_ground and the dimension's uniforms
Packs read these Iris uniforms as zero: isRightHanded (the Main Hand option), is_on_ground, and
the dimension type's bedrockLevel, heightLimit, logicalHeightLimit, hasSkylight and hasCeiling
(from the level's shape; hasCeiling in the Nether). A pack reading bedrockLevel 0 and heightLimit
0 in the overworld puts its fog or its sky's horizon at the wrong height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:37:23 +07:00
TobiasLogicandClaude Opus 5.5 9a02a6a0ed Shader packs: gl_FogFragCoord travels from the vertex stage to the fragment stage
GLSL 1.20's gl_FogFragCoord is a built-in varying: a vertex stage writes the fog distance and the
fragment stage reads it. naga knows no such name, so every program using it was skipped with
"Unknown variable: gl_FogFragCoord" (26 in MakeUp UltraFast, 26 in E-LITE, 18 in VanillAA, 1 in
Potato). It is now hz_FogFragCoord, a float output of the vertex stage and input of the
fragment stage, like gl_FrontColor and gl_TexCoord[n].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:37:21 +07:00
DoodlyyousufandGrok 4.6 e0d512e890 Swing the hand when a painting is placed
Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 13:36:50 +00:00
Ludovic MarieandClaude Opus 5.5 24399301ba Far terrain docs: dedicated servers only for now (an open-to-LAN world has no distance setting yet)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:36:47 +01:00
Ludovic Marie a9e7b49f04 Merge #4946 (main merged) into #4947 2026-11-01 14:36:28 +01:00
Ludovic Marie 6ee16d87cb Merge #4945 (main merged) into #4946 2026-11-01 14:36:25 +01:00
Ludovic Marie 1f6b07c82f Merge main into #4945 2026-11-01 14:36:24 +01:00
Ludovic Marie e1ac85c149 Merge pull request #5675 from martipops/split/fresh-profiles-switch
🤖 Agent: verify is green and the branch merges cleanly with main. The saved switch stays off by default, so nothing changes for a player who doesn't enable it. Queued for merge, thanks @martipops.
2026-11-01 14:36:24 +01:00
Ludovic Marie 867ed9b2eb Merge pull request #5623 from Sylviromi/native-reload-files
🤖 Agent: verify is green, the branch merges cleanly with main, and levzzz5154's review found it ready (load order below the world's packs, both first open and /reload covered). The hook's API number comes from its fragment when it lands. Queued for merge, thanks @Sylviromi.
2026-11-01 14:36:19 +01:00
TiggerTakkyTTTandCodex c05bd1a78c Add live difficulty methods to server management
Read and change the dedicated server difficulty through vanilla management
requests, broadcasting the change and saving it for restart.

Part of #5147

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 13:35:11 +00:00
Ludovic MarieandClaude Opus 5.5 12f28cf6c3 Merge main into #5598: the secrets file tests beside main's session refresh tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:34:07 +01:00
NerfPhaselockandClaude Opus 5.5 27a5ccdbca Renderer: translucent terrain in an arena of its own (off by default)
Translucent quads (water, stained glass, ice) sat right after their
section's solid quads in the 256 MiB arena pages, so the back-to-front list
changed page every few sections and only short runs merged: 3,832 draw calls
a frame over the ocean at render distance 64. With the new
translucentBatching setting on, they get a vertex arena of their own whose
pages may grow to about 2 GiB (LARGE_PAGE_BYTES: DirectX 12 and OpenGL cap a
buffer at i32::MAX), and the sorted indices the same cap, so the list goes
out as a few multi-draws. Vertex data, sorted indices and draw order are
unchanged.

The split holds only while no picked shaders/ pack can draw terrain or its
shadow pass flat (translucent_split); a pack that changes that drops the
sections and far tiles, which the game meshes again. compact_arena moves
what a section holds in the section arena: its solid range when split,
skipping sections without solid quads. LOD tiles and their fade stand-ins
draw and release from the translucent arena too. Batching is offered with
indirect multi-draws, not OpenGL's, and not where vanilla frames draw flat
(translucent_batchable). Every build_renderer caller passes the setting,
splitscreen and resource reloads included.

Off by default. RTX 4090, 1080p, off -> on: +4 to +16 % FPS at render
distance 32, up to +43 % at 64 (ocean 284 -> 406 FPS, 3,832 -> 354 draw
calls); arena memory +1 to +15 %.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:33:20 -05:00
Ludovic MarieandClaude Opus 5.5 aaf25ac9a9 Merge main into #5693: the pack inputs keep main's mod params beside the two new uniforms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:32:28 +01:00
Ludovic MarieandClaude Opus 5.5 5c785a252c Format the pass timing calls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:27:30 +01:00
Ludovic Marie 7ec69de302 Merge remote-tracking branch 'origin/main' into pk1/5887 2026-11-01 14:27:16 +01:00
Ludovic MarieandClaude Opus 5.5 9ec5db50d1 Arrows keep the shooter's horizontal motion on the ground, as vanilla
Projectile.shootFromRotation adds the shooter's known movement and drops
only its y on the ground, and sets no rotation after it: an arrow shot
while strafing drifts with the player in vanilla too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:27:05 +01:00
Ludovic Marie dae5b4bce9 Merge #5894 into the shader API union 2026-11-01 14:27:00 +01:00
Ludovic MarieandClaude Opus 5.5 19fcd0d8a9 Keep loot tables as their JSON, read on first roll
As serde_json Values, vanilla's ~1,450 loot tables (0.6 MB of JSON,
~10,450 objects, a B-tree node each) held ~7 MB of a server's idle heap.
LootTables keeps each table's text and reads it into a Value the first
time it is rolled; every table is still read whole when it loads (a bad
file fails the load as before) and the native hook still runs on it, in
id order, before anything rolls it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:26:39 +01:00
HrafnaValkyrjaandClaude Opus 5.5 394725e84e Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:26:11 -05:00
Ludovic Marie 374b50d48f Merge pull request #5890 from Teiwazik/fix/mesh-pool-lights
🤖 Agent: verified at e241409 on the Mac mini: fmt, clippy -D warnings (horizon-client-render), mesh_pool tests pass; the new assert fails without the fix. Thanks @Teiwazik.
2026-11-01 14:24:50 +01:00
Kacpherek aa4622805d Draw opaque terrain through a shader that never discards
The solid terrain shader could discard every pixel (the fade dither and
the alpha cutout), and one discard anywhere in a pipeline turns off the
hidden surface removal of tiled GPUs (Apple's) for everything it draws.
The world pass now draws each solid list twice: first the quads over
opaque texels that are not fading, through fs_opaque, which has no
discard, then the rest through fs_solid. Each vertex shader decides its
pass before any other work; a quad of the other pass lies outside the
clip volume. Shader packs and native materials keep the one pipeline.

M1 Pro, 1280x720, same commit before and after: GPU 10.4 -> 9.6 ms in a
flight over new ground, 12.9 -> 11.0 ms in rain, 9.5 -> 8.1 ms with 300
mobs in view. Screenshots are the same.
2026-11-01 14:22:09 +01:00
JoansitohandClaude Sonnet 5.5 0ce75665e4 Keep the held lead end on the look from the start of the tick
On the server-thread client, Game::tick returns tick_remote() before the
rope_rot_old write, so the first-person lead end eased toward the initial
(0, 10) every tick. Write it in tick_remote_level, after the unlit-chunk
early return, as the local tick does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:16:53 +01:00
JoansitohandClaude Sonnet 5.5 10531b1d64 Rebuild the world-border wall when the camera changes section
The wall mesh only holds the stretch inside render distance at build time
and was rebuilt only when the border itself changed, so after a respawn or
a long walk it stayed on the old stretch. Invalidate it when the camera's
section (floor >> 4 on X, Y, Z) changes, as LevelRenderer.repositionCamera.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:16:52 +01:00
HrafnaValkyrjaandClaude Opus 5.5 ce65bf2fd8 Evokers and illusioners show hand particles while casting
Two coloured entity_effect particles per tick, as SpellcasterIllager.tick
draws them, for guests too. The illusioner spell ids were swapped; mirror
is 4 and blindness 5.

Fixes #5886

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:16:50 -05:00
HrafnaValkyrjaandClaude Opus 5.5 0bf5c156fe Hoglins and other wide mobs suffocate in walls
The in-wall check tested one block and skipped mobs 1.0 or wider. It now
follows Entity.isInWall: a box 0.8 of the width at the eye.

Fixes #5872

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:16:31 -05:00
Ludovic Marie b01a37b403 Merge pull request #5938 from horizon-rust/fix/ac-sprint-one-tick
🤖 Agent: reviewed (the replay takes the shared flags' sprint bit the server sends, as vanilla's client); rebased on main; pr_ready --quick green on 2a1dd55 (1711 tests); the new test reproduces the live 0.0294/0.0455 signature before the fix; a +0.05 speed lie still counts from its first tick.
2026-11-01 14:12:17 +01:00
hsafandOpenAI Codex 499093d3da Fix Windows CPU name in F3 debug screen
Read ProcessorNameString from the Windows registry instead of displaying the raw processor identifier.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 15:09:16 +02:00
Ludovic MarieandClaude Opus 5.5 2a1dd555d0 Anticheat prediction: the server's sprinting flag reaches the replay
A client whose STOP_SPRINTING and START_SPRINTING reach the server in two
server ticks gets the STOP's shared flags and movement speed (no sprint
modifier) after it sprints again: its flag cleared, LocalPlayer.aiStep
starts the sprint again, modifier included, and sends nothing. The
replay took the attributes but not the flag, so it sprinted on without
the modifier until the START's attributes came: 0.029 then 0.045 off,
the official server's "ground sprinting" flags (#5935).

Record the sprinting bit of the shared flags the server sends the player
(Change::Sprinting) and apply it to the replay's flag alone, as
SynchedEntityData.assignValues does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:04:46 +01:00
Ludovic Marie 8c02c0b853 Merge pull request #5873 from horizon-rust/fix/prediction-item-use
🤖 Agent: rebased onto main (conflict with #5856's frozen test helper, both kept); pr_ready --quick green on 30dfdc3 (1689 horizon-client tests, 19 prediction tests on vanilla data).
2026-11-01 13:59:30 +01:00
Ludovic MarieandClaude Opus 5.5 30dfdc3e1c Anticheat prediction: a client still slowed by an item use the server ended isn't flagged
For 10 ticks after the server's item use ends, the replay also tries the
client still slowed by it: the client finishes a use on its own ticks and the
server on its own (food eaten one after another with the key held), and the
server stops one for its own reasons. Slower than the keys allow is no
advantage; a use the server has and the client may not still counts at once.

tools/anticheat_e2e.py: prediction-items (a shield, food and a bow used on
the move) and prediction-water (a deep pool, a current, a bubble column, a
ledge) with a real Horizon client.

Fixes #5860.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:50:39 +01:00
VaspyyyandClaude Opus 5.5 e855aea99b Native mods: native_mob_submit's frame after main's kept geometry
Main added `kept` to BlockEntityDraw (keep a block entity's geometry);
a mob's frame starts with it unset, and the hook says `keep` does nothing
for a mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRKvdg3SMVP1onGqS3
2026-11-01 12:50:04 +00:00
Ludovic Marie 35f5e95f5d Merge pull request #5931 from horizon-rust/fix/props-needless-borrow
🤖 Agent: fixes main's verify (clippy needless_borrow from #5915). `cargo clippy --workspace --all-targets -- -D warnings` (clippy 0.1.99, CI's version) and `cargo fmt --check` clean on the Mac.
2026-11-01 13:47:01 +01:00
[email protected]andClaude Opus 5.5 cb11c1d221 Block states: drop a needless borrow clippy 1.99 refuses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:45:12 +01:00
Ludovic Marie 82f8d0a0be Merge pull request #5930 from horizon-rust/fix/connection-write-timeout
🤖 Agent: proof: pr_ready --quick green (1680 horizon-client tests); write timeout = read timeout; local frozen-player run ends both threads; ships in the next batched restart for the live measurement.
2026-11-01 13:44:48 +01:00
Claude 7a767d30db Merge remote-tracking branch 'upstream/main' into feat/native-mob-submit 2026-11-01 12:43:51 +00:00
Ludovic MarieandClaude Opus 5.5 4a6ed638b3 Fix clippy's needless borrow in BlockProperties::from_shared
#5915's last revision made the shared pairs references, so the sort's
comparison borrowed a reference: main's verify (clippy -D warnings)
fails on it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:43:50 +01:00
[email protected] 2aa6aa28cd Merge remote-tracking branch 'upstream/main' into staging/unified-hmod 2026-11-01 13:43:44 +01:00
Ludovic Marie f8d5d60c0d Merge pull request #5923 from horizon-rust/fix/connection-cap-per-address
🤖 Agent: proof: pr_ready --quick green on 98e49e3 (1680 horizon-client tests); live on the official server since 12:22Z as dd4b209 (this head minus the test commit), status pings ok every minute, 99 joins in 15 min; the deadlock of the first deploy is reproduced by the test without the fix.
2026-11-01 13:38:28 +01:00
Ludovic MarieandClaude Opus 5.5 1dc2f505bb lan: a write waits at most 30 s; log places closing
A peer that stops reading (a frozen game, a dropped network) held its
writer in a full send buffer until TCP gave up, minutes later, and the
connection's place with it: play() joins the writer. Writes now time out
like reads. The turned-away report also says how many places are before
play and how many wait for a writer to finish.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:37:34 +01:00
Ludovic Marie b8b5020fbb Merge pull request #5679 from TiggerTakkyTTT/fix/end-portal-entities
🤖 Agent: main moved after the queue (#5875 and #5755 merged), so I merged main in again (clean, d635bc57f). `cargo check -p horizon-client --lib --bins --tests` passes. Requeued.
2026-11-01 13:36:36 +01:00
[email protected]andClaude Opus 5.5 df6e0f0547 Mods: keep publishing .hnmod, write the mod file format down
Review on #5909: native versions published as .hmod would reach
launchers that drop their content or refuse them. The registry keeps
.hnmod until launchers and the game read native .hmods.

docs/api/mod-files.md holds every rule of a .hmod/.hnmod in one
place: zip and path rules, top-level entries, the horizon-native.json
schema, what mixins/<id>/ may hold and the size limits.

The registry's Cargo.lock catches up with horizon-mods' png
dependency so its --locked check passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:35:43 +01:00
Ludovic Marie ff6cad6206 Merge pull request #5903 from repositorykreml1n/fix/attack-through-gaps-5772
🤖 Agent: proven on a union with main c1f4a7c19 (+ #5875, #5755, #5679): `pr_ready.py --quick` passed (fmt, clippy -D warnings, horizon-client tests, doc tests). I added a lan_e2e check, `attack_over_slab` (97c18d712): a NoAI zombie stands behind a bottom slab, the player aims at its head over the slab and left-clicks. It passes for the host (20 → 19.06) and for the guest (19 → 18). Queued for merge. Thanks @repositorykreml1n!
2026-11-01 13:35:28 +01:00
Ludovic Marie d635bc57f9 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-01 13:34:42 +01:00
Ludovic Marie 7bca933bb5 Merge pull request #5755 from navinor/fix/unbreakable-durability-bar
🤖 Agent: verified by Ludovic's AI agent (Claude). ItemStack.isBarVisible needs isDamageableItem, which an unbreakable stack isn't, and the unbreakable unit component now reaches clients. pr_ready --quick passed on main c1f4a7c19 with this PR, #5875, #5903 and #5679 merged in (fmt, clippy, horizon-client tests incl. the new sync test).
2026-11-01 13:30:37 +01:00
Ludovic Marie d0afa1edc1 Merge pull request #5875 from TiggerTakkyTTT/fix/creative-shulker-drops
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against ShulkerBoxBlock.playerWillDestroy and Block.popResource (offsets drawn before the block_drops check, as vanilla). pr_ready --quick passed on main c1f4a7c19 with this PR, #5903, #5755 and #5679 merged in (fmt, clippy -D warnings, horizon-client tests, Python tests). CI parity covers the new command_exec cases.
2026-11-01 13:30:33 +01:00
Ludovic Marie 5bbecdc497 Merge pull request #5915 from horizon-rust/perf/block-state-properties
🤖 Agent: playhorizon 4 CPUs: idle RSS −17 MB with THP off (241.7 → 224.7, every start) and −18 MB median with THP on (4/4), live heap −13.6 MB, Done 2.06 → 1.955 s (4/4); 20-bot MSPT unchanged (3 rounds). pr_ready tests 2066 + content roots pass; merge with main checks; fmt/clippy/Python clean on the Mac.
2026-11-01 13:23:50 +01:00
Ludovic MarieandClaude Opus 5.5 98e49e36c8 lan: make room in the test with a log writer set
The deadlock fixed in the previous commit only shows with a log writer;
the test hangs without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:22:51 +01:00
Ludovic MarieandClaude Opus 5.5 49e91c58c2 Merge #5875 into #5679: the harness's break and falling ops side by side
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:21:34 +01:00
Ludovic MarieandClaude Opus 5.5 675fba85db Merge main into #5679; a native-spawned falling block starts with no portal state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:21:11 +01:00
Ludovic MarieandClaude Opus 5.5 97c18d7127 lan_e2e attack_over_slab: a hit aimed at a zombie's head over a bottom slab lands (#5772)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:21:05 +01:00
Ludovic Marie b1d9448018 Merge remote-tracking branch 'origin/main' into g/5679 2026-11-01 13:21:05 +01:00
HrafnaValkyrjaandClaude Opus 5.5 055ce500a0 Tridents, rockets and fishing hooks press plates and trip tripwires
They live in their own lists, so a plate's entity count never saw them.
They now count, as vanilla's getEntitiesOfClass does. A rocket boosting a
player and a hook on an entity count but skip entityInside, whose step
their ticks skip too. A guest's hook ends when the guest changes level, so
it can't act on the blocks of the level it arrives in.

Fixes #5896

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 07:14:27 -05:00
[email protected]andClaude Opus 5.5 f2b508c4f2 test(client): read the loot hook source independent of line endings
loot_table_load_stays_hookable searched for "\n}\n", which a CRLF checkout
(core.autocrlf=true on Windows) never contains. Normalize as the other
source-reading test in this file does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:11:46 +01:00
Ludovic MarieandClaude Opus 5.5 dd4b209ae7 lan: don't make room while holding the address map
make_room logs turned-away connections, and the log reads by_address:
count called it with that lock held, so the accept loop deadlocked the
first time the server filled with a log writer set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:06:19 +01:00
[email protected]andClaude Opus 5.5 02122e2532 Mods: one .hmod for content and native code (WIP, #5865)
A .hmod's contents decide what it is: content only, native code
(horizon-native.json + mixins/<id>/), or both in one file. Native
bundle reading skips a .hmod's content entries, checks both
manifests name one mod, and refuses mod.wasm with a clear error.
The registry publishes native-mod versions as .hmod and still
accepts .hnmod; .hnmod keeps loading unchanged.

Launcher install, game content loading of native .hmods and the
GitHub install path are not done yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:04:52 +01:00
Ludovic MarieandClaude Opus 5.5 21249821f2 lan: sort the turned-away addresses by key (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:51:42 +01:00
Federico Tagliani 5ae8f36192 Merge remote-tracking branch 'origin/main' into fix/nightly-parity-regressions 2026-11-01 12:50:48 +01:00
Federico TaglianiandClaude Sonnet 5.5 f0590964bb Parity: a JVM oracle that dies without an exception says how it ended
decorated_region (through chunk_generation's oracle) failed in the nightly audit with
"oracle failed: " and nothing after it, and datafix with a CalledProcessError that
names the command; both Java oracles stopped with no exception to print. Show the exit
status (and the signal that killed it) and the last lines of its output.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:46:20 +01:00
SylviromiandClaude Opus 5.5 56d304c724 Data packs and content mods: remove and replace recipes by filter
A file data/<ns>/recipe_filter/<name>.json lists filters that match
recipes by id or type (with *), by an input or an output item, and
remove them or swap one input or output for another. They run on each
recipe's JSON after the packs are merged and its load conditions held,
so they reach every recipe type, mods' own included, in every loader:
crafting, the stations, brewing, the recipe book and the recipes the
players are sent. A filter with an unknown key or not exactly one
action is skipped with a warning, so a typo never matches every recipe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:45:53 +03:00
Ludovic MarieandClaude Opus 5.5 ea9b10f290 lan: a full server closes the connection that waited longest
When every place is taken, the oldest connection still before play
(handshake, status, login, configuration) that has waited more than 2 s
is closed to admit the new one. A crowd of sockets that never finish can
no longer keep players out; players in the game are never closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:41:42 +01:00
Ludovic MarieandClaude Opus 5.5 21b8af4bf3 Server: one address takes at most 10 connection places; the login waits 10 s
The connection cap (#5776, players + 64) filled up on the official server
within minutes of a restart and every new join was reset. Now one public
address holds at most 10 connections (local and private addresses, where a
proxy or a port forward hands everyone over, stay unlimited), the handshake,
status and login phases wait 10 s for the client instead of 30 s, and once a
minute the log says how many connections were turned away and which
addresses held the most places.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:36:46 +01:00
Ludovic Marie c1f4a7c196 Merge pull request #5882 from horizon-rust/launcher-win-account-flaky
🤖 Agent: fixes the Windows-only account test flakes that blocked launcher 0.2.33 (per-store account lock). Verified: windows-latest green twice on this head (runs 41300356019 push, 41300386770 dry run: 352 passed), Mac mini account tests + clippy clean.
2026-11-01 12:36:29 +01:00
repositorykreml1n d2e4a36055 Aim a guest's attack at a visible point of the mob, so hits through a slab or trapdoor gap land
In single-player the player's attack goes through the integrated server as a guest attack. guest_attack calls
look_at_entity, which aimed at the centre of the target mob; Game::attack then drops the hit when a block outline is
nearer than the mob along that ray. The attack packet names only the entity, so a mob that the player's own crosshair
hits through a gap (a top slab, a closed top-half trapdoor, ...) had its centre hidden and the hit was dropped.

For mobs, look_at_entity now aims at the centre when it is visible, else at the visible point of a grid of points in
the mob's box nearest to the centre (nearer the eyes on ties), found with a clip_ray from the eyes. A mob whose whole
box is hidden keeps the centre, so a really blocked hit is still refused. Other entity kinds are unchanged.

Fixes #5772
2026-11-01 14:34:20 +03:00
SylviromiandClaude Opus 5.5 e6b365b01a Native mods: hear that the server loaded or reloaded its data
native_server_data_loaded(reload) fires once the server's data tables are
built: on a world's first open, before its chunks and players, and after
every /reload, once the players were sent the new data. NeoForge's
TagsUpdatedEvent with SERVER_DATA_LOAD. A mod drops caches derived from
the data and rebinds what depends on it there instead of polling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:29:52 +03:00
TeiwazikandClaude Opus 5.5 99eb871255 Shader API: the material stage reads its own block texture
block_texture, block_texture_level, block_texture_size and block_texture_lod read the surface's
sprite anywhere on it. The LabPBR _level functions now count the texture's mip levels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 12:24:13 +01:00
Ludovic MarieandClaude Opus 5.5 d44fb4367a Block states: shared property strings are the process's, not counted
Cloning a registry state bumped one Arc per name and value; world
generation threads clone the same states, and 20 bots cost 0.25 ms of
streaming. Shared strings are now interned once for the process (the
registries' names and values, never a player's) and copied as pointers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:22:01 +01:00
[email protected]andCodex 452910ffce fix(parity): keep saved probe selections compatible with the library split
Co-Authored-By: Codex <[email protected]>
2026-11-01 12:17:53 +01:00
Ludovic Marie 9543d2a686 Merge pull request #5891 from horizon-rust/fix/prediction-log-where
🤖 Agent: pr_ready --quick green (1674 horizon-client tests, clippy, fmt); log format test updated; diagnostic only (log-line fields).
2026-11-01 12:16:22 +01:00
JoansitohandClaude Sonnet 5.5 d3fe2797d9 docs: list hideBlockParticles in the extras intro
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:10:10 +01:00
JoansitohandClaude Sonnet 5.5 645326f21e Primed TNT carries its Passengers
/summon minecraft:tnt {Passengers:[...]} dropped them. Load them as riders
(EntityType.loadEntityRecursive) and seat them on the TNT's top.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:09:03 +01:00
Kacpherek ad4877f432 List gpu-passes in the horizon-ctl reference 2026-11-01 12:07:52 +01:00
TeiwazikandClaude Opus 5.5 e241409e65 Clear a recycled mesh's light sources
SectionMesh::clear kept `lights`, so a mesh taken from the pool still listed the sources of the
section it last held: a broken torch stayed in the colour grid, and sources piled up elsewhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 12:06:42 +01:00
levzzz 07b7ea30d0 Merge pull request #5739 from Stink-O/native/push-and-flying-speed
feat(mods): native mods can push the player and set their flying speed
2026-11-01 16:06:22 +05:00
levzzz 4d75ff55f9 Merge pull request #5617 from Sylviromi/screen-key-input
Native mods: screen_key_pressed and screen_char_typed, keys over the open screen (API next)
2026-11-01 16:06:18 +05:00
[email protected]andCodex 42a5981e94 refactor(client): isolate gameplay from the reusable client and probes
Keep native hook targets and source paths stable. Run gameplay tests in the gameplay library, and build ctl/probes from horizon-client-lib without gameplay. Update build/test selection and the fixed mixin-tool pin.

Co-Authored-By: Codex <[email protected]>
2026-11-01 12:05:47 +01:00
Ludovic MarieandClaude Opus 5.5 8f2e53a966 Launcher: give each memory store its own account lock
Every unit test shared the process-wide account operations lock. On Windows a connection
to the tests' closed port (127.0.0.1:9) takes about 2 s to be refused, and tests that hold
the lock meanwhile made another test's status read answer "busy" after its 3 s wait
(Retry instead of SignInRequired), or starved a 5 s lock_within behind blocked waiters.

The in-process lock now comes from SecretStore::operations: the system keychain keeps the
one process-wide lock (one keychain per computer), a MemoryStore has its own, as its
secrets are its own. The launcher uses a single store, so nothing changes for players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:05:37 +01:00
Ludovic MarieandClaude Opus 5.5 787a59fd85 Anticheat prediction: a log line says the tick's keys, where it ended and the replay's velocity
So a flag can be found again in the world and played there (#5879: water
flags that don't reproduce in the harness).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:05:04 +01:00
Ludovic Marie f4cd2e97a9 Merge pull request #5757 from Teiwazik/fix/cutout-mips-keep-coverage
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:05:03 +01:00
Ludovic Marie 393e3f0034 Merge pull request #5830 from Teiwazik/shader/post-targets
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:04:58 +01:00
JoansitohandClaude Sonnet 5.5 20c472f008 mcp: derive launcher PROTOCOL from SUPPORTED, tidy tests
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:04:20 +01:00
VaspyyyandClaude Opus 5.5 87fb637889 Advancement toast: draw the task/goal label yellow again (#5850)
The HUD batch takes colours packed as hud::rgba (red in the low byte);
0xFFFF_FF00 there is cyan. AdvancementToast draws the label -256 (yellow),
or -30465 (pink) for a challenge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:00:56 +01:00
Ludovic Marie db42d44415 Merge pull request #5819 from Teiwazik/shader/labpbr
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:34 +01:00
Ludovic Marie bfa86ce98d Merge pull request #5753 from Teiwazik/shader/lights
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:29 +01:00
Ludovic Marie 91c086865a Merge pull request #5871 from horizon-rust/fix/release-green-window
🤖 Agent: One-number change in tools/release_train.py (10 → 50 parity-main runs looked back); its 25 unit tests pass. Unblocks the Nightly train.
2026-11-01 12:00:25 +01:00
Ludovic Marie 719a7d97ed Merge pull request #5681 from Teiwazik/shader/textures
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:22 +01:00
Ludovic Marie 6ed842f35b Merge pull request #5663 from Teiwazik/shader/weather
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:18 +01:00
Ludovic Marie 5bd8da2db5 Merge pull request #5652 from Teiwazik/shader/pack-pages
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:14 +01:00
Ludovic Marie 66bf45924c Merge pull request #5658 from Teiwazik/shader/sky-flags
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:11 +01:00
Ludovic Marie ad3d1ac77d Merge pull request #5824 from Teiwazik/shader/material-fog
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:07 +01:00
Ludovic Marie 411202c6bb Merge pull request #5814 from Teiwazik/shader/texture-budget
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:03 +01:00
Ludovic Marie 46716474ef Merge pull request #5827 from Teiwazik/shader/stage-files
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 11:59:59 +01:00
Ludovic Marie 4fbe3bf2dd Merge pull request #5619 from Teiwazik/shader/s9-materials
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 11:59:55 +01:00
Ludovic Marie 294097d298 Merge pull request #5590 from Teiwazik/shader/s8-shadow-entities
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 11:59:51 +01:00
Stink-O 55f58be289 Merge #5617's approved head into #5741 2026-11-01 10:58:58 +00:00
Stink-OandClaude Opus 5.5 13327b6e0a refactor(mods): the mod screen's Game field after native_net
So it no longer sits on the lines #5731 adds its field to: the two merge in either order without a conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:40 +00:00
Ludovic MarieandClaude Opus 5.5 7a6ff6cc32 Release train: look back 50 parity-main runs, not 10
Every merge cancels the parity-main run before it. In a busy hour of merges, the
last finished run fell out of the 10-run window, green_main_head found nothing,
and no Nightly went out for 14 h (2026-11-01) although parity-main had passed
on bd8c53bfa.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:57:49 +01:00
JoansitohandClaude Sonnet 5.5 ad462ede29 fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:57:47 +01:00
oskal675-designandCodex GPT-6.1 7b2497f52c Keep item use ahead of later player movement
Preserve the throw origin and inherited movement by preventing queued movement from overtaking a deferred UseItem. Compare standing and gliding throws against a real vanilla server.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 15:56:52 +05:00
levzzz e918209069 Merge remote-tracking branch 'hr/main' into pr-5783 2026-11-01 15:53:01 +05:00
TiggerTakkyTTTandCodex 2daf81f487 Fix filled shulker box drops in Creative
Preserve collected components when breaking filled shulker boxes in Creative,
including with block drops disabled. Check all variants against vanilla through
the integrated server player-break path.

Fixes #5821

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 10:51:32 +00:00
[email protected]andCodex 2c78a5f1b0 Merge upstream/main before revising the client library layout
Co-Authored-By: Codex <[email protected]>
2026-11-01 11:50:56 +01:00
levzzz af669895a8 Merge pull request #5792 from CocofireHD/feature/mod-particle-types
Native mods: a mod's own particle types
2026-11-01 15:50:47 +05:00
levzzz 863b9034e7 Merge pull request #5780 from lukehinojosa/feat/native-lead-drops
Native mods: lead drops through native_loot_drops
2026-11-01 15:50:43 +05:00
levzzz 9203ebc1b8 Merge remote-tracking branch 'hr/main' into pr-5739 2026-11-01 15:48:10 +05:00
levzzz 2f077e899b Merge remote-tracking branch 'hr/main' into pr-5617 2026-11-01 15:48:10 +05:00
Jinuk Chanthusa 39c90ba242 Fix parity remote startup failures
Fix parity remote startup failures
2026-11-01 16:17:49 +05:30
JoansitohandClaude Sonnet 5.5 5cd320b24c Extras: hideBlockParticles hides block-break particles
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:47:25 +01:00
JoansitohandClaude Sonnet 5.5 0f3ee35c21 Open the on-screen keyboard in pointer menu mode
Part of #5642.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:45:43 +01:00
Ludovic MarieandClaude Opus 5.5 eecf134cec Merge the shader series up to #5830 into #5757
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5830 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:56 +01:00
Ludovic MarieandClaude Opus 5.5 606df956bf Merge the shader series up to #5819 into #5830
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5819 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:49 +01:00
Ludovic MarieandClaude Opus 5.5 e08bc408a3 Merge the shader series up to #5753 into #5819
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5753 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:44 +01:00
Ludovic MarieandClaude Opus 5.5 83966d3fb3 Merge the shader series up to #5681 into #5753
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5681 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:40 +01:00
Ludovic MarieandClaude Opus 5.5 5e967a2730 Merge the shader series up to #5663 into #5681
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5663 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:32 +01:00
Ludovic MarieandClaude Opus 5.5 13eeb8147b Merge the shader series up to #5652 into #5663
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5652 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:25 +01:00
Ludovic MarieandClaude Opus 5.5 76e7861108 Merge the shader series up to #5658 into #5652
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5658 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:20 +01:00
levzzz bb03a6b2e0 Merge pull request #5748 from lukehinojosa/feat/native-give-experience
Native mods: read experience orbs, give experience and levels
2026-11-01 15:42:19 +05:00
Ludovic MarieandClaude Opus 5.5 6d2f22af83 Merge the shader series up to #5824 into #5658
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5824 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:13 +01:00
Ludovic MarieandClaude Opus 5.5 ea73dab8ce Merge the shader series up to #5814 into #5824
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5814 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:08 +01:00
Ludovic MarieandClaude Opus 5.5 76154d843f Merge the shader series up to #5827 into #5814
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5827 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:00 +01:00
Ludovic MarieandClaude Opus 5.5 aa125d76a7 Merge the shader series up to #5619 into #5827
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5619 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:41:53 +01:00
Ludovic MarieandClaude Opus 5.5 2f5862e0e3 Merge the shader series up to #5590 into #5619
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5590 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:41:48 +01:00
Ludovic MarieandClaude Opus 5.5 df761058da Merge main into #5590
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:41:43 +01:00
JoansitohandClaude Sonnet 5.5 911aebaaf2 mcp: answer initialize with a protocol version we support (part of #5673)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:41:19 +01:00
JoansitohandClaude Sonnet 5.5 d6c1bf1791 Recover bitmap font glyphs when the dynamic atlas region fills up
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:41:07 +01:00
Ludovic Marie d7a41d6ff3 Merge pull request #5856 from horizon-rust/fix/prediction-join-freeze
🤖 Agent: pr_ready --quick all green (1671 horizon-client tests, clippy, fmt); new replay test + 16 existing pass; anticheat_e2e --check prediction: honest client never flagged, cheating bot flagged 13x and sent back 12x.
2026-11-01 11:41:06 +01:00
Lostpold88andAntigravity 6548234ac5 Bow arrows no longer drift when strafing and remote arrows update flight pitch
When charging a bow while strafing on the ground, arrows drifted sideways
because player ground movement was added to the projectile velocity and
rotation was not aligned to the resultant vector. In addition, client-side
remote arrows did not update their pitch and yaw during flight, causing
them to fly horizontally like static lasers without following their
parabolic trajectory.

Align bow release with vanilla behavior by not injecting ground strafe
velocity and updating rotation in `shoot_from_rotation`. In
`remote_arrow_tick`, update pitch and yaw every tick using `lerp_rotation`
matching `AbstractArrow.tick()`.

Fixes #5855.

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 11:40:59 +01:00
levzzz 08e734236d Merge remote-tracking branch 'hr/main' into pr-5780 2026-11-01 15:38:54 +05:00
VaspyyyandClaude Opus 5.5 1cdc20c2cc Native mods: biome colours a client mod changes, for seasons (native_biome_colors)
`native_biome_colors(biome, colors)` changes a biome's grass, foliage,
dry foliage and water tints. It is asked for every biome when a mod calls
`native_refresh_biome_colors()`, which swaps the mesh workers' biome table
and rebuilds the loaded sections and far terrain when the colours differ
(LevelRenderer.allChanged, as a Biome Blend change does).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRKvdg3SMVP1onGqS3
2026-11-01 10:38:04 +00:00
levzzz 3e32651f45 Merge pull request #5763 from lukehinojosa/feat/native-interaction-drops
Native mods: player-use drops through native_loot_drops
2026-11-01 15:37:55 +05:00
levzzz 85072be4af Merge pull request #5815 from Detimes/feat-5682-console-native-commands
Native mods: a registered command typed on the dedicated server's console (part of #5682)
2026-11-01 15:37:51 +05:00
levzzzandClaude Opus 5.5 24f539c633 Content mod docs: every kind it declares, multiplayer, the sounds key, sections in place
Closes #5852.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:36:58 +05:00
Stink-OandClaude Opus 5.5 c107eeb1a4 fix(mods): a mod screen opens only where the screen hooks can draw it
native_mod_screen_open refuses what screen_hooks_open would: an overlay that leaves the screen at Playing (a
book, a sign editor, a dialog, a prompt), the world loading, no player, and anywhere but the window's own client
(no renderer, or a LAN guest's swap on the server thread), so a screen never opens that nothing could draw or
close. The docs and fragment say so, and that a gamepad can close it but not click on it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:33:46 +00:00
VaspyyyandClaude Opus 5.5 91e754dd88 Native mods: draw a mob with the mod's own model (native_mob_submit)
A client mod is asked once a frame for each mob in view (players aside)
with the mob's render state: id, kind, custom name, baby, partial tick,
age, body and head turns, pitch, walk cycle, hurt and death time. It draws
with the block entity drawing helpers under a pose at the mob's feet,
turned with its body, and sets `drawn` to leave out the vanilla model
(EntityRenderer.submit; NeoForge RenderLivingEvent.Pre, cancelled).
Name tag, shadow, fire and glowing outline still draw.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRKvdg3SMVP1onGqS3
2026-11-01 10:32:50 +00:00
Kacpherek c627a02c56 Pass the timing option on without re-borrowing it 2026-11-01 11:32:38 +01:00
levzzz dcbfa2e3ed Merge remote-tracking branch 'hr/main' into pr-5748
# Conflicts:
#	crates/horizon-client/src/game/native_server.rs
#	docs/native-mods.md
2026-11-01 15:32:28 +05:00
Ludovic MarieandClaude Opus 5.5 b6709fee4f Block states: facts.json reads properties straight into shared strings
The registry's states were read into a BTreeMap of owned strings, then
shared: two allocations and a hash per string, 50 ms of the start. The
facts.json decoder now shares each name and value as it reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:32:21 +01:00
levzzz 59744ec017 Merge pull request #5661 from lukehinojosa/feat/native-give-stack
Native mods: give a whole stack to any player (native_give_stack)
2026-11-01 15:31:38 +05:00
levzzz 4afb288b43 Merge pull request #5770 from d3xc0/native-hooks
Native mods: read redstone signal, light, weather, time, world height and stack size
2026-11-01 15:31:34 +05:00
Kacpherek 9025a1ac46 Show Horizon's own GPU pass times in F3 and the control socket
The renderer's own pass times were not reachable from the client. The F3
overlay now shows them under their own heading, next to the shader pack
group. A new gpu-passes control command times them without opening F3,
and the stats and bench summaries report them as gpu_passes.
2026-11-01 11:30:37 +01:00
Kacpherek 3e9805e295 Time Horizon's own render passes on the GPU
The per-pass GPU timer only covered shader-pack passes, so the F3
overlay could not say which of Horizon's own passes cost the frame.
Give the same timer to the frame's own passes (world, occlusion test,
OIT, HUD, post, HDR, hand, outline, menu blur, native shadow) and add
compute_writes for compute passes.

Passes that already carry timestamps (shader packs, the frame timer),
screenshots, one-off clears and test code are left as they were. The
timer begins at the top of the frame so that passes recorded before
the first draw, such as sprite animation, are timed too.
2026-11-01 11:30:37 +01:00
Stink-O 713798e681 Merge main into #5741 (after #5616) 2026-11-01 10:30:34 +00:00
Stink-O ad13187cf7 Merge #5617's current head into #5741
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 10:30:34 +00:00
levzzz 02197e6053 Merge remote-tracking branch 'hr/main' into pr-5815
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 15:28:07 +05:00
levzzzandClaude Opus 5.5 3892152091 Lead killers: each level prunes only its own corpses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:27:58 +05:00
levzzz 51f26d0576 Merge pull request #5825 from Detimes/feat-player-command-hook
Native mods: any command a player types, cancellable (native_player_command, part of #5816)
2026-11-01 15:27:46 +05:00
Ludovic MarieandClaude Opus 5.5 0626d6d505 Anticheat prediction: a client still loading the level isn't flagged for not falling
Until the client says it loaded the level (at join and after a respawn), the
ticks it ends with no move are excused: it is on its loading screen, where it
doesn't move, even once the server stops waiting for it after 60 ticks. Its
first move counts as any other.

Fixes #5849.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:27:33 +01:00
SylviromiandClaude Opus 5.5 4a02c7d5b9 Native mods: a contributed data pack file that isn't JSON is skipped alone
One bad file from a mod would fail its whole table and drop the world's
pack content with it; it is now skipped with a warning. The hook's docs
list every kind the tables read, trade sets, villager trades and dialogs
included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:26:24 +03:00
Ludovic Marie d8dc15794c Merge pull request #5847 from horizon-rust/perf/startup-sp2
🤖 Agent: playhorizon 4 CPUs, 6 alternated starts: Done 2.067 → 1.881 s median (6/6), core tables 0.70 → 0.52 s, idle RSS same; pr_ready tests 1702 + content roots pass, fmt/clippy/Python clean on the Mac.
2026-11-01 11:23:54 +01:00
Ludovic Marie de552471f6 Merge pull request #5848 from horizon-rust/fix/main-build-attributes-after-first-batch
Server: a joining player's attributes wait for its first chunk batch (dedicated_server flake)
2026-11-01 11:23:09 +01:00
Ludovic MarieandClaude Opus 5.5 d10b514182 Server: a joining player's attributes wait for its first chunk batch
parity-main fails on dedicated_server now and then: "+play-start
minecraft:update_attributes". #5597 held the join tick's dirty attribute
instances for one tick, because vanilla syncs them on the next tick, after
the first chunk batch. But this server sends no batch before placed_at + 2,
and only once the player's chunks are ready: on a loaded runner the first
batch came after that next tick, so the attributes went out before it.
They now wait until a batch has been sent (unacknowledged > 0, or the
client already acknowledged one), which keeps vanilla's order however long
the chunks take.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:22:24 +01:00
levzzz f9187fe58b Merge pull request #5707 from ErikKalikas/mod-block-entity-keep
Native mods: keep a mod block entity's geometry while it doesn't change
2026-11-01 15:21:38 +05:00
levzzzandClaude Opus 5.5 aa21d36fff Launcher: translate the native builds' In use and section labels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:16:27 +05:00
levzzzandClaude Opus 5.5 d3fb451a44 Flying speed: the server's movement replay uses it; docs: client pushes and the anticheat, false inside a player's hook
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:14:50 +05:00
levzzzandClaude Opus 5.5 8560cb7287 Creative search: a number key the hovered slot took types nothing; scroll hook doc: discrete scrolling, scroll-at
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:14:14 +05:00
levzzzandClaude Opus 5.5 5b4333059f Mod particle types: an API fragment instead of a hand-bumped number; say vanilla clients aren't sent them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:13:43 +05:00
levzzz 5c0ee1ef23 Merge #5763's branch (fixed) into the lead drops 2026-11-01 15:12:40 +05:00
levzzzandClaude Opus 5.5 a9c6d86b39 Spill: a use whose loot came out empty still reaches the hook and is drained
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:34 +05:00
levzzzandClaude Opus 5.5 4e40cd0eb4 Experience docs: no level-up sound for levels, when a give can be refused, merged orbs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:26 +05:00
levzzzandClaude Opus 5.5 079292b669 Give stack: reach no other player inside a player's hook
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:17 +05:00
levzzzandClaude Opus 5.5 e7fedb9bc3 World reads: no light in unloaded chunks, no weather outside the overworld, 26.3's clock name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:05 +05:00
levzzzandClaude Opus 5.5 723299c6ab Console mod commands: look the line up trimmed, as the console reads it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:11:40 +05:00
levzzzandClaude Opus 5.5 2a1b8a321a Player command hook: hear quiet command batches, name it canceled, catalog keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:11:31 +05:00
levzzzandClaude Opus 5.5 c3af108912 Clear kept mod block entity geometry with the world; say what keep() freezes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:10:58 +05:00
levzzz 4ef0afc99c Merge remote-tracking branch 'hr/main' into pr-5707
# Conflicts:
#	crates/horizon-client/src/game/native_block_entity_draw.rs
2026-11-01 15:10:58 +05:00
levzzz a75f58462f Merge remote-tracking branch 'hr/main' into pr-5783 2026-11-01 15:10:25 +05:00
levzzz 648a65259d Merge remote-tracking branch 'hr/main' into pr-5739 2026-11-01 15:10:25 +05:00
levzzz bbc31dd472 Merge remote-tracking branch 'hr/main' into pr-5617 2026-11-01 15:10:24 +05:00
levzzz e0f5ad643b Merge remote-tracking branch 'hr/main' into pr-5792 2026-11-01 15:10:24 +05:00
levzzz 6d744151ac Merge remote-tracking branch 'hr/main' into pr-5763 2026-11-01 15:10:24 +05:00
levzzz 50069df898 Merge remote-tracking branch 'hr/main' into pr-5748 2026-11-01 15:10:24 +05:00
levzzz a69a65d099 Merge remote-tracking branch 'hr/main' into pr-5661 2026-11-01 15:10:24 +05:00
levzzz 7239b38b1d Merge remote-tracking branch 'hr/main' into pr-5770 2026-11-01 15:10:24 +05:00
levzzz 2b7549c556 Merge remote-tracking branch 'hr/main' into pr-5815 2026-11-01 15:10:23 +05:00
levzzz 9802a6dccb Merge remote-tracking branch 'hr/main' into pr-5825 2026-11-01 15:10:23 +05:00
levzzz bd8c53bfa4 Merge pull request #5616 from Sylviromi/screen-mouse-scrolled
Native mods: screen_mouse_scrolled, the wheel over the open screen (API next)
2026-11-01 15:09:48 +05:00
levzzz afac2f98eb Merge pull request #5729 from Stink-O/native/clock
feat(mods): native mods can read the world's clock
2026-11-01 15:09:42 +05:00
levzzz aa85e03ee2 Merge pull request #5687 from ErikKalikas/mod-block-entity-draw-item-helpers
Native mods: draw items in mod block entities, read their block state's properties
2026-11-01 15:09:35 +05:00
Stink-OandClaude Opus 5.5 66ac20304e refactor(extras): the UI sound reserve's match arms on lines of their own
So #5675 (splitscreen), which edits the same two arms, merges with this in either order; only the one-line
serialize format string still meets it (keep both keys).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:03:54 +00:00
Ludovic MarieandClaude Opus 5.5 49e08de26d Block states: a palette entry's properties collect in one pass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:59:22 +01:00
Ludovic MarieandClaude Opus 5.5 e7f99e7ce1 Block states: compact properties with shared strings
A block state's properties were a BTreeMap<String, String>: a 544-byte
leaf and two allocations per property for each of the registry's 35,723
states (about 30 MB of a dedicated server's live heap), for 237 distinct
strings. BlockProperties keeps them as a sorted slice of pairs, read like
the map (get, insert, iter, indexing, order, equality and hashing pair by
pair); the registry shares its states' strings through one Arc each.
States made elsewhere (chunk palettes, commands) own theirs, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:14 +01:00
CocofireHD 42aec3c9f6 Native mods: keep mod particles off vanilla clients, pack their sprites 2026-11-01 10:46:01 +01:00
TeiwazikandClaude Opus 5.5 4ee84d5fa6 Shader API: [[post]] output and scale, a pack's own targets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:38:07 +01:00
Ludovic MarieandClaude Opus 5.5 bef4061052 Block facts: validate the JSON beside the decode
The gameplay facts load parses its 56 MB file twice: a validation pass that
only keeps the JSON errors, then the typed decode. The validation now runs
on a scoped thread while this one decodes, and is joined before the load
returns, so its error still wins. With a single worker (`parallel` false)
the two passes run one after the other as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:33:35 +01:00
885a33e930 Native mods: the world options boxes type a key's characters only
The window now hands a key's characters to the character hook instead of
the key, so `world_options_key` must not read the missing text as a
backspace: only a key that carries text types, and a `None` stays the
control socket's `\b`.

The character hook fires after the key's screen effects, as GLFW's
separate char callback (`KeyboardHandler.charTyped`) comes after its key
callback, and its characters survive a cancelled press, as NeoForge's
`CharacterTyped.Pre` comes after a cancelled `KeyPressed.Pre`.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Code <[email protected]>
2026-11-01 12:31:27 +03:00
TeiwazikandClaude Opus 5.5 a8b2fe2969 Shader API: a stage's files, WGSL only that stage reads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:27:10 +01:00
SylviromiandClaude Opus 5.5 f75040061d Native mods: a key's characters reach the screen after the key has acted
GLFW's char callback comes after its key callback, so the character
hook now fires once the screen has acted on the key, on the screen open
then: a key that closes the screen reports no character, and one that
moves the focus types into the box it moved to. Options keeps its text
on the key, as its key path routes it between boxes and key binds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:26:44 +03:00
levzzz f77b1391eb Merge pull request #5649 from reservedbytes/feat/native-item-use-over-time
feat(mods): native mods use items over time (duration, animation, can eat, start and finish)
2026-11-01 14:26:32 +05:00
TeiwazikandClaude Opus 5.5 4bb3e393d9 Shader API: [material] fog, for a pack that fogs its surfaces itself
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:23:34 +01:00
levzzz 3477f0d8ce Merge pull request #5692 from reservedbytes/feat/content-entity-types-save-summon
feat(mods): content mods' thrown entity types are saved, summoned and listed
2026-11-01 14:19:27 +05:00
levzzz b6bf49c1a9 Merge pull request #5809 from Xmilon/native/item-decorations
feat(mods): custom item icon decorations
2026-11-01 14:16:31 +05:00
levzzz b0358127e6 Merge pull request #5806 from Xmilon/native/client-resource-reload
feat(mods): notify clients after resource-pack reloads
2026-11-01 14:16:25 +05:00
[email protected]andCodex a303d7d105 Merge upstream/main into the client library split
Retain upstream block_contact and the split entry/game exports with test isolation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 10:15:44 +01:00
Detimes 64c3a68a71 Native mods: any command a player types, cancellable (native_player_command) 2026-11-01 09:15:18 +00:00
levzzz 4e25d33e58 Merge pull request #5802 from Xmilon/native/5797-render-hand
feat(mods): cancellable first-person hand rendering
2026-11-01 14:15:16 +05:00
TeiwazikandClaude Opus 5.5 e7aa7cb796 Shader API: the material stage reads resource packs' LabPBR maps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:08:39 +01:00
levzzzandClaude Opus 5.5 b91ef70301 fix(mods): a use's client countdowns follow its started duration
LivingEntityUseItemEvent.Start may change a use's duration, which
start_using_item keeps in use_duration (vanilla's useItemRemaining). The
first person pose asked getUseDuration again each frame for the remaining
ticks, and a server's client counted a food's crumbs from its consumable
time: both now count from use_duration, as getUseItemRemainingTicks does.
A use the server's flags start on the client takes getUseDuration, as
onSyncedDataUpdated does.

The fragment carries the catalog's neoforge, side and cancellable keys,
which the stamp tool now copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:06:29 +05:00
dill-lkandCopilot d34380935d Fix parity remote startup failures
Co-authored-by: Copilot <[email protected]>
2026-11-01 09:05:53 +00:00
Detimes 8187bdafaf Native mods: a player respawning (native_player_respawned) 2026-11-01 09:04:22 +00:00
levzzzandClaude Opus 5.5 69722dfc5e Merge main into feat/native-item-use-over-time
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:04:09 +05:00
levzzzandClaude Opus 5.5 a77659a199 fix(mods): mod entity rows appended as text; the summon test compiles
The content root appended each mod type's entity-types.json row by parsing
and rewriting the table, which needed serde_json's float_roundtrip to keep
a zombie's movement_speed to the bit. Cargo unifies that feature over every
crate in the game build (worldgen JSON included), while horizon-world's
parity probes, built alone, would parse without it. The rows are now
appended as raw text copied from the template's row, so no vanilla float
is reparsed and the feature goes.

The save test compared p.kind by value, then borrowed p: ProjectileKind
isn't Copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:03:48 +05:00
SylviromiandClaude Opus 5.5 791ccd18da Native mods: screen_render_post skips the screen the server pack prompt hides
While PackConfirmScreen is open the frame draws no screen under it, but
the render hook still reported the hidden one, so a mod drew over a
screen nobody saw. It now skips that frame, as the input hooks already
do through screen_over_playing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:03:21 +03:00
levzzz ac68376123 Merge remote-tracking branch 'hr/main' into pr-5692 2026-11-01 14:02:39 +05:00
Detimes 678eecf01c Hook catalog fragment: name without the native_ prefix (#5682) 2026-11-01 09:02:34 +00:00
levzzzandClaude Opus 5.5 ccc8d4a9ae Merge main into native/item-decorations; the batch takes GUI units
The stub moves next to native_item_bar: #5804's block highlight took the
spot after native_tick. The docs said the batch uses screen coordinates;
it takes the same GUI units as x and y (draw_slot_item fills there too).
They now also list the other screens that draw item icons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:02:16 +05:00
levzzzandClaude Opus 5.5 402bb0be5e Merge main into native/5797-render-hand; the hook only hides
The stub moves next to native_camera_shake: #5804's block highlight took
the spot after native_tick. Its docs no longer say mods can draw their own
hand geometry: the hook passes no vertex buffer, pose or light.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:02:00 +05:00
levzzzandClaude Opus 5.5 f7dc1bd0d5 Merge main into native/client-resource-reload
The hook stub moves next to native_frame: #5804's block highlight took
the spot after native_tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:01:40 +05:00
Stink-OandClaude Opus 5.5 7096f3fe39 fix(mods): save a player's flying speed and document the motion helpers
Vanilla saves Abilities.flyingSpeed with the player, so a speed a server
mod set now goes into abilities.flySpeed and comes back on the next join;
a file's own speed is read. Every abilities packet comes from one
Guest::abilities, so the join, a game mode change, a respawn and a level
change all carry it (tested with the save round trip). The fragment is
renamed to native_motion for both helpers, FLYING_MAX says why 50 and what
the movement checks do with a fast non-owner, the guest comment names
the server mod, the fork wording goes, and docs/native-mods.md gets a section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 09:00:42 +00:00
levzzz a1fa1ac0a6 Merge pull request #5804 from Xmilon/native/block-highlight
feat(mods): cancellable targeted block highlights
2026-11-01 14:00:40 +05:00
levzzz e79b7e80d8 Merge pull request #5713 from reservedbytes/feat/mod-menu-types
feat(mods): mod menu types with their own slot count and data slots
2026-11-01 14:00:36 +05:00
levzzz 7ac08d8634 Merge pull request #5677 from arismoko/fix/native-tell-acting-player
Fix native chat to the acting player inside hooks
2026-11-01 14:00:31 +05:00
levzzz 9adc39ae7a Merge pull request #5796 from Xmilon/native/5672-cancelled-click-focus
fix(mods): cancelled screen clicks preserve navigation focus
2026-11-01 14:00:26 +05:00
Sylviromi 9d255bbdf0 Merge branch 'screen-mouse-scrolled' into screen-key-input
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 11:58:47 +03:00
Stink-OandClaude Opus 5.5 d0135bf354 fix(mods): native_clock with then_some (clippy unnecessary_lazy_evaluations)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:57:42 +00:00
SylviromiandClaude Opus 5.5 adb8f735ec Native mods: a cancelled key still types the characters left, and the socket's backspace stays a key
A key press a mod cancelled hands the characters no mod cancelled to the
text box, as vanilla's separate charTyped does; the control socket's
type \b no longer offers its backspace sentinel as two characters, and
its cancelled tap sends the release the docs promise. The key and
character hooks' docs name the control socket and say a key that closes
the screen reports its character there first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:56:13 +03:00
Sylviromi e69ed14ade Merge remote-tracking branch 'origin/main' into screen-mouse-scrolled 2026-11-01 11:49:37 +03:00
Detimes 7176677496 Native mods: a registered command typed on the dedicated server's console (#5682)
native_perform_console_command hears a command a mod registered when it is typed on the
dedicated server's console (level 4, no player), before vanilla's dispatcher; the answer goes
to the console through native_feedback. The single player owner's console is not covered.
2026-11-01 08:45:06 +00:00
TeiwazikandClaude Opus 5.5 5445cf8a52 Graphics setting: textureBudgetMiB, the block textures' video memory
The block sprite array was held to a fixed 256 MiB, which samples a
high-resolution pack down to 128 px. The default stays that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 09:36:57 +01:00
Ludovic Marie b4e123280d Merge pull request #5810 from horizon-rust/fix/main-build-random-tick-push-up
random_tick: the probe skips push_entities_up (parity-main green again)
2026-11-01 09:33:02 +01:00
Ludovic MarieandClaude Opus 5.5 48af02d0a7 random_tick: a block's push_entities_up lifts nobody in the probe's cases
Since #5100, turn_to_base_block (a dirt path or farmland turning to dirt)
emits a push_entities_up block event, which the block-update probe printed
as "X push_entities_up". Vanilla's Block.pushEntitiesUp only moves the
entities in the new block's box, and these cases have none, so the oracle
logs nothing: the dirt_path case differed and parity-main failed. The probe
now skips it, as it skips the other events that need someone there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 09:32:19 +01:00
[email protected]andCodex c411dcd917 feat(mods): append custom item icon decorations
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:25:36 +01:00
[email protected]andCodex b87b52e3f3 feat(mods): notify clients after successful resource reloads
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:23:36 +01:00
[email protected]andCodex f4d6ebab0c feat(mods): allow cancelling targeted block highlights
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:21:41 +01:00
Ludovic Marie 7e5e3d1ada Merge pull request #5799 from horizon-rust/perf/ticks-fx-hash
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_blocks 5.35→5.06 ms (−5.5 %), MSPT 25.91→25.46. Tick sets only looked up (keys sorted where listed). fmt + clippy -D warnings + horizon-core tests pass (Mac mini); 955 render+core tests pass (playhorizon); its 2 Python failures are its VSCREEN env.
2026-11-01 09:19:12 +01:00
[email protected]andCodex f9e03cd32b feat(mods): add cancellable first-person hand rendering
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:19:01 +01:00
[email protected]andCodex 47a342e6c9 fix(mods): keep cancelled screen clicks from moving navigation focus
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:14:33 +01:00
Stink-OandClaude Opus 5.5 eb47e82bac fix(mods): native_quad fills as native_fill does; the mod screen's rules tested and documented
native_quad draws through HudBatch::fill_quad (the HUD atlas's white, as native_fill) instead of borrowing the
lightning renderer's white_uv. The rules for opening a mod's own screen move into mod_screen_opens, with a test:
only over the world, never over another screen, the title screen or another mod's, and again a no-op. The docs
say every mod's own screen shares the id `mod` (check native_mod_screen() is yours) and that it doesn't pause a
single-player game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:10:45 +00:00
Stink-OandClaude Opus 5.5 09a73636d0 fix(mods): native_clock gives the clock as the world keeps it
Renamed from native_get_clock (no other helper has a get_ prefix). Both fields are i64 ticks as
Level.getGameTime and getDayTime return them, without the clamp to 0: the day time counts on past 24000
and /time set can make it negative, and the docs say so. Documented as the shown world's clock, for
client mods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:07:17 +00:00
Stink-OandClaude Opus 5.5 19a4e7c31c feat(extras): the UI sound channel reserve is an extra, off by default
With it off the static pool is vanilla's again (247 channels to whatever starts first), so the sound_engine
parity harness is unchanged; `uiSoundReserve` (Extras > Sound, "Keep channels for UI sounds") turns on the 16
channels positional sounds leave for the ones played to the listener. The test checks both, and its
`use test_backend::Null` moves into it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:04:02 +00:00
Stink-O 047b17f57b Merge main into #5686 2026-11-01 08:00:19 +00:00
Stink-O dd658dc0b3 Merge main into #5685 2026-11-01 08:00:18 +00:00
Stink-O 8b9556cc9d Merge main into #5739 2026-11-01 08:00:13 +00:00
Stink-O 670584316e Merge main into #5741 2026-11-01 08:00:13 +00:00
Stink-O 54335a438d Merge main into #5729 2026-11-01 08:00:13 +00:00
Ludovic Marie 5edb339a76 Merge pull request #5793 from horizon-rust/perf/chunk-encode-palette-ids
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_lan 4.22→3.96 ms (−6 %), MSPT 25.65→25.32. Same bytes (new test vs from_values). fmt + clippy -D warnings pass (Mac mini); workspace tests 3982 pass (playhorizon); the 2 Python failures there are its VSCREEN env, they pass on the Mac mini.
2026-11-01 08:55:26 +01:00
CocofireHDandDeepSeek Harness a7bda85c03 Native mods: a mod's own particle types
A native mod can now send a particle of its own type, drawn by every client
from the sprite set the mod's pack ships, instead of borrowing a vanilla one.

The type is the mod's namespaced name in ParticleOptions::named ("mymod:spark");
the client builds it as vanilla's SimpleParticleType with a sprite set: a
plain billboard, no tint, 20 to 40 ticks, on the engine's own random. The set
comes from assets/<namespace>/particles/<name>.json, the file vanilla's
SimpleParticleType reads; its textures resolve as particle/<texture> in the
atlas, the same rule vanilla's own particle JSONs use.

On the wire a mod particle is Horizon's own extension: the sentinel id
MOD_PARTICLE_ID (-1, never a vanilla registry id) followed by the string,
which vanilla's Particle codec has no room for. A client that does not know
the mod ignores it like any unknown particle, and native_send_particles
refuses an unnamespaced name.

native_send_particles keeps its signature; a mod builds the options itself,
so no hook point moved. NATIVE_HOOKS_API is 22 for the new capability and
docs/native-mods.md says what is supported.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-11-01 08:52:10 +01:00
Kacpherek ba01d1e9f7 Draw the ender dragon in every dimension, not only the End
The dragon was drawn only while the player stood in the End, so a
dragon summoned in the Overworld (/summon ender_dragon, which the
server sends to the client as an AddEntity) was heard, flapping and
growling, but never shown. Vanilla's EnderDragonRenderer draws an
EnderDragon in any dimension like any other entity; the gate was
added with the fight, when the dragon only ever existed in the End.
The dragon fireballs share the same block and are now drawn in every
dimension too.
2026-11-01 08:38:50 +01:00
Ludovic MarieandClaude Opus 5.5 d16d57b197 Scheduled ticks: FxHash for the per-tick position sets
LevelTicks and ChunkTicks hash a (type, position) key at every schedule,
poll and dedup check, and the chunk map at every lookup, with std's SipHash.
They are only looked up, never iterated in order (the keys of an area are
sorted), so they now use the Fx hasher, which moves from
horizon-client-render to horizon-core (re-exported under the old path).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:35:53 +01:00
Detimes d172784bdd docs: a player's arrow hurts other players on a served world (#5785)
The server doc still listed bow PvP between guests as missing; it works since 5763854b0 (lan_e2e 'arrows'), and a dedicated server run with two protocol clients agrees.
2026-11-01 07:33:48 +00:00
Ludovic Marie 78043f09a9 Merge pull request #5787 from horizon-rust/fix/client-input-auto-jump
🤖 Agent: #5786. Auto-jump now in ServerboundPlayerInputPacket as vanilla's keyPresses. Staircase walk with autoJump on, same server: 75 prediction flags with main's client, 0 with this one. pr_ready --quick all green on 7a8a97dd5.
2026-11-01 08:33:08 +01:00
Ludovic MarieandClaude Opus 5.5 17410e0123 Client: the input packet carries the auto-jump, as vanilla's does
LocalPlayer.sendChanges sends input.keyPresses, which an auto-jump's makeJump
sets to jump for that tick. Horizon sent the keys as held, so with Auto-Jump on
the server saw jumps its keys didn't allow: the anticheat's prediction flagged
every auto-jump and the 0.4 a tick it left (75 flags on a staircase walk, none
with this change). The official server's log has the same lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:32:09 +01:00
Stink-O 07901bae47 Merge main into #5741 (both sides' hook tests kept) 2026-11-01 07:26:10 +00:00
Ludovic Marie 58ffd7ed33 Merge remote-tracking branch 'origin/main' into g/5595
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/ja.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/ru.json
#	launcher/lang/tr.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-11-01 08:23:44 +01:00
Ludovic Marie 07f5e6164d Merge pull request #5781 from horizon-rust/fix/anticheat-prediction-one-flag-per-slip
🤖 Agent: #5725. One prediction flag per slip across instead of 10-20 (87% of live lines continue an earlier slip). Strict cheat tests unchanged; new tests for a missed push (counts once) and a kept-up 0.05 speed lie (35 of 40 ticks count). pr_ready --quick all green on 7a8a97dd5.
2026-11-01 08:14:18 +01:00
lukehinojosaandClaude Opus 5.5 cd12172a8e Native mods: lead drops through native_loot_drops
Leads dropped through break_leash without telling native_loot_drops,
so a mod couldn't see or change the lead a player gets back, nor the
one a leashed mob drops when a player kills it.

Untying a lead, cutting it with shears (on the mob or its fence
knot) or re-leashing a mob from another holder now passes it through
the hook as a use (break_leash_used: the leashed mob, the acting player).
A mob a player kills while leashed records its killer (lead_killers);
the corpse drops its lead on its next tick as vanilla does
(Leashable.tickLeash), as a call of its own naming that killer. A lead
that snaps or loses its holder names no player and drops as before.

The API number comes from the lead_drops fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:12:10 -05:00
Ludovic MarieandClaude Opus 5.5 82888ca49d Prediction: a slip across counts once, not on every tick its velocity lasts
87% of the official server's prediction flags (6,002 of 6,900 lines on
2026-11-01) continue a divergence that started on an earlier tick: once the
replay's velocity is off (a hit it missed), the offset dies out by the air drag
(0.91) or ground friction each tick, and each of those ticks flagged again.

The first tick off after ten that agreed now has its follow-up tick try the
velocity the client was left with (across only, at most 0.5), as another
candidate. A single slip then counts once; a blink leaves the velocity alone; a
tick that agrees only thanks to it never becomes the setback point. Not up and
down: the vertical drag (0.98) would let a slow fly pass for a slip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:04:40 +01:00
TeiwazikandClaude Opus 5.5 6bb88d2149 Merge main: reference and formatting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 08:01:18 +01:00
Teiwazik 1720b747dd Merge remote-tracking branch 'origin/main' into shader/textures
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_pack.rs
#	crates/horizon-client-render/src/gfx/native_post.rs
#	crates/horizon-client-render/src/gfx/native_sky.rs
#	crates/horizon-shader-api/src/check.rs
#	docs/api/shaders.md
#	launcher/Cargo.lock
2026-11-01 08:01:09 +01:00
TeiwazikandClaude Opus 5.5 d3e42f3761 Merge main: reference and formatting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 07:59:11 +01:00
Teiwazik 2c1aa246ac Merge remote-tracking branch 'origin/main' into shader/weather
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_pack.rs
#	crates/horizon-shader-api/src/stages.rs
#	docs/api/shaders-reference.md
#	docs/api/shaders.md
2026-11-01 07:59:02 +01:00
Teiwazik 2b8e602c5b Merge remote-tracking branch 'origin/main' into shader/sky-flags
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_pack.rs
#	docs/api/shaders.md
2026-11-01 07:55:45 +01:00
Ludovic Marie 7a8a97dd5f Merge pull request #5776 from horizon-rust/fix/server-thread-limit-crash
🤖 Agent: #5775. Official server crash 05:15Z (spawn worker WouldBlock, no save). Worker spawn failure non-fatal; connection threads capped at max_players+64. New flood test fails without the cap, passes with it; pr_ready --quick green apart from one ENOSPC test failure that passes alone.
2026-11-01 07:55:38 +01:00
abit-foggyandCodebuff 80a8c274d1 terrain: sample distant tiles at an explicit mip so anisotropic filtering stops streaking
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-31 20:55:02 -10:00
Ludovic MarieandClaude Opus 5.5 76ae6b7763 Server: a refused thread no longer crashes it; connection threads are capped
A dedicated server crashed on the official server (2026-11-01 05:15Z) when the
system refused a new thread: Streaming::new's .expect("spawn worker") panicked
and the process died without saving. Worker spawn failures are now logged and
the pool runs with the workers that started.

The server starts a thread per accepted socket (vanilla's Netty uses a few for
all): past max_players + 64 live connection threads, a new socket is closed at
once, so a flood of silent sockets can't use up a container's thread limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:54:08 +01:00
Teiwazik 669cc4d8fa Merge remote-tracking branch 'origin/main' into shader/pack-pages
# Conflicts:
#	docs/api/shaders.md
2026-11-01 07:53:01 +01:00
Ludovic MarieandClaude Opus 5.5 6d035f2fa1 Chunk sending: encode a section from its stored palette ids
A section's first encoding unpacked its 4,096 block states, then built a
palette from them again (Paletted::from_values). For a list palette the
stored ids are now renumbered in first-seen order instead: the same palette,
indices and bytes, without the values. Global palettes keep the old path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:52:40 +01:00
Ludovic Marie 04029578c5 Merge pull request #5774 from horizon-rust/perf/mob-tick-no-copy
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_creature_entities 4.95→4.32 ms (−13 %), MSPT 26.59→25.78 (−3 %). pr_ready --quick pass (1655 horizon-client tests, 511 Python). Tick order and RNG unchanged; 34 harnesses in CI.
2026-11-01 07:48:35 +01:00
Teiwazik c958240f31 Merge remote-tracking branch 'origin/main' into shader/s9-materials 2026-11-01 07:45:57 +01:00
Teiwazik cf281cc812 Merge remote-tracking branch 'origin/main' into shader/s8b-player-shadow 2026-11-01 07:38:39 +01:00
Arthur Black bca8c9a0b8 Native mods: read light, weather, time of day, world height and stack sizes 2026-10-31 23:28:28 -07:00
Arthur Black 14dcadde70 Native mods: read the redstone signal reaching a block (native_neighbor_signal) 2026-10-31 23:28:28 -07:00
Ludovic Marie 26747867df Merge pull request #5714 from TiggerTakkyTTT/fix/native-mod-list
🤖 Agent: Fixes #5690 (no Build button with only GitHub mods); verify + launcher green; approved as code owner. Queued.
2026-11-01 07:17:47 +01:00
Aaron Wang e3fcbf1b3c Keep unbreakable when syncing an inventory stack
The integrated server retained minecraft:unbreakable, but network_stack
never emitted its unit component. The client therefore still drew a bar
even with the rendering guard in the previous commit. Send UNBREAKABLE
as Component::Unit; the client decoder already preserves unit components.

Add a regression test through stack_to_network, Slot write/read and
from_network, checking both ordinary and unbreakable damaged hoes.
It fails without the encoder entry and passes with it. Verify the actual
server-thread client retains the component and loses only the unbreakable
hoe's bar in the hotbar screenshot.

Written with Claude Opus 5.5 in OpenCode.
2026-11-01 01:16:43 -05:00
Ludovic MarieandClaude Opus 5.5 ab376df4e0 Mob ticks: no copy of every mob each tick; a fast hasher for the per-mob sets
tick_creature_entities takes the mob list, ticks it, and appended it back
into the emptied list: a new buffer and a copy of every Mob each tick (and
the old buffer freed). With no mob added meanwhile the list now goes back as
it is. The dead-mob set and the guest bindings, looked up per mob each tick
and never iterated, use FxHash instead of SipHash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:13:44 +01:00
Ludovic Marie 87e5ca82de Merge pull request #5767 from horizon-rust/fix/anticheat-prediction-item-use
🤖 Agent: #5725 step 2. Guest item use (eating, bow, shield) now slows the replay as it slows the client. Vanilla 26.3 client walking/sprinting while drawing a bow 3 min: 503 prediction flags → 2 (0.045). pr_ready --quick all green on 3bc251ea5.
2026-11-01 07:11:40 +01:00
Stink-OandClaude Opus 5.5 f641477ff6 feat(mods): native mods can open custom menus
`native_mod_screen_open(mod_id)` opens a full-screen menu of a client mod's own over the world (`Screen::Mod`, its
`NativeScreenView.id` `mod`), `native_mod_screen_close` closes it, `native_mod_screen()` says whose is open, and Escape
closes it as any screen. It draws nothing itself: the screen hooks fire over it as over any other, so the mod draws it
in `native_screen_render_post` and takes its clicks, scroll, keys and typing from the mouse, scroll, key and
character hooks (#5543). Two drawing helpers beside `native_fill` for that: `native_quad` (a wedge or a slanted bar)
and `native_item_model` (an icon drawn with another `item_model`).

Ported from Stink-O/horizon ae531730c (`ui.show`) and 848a0686c, reshaped onto the screen hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:10:40 +00:00
Ludovic Marie c624e9c1d8 Merge pull request #5765 from horizon-rust/feat/server-low-memory
🤖 Agent: [engine] low_memory (off by default) sets MIMALLOC_ALLOW_THP=0 for the game. Measured playhorizon 20 bots: idle RSS −60 MB, −190 MB at 20 players, MSPT +1.2 % (5 rounds). pr_ready --quick pass (98 horizon-server tests incl. the new env test, 511 Python); server reference regenerated.
2026-11-01 07:04:41 +01:00
Ludovic MarieandClaude Opus 5.5 16559f6668 Anticheat prediction: play a guest's item use as its client does
An item in use (food, a bow, a shield) slows the client to its
use_effects speed (0.2 for most) and stops its sprint. The replay plays a
guest on the server's copy of its body, which takes the item use from
Game.sync_player_body, and that runs only for the window's own player: a
guest's copy never had one. So the replay walked a guest who was eating
or drawing a bow at full speed. That put it 0.8 of a walk (0.173 blocks a
tick) or of a sprint (0.224) off, every tick of it. 0.173 was the official
server's most common prediction flag.

guest_replay now sets the copy's item use speed and its no-sprint flag
from the item the guest is using, as it already did for the glider. A
vanilla 26.3 client sprinting and walking while drawing a bow for 3
minutes: 503 flags before, 2 after (0.045 blocks).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:02:51 +01:00
Ludovic MarieandClaude Opus 5.5 8e692458eb horizon-server: [engine] low_memory turns transparent huge pages off
mimalloc backs the game's memory with transparent huge pages where Linux
allows them; many are only partly used. low_memory = true starts the game
with MIMALLOC_ALLOW_THP=0: 60 MB less idle and 190 MB less at 20 players,
for ~1.2 % more tick time (playhorizon, 20 bots, 5 rounds). Off by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:01:01 +01:00
lukehinojosaandClaude Opus 5.5 aa805243b6 Native mods: player-use drops through native_loot_drops
native_loot_drops heard block breaks and mob deaths only, so a mod
couldn't tell, or change, what a player's shears, brush or hit
dropped, nor who caused it. An auto-pickup or a global loot modifier
needs those too.

The player-use drops now pass through it, with the acting player
named and the new NativeLootDrops::used set: shears on a sheep, a
mooshroom, a snow golem or a bogged; a brush on an armadillo or a
suspicious block; shears carving a pumpkin; picking sweet berries or
cave vines; and a player's hit taking an item frame or its item. One
helper, native_use_drops, builds the context. The berry and cave vine
drops are popped inside the block behaviour, so their run of spill
spawns is tagged and retold when the spill flushes (retell_runs),
keeping each stack's spot and the random draws when no mod changes it.

The API number comes from the use_drops fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:49:44 -04:00
Aaron Wang 8860e928fc No durability bar on an unbreakable item
ItemStack.isBarVisible is isDamaged, which needs isDamageableItem: a
max_damage and a damage, and no minecraft:unbreakable. draw_item_decorations
only looked at the item's max_damage and the stack's damage, so a damaged
unbreakable stack (/give @s diamond_hoe[damage=58,unbreakable={}]) showed a
green bar. It now asks stations::is_damageable, the port of
isDamageableItem the anvil and the grindstone use.

Fixes #5721

Written with Claude Opus 5.5 in OpenCode.
2026-11-01 01:39:49 -04:00
reservedbytes d81a16f409 feat(mods): content mods' thrown entity types are saved, summoned and listed 2026-11-01 04:33:54 -01:00
Teiwazik cb1f775f0c Merge remote-tracking branch 'origin/main' into fix/cutout-mips-keep-coverage 2026-11-01 06:33:00 +01:00
Ludovic Marie 3bc251ea5a Merge pull request #5751 from horizon-rust/perf/light-views-on-workers
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: streaming 4.53→3.46 ms (−24 %), MSPT median 26.59→26.37. pr_ready --quick pass (831 render tests, 511 Python); forced_chunks/generation_saves/streaming_meshes pass. Light output unchanged; 117 harnesses in CI.
2026-11-01 06:32:03 +01:00
TeiwazikandClaude Opus 5.5 450feba95d Shader API: [[lights]], the colour of block light by the block giving it off
Block light has a brightness but no colour, so a pack can't make a soul torch blue. Section meshes now
list their light sources, and a 3D grid around the camera holds the colour reaching each cell, read
through block_light_color. Block light keeps its own brightness, which walls stop, so the grid only
tints it and needs no occlusion of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 06:24:52 +01:00
Ludovic Marie 9cf948a102 Merge pull request #5750 from horizon-rust/fix/main-build-throwable-no-gravity
Thrown projectiles keep NoGravity: save_roundtrip passes again
2026-11-01 06:23:19 +01:00
Ludovic MarieandClaude Opus 5.5 2c935d8d91 Thrown projectiles keep NoGravity: save_roundtrip passes again
Since #5641 a saved snowball, egg or ender pearl loads as a live projectile,
but NoGravity was dropped: the scene's floating snowball and pearl fell into
the floor and were gone before the save. Entity.getGravity is 0 with
NoGravity; the projectile now reads it, honours it and writes it back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:22:38 +01:00
lukehinojosaandClaude Opus 5.5 e2b0df56bd Native mods: read experience orbs, give experience and levels
A mod could cancel an experience orb in native_entity_join_level but
not read its value first, nor give experience to anyone. An
auto-pickup or a machine that pays out experience needs both.

native_experience_orbs(dimension, min, max) reads the orbs in a box,
as native_item_entities does for items. native_give_experience(uuid,
points) gives points as an orb a player touches does: Mending repairs
first (repairPlayerItems), then giveExperiencePoints with the level-up
sound. It reaches any player from any server hook, through as_guest
for a guest, and refuses one swapped out by another player's hook.
native_give_levels(uuid, levels) adds levels or, negative, takes them
off (giveExperienceLevels), as the anvil and enchanting table charge.
Both go through one dispatch, native_as_player.

The API number comes from the give_experience fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:14:57 -04:00
some-du6eandClaude Opus 5.5 64d0b1a4e5 OptiFine CEM: refuse oversized pack files before reading them, log what a model left out
Follows the .jem reader's review changes: files over the model's byte
budget are refused by size, and the reader's warnings reach the log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:01:03 -06:00
some-du6e d3980fec3a Merge optifine/emf-expr into optifine/emf-geometry 2026-10-31 22:59:54 -06:00
Ludovic Marie cbfae59314 Merge pull request #5702 from rdbtCVS/perf/frame-cpu
Cull off-screen dropped items; fix index crash after translucent sections
2026-11-01 05:57:28 +01:00
some-du6e b88a1aad19 Merge optifine/emf-jem into optifine/emf-expr 2026-10-31 22:57:10 -06:00
some-du6e ddfb64045f Merge the third review round into optifine/emf-expr 2026-10-31 22:57:10 -06:00
Ludovic Marie 3dba8448f6 Merge pull request #5726 from horizon-rust/feat/anticheat-prediction-detail
🤖 Agent: log-only detail for prediction lines (#5725 step 1); unit tests for the worst tick's offset/state; vanilla client on a dedicated server logs the new detail; pr_ready --quick all green on 21a12582c.
2026-11-01 05:57:01 +01:00
Ludovic Marie d1e40035ef Merge pull request #5476 from Teiwazik/shader/s7-shadow-translucent
🤖 Agent: requeued after the LAND-STOP: holds #5461's main merge; tests + clippy pass.
2026-11-01 05:55:50 +01:00
Ludovic Marie 6ae81c6449 Merge pull request #5461 from Teiwazik/shader/s6-shadow-solid
🤖 Agent: requeued after the LAND-STOP: main merged (Frame.mods first, shadow fields after it, reference regenerated); shader-api + render native tests + clippy pass.
2026-11-01 05:55:46 +01:00
some-du6eandClaude Opus 5.5 d454a1fcbd OptiFine CEM: the work budget charges what is walked and copied
- Strings read or copied out of the files (part names, ids, animation
  targets and expressions, axes, numbers written as text) cost one per
  started 64 bytes, each time; animations pay theirs before the copy.
- A submodel costs its keys, a part the layers its keys go through.
- by_id no longer copies an id it already has.
- Tests: shared .jpm with a 1 MB expression and with 200k submodel keys,
  the .jem's bytes in the byte budget, non-object animation items, junk
  boxes that only exhaust when keys are charged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:55:22 -06:00
some-du6eandClaude Opus 5.5 2c4c4f83d0 Merge main: the coarse backdrop in main's wanted queue
Main rebuilds a heap of wanted tiles when the view moves and selects
incrementally. The backdrop becomes a class in `Wanted`'s order (seam,
backdrop, the rest), and `evaluate` keeps split tiles at the seam from
standing in, as `Plan::select` does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:54:30 -06:00
Ludovic Marie 46a177c046 Merge main and #5461's main merge into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/api/shaders.md
2026-11-01 05:53:46 +01:00
Ludovic Marie 205d4c32c7 Merge main into #5461 (after the native shader params, #5411)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
#	crates/horizon-shader-api/src/inputs.rs
#	docs/api/shaders-reference.md
#	docs/api/shaders.md
2026-11-01 05:52:57 +01:00
Ludovic Marie 8a4ea81699 Merge pull request #5413 from levzzz5154/launcher/gpui-fast
🤖 Agent: launcher on GPUI Fast (Apache-2.0; fixes the NVIDIA/blade crash #3812); macOS text fixed (font-kit) and checked against main by screenshot; launcher tests + clippy pass on macOS; approved as code owner.
2026-11-01 05:51:27 +01:00
some-du6eandClaude Opus 5.5 a0144420a9 OptiFine CEM: third review round of the expression compiler
- pow follows Java's Math.pow: a NaN exponent is NaN, and 1 or -1 to an
  infinite exponent is NaN; x to 0 stays 1, NaN included
- Test clamp with a NaN upper bound
- The too-long test checks the error lands inside the expression instead
  of pinning an offset that depends on when nodes are counted
- Boundary test: a 512-term sum (1023 nodes) compiles and runs, 513 terms
  are too long

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:49:34 -06:00
Ludovic MarieandClaude Opus 5.5 ef2b5d25fa Launcher: GPUI Fast's macOS text system needs font-kit
Without the feature gpui-fast-platform has no font source on macOS: the
launcher drew no text at all and half its home page was blank. With it,
the home page matches main's GPUI build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:48:28 +01:00
Ludovic MarieandClaude Opus 5.5 eb6d6c3616 Merge main into #5413
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:47:41 +01:00
Ludovic MarieandClaude Opus 5.5 6fa9bef54f Anticheat prediction: log what the worst tick did
A prediction line said only how far off a move was. On the official server
it logs about 15,000 lines an hour, from ordinary players (mostly 0.1 to
0.2 blocks) and cheaters (1 to 10 blocks) alike, and nothing in a line
tells a glide, water, a ladder or a lag from a cheat, so the check can't
be trusted to act.

Each line now ends with its worst tick as the replay played it: where the
client said it went less where the replay ended (x, y, z), how far the
client said it moved, and what the replayed body was doing (ground or air,
water, gliding, sprinting, sneaking, swimming, flying, riding). Only
flagged ticks pay for the text; the state is a few bits per played tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:47:06 +01:00
some-du6e 344faf4ee9 Merge optifine/emf-jem into optifine/emf-expr 2026-10-31 22:43:49 -06:00
Ludovic MarieandClaude Opus 5.5 f8ee6221ff Merge main into #5413
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:42:46 +01:00
Ludovic Marie 81b0cd084e Merge pull request #5484 from guilhermejansen/fix/macos-updater-keychain
🤖 Agent: macOS updater keeps retired bundles until unused (fixes #5427 Keychain 100002); main merged; launcher 433 tests incl. 7 mac_update + clippy pass on macOS; approved as code owner.
2026-11-01 05:42:36 +01:00
some-du6eandClaude Opus 5.5 3a16789202 OptiFine CEM: fourth review round of the .jem reader
- An entry the budget cuts down to no quads is left out, not drawn empty
- Animations are all or nothing per entry, checked and charged before baking
- A box costs max(faces, keys, 1); non-object items of boxes, submodels and
  animations cost 1, and so do non-scalar animation values
- MAX_BYTES (now 4 MiB, pub) bounds the .jem and all its .jpm files together;
  a cached .jpm counts once
- Boxes whose corners or UVs overflow to non-finite numbers are skipped
- baseId dedup compares only against the entry's own .jpm layer
- Tests: a real lone-surrogate escape case, valid oversized JSON for the byte
  limit, the shared byte budget, repeated keys, non-finite boxes, 1 unit left

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:42:31 -06:00
Ludovic Marie 37fd2a4722 Merge pull request #5458 from xFedeT/feat/debug-world
🤖 Agent: DebugLevelSource grid + isDebug gates (rdbtCVS approved); main merged (random-tick masks, superflat structures); clippy + debugworld/save tests pass.
2026-11-01 05:39:54 +01:00
Ludovic MarieandClaude Opus 5.5 c539244947 Merge main into #5484
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:39:40 +01:00
some-du6eandClaude Opus 5.5 883cf5b01e OptiFine CEM: second review round of the expression compiler
- clamp keeps a NaN value NaN, like Java's x < lo ? lo : Math.min(x, hi)
- round returns Java's int: NaN is 0, results saturate at i32::MIN/MAX
- calls take up to u16::MAX arguments, so long keyframe tracks compile
- tests: laziness values, an expression just under the node cap, the
  offset of the too-long error, a 300-frame keyframe, clamp/round NaN
  and infinities

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:39:20 -06:00
Ludovic Marie 7baaa4423a Merge pull request #5412 from Vaspyyy/feat/native-camera-shake
🤖 Agent: native_camera_shake: identity without a mod, scaled by Distortion Effects, bounded; holds #5411. pr_ready --quick on chain-5412 (holds #5406 #5409-#5412, main f99f8feaa): fmt, clippy -D warnings, 2493 tests, 511 Python tests pass.
2026-11-01 05:37:55 +01:00
Ludovic Marie acc1fdceb5 Merge main into #5458
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/world_loading.rs
2026-11-01 05:37:29 +01:00
Ludovic Marie 31fccc920b Merge pull request #5409 from Vaspyyy/feat/native-falling-blocks
🤖 Agent: native_spawn_falling_block: server-only, bounded speed, 1024 cap, loaded chunk; holds #5406. pr_ready --quick on chain-5412 (holds #5406 #5409-#5412, main f99f8feaa): fmt, clippy -D warnings, 2493 tests, 511 Python tests pass.
2026-11-01 05:36:35 +01:00
ErikKalikas a5937ef682 Merge remote-tracking branch 'upstream/main' into mod-block-entity-draw-item-helpers
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 06:34:10 +02:00
Ludovic MarieandClaude Opus 5.5 68c5daa3ae Merge main and #5461's main merge into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:33:13 +01:00
Ludovic MarieandClaude Opus 5.5 e554c66ae2 Merge main into #5461
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:33:13 +01:00
Ludovic Marie 3099586bbc Merge pull request #5447 from Qwerner88880/hooks-ingredients
🤖 Agent: NeoForge ingredient shapes (compound = union, intersection, difference); rdbtCVS's points fixed; HashSet-order test made order-free; holds #5439.
2026-11-01 05:32:46 +01:00
Ludovic Marie a1a4fd46be Merge pull request #5439 from Qwerner88880/hooks-conditions
🤖 Agent: neoforge:conditions gate on every recipe loader; rdbtCVS approved, arismoko checked in game; main merged; conditions tests + clippy pass.
2026-11-01 05:32:43 +01:00
Stink-OandClaude Opus 5.5 8f257f4672 feat(mods): native mods can push the player and set their flying speed
`native_push_player([x, y, z])` on a client, added to the local player's velocity as an explosion's knockback is (the window's player moves itself, as vanilla's client does), and `native_set_flying_speed(player, speed)` for server mods, a player's creative flying speed kept as vanilla keeps its abilities (a respawn or a game mode change keeps it). A test for the push. Ported from Stink-O/horizon 63ea5c582 and 848a0686c.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:32:08 +00:00
some-du6e 25023c28c9 Merge optifine/emf-jem into optifine/emf-expr
# Conflicts:
#	crates/horizon-optifine/src/cem/mod.rs
2026-10-31 22:31:42 -06:00
Ludovic MarieandClaude Opus 5.5 b7ce7c00da crafting test: a tag's items have no fixed order
The test tag is a HashSet, so the compound's union came out in either order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:31:41 +01:00
Stink-OandClaude Opus 5.5 9dc99a5147 feat(mods): native mods can read the world's clock
`native_get_clock()` gives a client mod the world's game time and time of day in ticks (none on the title screen), as `Level.getGameTime` and `getDayTime`: for anything timed to the day, the sun or the world's own clock. Ported from Stink-O/horizon's `game.get-clock`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:31:32 +00:00
Ludovic MarieandClaude Opus 5.5 ae0ed11caf Streaming: build the light views of proto chunks on the workers
A light job for a column of the last ring reads its neighbours past the
area as views of their decorated proto chunks. The server thread built
those views itself while it scheduled light (~8 columns per tick at 20
players). The proto chunk is now checked out to a worker, like a save,
which builds the view; the light job is scheduled once it is back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:29:03 +01:00
some-du6eandClaude Opus 5.5 c0be27ae15 OptiFine CEM: first review round of the expression compiler
- keyframe: no usize overflow for infinite, huge or NaN k; past the end is the last frame
- parser caps nesting at 64 and nodes at 1024 ("expression too deep/long" with an offset),
  so no pack can overflow the stack while parsing, folding or dropping
- programs deeper than the 64-value stack (long keyframe tracks) run on a per-thread buffer
- ifb returns 1/0; nbt(key, exists:false) compiles (raw test skipped, value 0)
- catch falls back on NaN only; min/max return NaN like Java's; round is Java's, exact
- unseeded random() starts each thread somewhere else; unary + parses
- !, && and || lower to comparisons and ifs (Not/And/Or/Bool gone); easings derived from
  ease-in and moved with the splines to cem/curves.rs; Expr::slots removed
- bench prints CPU time; Error documented; tests for each of the above

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:27:40 -06:00
Ludovic Marie 05f240102a Merge pull request #5480 from Vaspyyy/fix/native-summon-orbs-passengers
🤖 Agent: native_summon root ids: orbs (canceled → None), end crystals and cushions; passengers placed by /summon's place helper; clippy clean.
2026-11-01 05:24:58 +01:00
some-du6eandClaude Opus 5.5 a52700ebae OptiFine CEM: third review round of the .jem reader
- Past the node budget, later parts are left out instead of replacing their vanilla part
  with an empty bone.
- Budget: every box costs at least one, the last box keeps the faces that fit, and the
  "left out" warning fires only when something was cut (Bake { left, cut, unread }).
- Animation pairs count against the same budget.
- Layers carry a key index and a base's layers already present are not added twice.
- Per-part textures and sprites are noted in submodels too.
- JSON: a high surrogate before a non-low escape gives U+FFFD and the escape is read on its
  own; one shared `get`.
- Tests for byte limits, a missing .jpm named twice, own keys over a .jpm's (also in a
  base), the budget's edges, surrogates and comment marks in strings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:24:16 -06:00
Ludovic MarieandClaude Opus 5.5 1488ba172e Merge main and #5439's main merge into #5447
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:24:08 +01:00
Ludovic Marie 88e4e2b847 Merge main into #5439
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/native-mods.md
2026-11-01 05:24:07 +01:00
Ludovic Marie b6134f4841 Merge pull request #5414 from sirrayi/fix/fox-trust
🤖 Agent: merged main. Fox trust list, save/load, breeding and egg-on-fox trust (rdbtCVS's fix, approved). pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:27 +01:00
Ludovic Marie ba8be81ecb Merge pull request #5450 from reservedbytes/fix/native-hurt-server-canceled-notes
🤖 Agent: merged main. Canceled hits forget their attackers on every damage path (PvP, /damage, thorns, dragon, blast, potion); test. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:23 +01:00
Ludovic Marie 9267c6870b Merge pull request #5442 from ezejde/fix/head-profile-texture-patch
🤖 Agent: merged main. ResolvableProfile texture patch drawn for heads, read from the active resource root, reloaded on pack change. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:19 +01:00
Ludovic Marie 50f47f3acd Merge pull request #5441 from xFedeT/fix/rabbit-raids-carrots
🤖 Agent: merged main. Rabbit.RaidGardenGoal + MoreCarrotTicks; customServerAiStep's nextInt(3) checked in bytecode (same order). pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:15 +01:00
Ludovic MarieandClaude Opus 5.5 5fc86a7384 native_summon: end crystals and cushions keep their root ids too
The fallback for roots no command Target stands for checked only orbs after
the canceled-orb fix; end crystals and cushions also take an id at once
(rdbtCVS's re-review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:23:07 +01:00
Ludovic MarieandClaude Opus 5.5 f5a7175f27 Merge main into #5480
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:21:40 +01:00
Ludovic Marie e91a4ba7c0 Merge #5411 (queued ahead of it) into #5412
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 05:20:51 +01:00
Ludovic MarieandClaude Opus 5.5 6fbec036d7 Merge main into #5412
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:51 +01:00
Ludovic Marie b36c7f9da5 Merge #5410 (queued ahead of it) into #5411
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 05:20:50 +01:00
Ludovic MarieandClaude Opus 5.5 aff0fec800 Merge main into #5411
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:50 +01:00
Ludovic Marie 8a50d1c414 Merge #5409 (queued ahead of it) into #5410
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/native-mods.md
2026-11-01 05:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 e600371fc9 Merge main into #5410
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 59d8117f64 Merge main into #5409
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:48 +01:00
Ludovic Marie 21a12582cf Merge pull request #5433 from k3yh/fix/nether-water-evaporate
🤖 Agent: merged main. BucketItem.emptyContents in the Nether: sendParticles(LARGE_SMOKE, corner, 8, 1,1,1, 0, ALTERNATIVE) now reaches clients; ALTERNATIVE spread added. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:20:34 +01:00
Ludovic Marie aef5704b1e Merge pull request #5418 from xFedeT/feat/spawner-minecart
🤖 Agent: merged main. MinecartSpawner: BaseSpawner ticks at the cart, saved/loaded, drops and picks as a minecart, entity event broadcast. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:20:30 +01:00
Ludovic Marie 8bf92ca0ae Merge pull request #5715 from horizon-rust/perf/lan-attribute-ids
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: lan_entities 1.13→0.92 ms (−19 %), tick_lan 4.52→4.27 ms. pr_ready --quick pass (1635 horizon-client tests, 511 Python). attribute_updates harness PASS; same ids, bases and modifiers sent.
2026-11-01 05:20:26 +01:00
some-du6eandClaude Opus 5.5 d0377992db OptiFine CEM: inheriting shares instead of copying, every entry and bone counts
Second review round of the .jem reader:
- an entry's keys are layers (own, .jpm, baseId's) over shared objects,
  so a big base or .jpm costs nothing per entry; the first entry with an
  id is the one baseId finds
- the 1024-entry cap counts every entry, bones count against the face
  budget, and a .jem or .jpm over 8 MiB is refused
- the JSON reader takes Gson's = and => for colons and ; between items,
  quoted empty keys, and \u surrogate pairs; \u needs four hex digits
- per-part textures and sprites, not drawn yet, get one warning

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:15:02 -06:00
some-du6eandClaude Opus 5.5 5eeb2cdd7e OptiFine reader: paths with a backslash are refused too
On Windows a backslash separates folders, so a pack's ..\x would leave
the resource root once joined onto it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:15:01 -06:00
ErikKalikasandClaude Opus 5.5 63bc913369 Native mods: name ItemStackRenderState.submit, what a campfire draws its items with
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:12:44 +02:00
some-du6eandClaude Opus 5.5 af00d7af2b CLAUDE.md: list horizon-optifine among the crates
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:09:23 -06:00
TiggerTakkyTTTandCodex eb46a6d619 fix(launcher): unify installed native mods and expose Build
Include GitHub and local native bundles in both installed-mod views, retaining source/update actions and the existing build confirmation.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 11:09:00 +07:00
Teiwazik 6018159ff1 Merge remote-tracking branch 'origin/main' into fix/cutout-mips-keep-coverage
# Conflicts:
#	crates/horizon-client-render/src/graphics.rs
2026-11-01 05:08:57 +01:00
some-du6eandClaude Opus 5.5 9469537b14 OptiFine CEM: read .jem files the way OptiFine does, and keep packs inside the root
Review fixes for the .jem reader:
- a small JSON reader keeps keys in file order (animations read what
  earlier ones wrote) and accepts what Gson does: a BOM, comments,
  trailing commas, single quotes, bare keys
- an entry's own .jpm wins over what it inherits through baseId; each
  .jpm is read once
- .jpm paths go through paths::resolve, which refuses ones that leave
  the pack; a missing or broken .jpm, or a baseId naming nothing, leaves
  that entry out with a warning instead of the whole model
- rotations through an odd number of inverted axes turn the right way
- numbers must be finite; attach and animation values may be strings or
  numbers; a file builds at most 1024 entries and 65536 faces
- shadowSize, per-bone textures and attachments are no longer parsed
  until something draws them
- CLAUDE.md lists the crate

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:08:39 -06:00
some-du6e d8c627e75e Merge main into optifine/emf-jem 2026-10-31 22:05:18 -06:00
calclbandDeepSeek Harness 0eb8fc511b name the TNT igniter in the blast's death message
Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 23:03:28 -05:00
TobiasLogicandClaude Opus 5.5 34a47de0bf Shader packs: a pack's vignette=false hides the game's vignette
shaders.properties' `vignette` key (on unless `false`) says whether the game draws its vignette
over the pack's image; most packs turn it off (BSL, Complementary, Bliss, Bloop, FastPBR,
Hysteria...) because they draw their own. Horizon ignored the key, so with the Vignette option on
those packs got both. The game's vignette now draws only when the option is on and the loaded
pack doesn't turn it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:52:27 +07:00
ErikKalikasandClaude Opus 5.5 7cb008681f Native mods: native_draw_item takes an owner, as a shelf's items have
ShelfRenderer passes its block entity as the ItemOwner (at the block's
centre, turned by its facing), so a compass on it points to its target;
without one it always points the same way. The client's copy of a block
entity has no item slots: the docs say to send the stacks drawn in the
update tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:51:54 +02:00
TobiasLogicandClaude Opus 5.5 c7782f2656 Merge main into #5701 (#5371 changed translate/rewrite.rs and mod.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:50:06 +07:00
rdbtCVSandClaude Opus 5.5 139ef2f801 Cull off-screen dropped items; restore quad indices after sorted pages
dropped_item_geometry resolved and meshed every item within 64 blocks,
even behind the camera. Apply EntityRenderer.shouldRender's frustum test
(box grown by 0.5) first, as mobs already do: ~0.2 ms -> ~0.01 ms per
frame with a few dozen items around.

draw_sections left a translucent sorted-index page bound, so the cloud
draw after it indexed past its end and wgpu aborted on the first frame.
Rebind the shared quad indices when a sorted page was used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:47:09 +00:00
reservedbytes 9a47759df3 feat(mods): mod menu types with their own slot count and data slots 2026-11-01 02:44:59 -01:00
ErikKalikasandClaude Opus 5.5 6e96e6f975 Native mods: keep a mod block entity's geometry while it doesn't change
draw.keep() in native_block_entity_submit keeps what that call drew,
relative to the block as a sign's text is kept, and the block is drawn the
same again without calling the mod until its state, its block entity, its
light or the resource packs change. Idle machines then cost a copy moved to
the camera, not a call into the mod, each frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:42:29 +02:00
asownerytandClaude Opus 5.5 10469d5c99 Endermen riding a boat no longer teleport in a loop
Enderman.teleport(x, y, z) fails for a passenger, repeatedlyTryToTeleport
returns before drawing, and EndermanLookForPlayerGoal.tick skips its
teleports while the enderman rides something. Horizon had none of these
checks, so an enderman in a boat "teleported" every tick (sound and
portal particles) and was snapped back to its seat, which also made its
held block jitter. Fixes #4865.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:39:23 +13:00
ClickingMasterandClaude Opus 5.5 bd837ade74 Fly picked-up tridents to their collector on server clients
A server's client keeps thrown tridents apart from its other projectiles,
so TakeItemEntity never found them and the trident just vanished instead
of playing vanilla's ItemPickupParticle. Look the trident up there too,
turn it into a pickup particle and draw it on its way to the collector.

The host also sent the pickup only for tridents that gave an item, so a
creative player's trident vanished on other clients; it now always sends
it, as Player.take does in AbstractArrow.playerTouch.

lan_e2e: trident_pickup throws a trident at each player's feet and checks
that it comes back and flies to them on both clients (fails before this).

Fixes #5577

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:38:52 -07:00
JoansitohandClaude Sonnet 5.5 6ad0a9df83 Shader packs: provide the cloudHeight and isElytraFlying uniforms
Java-format packs read both names as zero today. isElytraFlying is the
player's fall-flying state; cloudHeight is the cloud_height environment
attribute (default 192.33, dimension_type visual/cloud_height) when the
dimension's cloud colour alpha is above 0, as in vanilla's LevelExtractor,
and NaN otherwise ("no clouds"). The 192.33 literal of clouds.rs becomes
a shared constant.

Part of #3172.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 04:33:46 +01:00
Ludovic Marie c51dcda011 Merge pull request #5691 from horizon-rust/fix/lunge-motion-skipped-tick
lunge_motion: name a server tick that ran without a move, retry the harness once (#5656)
2026-11-01 04:30:59 +01:00
Ludovic MarieandClaude Opus 5.5 9228c44123 lunge_motion: name a server tick that ran without a move, and retry the harness once (#5656)
#5656's falling motion (-0.3015 against vanilla's -0.2277) is a tick of
gravity more, not a change in the game: main, and the exact tree CI
failed on (#5502's head merged into 76dc5e3bd), pass every time on a
quiet machine. On a loaded one the server catches up with two ticks for
one of the jump's moves (seen: "the server ran 2 ticks for move 7"), and
the server's copy of the player falls in the tick with no move. Before
the jab that changes the motion the jab sends.

LocatorBarClient's arc now records an error when that happens up to the
jab, so the failure says why, and lunge_motion joins known_flaky so the
runner gives it its one retry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:30:20 +01:00
spiderandClaude Opus 5.5 5fb3fa4c32 Frame generation: mobs' and dropped items' own motion vectors
The camera's motion vectors see mobs and dropped items as still, so a made frame
would show them where the real one does. The game now tags each mob's and
dropped item's vertices with its entity id (Frame::entity_motion); one whose
model has the same shape as last frame gets each vertex's own motion, drawn into
a texture at the render size where the world shows it (its depth, or a
translucent item's texture). The interpolation takes that texture as
object_motion and uses it over the camera's motion vectors. Nothing runs it yet:
the Frame Generation setting comes next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:23:24 +01:00
Ludovic Marie 521614fc7f Merge pull request #5689 from horizon-rust/fix/launcher-github-build-hint
🤖 Agent: Message text only, in 11 lang files that all parse; the launcher CI job runs the i18n tests. Merging once it's green.
2026-11-01 04:23:20 +01:00
Ludovic MarieandClaude Opus 5.5 a50005adb4 Launcher: point the GitHub-install message at the Mods page's Build
The Build button moved from Settings > Native mods to the Mods page, but the
message after installing a mod from GitHub still sent players to the old
setting (reported on Discord, 2026-11-01). Updated in English and the ten
translations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:22:22 +01:00
Solenopsisbot 022472cea8 Merge remote-tracking branch 'origin/main' into accounts/player-dir
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/options.rs
2026-11-01 14:21:02 +11:00
Ludovic Marie fbd0ca7f11 Merge pull request #5688 from horizon-rust/automation/native-hook-stamp
🤖 Agent: The stamp commit from the native hook stamp workflow (the bot can't open PRs). Merging once CI is green.
2026-11-01 04:18:28 +01:00
Ludovic MarieandClaude Opus 5.5 bc1d4d1516 Entity tracking: a mob's movement speed looked up once per tick
mob_attributes asked the attribute catalog for movement speed (by name)
and recomputed its base value up to three times per mob and tick, on
top of the registry id built with format! per attribute (Attr::id, the
previous commit). The speed instance, the baby and the sprinting
modifiers now share one lookup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:12:45 +01:00
Ludovic MarieandClaude Opus 5.5 75a9116efb Attributes: static registry ids, not format! per mob and tick
The type's default of an attribute (base_value, read by mob movement and
every tick's entity tracking) and the LAN attribute sync built the id
"minecraft:<name>" with format! on each call: for a server's 2000 mobs that
is several allocations per mob per tick. Attr::id returns the id as a
static string.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:10:17 +01:00
Teiwazik 177e6be16c Merge remote-tracking branch 'origin/main' into shader/s9-materials
# Conflicts:
#	crates/horizon-client/src/game/native_shaders.rs
2026-11-01 04:07:32 +01:00
TiggerTakkyTTTandCodex 48b338836f fix(portals): transfer mobs and falling blocks through End portals
Keep entity portal state with its level so another ticking dimension cannot
clear it. Handle falling-block portals after movement and impact damage but
before placement, preserving cooldowns, saved data and vanilla's End transfer
duplication branch. Extend integrated-server command parity with direct
falling-entity probes and remove its stale in-frame runner override.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 10:03:10 +07:00
TeiwazikandClaude Opus 5.5 af1325e6a5 Shader API: [[textures]], PNGs every stage of a pack reads
Noise, lookup tables and the like had to be computed in WGSL. A pack can now ship up to eight PNGs,
which the check decodes and every stage reads as <id> and <id>_sampler.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:55:37 +01:00
Stink-OandClaude Opus 5.5 4a7c664eb8 fill: skip blocks that are already the block, match the filter once per state
A fill visits up to 32768 blocks of a handful of states. It now skips
blocks that are already the block it would place before matching the
filter (place_state would change nothing there), and matches each block
state against the filter once per fill instead of once per block.
Results, counts and side effects are unchanged. Ported from
Stink-O/horizon 242b45037 (its fill part, the same code).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:55:15 +00:00
Stink-OandClaude Opus 5.5 9fdf206c4b Keep crater break sounds from silencing sounds played to the player
Positional sounds leave 16 static channels free for sounds played to the
listener. Ported from Stink-O/horizon a72b65ea3 (sound_engine.rs, now in
horizon-client-render); that commit's other half, block destroy events
only within 64 blocks, is already here (lan/containers.rs level_event).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:55:15 +00:00
Claude 88784b6910 Merge remote-tracking branch 'upstream/main' into fix/native-summon-orbs-passengers
# Conflicts:
#	crates/horizon-client/src/game/native_entities.rs
2026-11-01 02:50:55 +00:00
Claude 64aa0b4473 Merge remote-tracking branch 'upstream/main' into feat/native-camera-shake
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:26 +00:00
Claude a62c609529 Merge remote-tracking branch 'upstream/main' into feat/native-shader-params
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:25 +00:00
Claude c6a5386e9f Merge remote-tracking branch 'upstream/main' into feat/native-local-sound
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:24 +00:00
Claude a8b7cdc12e Merge remote-tracking branch 'upstream/main' into feat/native-falling-blocks
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-01 02:50:23 +00:00
ErikKalikasandClaude Opus 5.5 dcec52b07e Native mods: native_draw_item and native_block_state_property for drawing block entities
An item stack drawn under a block entity's pose as a campfire's, and a block
state's property (its facing) read without a world lookup. Helper-only, with a
fragment of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:40:50 +02:00
JustKarkat eb837f2edf Fix: BlockStateId is Copy, drop the pane clone
CI clippy (--all-targets) flags clone on the Copy type; local clippy ran
lib-only and missed the test profile.
2026-10-31 21:40:33 -05:00
Ludovic Marie b6ac0602d7 Merge pull request #5670 from horizon-rust/fix/sprite-split-adapter-limit
🤖 Agent: unit tests for the split choice and resplit; HORIZON_SPRITE_SPLIT=512 renders the same world as the default on the Mac mini; pr_ready --quick green. Merging after verify + parity.
2026-11-01 03:39:17 +01:00
martipopsandOpenAI Codex 9d3c50dd6c Keep the guest profile action distinct from the enable page
Give the pause action its own translated Splitscreen Settings label. Check the transient test override through the visible saved switch rather than waiting for an untouched settings file to be written.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-31 22:36:31 -04:00
github-actions[bot] 593e4af363 Stamp native hook API fragments 2026-11-01 02:35:29 +00:00
Ludovic Marie 929f53ad3e Merge pull request #5578 from Sylviromi/screen-mouse-button
🤖 Agent: merger STOP was a conflict with main after #5571 landed; main merged (clean). clippy -D warnings, the native hook tests (55) and rustfmt pass on it; the rest was proven in union3 with #5571.
2026-11-01 03:35:07 +01:00
Ludovic MarieandClaude Opus 5.5 475af9674a Renderer: cut block sprites by the GPU's texture array limit
A GPU with 512 layers per texture array refused to start ("GPU supports 512
texture layers; 1291 required") although terrain binds 8 sprite arrays. The
split is now the smallest of SPRITE_SPLIT, HORIZON_SPRITE_SPLIT and the
adapter's max_texture_array_layers (one less on OpenGL when a full array would
be padded past the limit), computed once when the renderer is made and used
by the sprite arrays, the shader constants and the shader pack atlas. Mip
chains prepared before the adapter was known are cut again for it. Startup
only fails when the sprites need more than 8 arrays of that split.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:35:06 +01:00
arismoko e8d5b826b0 Fix native chat to acting players in hooks 2026-10-31 21:31:21 -05:00
Ludovic Marie 9be37e6877 Merge remote-tracking branch 'origin/main' into n5578 2026-11-01 03:30:51 +01:00
d9350b549e Splitscreen: enable joining and choose local profiles
Rebuild the profiles and saved switch increment on current main after the compositor, controller, windows and profile-page model landed. Preserve pause dialogs and keyboard navigation, keep test overrides transient, and validate profile folder names.

Co-Authored-By: OpenAI Codex <[email protected]>

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:29:57 -04:00
Ludovic Marie ae4e28774f Merge pull request #5569 from v3nn7/fix/mount-offhand-steering
🤖 Agent: vanilla's Pig/Strider getControllingPassenger use player.isHolding, which checks both hands; the server path (lan_vehicles → tick_mount_riding) shares this check. Main merged; its test passes. union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:29:27 +01:00
Ludovic Marie 678c9b7073 Merge pull request #5573 from some-du6e/feat/native-modify-default-components
🤖 Agent: main merged (item-default and loot free hooks both kept), clippy needless borrow fixed. union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:29:23 +01:00
Ludovic Marie 63e9b9207a Merge pull request #5571 from Sylviromi/screen-render-post
🤖 Agent: main merged (the hook fires, then main's split-screen HUD). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:15 +01:00
Ludovic Marie 8978c7c35e Merge pull request #5566 from Qwerner88880/hooks-enchant
🤖 Agent: main merged (both doc sections kept). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:11 +01:00
Ludovic Marie c7095cc9e5 Merge pull request #5535 from mariuslozo-boop/fix/horse-inventory-close
🤖 Agent: main merged (the level check before main's lan_mount_valid). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:07 +01:00
Ludovic Marie 78547c3b61 Merge pull request #5516 from sirrayi/mods/placement-state
🤖 Agent: main merged (both sides' level hooks kept). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:03 +01:00
Teiwazik 1781a7bd3e Merge remote-tracking branch 'origin/main' into shader/pack-pages 2026-11-01 03:26:37 +01:00
JustKarkat a23b218ae0 Test: phantom pane-bite parity for #4928
Vanilla 26.3's PhantomSweepAttackGoal.tick bites on a plain box check
(getBoundingBox().inflate(0.2).intersects(target)) with no line-of-sight
or block test, and its targeting gate (TargetingConditions' eye-ray) is
what keeps a pane-hidden player safe. Horizon ports both, so:

- a phantom pressed against the far side of a foot-high pane wall, whose
  eye-ray passes over the wall, bites the player pressed behind it, in
  Horizon exactly as in vanilla;
- behind a window-height pane wall the eye-ray is blocked and the
  phantom never takes the player as a target, also as in vanilla.

Two parity cases pin both sides of that behaviour, mirroring the live
geometry (player and phantom each pressed against their face of the
pane, 0.125 apart, bite reach 0.2). Refs #4928.
2026-10-31 21:24:17 -05:00
Ludovic MarieandClaude Opus 5.5 db0ec1edd4 content_identity: no needless borrow (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:23:26 +01:00
TeiwazikandClaude Opus 5.5 f11233f125 Shader API: solid_materials work without a material stage
A pack with [shadow] and only [[post]] can now name the ids its shadow pass reads, and the test of
solid ids checks which layers lose their cutouts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:20:08 +01:00
Teiwazik ddadcb2203 Merge branch 'shader/s8b-player-shadow' into shader/s9-materials 2026-11-01 03:19:32 +01:00
Ludovic Marie 1734ca3982 Merge pull request #5445 from navinor/fix/armor-stand-lerp
🤖 Agent: Review points from some-du6e fixed and confirmed (seat_own_stands ordering, the cancel() test, port-ledger citations); local lan_e2e proof posted by the author's agent; CI green on 6f941a5e9. Queued for merge.
2026-11-01 03:19:11 +01:00
Ludovic Marie 8c936ae640 Merge remote-tracking branch 'origin/main' into n5569 2026-11-01 03:19:02 +01:00
Ludovic MarieandClaude Opus 5.5 c4cbf36967 Merge main into #5573 (item-default and loot hooks both kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:18:00 +01:00
Ludovic Marie ddeecbad3b Merge remote-tracking branch 'origin/main' into n5578 2026-11-01 03:17:38 +01:00
Ludovic Marie 6ac25bbc02 Merge remote-tracking branch 'origin/main' into n5571 2026-11-01 03:17:38 +01:00
Ludovic Marie d2ad79e4c1 Merge remote-tracking branch 'origin/main' into n5566 2026-11-01 03:17:38 +01:00
Ludovic Marie 072ad5d00a Merge remote-tracking branch 'origin/main' into n5535 2026-11-01 03:17:38 +01:00
Ludovic Marie 57ae3494b8 Merge remote-tracking branch 'origin/main' into n5516 2026-11-01 03:17:38 +01:00
Ludovic MarieandClaude Opus 5.5 c30ebd6aaf Merge main into #5516 (both sides' level hooks kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:17:37 +01:00
Ludovic Marie ba8b5b4146 Merge pull request #4250 from martipops/feature/launcher-auth-status
🤖 Agent: launcher account status, profile heads, refresh and targeted sign-in (Microsoft only); rdbtCVS's points fixed earlier; main merged here (lang union, both sides' account tests, a string main dropped removed). Proven: union of main 956e35469 with 19 PRs, launcher clippy + tests green (the i18n tests after the string fix), game clippy/tests green.
2026-11-01 03:16:32 +01:00
Ludovic Marie 22a3c3c55b Merge branch 'n5571' into n5578 2026-11-01 03:16:20 +01:00
Ludovic MarieandClaude Opus 5.5 29761e3803 Merge main into #5571 (the hook, then main's split-screen HUD)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:16:20 +01:00
Ludovic MarieandClaude Opus 5.5 cd39ec5fdc Merge main into #5566 (both hook sections kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:16:04 +01:00
Ludovic Marie 5a8414ddad Merge pull request #5561 from some-du6e/mods/loot-table-hooks
🤖 Agent: native loot table and drop hooks. Fixed here: NativeLootDrops.player only while the hurt memory lasts (dropFromLootTable's playerKilled). Proven: union of main 956e35469 with 19 PRs had this PR's code (clippy, workspace + content-root tests green); the author's later commits are docs, a test and rustfmt, and CI verify passes on this head.
2026-11-01 03:15:19 +01:00
Ludovic MarieandClaude Opus 5.5 9c1bd32c77 Merge main into #5535 (the level check before main's lan_mount_valid)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:14:53 +01:00
Ludovic Marie 42ffde8cd2 Merge remote-tracking branch 'origin/main' into n5573 2026-11-01 03:14:33 +01:00
TeiwazikandClaude Opus 5.5 753c565883 Shader API: the blob stays where a pack has no sun to cast with
Also names in the docs that entities the camera culls don't cast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:14:15 +01:00
Ludovic Marie af2edf2e86 Merge pull request #5558 from Qwerner88880/hooks-consume
🤖 Agent: native consume effect kinds; fixed here: an empty namespace is minecraft's. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:54 +01:00
Ludovic Marie 735f4371c5 Merge pull request #5536 from Xmilon/native/block-touch-hooks
🤖 Agent: native block contact / landing hooks; fixed here: no item/projectile sweep without mod blocks (hot path), vanilla stepOn-then-inside order, getOnPosLegacy. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:50 +01:00
Ludovic Marie 213a16d88a Merge pull request #5527 from sirrayi/mods/bone-meal-on-mod-blocks
🤖 Agent: native bone meal hooks; fixed here: Some(false) keeps the meal as growCrop does, None leaves the template's. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:47 +01:00
Ludovic MarieandClaude Opus 5.5 553c11fd2f Launcher lang: drop the string main's redesign no longer uses
"Microsoft account · signed in" came back with this branch's tables in the
main merge; the code lost it in 86149f7da (i18n tests).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:13:12 +01:00
Ludovic Marie 09d3345c9b Merge pull request #5514 from kacpherek/fix/knot-and-orbs
🤖 Agent: fence knot clicks and orb smoothing; fixed here: the orb draws its correction step (previous kept), knot hit only within entity range; main merged. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:10:58 +01:00
Ludovic Marie 0a8def17fa Merge pull request #5654 from horizon-rust/sweepF/5521
🤖 Agent: #5521 by rdbtCVS finished (main merged: serialize keeps screenshotQuality and main's shadowCache). Screenshot Quality defaults to 1x, so at 1x and 100% scale F2 saves the window's frame as before; the scaled frame draws offscreen inside OOM/validation error scopes. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green (clippy -D warnings, workspace tests).
2026-11-01 03:08:38 +01:00
Ludovic Marie 6253329644 Merge pull request #5562 from ErikKalikas/mod-block-entity-draw
🤖 Agent: native block entity renderers; main merged here. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:08:32 +01:00
Ludovic Marie f28c2b348c Merge pull request #5574 from some-du6e/optifine/properties
🤖 Agent: OptiFine .properties reader (new crate, no dependents); fixed here: no .. paths, linear glob, no link loops; skip-release. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:06:11 +01:00
Ludovic Marie 707340c312 Merge pull request #5570 from some-du6e/perf/faster-prepare
🤖 Agent: parallel horizon-prepare (same output digest, release check covers both lane layouts); fixed here: the extraction test on case-insensitive disks. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:06:08 +01:00
Ludovic Marie 98642e8b22 Merge pull request #5512 from PzmuV1517/mods/can-survive
🤖 Agent: native can_survive / may_place_on hooks; fixed here: a piston head's neighbour change asks can_survive. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:36 +01:00
Ludovic Marie f752cb0841 Merge pull request #5503 from navinor/feat/mod-advancement-triggers
🤖 Agent: native advancement criteria triggers (+ the author's on_fire fix); main merged here. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:32 +01:00
Ludovic Marie 9a4faa20d7 Merge pull request #5502 from sirrayi/mods/biome-modifiers
🤖 Agent: biome modifiers place mod features; fixed here: unread modifier types and unknown names are skipped with a warning instead of dropping every content mod. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:27 +01:00
Ludovic Marie f90fb824b9 Merge pull request #3825 from martipops/split/09-windows
🤖 Agent: splitscreen part 9 (on #3801, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:22 +01:00
Ludovic Marie 0fefb3744c Merge pull request #3801 from martipops/split/08-join-with-controller
🤖 Agent: splitscreen part 8 (on #3794, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:18 +01:00
Ludovic Marie 3e34340e75 Merge pull request #3794 from martipops/split/07-controllers
🤖 Agent: splitscreen part 7 (on #3777, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:14 +01:00
Ludovic Marie 84ccfa68de Merge pull request #3777 from martipops/split/06-two-players
🤖 Agent: splitscreen part 6 (HORIZON_SPLIT testing only, off by default); main merged here (main's headless loop runs before the split's event loop). Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:09 +01:00
TeiwazikandClaude Opus 5.5 000eb647aa Shader API: the [weather] stage colours rain and snow streaks
A native pack had no way to light or tint weather, so vanilla's streaks stood out against a pack's
own lighting. Horizon still places and animates the streaks; the pack only returns their colour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:03:24 +01:00
reservedbytes e26bd0fe58 feat(mods): native mods use items over time (duration, animation, can eat, start and finish) 2026-11-01 01:03:17 -01:00
some-du6eandClaude Opus 5.5 c2e44bda19 Native mods: the content identity hashes the changed defaults
origin/main's identity hashes mod components' defaults so peers that
disagree are told apart; it read items.json raw, so a native mod's change
slipped past it. It reads them through item_defaults::read now. The read
and hook docs mention the stack-size range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:01:14 -06:00
lukehinojosaandClaude Opus 5.5 b126de85e3 Native mods: give a whole stack to any player (native_give_stack)
Server mods could only give plain items to the acting player
(native_give_items). An auto-pickup or a machine's output needs to hand
over the stack it has, with its damage, name and enchantments, from any
server hook. native_give_stack(uuid, stack) places it as Inventory.add
does and returns what did not fit.

Game::give's slot loop moves into inventory_add, shared by both, so
Inventory.add stays one port. The API number comes from the
give_stack fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:00:38 -04:00
some-du6eandClaude Opus 5.5 9fb61eafa6 Native loot hooks: rustfmt the hookability test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:59:46 -06:00
Ludovic Marie 690a1a4512 Merge pull request #5641 from reservedbytes/fix/save-thrown-projectiles
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:59:31 +01:00
some-du6e 2413dabfae Merge remote-tracking branch 'origin/main' into mods/loot-table-hooks
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-31 19:58:43 -06:00
some-du6eandClaude Opus 5.5 91b523cc48 OptiFine CEM: packs' .jem models reshape mobs
Each optifine/cem (or EMF's emf/cem) .jem replaces or joins the parts of
the model layer it names, with OptiFine's part names mapped to the game's
and baby models falling back to the adult's. Built when the packs load
(and on every pack reload, on the reload worker); a mob's draw only swaps
the layer's part tree and, when the .jem names one under textures/, its
texture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:58:33 -06:00
Ludovic Marie 6647659a3b Merge pull request #5542 from kacpherek/fix/remote-ride-gone-vehicle
🤖 Agent: stop riding a remote vehicle that is gone (Entity.baseTick); approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:57:14 +01:00
Ludovic Marie 321af52ea4 Merge pull request #5640 from rdbtCVS/fix/armor-stand-disabled-slots-and-lead-owner
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:36 +01:00
Ludovic Marie 504f90a09a Merge pull request #5639 from rdbtCVS/fix/mob-equipment-itemstack
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:33 +01:00
Ludovic Marie b3f954f038 Merge pull request #5636 from mariuslozo-boop/fix/inventory-revert-update
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:29 +01:00
Ludovic Marie c4fd435788 Merge pull request #5635 from rdbtCVS/fix/autosave-saves-players
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:25 +01:00
Ludovic Marie 039d514f4f Merge pull request #5634 from rdbtCVS/fix/removed-entity-drops-contents
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:22 +01:00
Ludovic Marie 8df8d78559 Merge pull request #5633 from rdbtCVS/fix/menu-still-valid-on-click
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). Stacked on #5630, tested together.
2026-11-01 02:56:18 +01:00
Ludovic Marie 231b549303 Merge pull request #5632 from rdbtCVS/fix/open-menu-closes-previous
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). Stacked on #5630, tested together.
2026-11-01 02:56:15 +01:00
Ludovic Marie daac34040f Merge pull request #5630 from rdbtCVS/fix/close-menus-on-leave
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:11 +01:00
Ludovic Marie bd9f2f2683 Merge pull request #5628 from JustKarkat/fix/held-block-item-culling
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:07 +01:00
Ludovic Marie ab49db6a46 Merge pull request #5626 from Teiwazik/shader/pack-settings
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:02 +01:00
Ludovic Marie 2b7b1711c9 Merge pull request #5605 from Xmilon/native/image-tooltips
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). The image follows the text and the bundle component, the rule Horizon's bundle tooltip already uses; moving both under the title, as vanilla's index 1, is a separate parity fix.
2026-11-01 02:55:59 +01:00
Ludovic Marie 483a51e9f0 Merge pull request #5538 from xFedeT/fix/tadpole-brain
🤖 Agent: tadpole AbstractFish goals and TadpoleAi brain, mob_ai scenarios; approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:55:55 +01:00
Ludovic Marie 904e161bf1 Merge pull request #5519 from elythepreciouscat/perf/leaf-cutout
🤖 Agent: fancy leaves sorted front to back on the mesh workers (no render-thread cost); approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:55:50 +01:00
Ludovic Marie 580a38ec0c Merge pull request #4319 from v3nn7/fix/menu-panorama
🤖 Agent: six-face panorama check in horizon-prepare check.rs, rustfmt fix; approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:55:46 +01:00
Ludovic Marie 3b04141a4b Merge pull request #5612 from Lostpold88/fix/rebound-sneak-heal-latches
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:55:42 +01:00
TeiwazikandClaude Opus 5.5 fbf8c46b8e Shader API: [sky] clouds, sun and stars, for a sky that draws its own
A pack's sky stage only replaced the dome: Minecraft's clouds, sun and stars still drew over it, so a
pack with its own had two of each. Each key defaults to true, so existing packs don't change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 02:54:22 +01:00
some-du6eandClaude Opus 5.5 bb3d830ed9 Native loot hooks: the drops doc lists a mob's inventories and what comes before its table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:53:38 -06:00
Ludovic Marie e0af360ef4 Merge main into #5595's branch
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-11-01 02:53:00 +01:00
some-du6e e6d8501486 Merge remote-tracking branch 'origin/main' into feat/native-modify-default-components 2026-10-31 19:49:47 -06:00
some-du6eandClaude Opus 5.5 885988f9d1 Native loot hooks: name every non-item death drop; say which tables a mob's drops come from
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:49:23 -06:00
Ludovic MarieandClaude Opus 5.5 df8aa42d9b Merge main into #5521 (serialize: screenshotQuality and main's shadowCache)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:52 +01:00
Ludovic MarieandClaude Opus 5.5 737633786c Merge #5571's main merge into #5578
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:33 +01:00
Ludovic Marie 956e35469f Merge pull request #5648 from horizon-rust/fix/native-build-hooks-move-to-game
🤖 Agent: reproduced #5352 with launcher 0.2.31 and Nightly d91cab6 in scratch homes; fixed launcher builds Zoom, Minimap, Recipes and X To Xray on d91cab6 (all ON in Options > Mods). pr_ready --quick green.
2026-11-01 02:48:29 +01:00
Ludovic Marie 08bf8731b0 Merge pull request #5627 from horizon-rust/sweepF/5508
🤖 Agent: online_mode harness (finished from #5508): the services' key fetch stays out of the log. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:48:26 +01:00
Ludovic Marie 360a360f07 Merge pull request #5579 from horizon-rust/fix/3108-vault-display-spin
🤖 Agent: vaults' display item spins on remote clients (VaultBlockEntity.Client.tick); verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:48:22 +01:00
Ludovic Marie 81f7f429f0 Merge pull request #5546 from Vaspyyy/feat/stack-blocks-attacks
🤖 Agent: a stack's own blocks_attacks component is read (Item.use, getItemBlockingWith); approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:48:19 +01:00
Ludovic MarieandClaude Opus 5.5 cfa8e57125 Merge main into #5571 (both new tests)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:08 +01:00
Ludovic MarieandClaude Opus 5.5 c652b50920 Merge main into #5562 (module list)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:08 +01:00
Ludovic MarieandClaude Opus 5.5 003f47c465 Merge main into #5535 (both new lan_e2e checks kept whole)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:57 +01:00
JoansitohandClaude Sonnet 5.5 4647ce7af7 CI: daily check for a republished Mojang version JSON (#3333)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 02:47:32 +01:00
Ludovic MarieandClaude Opus 5.5 2c9b07d6d1 Merge main into #5514 (ExperienceOrb::tick's new argument kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:12 +01:00
Ludovic MarieandClaude Opus 5.5 08b3aca04c rustfmt's module order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:04 +01:00
Ludovic MarieandClaude Opus 5.5 175635b82b Merge main into #5503 (native_criteria next to main's native_components; both doc sections)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:46:57 +01:00
some-du6eandClaude Opus 5.5 96b7a3332f Native mods: keep the stack size in range for every reader
The clamp moves from the item table into item_defaults::read, after the
hook, so loot and item models read the same 1 to 99 (a negative size too).
The durability tooltip no longer underflows when a mod lowers max_damage
below a saved stack's damage (getDamageValue clamps to the max).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:46:50 -06:00
Ludovic MarieandClaude Opus 5.5 f9839553c7 Merge main into #4250 (lang tables: both sides' strings; account tests: both)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:46:38 +01:00
Ludovic Marie ad5aef0c59 Merge pull request #5541 from kacpherek/fix/enderman-carried-cull
🤖 Agent: enderman's carried block culls back faces (ITEM_CUTOUT); approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head (pr_ready --quick green for it). Parity rerun: the failed run was only online_mode (harness bug, #5627) / dedicated_server (flake #5560).
2026-11-01 02:43:44 +01:00
Ludovic Marie 95f80de2a8 Merge pull request #5529 from kacpherek/fix/p12-saves
🤖 Agent: backup before restoring level.dat_old, other-series worlds refused on every open path; approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head (pr_ready --quick green for it). Parity rerun: the failed run was only online_mode (harness bug, #5627) / dedicated_server (flake #5560).
2026-11-01 02:43:40 +01:00
Ludovic Marie dfadee6f1c Merge pull request #5169 from masa-masa-0314/fix/reconnect-keys
🤖 Agent: reconnect decided by the disconnect's translation key; author's main merge leaves the diff unchanged; approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head (pr_ready --quick green for it). Parity rerun: the failed run was only online_mode (harness bug, #5627) / dedicated_server (flake #5560).
2026-11-01 02:43:36 +01:00
Ludovic Marie db4acf3105 Merge branch 'ss3801' into ss3825 2026-11-01 02:43:29 +01:00
Ludovic Marie a72a10a288 Merge branch 'ss3794' into ss3801 2026-11-01 02:43:28 +01:00
Ludovic Marie d5aefe39e2 Merge branch 'ss3777' into ss3794 2026-11-01 02:43:27 +01:00
Ludovic MarieandClaude Opus 5.5 96e310baa6 Merge main into #3777 (main's headless loop before the split's event loop)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:43:19 +01:00
Aaron Wang 6f941a5e92 Merge branch 'main' into fix/armor-stand-lerp 2026-10-31 21:42:57 -04:00
Ludovic Marie fbac31053d Merge pull request #5568 from xFedeT/chore/port-map-clean
🤖 Agent: 16 port-map rows say port; both review points fixed; data only. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:42:17 +01:00
Ludovic Marie f593240a0e Merge pull request #5564 from reservedbytes/feat/content-entity-types
🤖 Agent: content mods declare thrown entity types (snowball template); rdbtCVS found no defects; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:42:14 +01:00
Ludovic Marie 9fff1c5c5d Merge pull request #5563 from arismoko/tag-stamp-unblock
🤖 Agent: server tag hook fragment fixed for the stamp tool (no game code); verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:42:10 +01:00
Ludovic Marie b08b4a5db2 Merge pull request #5547 from navinor/fix/lan-e2e-vehicle-punches
🤖 Agent: lan_e2e punches vehicles fully charged so they break; approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:42:06 +01:00
Ludovic Marie f2a8539509 Merge pull request #5540 from levzzz5154/launcher/mixins-a2af5c0
🤖 Agent: horizon-mixins pin a2af5c0 (only horizon-mixins#15); approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:42:02 +01:00
Ludovic Marie 79a8e53125 Merge pull request #5511 from posok124521/fix/item-use-screen
🤖 Agent: keybind-opened screens release the item use in the same tick (Gui.setScreen releaseAll), with the use-screen e2e scenario; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:41:58 +01:00
Ludovic Marie 8788e9438e Merge branch 'pr/F5570new' into fix5570 2026-11-01 02:41:57 +01:00
Ludovic Marie 26aa38506d Merge pull request #5510 from levzzz5154/fix/appimage-test-unix
🤖 Agent: the AppImage path test runs on Unix only (fixes the Windows launcher build); code-owner path, approved here. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:41:55 +01:00
Ludovic Marie d0d79d7af5 Merge pull request #5509 from xFedeT/feat/superflat-structures
🤖 Agent: superflat structure sets (FlatLevelSource.createState) with every review point fixed (ring seed 0, sea level/min Y, null layers, no creature packs, generator min Y); verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:41:51 +01:00
Ludovic Marie 884a3cf834 Merge pull request #4178 from Domanffe/feat/launcher-ru-strings
🤖 Agent: launcher strings through tr()/trf(), Turkish table completed; approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:41:47 +01:00
Ludovic Marie 9f2b4c5aab Merge pull request #5584 from Solenopsisbot/accounts/play-account
🤖 Agent: verified by Ludovic's AI agent (Claude). pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others: launcher fmt, clippy -D warnings, and launcher tests (one failure in backend::tests::prepared_versions_get_what_the_asset_store_adds_without_preparing_again was a prepare-lock race; it passes alone with the account tests). The owner gate runs as that signed-in account; no offline name. I fixed the review's doc nit.
2026-11-01 02:41:43 +01:00
Ludovic Marie d4c8d4266a Merge pull request #3947 from kacpherek/fix/split-boats-riding
🤖 Agent: boats in bubble columns as vanilla, countdown test added by rdbtCVS; approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:41:39 +01:00
Ludovic Marie d4a22143af Merge pull request #3702 from Ashura006/shadow-cache
🤖 Agent: opt-in shader-pack shadow cache (off by default), rdbtCVS's custom-image redraw fix read; approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:41:35 +01:00
Ludovic Marie 2992bf7829 Merge pull request #3466 from AHHhHHHHH-H/pr/vram-arena-compaction
🤖 Agent: VRAM arena compaction, rdbtCVS's last fix (LOD stand-ins off the closing page) read; approved by rdbtCVS; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:41:31 +01:00
TeiwazikandClaude Opus 5.5 622786d903 Shader packs: [[pages]], the layout of a Horizon pack's options page
A pack with dozens of options needs them in categories. The first page is the one the button opens,
"page:<id>" opens another, "" leaves a slot empty and columns = 1 gives full-width buttons. Every page
has to be reached from the first and every option has to be on a page, or the check refuses the pack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 02:41:23 +01:00
Ludovic MarieandClaude Opus 5.5 fd5f849c42 horizon-prepare test: ask the disk whether Tag.json and tag.json are one file
The skip compared Paths, which compare case-sensitively, so it never fired
and the extraction test failed on macOS's case-insensitive file system (the
later member, tag.json, wins there). It now checks whether a case-folded
path exists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:40:52 +01:00
some-du6eandClaude Opus 5.5 d0013cfc31 Native loot hooks: document the hurt memory behind the killing player; scan the loot hook from its doc comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:40:30 -06:00
some-du6eandClaude Opus 5.5 c6a450499b horizon-prepare: HORIZON_PREPARE_JOBS caps each step's threads
Each copying, hashing or compiling step spreads over that many threads; the lanes run theirs side by side, so it is no total limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:38:53 -06:00
Aaron Wang 1c6d6935d4 lan_e2e objects: a stand that falls, so the guest follows its moves
The objects check summoned its stands on the floor, where a guest that
never moved a remote stand still matched the host. This one starts 3
blocks up and turned to 200 degrees, so where it lands and its yaw are
compared between host and guest.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 21:38:02 -04:00
Ludovic Marie c4a65832eb Merge pull request #5621 from Razerpoa/fix/spectator-ghost-head
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests). I set vanilla's forced-translucent colour (alpha 38).
2026-11-01 02:37:25 +01:00
some-du6eandClaude Opus 5.5 02a522b868 OptiFine CEM: compile animation expressions once, evaluate them without allocating
The CEM expression language from OptiFine's cem_animation.txt with EMF's
documented additions (keyframe, keyframeloop, easings, splines, wrapdeg,
degdiff, catch, randomb, global constants e and nan), compiled to a flat
stack program with constants folded; if, && and || only evaluate the
branch they take. Variables are caller-resolved slots. The example
cem_expr_bench times an animation pack's frame of 20 expressions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:36:18 -06:00
Ludovic Marie 14262d0595 Merge pull request #5610 from horizon-rust/fix/3833-text-display-backface-cull
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:35:52 +01:00
Ludovic Marie 5a0eaad0db Merge pull request #5607 from 1038april/fix/debug-crosshair-after-leaving-world
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:35:48 +01:00
Ludovic Marie 92549192f1 Merge pull request #5606 from horizon-rust/fix/2800-shield-full-block-easy
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:35:44 +01:00
Ludovic Marie fd62b77cc4 Merge pull request #5603 from Solenopsisbot/headless/client
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests). I stopped the narrator on headless exit and moved idle's doc back.
2026-11-01 02:35:39 +01:00
Ludovic Marie 270bfd3ca4 Merge pull request #5602 from horizon-rust/fix/4571-cushion-break-dismount
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:35:35 +01:00
Ludovic Marie c0d3856416 Merge pull request #5601 from JustKarkat/fix/dragon-xp-orb-float
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests). I added the server-only gate for the stuck push and one shared moveTowardsClosestSpace.
2026-11-01 02:35:30 +01:00
Ludovic Marie d28cb5162b Merge pull request #5600 from horizon-rust/fix/5353-unconfigured-surface
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:35:26 +01:00
Ludovic Marie b6fc6f90de Merge pull request #5597 from horizon-rust/fix/5560-join-update-attributes
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:35:21 +01:00
Ludovic Marie b9c81f7166 Merge pull request #5596 from horizon-rust/fix/5435-interaction-hitbox
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:35:17 +01:00
Ludovic Marie ac98ceb89d Merge pull request #5593 from horizon-rust/fix/5136-rain-water-depth
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:35:13 +01:00
Ludovic Marie cf9d30c1a4 Merge pull request #5592 from horizon-rust/fix/5115-mojang-studios-logo
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:35:09 +01:00
Ludovic Marie 0e94106031 Merge pull request #5588 from Xmilon/native/dispenser-behaviour
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests). I made the answer carry its level event, added API markers, and the stamp tool now keeps side.
2026-11-01 02:35:06 +01:00
Ludovic Marie be6f0c5bcd Merge pull request #5587 from horizon-rust/fix/2743-guest-border-collision
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:35:01 +01:00
Ludovic Marie 8f2dde537a Merge pull request #5585 from Xmilon/native/summon-root-ids
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests).
2026-11-01 02:34:57 +01:00
Ludovic Marie 46cea85d55 Merge pull request #5580 from mariuslozo-boop/fix/creative-tab-hotbar-throw
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against vanilla 26.3. pr_ready --quick passed on main bfc2a2798 with this PR merged with 17 others from the same sweep (fmt, clippy -D warnings, horizon-client and horizon-client-render tests, Python tests). Ctrl+Q on the Survival Inventory tab uses the item's max stack size, like every other Horizon menu (none reads a max_stack_size component yet).
2026-11-01 02:34:53 +01:00
some-du6eandClaude Opus 5.5 2a3429819e Native mods: keep a changed stack size to vanilla's 1 to 99
A mod setting minecraft:max_stack_size to 0 made merchant.rs clamp(1, 0)
panic on the server thread. Items now keeps it to ExtraCodecs.intRange(1, 99),
and the docs say what else a malformed value does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:34:52 -06:00
some-du6eandClaude Opus 5.5 0ed5f554b3 Native loot hooks: name what the drops hook doesn't hear; check the loot hook's attributes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:34:39 -06:00
Ludovic MarieandClaude Opus 5.5 5206ad3fac Launcher: native mods needing newer hook points build on the installed game
A rebuild stays on the Horizon its native build was made on, and mods pinning an
older commit (eight registry mods pin 142388e) set the build's revision too. When
a mod needs newer hook points than that revision has (recipes v2, xtoxray v7),
and the hook mods were tested on different commits, the build was refused with
"Horizon 142388e doesn't have the native hook points these mods use", even
though the installed Nightly has them all and no Update button was offered
(#5352). The build now moves to the revision of the release or Nightly it
starts from when that one has them; pinned mods then wait for Build anyway,
as for any version mismatch. The refusal names only the mods that don't fit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:32:47 +01:00
Claude 82dee152fb Launcher: keep counting a secret another process renewed in the file
Review fix for the secrets file (#5095): when the keychain took a
secret but another process had written a newer copy to the file
meanwhile, the newer copy rightly stayed, yet the run stopped counting
it as seen in the file. Once that run stopped asking the keychain and
another process handed the copy over, the run couldn't read it.

The clean-up now says whether the file still has the entry, and the run
keeps counting it while it does.

Test: a launcher on the file renews A during a start's hand-over of A,
the keychain refuses B; after another start hands both over, the first
start reads the renewed A and B.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 01:32:00 +00:00
rdbtCVSandClaude Sonnet 5.5 c1348a3526 Armor stand DisabledSlots and another player's lead are locked
ArmorStand.interactAt ignored `DisabledSlots` (not even read or saved), so
locked stands could be emptied and filled. The stand now keeps the bitmask:
bit `filterFlag` disables a slot (an empty hand then takes from the main
hand), `+8` stops taking from it and `+16` putting into it
(`isDisabled`, `swapItem`), and the save writes it back.

A lead tied to another player was untied by anyone who clicked the mob;
only the player it is tied to unties it (`getLeashHolder() == player`).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:31:01 +00:00
Ludovic Marie 998049b7dd Merge branch 'pr/F5561new' into fix5561 2026-11-01 02:30:58 +01:00
some-du6eandClaude Opus 5.5 63eb8a3b8c Fill far terrain to the horizon with a coarse backdrop while it loads
Main samples only the planned tiles, nearest first: the seam with the
near chunks is fine at once, but the far terrain ends in a hard edge
until the last of ~3000 tiles (Full) is in, minutes on a slow machine.

After the seam, sample a coarse backdrop first: each far tile's
ancestor two levels up (one where that reaches the seam), ~230 tiles,
about a tenth more work. Ready ancestors already stand in for missing
children; now no tile stands in within 3 chunks of the render distance,
so broad boxes never sit beside the near chunks. The horizon is covered
after under 1000 jobs instead of the last one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:30:26 -06:00
Ludovic MarieandClaude Opus 5.5 8d3e175d49 Native loot drops: the killing player only while the hurt memory lasts
LivingEntity.dropFromLootTable supplies LAST_DAMAGE_PLAYER only when
playerKilled (lastHurtByPlayerMemoryTime > 0), so NativeLootDrops.player is
None for a mob that dies to the environment after the memory ran out
(arismoko's review). Vanilla's own loot is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:30:06 +01:00
Ludovic MarieandClaude Opus 5.5 58f3f290e6 Block contact hooks: no sweep without mod blocks, vanilla's order and on-pos
- swept_inside returns at once when no loaded block is outside minecraft:,
  so a server's items and projectiles no longer sweep their blocks every tick
  with no content mod (rdbtCVS's hot-path note).
- An item's step_on comes before its inside contacts, as
  Entity.applyEffectsFromBlocks calls stepOn before checkInsideBlocks.
- A mob's step_on (and the magma floor, turtle egg checks with it) reads
  getOnPosLegacy through block_on_legacy, so a mob over the edge of its
  supporting block still steps on it (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:28:31 +01:00
simonandClaude Opus 5.5 221eb8123d The F3 crosshair goes away with the world
GameRenderer.extract only renders the level when there is one, so vanilla
never draws the debug crosshair on the title screen. The F3 overlay stays
on when a world closes, and the crosshair was built without looking at
whether the frame showed a level, so its axes stayed over the panorama.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:27:42 +10:00
rdbtCVSandClaude Sonnet 5.5 d4941b1155 Mobs keep the whole stack in each equipment slot
Mob equipment was an item id plus enchantments and a trim, so a picked-up
sword or armour piece lost its damage, name and components, a hand took one
item of a stack, a worn piece always compared as fresh, and death or
replacement dropped it repaired and unnamed.

`Gear.stacks` (the main hand keeps `main_hand_stack`) now holds the stack of
a slot that is more than one plain item. `set_item_stack` stores it,
`stored_stack`/`item_stack` read it back, replacing the item forgets it.
`equipItemIfPossible` takes the whole stack into a hand (one into an armour
slot) and drops the replaced stack as it was; `canReplaceCurrentItem` sees
the worn piece's damage and name; death drops the stack itself
(`Drop::Stack`), with the rolled damage when the drop is not preserved; the
save writes and reads it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:27:41 +00:00
JustKarkatandJustKarkat's AI agent ad13f8c39c Command-exec parity: a group's failures, counted by position
The summary charged a group with every mismatch whose command text also
appears in it, so two groups that probe the same thing (the orb groups'
`data get entity @e[type=minecraft:experience_orb,...]` lines) each
showed the other's failures. Positions are what the per-group ranges
already are; count by those.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-10-31 20:27:16 -05:00
TiggerTakkyTTTandCodex 49cdc5aa61 fix(render): match dragon death pose and rays to vanilla
Freeze flight-history interpolation after death and render death rays with
vanilla's depth writes, back-face culling and distance fog. Add a frozen-pose
regression and a vanilla oracle comparison for ray geometry and colours.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 08:27:16 +07:00
JustKarkatandJustKarkat's AI agent 564206afb7 Command-exec corpus: an orb boxed in, out of a block
The review of #5601 noted the new "experience orb fall" group never
reaches the one path of ExperienceOrb.tick that draws a random: the
push out of a block. This group pins it — a 3x3 block placed over a
settled orb leaves it colliding with only its top face free, so the
server nudges it up (Entity.moveTowardsClosestSpace) with gravity
skipped while its box is inside blocks, and it comes to rest on top of
the block. The draw's magnitude only decides how many ticks the escape
takes, so the checkpoints after it are the same either way; with the
push disabled the orb stays boxed in at y 215 and the group fails.

Corpus with both orb groups: 973/973 commands match.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-10-31 20:27:16 -05:00
Ludovic MarieandClaude Opus 5.5 064313363c Biome modifiers: skip what can't be read, with a warning
A modifier of a type not read yet (add_spawns, add_carvers, a mod's own
serializer) or naming a biome, feature or tag the root doesn't have (another
mod's ids, #c: convention tags) dropped every content mod. Real mods ship
those beside their feature modifiers, so that modifier is now skipped with a
warning naming the mod and file, as NeoForge skips an unresolvable holder
set (reservedbytes's review). A malformed modifier of a read type (unknown
step) still stops the build. The docs say #tag in features reads vanilla's
feature tags only for now (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:26:32 +01:00
Claude a307a24a63 Launcher: count the secrets file's keys before the start's hand-over
Review fix for the secrets file (#5095): a start counted what its failed
hand-over left in the file only afterwards, so a hand-over by another
process in between left it reporting the keychain away for those
accounts. The start now counts the file's keys first, and drops each
one it hands over itself.

Test: another process hands the account over right as this start's
hand-over fails; the start still reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 01:25:42 +00:00
some-du6eandClaude Opus 5.5 9148cb676f Native mods: the default-components docs name every reader and thread
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:25:37 -06:00
some-du6eandClaude Opus 5.5 33efc55c5b Native loot hooks: keep a replaced table's random_sequence; test the loot hook stays hookable
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:24:36 -06:00
Ludovic MarieandClaude Opus 5.5 2c93d81d0c lan_e2e mount_other_level: horse and camel in the Nether by default
The whole matrix (7 mounts x 2 levels x 2 players + in_nether) takes about 10
minutes, and the nightly LAN suite already runs ~2,400 s of its 3,000 s
timeout (rdbtCVS's review). The default keeps the regression signal;
LAN_E2E_MOUNTS=all and LAN_E2E_LEVELS=all run everything.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:24:15 +01:00
Ludovic MarieandClaude Opus 5.5 934df75a12 Orbs draw their correction step; a knot is hit only within entity range
- Entity.commonTick sets the old position before the interpolation step, so
  the frame lerps from the pre-step position: previous keeps it now, where
  ExperienceOrb::tick had overwritten it and the orb still hopped (rdbtCVS).
- The knot is an entity, so a click counts as one on it only within the
  entity interaction range (filterHitResult), not the fence's block range
  (Joansitoh).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:23:26 +01:00
Ludovic MarieandClaude Opus 5.5 cb9e4996d3 OptiFine reader: paths stay in the root, linear globs, no link loops
rdbtCVS's review points 1-3:
- resolve returns None for a path with a .., . or empty segment, so a pack
  can never name a file outside the overlay root (vanilla refuses them).
- glob backtracks only to the last star: O(pattern x name), where every
  star tried every split before (a player-chosen item name, per frame).
- properties_files reads each real folder once and at most 16 deep, so a
  folder pack linking to its own parent no longer recurses to PATH_MAX.
Tests for each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:22:01 +01:00
Ludovic MarieandClaude Opus 5.5 8d98852911 Screen mouse hooks: buttons past 4 are the platform's numbers
winit's MouseButton::Other(n) is the raw X11/evdev number on Linux, not GLFW's
index, so the hook docs and the fragment say a button past 4 is
platform-defined (rdbtCVS's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:20:32 +01:00
rdbtCVSandClaude Sonnet 5.5 ca88f2e2e5 The dedicated server's autosave and save-all save every player
The player-file save in `save_game_metadata` only ran for a dedicated-mode
integrated server (the fixture), so a real `--dedicated` server (the
`DedicatedServer` role) skipped it on autosave and `save-all`: players were
only written when they left or the server stopped. It now runs for every
server role, as PlayerList.saveAll does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:19:35 +00:00
Ludovic MarieandClaude Opus 5.5 fef6f8080a Consume effects: an empty namespace is minecraft's
Identifier.bySeparator reads ":clear_all_effects" as minecraft:clear_all_effects
(Consumable.CODEC decodes both), so it is vanilla's clear, not a mod kind for
the native hook (arismoko's review). Test added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:19:25 +01:00
reservedbytes db4208923e fix(saves): thrown snowballs, eggs and ender pearls are saved with their chunk 2026-11-01 00:18:57 -01:00
Ludovic MarieandClaude Opus 5.5 985647d587 Merge main into #5595's branch: lang files take main's, plus Built on {base}
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:18:50 +01:00
rdbtCVSandClaude Sonnet 5.5 1e3758f6c1 A removed vehicle empties its container and drops its stand-in
A chest boat or chest/hopper minecart open in a menu keeps its slots in a
stand-in under a placeholder key. Removing the vehicle (broken, killed,
exploded, a rider's death, a portal) dropped the vehicle's stale items and
left the stand-in with a full copy a stale menu could still take from.
`release_vehicle_container` writes the menu's live slots back to the vehicle
first (so they are what drops or travels), then removes the stand-in, before
each removal.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:18:10 +00:00
Ludovic MarieandClaude Opus 5.5 124cd2ac33 Dispenser placement: getRotation is -90 facing up or down
DirectionalPlaceContext.getRotation is get2DDataValue() * 90, so a vertical
facing gives -90, not toYRot's 270. Both name East for vanilla's blocks
(from_y_rot, rotation segments), but the placement hook shows the raw value
(arismoko's review on #5516). Armor stands and boats keep toYRot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:17:18 +01:00
Ludovic MarieandClaude Opus 5.5 15b7ff9a69 Dispenser hook: the answer carries its level event; API markers; stamp keeps side
- native_dispense_item answers (remainder, event): 1000 click, 1001 fail
  (OptionalDispenseItemBehavior without success), 0 nothing (NOOP), so a
  mod can make a silent or failing behaviour.
- native_dispense_item and native_dispense_default carry the
  API @API:dispenser_behaviour@ marker; the fragment lists dispenser.rs.
- stamp_native_hooks.py copies a hook's neoforge, side and cancellable keys
  into native-hooks.json instead of dropping them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:16:20 +01:00
rdbtCVSandClaude Sonnet 5.5 f79e3b7e60 Check a menu's stillValid before every guest click
A menu was only validated once per tick, after the packet phase, so a click
could still land on a vehicle that was gone, a mount that died or left reach,
a lectern out of reach or a station block that was replaced. The click
packets (container click, button, slot state, rename, beacon, trade select,
bundle select, place recipe) now run one shared check first: the open menu,
whichever kind, must still be valid, else it closes like ContainerClose and
the click lands on nothing. The per-tick checks use the same predicates.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:16:19 +00:00
rdbtCVSandClaude Sonnet 5.5 a65385ff3e Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:15:36 +00:00
Claude f9fe596cba Launcher: a run started on an old secrets file finds it after a hand-over
Review fix for the secrets file (#5095): a launcher that started while
the keychain was away, on a file a previous run left, asked the
keychain again only for secrets it had written itself. Once another
process handed the file over, it reported the keychain away for
accounts the keychain now held.

It now also counts what it read from the file and what its own start
left there. Test: two launchers started on an old file while the
keychain is away (one read the account, one didn't) both read it after
a third hands it over.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 01:15:21 +00:00
Ludovic MarieandClaude Opus 5.5 06584db009 Bone meal hook: a mod's no keeps the meal, as vanilla's growCrop does
native_is_valid_bonemeal_target answers Option<bool>: Some(false) refuses
the meal even where the block's template takes it (BoneMealItem.growCrop
returns false when isValidBonemealTarget is false), None leaves the
template's BonemealableBlock (arismoko's review). The client prediction
falls through to growWaterPlant on a refusal, as vanilla's useOn does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:15:16 +01:00
rdbtCVSandClaude Sonnet 5.5 ff6ef169c3 Every guest menu open closes the previous menu first
Each opener closed a different subset of the menus the guest could have
open (the trading screen left a mount or crafting state behind, a vehicle's
stand-in stayed, the old window id still took clicks). They now all start
with `guest_close_all`, as `ServerPlayer.openMenu` closes the open menu:
containers, vehicles, mounts, merchants, stations, lecterns, the crafting
table and native menus.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:13:35 +00:00
Aaron Wang ae2088d2ab Merge remote-tracking branch 'origin/main' into fix/armor-stand-lerp 2026-10-31 21:13:12 -04:00
Aaron Wang 642ed5b09b A stand riding the player is seated after the player's move
position_riders ran before the player moved and before previous_position
was saved, so a name hologram on the local player was drawn a full tick
behind it. Vanilla ticks the player (tickNonPassenger) before its
passengers (positionRider), so seat_own_stands now runs after the move,
from the previous_position just saved to the moved position.

The snap test now walks toward x 5 when the far sync comes and checks no
step back toward it is left, so it fails if a snap stops clearing the
handler. The ledger citations move off the first doc lines they were
read as ports from.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 21:13:11 -04:00
rdbtCVSandClaude Sonnet 5.5 322d45fda5 Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:12:39 +00:00
Ludovic MarieandClaude Opus 5.5 6cc03b30db launcher: client_as's doc names only --account
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:12:38 +01:00
Ludovic Marie bfc2a2798f Merge pull request #5629 from horizon-rust/perf/light-view-blocks
🤖 Agent: proven on 8e5ab9ec6: pr_ready --quick green (fmt, clippy -D warnings, 1577 horizon-client tests, 510 Python tests); playhorizon A/B 3 alternated rounds 20 bots: streaming span 4.82 → 4.64 ms median (lower in every round), MSPT within noise. The 34 affected harnesses run in CI (run-parity).
2026-11-01 02:12:06 +01:00
Ludovic MarieandClaude Opus 5.5 2a9d7f2839 --headless: stop the narrator when the loop ends; idle's doc back on idle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:12:04 +01:00
rdbtCVSandClaude Sonnet 5.5 7a19762534 Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:11:47 +00:00
Ludovic MarieandClaude Opus 5.5 4eb27f4216 Piston head: a neighbour change asks can_survive, so a mod's answer counts
PistonHeadBlock.neighborChanged calls state.canSurvive; the direct
piston_head_can_survive call skipped native_can_survive (arismoko's review).
can_survive keeps the piston rule as its default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:11:18 +01:00
Ludovic Marie 0e2cdbeccb Merge pull request #5477 from k3yh/fix/minecart-command-slots
🤖 Agent: commands reach chest/hopper minecart slots (getChestVehicleSlot); open-menu copy fixed; rdbtCVS re-review clean; verify green.
2026-11-01 02:10:51 +01:00
Ludovic Marie a343e94506 Merge pull request #5422 from arismoko/content-state-fanout
🤖 Agent: content block model states for declared properties; rdbtCVS approved; verify green.
2026-11-01 02:10:46 +01:00
Ludovic Marie fd09f48076 Merge pull request #5416 from Akirabane/feat/rcon
🤖 Agent: vanilla RCON: off unless enable-rcon=true, needs rcon.password, listens on server-ip; rdbtCVS approved; verify and parity green.
2026-11-01 02:10:42 +01:00
Ludovic Marie 483ce5a763 Merge pull request #5406 from Vaspyyy/feat/native-explode
🤖 Agent: native_explode: server-only wrapper over server_explode with the TNT/anchor drop-decay rules, guarded on dimension, loaded chunk and radius; verify and parity green on this head.
2026-11-01 02:10:38 +01:00
Ludovic Marie d8fa0590df Merge pull request #5426 from arismoko/native-component-api
🤖 Agent: rdbtCVS's re-review addressed; dropped horizon-mods' new horizon-world dependency (local namespaced_id check, locks back to main's); horizon-mods content tests + clippy pass.
2026-11-01 02:10:33 +01:00
Ludovic MarieandClaude Opus 5.5 b919ab60a7 Spectator ghost: vanilla's forced-translucent colour, alpha 38 (654311423)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:09:37 +01:00
Ludovic MarieandClaude Opus 5.5 e3c5efcc77 Experience orbs: the push out of a block is the server's; one moveTowardsClosestSpace
ExperienceOrb.tick gates the stuck push with !level().isClientSide(): a
server client (remote.rs) no longer nudges stuck orbs and draws from its own
random between server updates. Items and orbs share one
towards_closest_space port instead of two copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:08:56 +01:00
some-du6eandClaude Opus 5.5 39e9a8e0d1 horizon-prepare: a stamp without outputs never matches; say what HORIZON_PREPARE_JOBS limits
stamp_matches compared a missing outputs key with the digest of a missing output as None == None. The docs and comments said HORIZON_PREPARE_JOBS is how many things run at once; it is how many threads copy and hash, the lanes all run at once unless it is 1, and it counts logical processors. With the split there are five lanes. java.rs's memory figure now says what it compared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:08:43 -06:00
JustKarkatandJustKarkat's AI agent 58a141917f Item quads: cull back faces like vanilla's item render types
A held two-sided model carries a quad per direction (a calibrated sculk
sensor's tendrils and amethyst planes, big dripleaf, block/cross), and the
item render types vanilla picks in ItemBlockRenderTypes -- solidBlockSheet
(entitySolid), cutoutBlockSheet (entityCutout), translucentItemSheet
(entityTranslucentCull) -- all keep the composite state's CULL. Horizon
culled only quads whose sprite has translucent pixels, so held blocks drew
both sides: the coplanar pairs striped (#4856, #4855) and a hay block's far
faces bled through the near ones (#4801).

Two-sidedness in vanilla is geometry, not culling: ItemModelGenerator's
bakeExtrudedSprite bakes a South and a North quad per layer, which
horizon-assets already mirrors.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-10-31 20:08:25 -05:00
Ludovic MarieandClaude Opus 5.5 139c4be315 horizon-mods: check component ids itself, without horizon-world
The namespaced-id check is Identifier's character rules, five lines; the
dependency pulled horizon-world (and horizon-core, postcard...) into
horizon-mods, the mod registry and the launcher builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:23 +01:00
MariuslozoandClaude Opus 5.5 7ca3d98901 Single player: the owner's leave saves the file it loads; .dat_old fallback
The owner loads the file level.dat names, but when it left (save_guest,
PlayerList.remove) only its own file was written; the named one waited for
the world's save that follows (stop_integrated: free_player_beds, the
chunks, then save_game_metadata). A game stopped or failing in between
left the named file older than the own one, and the owner then loaded the
older state. `playerdata::save_owner` writes the named file
(`Level::write_player`, also the player half of `Level::write`) before the
own one, so the file the owner loads is never the older.

`playerdata::load` (PlayerDataStorage.load) now falls back to the player's
`.dat_old`, as vanilla does: `write_dat` moves the old file aside before it
renames the new one in, so a save cut off there leaves only the
`.dat_old`, which the named file's read already used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
MariuslozoandClaude Opus 5.5 3e75d6484b Port map: load_owner's row keeps the load and datafix gap; no stale citation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
MariuslozoandClaude Opus 5.5 be7de6230c Single player: the owner loads the player file level.dat names (#5351)
PlayerList.loadPlayerData gives the single-player owner the file level.dat
names (`singleplayer_uuid`), not their own. Since #5069 the integrated
server saves the owner to both files, `players/data/<singleplayer_uuid>.dat`
(offline `Player`) and `<owner uuid>.dat`, but loaded the owner's own file
first. A build that saves the owner to the named file alone (0.2.93 and the
stable releases before #5069) leaves the own file behind, so after
switching back the owner got an older inventory and position, and the next
save wrote them over the newer ones.

`Level::read` now says whether its player came from the named file, and
`playerdata::load_owner` applies it before the owner's own file; the own
file stays the fallback when the named file is missing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
Ludovic MarieandClaude Opus 5.5 05a64ece79 Merge main into #5426
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:06:23 +01:00
Ludovic MarieandClaude Opus 5.5 8f39b7f41d online_mode harness: the services' key fetch is no login step
Both the vanilla and the Horizon server fetch getPublicKeys in the background, at any moment of a
scenario, so ('unknown', '/publickeys') turned up in four checks (parity run 41243825430 on #5508).
It now stays out of the log, like a bare GET /.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:05:54 +01:00
Ludovic Marie fd6d4536ad Merge remote-tracking branch 'origin/main' into sweepF/5508 2026-11-01 02:05:31 +01:00
Ludovic MarieandClaude Opus 5.5 28bd35e286 Merge main into #5414
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:42 +01:00
Ludovic MarieandClaude Opus 5.5 f8fa3c9996 Merge main into #5441
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:41 +01:00
Ludovic MarieandClaude Opus 5.5 e33808a648 Merge main into #5450
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:16 +01:00
Ludovic MarieandClaude Opus 5.5 9c77c753a5 Merge main into #5442
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:15 +01:00
Ludovic MarieandClaude Opus 5.5 8b9d3235e1 Merge main into #5433
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:12 +01:00
Ludovic MarieandClaude Opus 5.5 7fcd32faa1 Merge main into #5418
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:11 +01:00
Claude f795e86510 Launcher: serialize keychain writes so a hand-over can't undo a sign-out
Review fixes for the secrets file (#5095):

- A start's hand-over wrote a snapshot of the file to the keychain, so
  a token another process signed out or renewed meanwhile could come
  back or be overwritten. Every keychain write now holds
  account-secrets.keychain.lock, and the hand-over reads each entry
  again under it. The file's own lock still never waits on a prompt.
- MCP secrets_file is true only while the file holds a sign-in: not
  for sign-out marks alone, nor for a file that can't be read.
- Tests: a hand-over held on a prompt while a running launcher signs
  out or renews the same account; what counts as a sign-in in the file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 01:03:11 +00:00
some-du6eandClaude Opus 5.5 b2a09a2118 OptiFine CEM: read .jem and .jpm entity models into vanilla-frame geometry
Parses OptiFine's custom entity model files: models, part, attach, scale,
baseId, .jpm part files, invertAxis, translate, rotate, mirrorTexture,
textureSize, texture, boxes (textureOffset box UVs or per-face uvNorth..
with their aliases, sizeAdd, sizesAdd, EMF's sizeAddX/Y/Z), submodel(s),
attachments and the animation expressions as text. Boxes bake into the
quads ModelPart draws, on vanilla's axes around the vanilla part's pivot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:00:14 -06:00
some-du6eandClaude Opus 5.5 5b16c46f5d horizon-prepare: the release check's repeat run compares the manifest too; four JVMs with the split
The repeat run's file check skipped horizon-resources.json, so a manifest the repeat rewrote differently passed. The comments that said at most three JVMs run at once now say four with the second server JVM.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:58:51 -06:00
TeiwazikandClaude Opus 5.5 5cc2d09d5c Shader packs: a Shader Options page for a Horizon pack's [[options]]
A Horizon pack declares options, but they stayed at their defaults: the player had no way to set them.
The picked pack now gets the same Shader Options... button as a shaders/ pack, and its values live in
shaderpacks/<pack>.txt, the file that kind already uses. A saved value counts as a change to the pack,
so it reloads with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 01:58:23 +01:00
Aaron Wang a484e2b491 The player predicate sees a burning player on fire
player_entity left on_fire false, so a criterion's player predicate with
flags.is_on_fire true never matched, a mod's trigger (the reviewer's
case) or a vanilla one. It is Entity.isOnFire on a server now:
remainingFireTicks above 0 (players aren't fire immune).

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 20:56:58 -04:00
Claude 82ea5d145f Launcher: never hold the secrets file's lock while the keychain prompts
Review fix for the secrets file (#5095): Layered asked the keychain
while holding account-secrets.lock, so an unlock prompt held up every
other launcher and CLI process.

- Each call reads its file entry under the lock, asks the keychain
  with the lock released, then removes the entry only if it still
  holds what was read: another process's newer write stays. The start's
  hand-over works the same way, from a snapshot.
- Once the keychain refuses or doesn't answer (a dismissed prompt),
  the run stops asking it and parks new secrets in the file, until it
  finds that another process handed the keychain one it parked.
- Tests: a keychain waiting on a prompt while another process reads and
  writes the file, a dismissed prompt asked once a run, and a half
  refused hand-over followed by a sign-out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 00:54:18 +00:00
Ludovic Marie f99f8feaaa Merge pull request #5620 from horizon-rust/fix/anticheat-ground-no-float
🤖 Agent: fixes #5572. ground never sends back and a dropped claim never feeds the floating kick. anticheat_e2e ground (allow-flight off) passes; vanilla 26.3 client soak 25 min never flagged; vanilla client at the live-flagged spots on the server's seed 42 min / 7 spots, 0 flags (18 min of it on this branch, ground acting). pr_ready --quick all green on main 74698584e.
2026-11-01 01:52:37 +01:00
RazerpoaandHermes Agent <combo1> 159b4bd557 fix: render spectator player ghost head with translucency
player_model_shown() no longer blocks spectators, so their model
geometry generates in third person. The render path uses render_with
so the poses' visibility hides body parts, leaving only the head/hat.
body_color returns ~12.5% alpha for spectator players; OIT marks the
body already, so the ghost head looks ghostly.

Co-Authored-By: Hermes Agent <combo1> <[email protected]>
2026-11-01 07:52:27 +07:00
Lostpold88andAntigravity e9834be293 Update heal_hold_latches doc comment to reflect physical_keys clearing
Clarify that only physical_keys is cleared directly in heal_hold_latches, leaving logical sneak/sprint keys to the toggle loop.

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 01:48:24 +01:00
Ludovic MarieandClaude Opus 5.5 ffcba19aa8 Anticheat ground: drop the claim, never send back or feed the floating kick
When ground acted, a false claim sent the player back to where it was:
into the air it claimed to stand in. A player there claims it again, is
sent back again (+0.5 VL twice a second at the same spot) and hovers until
vanilla's "floating too long" kick: in 11 minutes on the official server,
four players got it 20 times. Only the anticheat's own kick (logged, told
to the operators, counted toward the ban) should end a false claim.

ground's correction is now the dropped claim alone (vanilla's fall damage
follows), logged as "claim dropped"; it never sends the player back, and a
move whose claim was dropped never counts toward clientIsFloating.
setback = 0 still makes the check log only. The ground e2e runs with
allow-flight off and fails on a setback or a floating kick.

Fixes #5572

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:43:32 +01:00
TeiwazikandClaude Opus 5.5 bbbb18595f Shader API: [[materials]], an id by sprite, and shadows cut whole
A pack treats hundreds of kinds of surface differently, where [[tags]] hold 32 sets. Each sprite takes
the id of the first [[materials]] entry that names it, as MaterialInput.material, through the same
generated switch as the tags. [shadow] solid_materials draws those ids into the map without their
cutouts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 01:43:03 +01:00
Claude ab8797a5bd Launcher: keep the Tailscale token out of the secrets file, say where sign-ins sit
Review fixes for the secrets file (#5095):

- The Tailscale access token administers the player's whole tailnet:
  it goes through account::keychain(), the keychain or memory for the
  run, never the secrets file.
- MCP account_list says secrets_file: true while sign-ins sit in the
  file, beside keychain (sign-ins outlast the launcher).
- launcher/README.md and the sign-out help (docs/launcher-commands.md,
  regenerated) no longer say tokens are never in a file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 00:42:09 +00:00
SylviromiandDeepSeek V4.1 Flash 5a1f9ed66f Native mods: format the contributed-files test
rustfmt only (cargo fmt --all); no behavior change.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-01 03:39:42 +03:00
Lostpold88andAntigravity c85748d294 Fix heal_hold_latches wiping logical sneak key when rebound away from Shift
self.keys tracks the logical gameplay keys, and sync_toggle(0) uses KeyCode::ShiftLeft to represent active sneak regardless of the physical key binding. Calling self.keys.remove(&key) when physical Shift is up cleared active sneak every frame for players who rebound sneak away from Shift (e.g. to Left Control or C).

Fixes #5608

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 01:37:12 +01:00
Claude b617e637b4 Launcher: read the keychain and the secrets file together on Linux
Review fixes for the secrets file (#5095):

- No store is picked once per run any more: on Linux and FreeBSD every
  read takes the file's copy first and the keychain's otherwise, and
  every write goes to the keychain, to the file only when it refuses.
  A launcher that wrote to the file keeps reading, renewing and signing
  out after another process hands the file to the keychain.
- Each start hands the keychain the file's secrets one at a time, each
  leaving the file once the keychain took it, so a move it half refuses
  never leaves a secret in both places, and signing out removes it.
- Signing out while the keychain is away leaves an empty entry that
  hides the keychain's copy until it can be deleted.
- macOS and Windows keep the keychain or memory, as before.
- The concurrency test now runs real child processes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 00:37:06 +00:00
rdbtCVSandClaude Opus 5.5 eba43f7fc9 Text displays: cull their back faces, like vanilla's text pipelines
RenderPipelines.TEXT_POLYGON_OFFSET and TEXT_SEE_THROUGH (and the
TEXT_SNIPPET they build on) never call withCull, so
RenderPipeline.Builder.build keeps its default, cull.orElse(true). Horizon
drew both with cull_mode None, so a text display showed its mirrored
text from behind (#3833).

Culling alone would have hidden the readable side instead: the text
display quads were taken in the wrong corner order and wound clockwise
toward the reader. They now use Font's glyph order (top left, bottom
left, bottom right, top right), the one name tags already use, through
text_display::CORNERS. A test projects fixed and billboard displays
through the real view and projection matrices and checks that the side
the text reads from is the counter-clockwise one.

The improved-transparency text pipeline is culled the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:35:13 +00:00
Ludovic Marie 74698584ed Merge pull request #5575 from horizon-rust/fix/commands-take-nicknames
🤖 Agent: players can /msg, /tell and /w a player by their nickname (shown as ~name); server guests included. SMP proof and pr_ready --quick passed on this head (comment above).
2026-11-01 01:32:29 +01:00
Ludovic MarieandClaude Opus 5.5 8e5ab9ec63 Light views of proto chunks: their blocks only
A finished column at the edge of the generated area is lit against its
decorated proto neighbours, each copied into a column on the server
thread (light_view). The light engine reads only their blocks, yet the
copy also rebuilt every section's biomes (64 biome lookups and a
palette per section), the scheduled ticks, post-processing marks and
structure references. The view now copies the blocks alone; the final
column (finish) still gets everything.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:32:12 +01:00
Solenopsisbot 1c8a721dee Merge remote-tracking branch 'origin/main' into accounts/player-dir
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/api/launch.md
#	docs/api/stability.md
2026-11-01 11:31:48 +11:00
[email protected]andGPT-6 4497abec98 feat(mods): support image tooltip components
Co-Authored-By: GPT-6 <[email protected]>
2026-11-01 01:27:20 +01:00
Claude d1eca94f9c Launcher: lock the secrets file across processes, hand it back to the keychain
Review fixes for the secrets file (#5095):

- Every read-modify-replace holds account-secrets.lock (std File::lock),
  so launcher and CLI processes signing in or out at once no longer
  overwrite each other; each write uses a temporary file of its own.
- The file no longer sticks once made: at the first start the keychain
  answers, its secrets move into the keychain and the file is removed,
  so an account kept in the keychain before a start it missed comes
  back beside the ones signed in meanwhile. If the keychain refuses any,
  the file stays whole and in use.
- A broken file's error names only the error kind, line and column:
  serde's own message can quote a string the file holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 00:26:28 +00:00
rdbtCVSandClaude Opus 5.5 94970aee25 Shield: a hit fully blocked on Easy no longer pulls the player in
LivingEntity.applyItemBlocking passes `damageBlocked >= damage` to
blockUsingItem with the damage Player.hurtServer already scaled by the
difficulty; Horizon compared against the raw damage, so on Easy a full
block counted as partial and blockedByItem's knockback pulled the
defender toward the mob. The hurt now records whether it was fully
blocked and the shield reads that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:24:44 +00:00
rdbtCVSandClaude Opus 5.5 0fde32cb82 Cushions: breaking the one you sit on leaves you on the floor
Entity.setRemoved makes a removed vehicle's passengers stopRiding, and
LivingEntity.dismountVehicle puts them at the higher of their own and
the vehicle's heights, at the free spot nearest there (findFreePosition).
break_cushion only cleared the riding id, and a server-thread client kept
riding a cushion that was gone, so the rider stayed at the seat, 0.35
below the cushion, and dropped into the block under it.

Both the server (break_cushion) and the client (the cushion's removal)
now dismount the rider that way, with no get-up sound
(Cushion.removePassenger plays none once the cushion is removed).

Fixes #4571

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:23:25 +00:00
SylviromiandDeepSeek 622c1a077a Native mods: data pack files in the server's data load
native_data_pack_files asks a native mod, while the server assembles its data
tables, for the pack files it contributes: (path, bytes) in the overlay's form.
It fires once per load — when a world opens, and again on every `/reload` — so
the ask is the mod's chance to recompute them from its config or from the
world. A mod that turns configuration into recipes, tags or loot needs no
content mod beside it and no data pack installed by hand: today neither can
recompute those files for a load.

The files join the load where the world's packs already do:
Game::build_world_tables is both a world's first open and reload_world_tables
(`/reload`, `/datapack`), and the hook is asked there and nowhere else. They
sit below those packs: PackOverlay::top is a path's last file, so a player's
data pack still overrides a mod's for the same path, and a later contribution
overrides an earlier one, as packs layer. The advancement prefetch takes the
same files, so a contributed advancement is there on the first open and not
only after a reload.

With no mod hooking it nothing is contributed: a world with no packs still
uses Resources.rest, and a world with packs builds exactly the tables it did
before.

The hook is API next (tools/native-hook-fragments/data_pack_files.json);
native-hooks.json and NATIVE_HOOKS_API are untouched.

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 03:23:16 +03:00
Ludovic MarieandClaude Opus 5.5 25c17cbfbc Server /msg: a player name finds the player going by that nickname
A guest's /msg on a server routes through lan_chat.rs, which matched only real names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:18:40 +01:00
TeiwazikandClaude Opus 5.5 11cd3f220d Shader API: the first-person player casts into the shadow map
The local player's model isn't drawn in first person, so a native pack's shadow pass gets it on its
own through the frame's shadow_only quads. The round blob under entities is left out while a pack
draws a shadow map, since the sun's shadow is already there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 01:16:22 +01:00
rdbtCVSandClaude Opus 5.5 06b8a7cfe3 Dedicated server: hold the join tick's dirty attributes for the next tick
Vanilla's first ServerEntity.sendDirtyEntityData for a player runs before
its first ServerPlayer.tick, whose updatePlayerAttributes (and doTick's
removeFrost) create the interaction range and movement speed instances.
Those go out on the next tick, after the first chunk batch. Horizon's guest
tick created them before sync_player_state sent them, so a vanilla client
got a default-valued update_attributes before the first chunk batch.

Fixes #5560

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:14:32 +00:00
rdbtCVSandClaude Opus 5.5 59f391f8df Retry a recreated window surface whose configure failed
A surface wgpu never configured has no error sink, so acquiring from it
panics instead of reporting Validation (#5353). recreate_surface now checks
configure through error scopes and a device-lost flag, and frames configure
such a surface again before acquiring, skipping until it takes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:12:28 +00:00
rdbtCVSandClaude Opus 5.5 d4e49be59f F3+B: draw the Interaction entity's hitbox
EntityHitboxDebugRenderer.emitGizmos draws every visible entity's box,
the Interaction too (its renderer is a NoopRenderer, the hitbox is not
the renderer's). Its box is EntityDimensions.scalable(width, height):
eyes at 0.85 of the height, not a LivingEntity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:12:05 +00:00
rdbtCVSandClaude Opus 5.5 14cbc388bb Launcher: list native builds on the Versions page, with Remove
Native mod builds only showed in the version picker, so there was no way to delete one by
hand. The Versions page now lists them above the releases, with what each was built on, its
size on disk, and the same Use and Remove (asked twice) as a release. Removing a version a
program still runs from is refused.

Fixes #5326

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:11:34 +00:00
rdbtCVSandClaude Opus 5.5 840ea9e89f Draw the Mojang Studios logo on the loading overlay
LoadingOverlay.extractRenderState blits textures/gui/title/mojangstudios.png
as two halves side by side over BRAND_BACKGROUND and draws its bordered bar
at 83.25% of the height; Horizon only filled the background. The logo comes
from the player's own prepared resources (the GUI atlas already packs it).

Fixes #5115

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:05:22 +00:00
Claude e7b5953ba4 Launcher: fix the secrets-file tests' names and comparison
kept() clashed with the tests' own helper, and Secrets has no PartialEq.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 00:04:41 +00:00
Sylviromi dd72fb8277 Merge branch 'screen-mouse-scrolled' into screen-key-input 2026-11-01 03:04:24 +03:00
Sylviromi c90161511b Merge branch 'screen-mouse-button' into screen-mouse-scrolled 2026-11-01 03:04:24 +03:00
Sylviromi 068b434a3d Merge branch 'screen-render-post' into screen-mouse-button 2026-11-01 03:04:24 +03:00
SylviromiandClaude Code ccb1474a03 Native mods: a server's trading window gives the screen view its title
remote_open_merchant left the menu title unset, so NativeScreenView.title was
None on that window although the docs promise the server's title.

Co-Authored-By: Claude Code <[email protected]>
2026-11-01 03:04:23 +03:00
7a6468bd8f Native mods: the screen hears of a key and of each typed character first
Adds screen_key_pressed_pre and screen_char_typed_pre (ScreenEvent.KeyPressed.Pre
and CharacterTyped.Pre) on the open screen, fired from the window's key event,
the control socket's key and type commands, and the input method's commit.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Code <[email protected]>
2026-11-01 03:02:51 +03:00
ErikKalikasandClaude Opus 5.5 600e7bec77 Native mods: test that infinite bounds skip a frustum that culls everything
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:02:38 +02:00
[email protected]andGPT-6 d3ad6d73cd feat(mods): add custom dispenser item behaviours
Co-Authored-By: GPT-6 <[email protected]>
2026-11-01 01:00:22 +01:00
Ludovic Marie d0ce8fa54e Merge pull request #5557 from horizon-rust/fix/dialog-registry-known-pack
🤖 Agent: follows vanilla's RegistrySynchronization.packRegistry for the dialog registry (fixes #5525; reverts #4698's always-sent dialog data, #5505 follow-up). Main merged + lan_bot strict again. dedicated_server harness PASS (failed on main), lan_bot 15/15, lan::data tests incl. the new one, clippy -D warnings, fmt ok.
2026-11-01 00:59:30 +01:00
rdbtCVSandClaude Opus 5.5 b264a7d9d5 Server: a guest's body collides with the world border
The server simulated its players' bodies with no world border (only the
window's own player got one, in player_border), so a server player's
re-simulated movement went through the border's walls. enter_guest now
gives the guest's body the level's border each turn, as vanilla's
Entity.collide collides every player with the level's WorldBorder.

Fixes #2743

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:58:21 +00:00
Claude e878abf1fd Launcher: keep Microsoft sign-ins in a file where no keychain answers
Many Linux systems run no Secret Service (GNOME Keyring, KWallet), and
the launcher then kept sign-ins in memory only: players signed in again
at every start (#5095).

Where the keychain doesn't answer, the secrets now go to
account-secrets.json in the launcher's data folder, readable by the
player only (0600 on Unix) and replaced whole through a temporary file.
The keychain stays first; a secrets file from an earlier start stays in
use so a flaky keychain doesn't sign players in and out, and signing the
last account out removes it. HORIZON_NO_KEYCHAIN still keeps secrets in
memory for the launcher's tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-10-31 23:58:12 +00:00
TeiwazikandClaude Opus 5.5 43207a5a3d Shader API: entities cast into the shadow map
The renderer's opaque entity quads now go into shadow_map through the shadow camera, with their
texture's cutout. They draw after the translucent map is copied, since where one casts the solid
map is the darker and decides. Unlit overlays, as entity.wgsl lists them, cast nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:57:54 +01:00
Ludovic MarieandClaude Opus 5.5 6fe69eac3c lan_bot: the known-pack check is strict again, dialogs included
#5559 let the dialog registry carry data for a client that knows the
core pack; with this PR the server follows vanilla's packRegistry again,
so every registry, the dialogs too, goes without data to that client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:56:28 +01:00
Ludovic Marie 56213a8148 Merge main into #5557 (lan_bot strict again over #5559) 2026-11-01 00:56:27 +01:00
rdbtCVSandClaude Opus 5.5 ac128db203 Draw rain after translucent terrain and write no depth (#5136)
The classic path drew weather before translucent terrain with depth writes
on, so the water behind the rain failed the depth test and the rain columns
cut holes in it. Vanilla 26.3's RenderPipelines.WEATHER is
DepthStencilState(GREATER_THAN_OR_EQUAL, false), and executeClassicTransparency
draws translucent terrain, clouds, then weather, then the world border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:56:23 +00:00
[email protected]andGPT-6 f4103d272e fix(mods): return deferred summon root IDs immediately
Co-Authored-By: GPT-6 <[email protected]>
2026-11-01 00:56:18 +01:00
ErikKalikasandClaude Opus 5.5 4ad692a07b Native mods: leave native_draw_item and native_block_state_property for a follow-up
Keeps this pull request near the size limit; the block state stays in the
hooks, whose signatures can't change once merged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:56:04 +02:00
SylviromiandDeepSeek b1d6819737 Native mods: the screen hears of the wheel before it scrolls
`native_screen_mouse_scrolled_pre` (`ScreenEvent.MouseScrolled.Pre`) hands a
mod the open screen, the wheel's movement in x and y as the game's scroll
handler already scales it (vanilla's doubles, the sign only when discrete
scrolling is on, a touchpad's pixels over 30) and GLFW's modifier bits, plus
`canceled`.

It fires in the `MouseWheel` arm once `scaled` is defined, before the dialog,
title, hotbar, bundle and every screen scroll, so a cancelled turn scrolls
nothing and leaves nothing half-applied; the hotbar reads the wheel only
while playing, which the hook's gate excludes. The pad's and the keyboard
focus's made-up wheel is not the mouse and fires nothing.

The hook doc names the screens that never fire, as the mouse button hooks
do, and the fragment is pending, so this is "API next".

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 02:51:37 +03:00
Teiwazik 3ab39ea984 Merge remote-tracking branch 'origin/main' into shader/s8-shadow-entities 2026-11-01 00:50:18 +01:00
rdbtCVSandClaude Opus 5.5 569bd24ac9 Spin the vault's display item on remote clients
tick_remote_spawners ticked spawners and trial spawners on a remote client
but not vaults, so VaultBlockEntity.Client.tick (display item spin, idle
particles and sounds) never ran on the server-thread client.

Fixes #3108

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:48:12 +00:00
ErikKalikasandClaude Opus 5.5 97c8e9ed15 Native mods: the block state in the block entity hooks, items and state properties for drawing
Both hooks get the block's state, as a renderer reads
blockEntity.getBlockState(); native_block_state_property reads its facing
without a world lookup, and native_draw_item draws an item stack under the
pose stack as a campfire does (resolve_item, submit_item_world).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:46:43 +02:00
arismoko f6afc1d26e Update mod registry lockfile 2026-10-31 18:45:36 -05:00
SolenopsisbotandClaude Opus 5.5 f91628df10 Launcher: play one launch as another signed-in account
`horizon-launcher play --account WHO` plays this launch as a signed-in account (name or UUID)
without changing the one `account select` picked, so several games of one instance can play
as several accounts at the same time (#4952). The owner gate runs as for any launch, and
what the services renew (tokens, names, skins) is kept.

account::session_for_game_as runs the gate as that account on the loaded accounts and puts
their choice of player back before saving them (launch_as). A Java instance refuses the
flag, as it does --world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:45:16 +11:00
Solenopsisbot 3f714300ed Merge remote-tracking branch 'origin/main' into headless/client 2026-11-01 10:41:53 +11:00
TeiwazikandClaude Opus 5.5 6a47c9eab5 Docs: shadow_color carries the vertex colour's shading
The colour is the mesher's vertex colour, so it includes the face's directional shade and ambient occlusion as well as the tint. The docs now say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:38:31 +01:00
SylviromiandClaude Opus 5.5 4685ab06e2 Native mods: fragment names the renamed gate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:35:45 +03:00
SylviromiandClaude Opus 5.5 7c42d32723 Native mods: the screen-hook gate is a helper, not a hook point
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:35:34 +03:00
some-du6eandClaude Opus 5.5 42cfa789b9 Native mods: document the default-components hook, share the catalog scan
docs/native-mods.md gets an "Items' default components" section beside the
render hook points: the target path, both sides, threads, vanilla clients,
creative search. The catalog test reads free-function hooks with one helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:34:11 -06:00
some-du6eandClaude Opus 5.5 9cecb86ec2 horizon-prepare: review fixes for the parallel preparation
- A failed exporter compile fails every lane at once instead of being
  retried by each lane that waits for it.
- The gameplay facts exporter streams to a .partial file and moves it into
  place: a run stopped halfway no longer leaves a short file the next run
  would keep.
- pool::map takes no per-thread state (a jar clone costs two Arc counts);
  pool::lanes and map are tested at 1 job and several. With 1 job the first
  failing lane stops the rest, as documented now.
- The lanes' log and stage messages go through relay(), which a test now
  checks; block behaviour reuses export_on; the stamp inputs are worked out
  once; the fifth lane exists only when it splits; dead checks are gone.
- check_prepare_exporters.py also prepares with 2 jobs (the default path
  below 6 cores, 5 JVMs) and repeats that run, which must start no JVM and
  change no file.
- The docs say what HORIZON_PREPARE_JOBS does, that the split is not yet
  timed on 6 cores, and the split's measured ~400 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:33:51 -06:00
Federico TaglianiandClaude Sonnet 5.5 0c6d794e8b Port map: Parrot.calculateFlapping and the horse step sound rows point at their full ports
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 00:33:44 +01:00
ErikKalikasandClaude Opus 5.5 ee34e1982a Native mods: cull mod block entities by the bounds their mod gives (getRenderBoundingBox)
native_block_entity_render_bounding_box lets a mod widen the box its block
entity is culled with (NeoForge's getRenderBoundingBox), or make it
infinite, so a tall part stays drawn while its block is out of view. The
view distance check is strict, as Vec3.closerThan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:33:44 +02:00
Federico Tagliani adb467c19e Merge remote-tracking branch 'origin/main' into chore/port-map-clean 2026-11-01 00:33:32 +01:00
SylviromiandClaude Opus 5.5 5f87b9e60d Native mods: mouse hook docs, which screens fire
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:33:16 +03:00
Ludovic Marie c9d8e3efa6 Merge pull request #5544 from horizon-rust/fix/dedicated-command-selection
🤖 Agent: pr_ready on the Mac mini (head 26748d7c1, base a4873dec4): fmt, clippy -D warnings, horizon-command tests 27/27 (new a_dedicated_server_keeps_seed_and_version_for_game_masters), Python 510/510, public tree ok; parity 41/44 affected harnesses pass incl. command_parse, command_exec, chat_screen, server_ops_feedback. The 2 failures predate this PR and don't involve commands: online_mode = this Mac's known IndexError (stray GET /, fails on main too), dedicated_server = config-phase minecraft:dialog registry data from #4698 (same root cause as #5559), the stage before any command. CI verify passed.
2026-11-01 00:30:54 +01:00
Ludovic MarieandClaude Opus 5.5 d8afea7ae3 Commands: a player name finds the player going by that nickname
On a server where a mod set nicknames (native_set_nickname), a player
argument naming no player matches the player whose nickname it is (any
case, without the ~ chat shows, or with it quoted), so /msg, /tell and /w
reach a nicknamed player by the name chat shows. Real names win; no
nickname, no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:29:37 +01:00
MariuslozoandClaude Opus 5.5 bc13a0403f lan_e2e mount_other_level: the player in the Nether, and no clicks on the last mount
The other way round from a force-loaded chunk: the player and a horse in the
Nether while the other player keeps the Overworld ticking; the Overworld's pass
must leave the menu alone (in_nether, --server-thread). The player lands in
creative and is put back on the platform once it stands, so a cave below the
spot doesn't drop it out of reach.

A killed mount lies at the spot for 20 ticks (LivingEntity.tickDeath), and the
next one, summoned there 10 ticks later, took the clicks meant for it on a
slow run: wait until the client no longer sees it. LAN_E2E_LEVELS takes a
comma list (the_nether, the_end, in_nether), as LAN_E2E_MOUNTS does; the
force-load is removed even when the wait after adding it fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:27:22 +01:00
JustKarkatandJustKarkat's AI agent 1703b9f78f An experience orb falls and lands as vanilla does (#4781)
ExperienceOrb.tick diverged from vanilla 26.3 in the way the dragon's
death XP shows: a landing orb kept its pre-collision speed through the
friction step and bounced back at 0.9 of it on every ground contact, so
the orbs rained onto the exit portal hopped high and for a long time —
the report reads them as floating in mid-air and spreading out.

The tick now follows the vanilla method: gravity 0.03 is skipped while
the orb's box is inside blocks (`!level.noCollision`), a stuck orb with
no player within 8 blocks gets Entity.moveTowardsClosestSpace (already
ported for items, now on the orb too, its own random drawing the
speed), the collided axes' velocities are zeroed the way
Entity.move's restituteMovementAfterCollisions does for a bounciness 0
orb (x and z by Mth.equal's 1.0E-5, y exactly), one friction of
getAirDrag() times the ground's friction — computed as a float, as
vanilla does — scales every axis including y, and the landing bounce
is `-fallSpeed * 0.4` gated on `fallSpeed < -getGravity()` instead of
a fixed -0.9. The friction anchor below the orb is getOnPos(0.999999)
as the item's already was, not the block one below.

tools/test_command_exec_parity.py gains an "experience orb fall"
group: an orb falling 45 blocks onto a stone platform, out of the 8
block follow radius, matches vanilla's Pos and Motion to the digit at
every checkpoint, the settled state included (969/969 with it).

Fixes #4781.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-10-31 18:27:12 -05:00
Federico TaglianiandClaude Sonnet 5.5 26d61054f7 Superflat: strongholds and mineshafts use the generator's minimum Y
MineshaftStructure and StrongholdStructure call moveBelowSeaLevel with
ChunkGenerator.getMinY(), which is 0 for FlatLevelSource (the noise settings'
minimum Y otherwise), not the level's -64. The Classic Flat stronghold sat
64 blocks lower than vanilla's.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 00:25:16 +01:00
SylviromiandDeepSeek e39e0088cb Native mods: mouse button press and release on the open screen
`native_screen_mouse_button_pressed_pre` and
`native_screen_mouse_button_released_pre` (`ScreenEvent.MouseButtonPressed.Pre`
and `.MouseButtonReleased.Pre`) hand a mod the open screen, the button's GLFW
index (0 left, 1 right, 2 middle, 3 back, 4 forward, `Other(n)` = n) and
GLFW's modifier bits (shift 1, control 2, alt 4, super 8), plus `canceled`
for a press the screen never sees.

They fire first in `handle_window_event`'s closure, before the screen or the
container hotkey, sort and header buttons act, so a cancelled press leaves
nothing half-applied and a cancelled release keeps a drag held. The pad's and
the keyboard focus's made-up clicks are not the mouse and fire nothing.

The gate and the view two more input hook families need are here: the pure
`glfw_modifiers` and `glfw_button`, `native_screen_open` (a screen of the
game's over `Screen::Playing`, no connect or load) and `screen_view_now`,
which builds only the `HudBatch` its rect needs. The hook docs and the
fragment name the screens that never fire: the title screen, the connect and
loading screens, the prompts, books and sign editors drawn over gameplay, and
any screen while a world loads or saves.

The fragment is pending, so this is "API next".

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 02:25:08 +03:00
some-du6eandClaude Opus 5.5 d253b46872 OptiFine packs: a shared reader for their .properties, matchers and paths
New crate horizon-optifine, the layer every OptiFine-format feature (CTM,
CIT, random and emissive entities, CEM, sky, colours) reads its files
through: Java .properties with escapes and continuations, name matchers
(pattern:, ipattern:, regex:, iregex:), range lists with parenthesised
negatives, id lists with ! exclusions, block lists with states, OptiFine
path resolution (~/, ./, namespace:) and the optifine/ and mcpatcher/
folders of every namespace. Written from OptiFine's public format docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:24:52 -06:00
MariuslozoandClaude Opus 5.5 5a6d492126 Creative Survival Inventory tab: Q while carrying drops from the slot
CreativeModeInventoryScreen.slotClicked drops one item (the stack up to its
max size with Control) from a slot of the Survival Inventory tab whatever the
hand carries; the menu's THROW needs an empty hand, so with a carried stack Q
did nothing there. The screen's carrying branch now covers that tab as well as
an item tab's hotbar, and sets the slot as the menu path does (equipment
changes included).

lan_e2e creative_throw also checks Control+Q with an empty hand on an item
tab's hotbar, Q and Control+Q over a display slot, and Q, Control+Q and Q
while carrying on the Survival Inventory tab.

Part of #3684.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:22:09 +01:00
MariuslozoandClaude Opus 5.5 85ec4c8c65 lan_e2e creative_throw: clear each step's drops before the next
A dropped item can be picked up again after 40 ticks; on a slow runner a
pickup between steps would change the counts the check compares.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:22:09 +01:00
MariuslozoandClaude Opus 5.5 c9bfcdcef7 Creative item tabs: Q on a hotbar slot drops the item instead of deleting it
On a creative item tab, Q over a hotbar slot took the stack out through the
menu's THROW, but on a client of a server (single player's server thread, LAN,
dedicated) the drop only went to the server on the Survival Inventory tab, so
the slot was synced empty and nothing dropped. Send it on every creative tab,
as AbstractContainerMenu.doClick calls Player.handleCreativeModeItemDrop.

Also port CreativeModeInventoryScreen.slotClicked's own branch for a THROW on
an item tab's hotbar slot while a stack is carried: one item (the whole stack
with Control) drops from the slot and the carried stack stays.

lan_e2e: new check creative_throw, on the host and the guest.

Part of #3684.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:22:09 +01:00
some-du6eandClaude Opus 5.5 619b5fe709 Native mods: change the default components of any item (API next)
A mod could not change a default component of an item it didn't add (a
stack size, a food, a tool rule), as NeoForge's ModifyDefaultComponentsEvent
lets it. native_modify_default_components hands a mod each item's defaults
in items.json's form as they are read, to set or remove components.

Every reader of items.json (the item table, loot, enchanting, item models,
banners, pot sherds, rarity in chat) now goes through
horizon_world::item_defaults::read, so a change reaches all of them, and
stacks are saved and sent against the changed defaults like vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:21:27 -06:00
Federico Tagliani 22a2632d61 Merge remote-tracking branch 'origin/main' into feat/superflat-structures 2026-11-01 00:18:00 +01:00
TeiwazikandClaude Opus 5.5 89be6259da Docs: the dither option's row has three cells
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:16:39 +01:00
Teiwazik b10619ee4f Merge branch 'shader/s6-shadow-solid' into shader/s7-shadow-translucent
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_shadow.rs
2026-11-01 00:16:37 +01:00
TeiwazikandClaude Opus 5.5 d5316caaaf Shader API: review fixes for [shadow]
The depth range now reaches past the build height below the camera and shadow_position clamps z,
so a point past the far end stays lit, the blank map included. The light's box culls the native
lists instead of a section radius that lost casters at low sun. The native camera's snap is
euclidean, the resolution is clamped once, and the distortion and the camera each live in one
place. The generated reference lists the shadow helpers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:14:36 +01:00
huiandClaude Opus 5.5 36b0d2670f Native mods: enchant_effect honours requirements and names the mob hit
The first cut told an entry's kind before its requirements were checked, so a
failed requirement still fired, and gave the hook no way to know which mob was
hit. The kind is now told after the check, with the mob's id. A bare type name
is minecraft's own, so ignite runs as minecraft:ignite instead of reaching the
hook. The docs now say what's told: attacker on victim, a player's weapon on a
mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:13:18 +01:00
some-du6eandClaude Opus 5.5 5bc2501d7f Native loot hooks: clippy's auto-deref
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:10:58 -06:00
Ludovic Marie 10b16c4b94 Merge pull request #5565 from horizon-rust/perf/random-tick-masks
🤖 Agent: proven on 8c936237a: pr_ready --quick green (fmt, clippy -D warnings, 1927 tests of horizon-client + horizon-world incl. the masked random-tick parity test), random_tick_sections harness PASS locally; playhorizon A/B 3 rounds: random_ticks 5.75 → 2.78 ms, tick_blocks 8.57 → 5.61 ms at 20 bots, RSS unchanged. CI parity (run-parity) covers the other harnesses.
2026-11-01 00:08:26 +01:00
some-du6eandClaude Opus 5.5 c3e5e7866c horizon-prepare: block behaviour on a second server JVM with 6+ cores
A fresh preparation on a 6-core laptop finished its other JVM lanes by ~17 s,
then ran the server lane alone for ~20 s more: its last export, the world
generator's block behaviour (11.5 s there), sat behind everything else on
that JVM. From 6 cores (pool::jobs, so HORIZON_PREPARE_JOBS too) it and the
structure blocks run on a fifth lane, in their own server JVM. It pays a
second Bootstrap and ~400 MB of peak memory, so it stays off below 6 cores:
forced on this 3-core box it took 44.9 s against 40.5 s.

Same output (digest unchanged either way). check_prepare_exporters.py now
runs its batched preparation that way and expects 6 JVM launches; it passes
(23,204 files).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:07:12 -06:00
some-du6eandClaude Opus 5.5 75047c93d9 horizon-prepare: cut its memory peak from 1.47 GB to 1.06 GB
Measured on the pinned 26.3 jars, sampling every process's RSS: the peak came
at the end, from the server worker alone (1.25 GB). Its live heap stayed under
~250 MB; the rest was garbage the serial collector promoted and kept, mostly
from two exporters:

- The collision exporter wrote, and the worker cached, 62 MB of verification
  inputs (printf'd, ~4 s) that only the parity harness reads. Inside the
  prepare worker it now skips them; standalone runs still write them.
- The gameplay facts exporter built its 56 MB file as a Gson tree, then a
  String, then bytes. It now streams each state to the file; the bytes are
  the same (re-serializing the parsed file with Gson.toJson gives them back).

The worker now peaks at ~480 MB. In the Rust process, every thread reading a
jar parsed its member list again (~10 MB each, eight at once); they now
share one parse through clones of the archive that read at their own offsets.
Its heap peak halved (96 to 51 MB).

check_prepare_exporters.py passes (23,054 files, 5 JVM launches); the output
digest is unchanged. "Movement and attributes" takes 12-13 s instead of 16-18 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:07:10 -06:00
Federico TaglianiandClaude Sonnet 5.5 eeecd4427a Port map: rows now ported on main point at their fns
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 00:07:05 +01:00
Teiwazik b13a22fdcb Merge remote-tracking branch 'origin/main' into shader/s6-shadow-solid 2026-11-01 00:06:32 +01:00
huiandZCode AI agent (GLM-5.3) f074c2d4bd Native mods: an enchantment's effect of a mod's own kind
An enchantment's effects entry that names an effect.type in a namespace
outside minecraft: used to be silently skipped at the firing point. It
now reaches native_enchant_effect(enchantment, kind, data, level) — the
post_attack components first, where the weapon's holder is the acting
player — and the game's own damage, sounds, effects and drops are open
to the hook. An unclaimed kind does nothing.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-11-01 00:06:13 +01:00
some-du6eandClaude Opus 5.5 a9d6eaf3b3 horizon-prepare: serial collector for its JVMs; time steps after compiling
The serial collector is as fast as the parallel one for these JVMs (fresh run
63-66 s either way, pinned 26.3 jars) and keeps the three that run at once to
1.25 GB together instead of 2 GB. An export's time no longer includes waiting
for javac. A compile error still names the export it stopped.

tools/check_prepare_exporters.py passes: 23054 resource files match between two
isolated runs and a batched one, with 5 JVM launches and no compiler.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:57 -06:00
some-du6eandClaude Opus 5.5 a935fe0bf2 horizon-prepare: run its JVMs as batch jobs (C1 only, ParallelGC)
The exporters, the data generator and javac are short batch JVMs, three of them
running at once. The C2 compiler's background work and G1's concurrent threads
cost them more CPU than those win back. With -XX:TieredStopAtLevel=1
-XX:+UseParallelGC (javac gets them through -J), a fresh preparation from the
pinned 26.3 jars took 67 s instead of 102-105 s on the same machine. The output
digest (tools/prepare_output.py) is unchanged. java.rs is not part of the
exporter bundle key, so cached bundles stay valid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:57 -06:00
some-du6eandClaude Opus 5.5 78d211e0a4 horizon-prepare: run independent steps at once, skip unchanged exporters
Fresh preparation ran everything one after another. The steps now run on four
lanes: the server exporter JVM (same order as before, since its exporters share
state), the client exporter JVM then the command registries', the data generator
then the creative inventory's, and the Rust-only copying. At most three JVMs run
at once, still five JVMs in total. A worker stops when its lane is done. The two
exporter sides compile at once. Asset extraction, structure templates, worldgen
bundles, server library unpacking, client library imports, inventories and pin
checks run on several threads. Every pinned file is still hashed in full.

Repeat runs always started the server JVM for the block behaviour and
structure-block exporters, which had no skip check. They now keep a stamp.json
in .local (exporter bundle key, server jar, preparer sources, output sha256), so
an unchanged repeat run starts no JVM. The prepared files are unchanged.

Every step logs how long it took. HORIZON_PREPARE_JOBS=1 runs it all serially.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:56 -06:00
MariuslozoandClaude Sonnet 5.5 276b77a0f0 lan_e2e: mount_other_level covers every mount, the End, getting off and reopening (#5331)
The check now opens a tamed horse, camel, llama, trader llama, nautilus
(owned), chested donkey and mule by shift + right click and by the
inventory key while riding, and again by shift + right click after getting
off, on the host and on the guest, with a chunk force-loaded in the Nether
and in the End. Every way is reported on its own and must still be open 20
ticks later.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 00:05:40 +01:00
some-du6eandClaude Opus 5.5 8226fb0b51 Native loot hooks: a mob's drops name its position as the origin
LivingEntity.dropFromLootTable's ORIGIN is the mob's position, not the point
its drops spawn from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:04:52 -06:00
SylviromiandClaude Opus 5.5 dc6b189768 Native mods: the screen view's rect is the drawn window; menu None when unknown
The rect comes from the same container image the game draws, so the
merchant and the beacon report their wider, shifted window. An unknown menu
type is None rather than an empty id, and slot_count is checked against
slots() in debug builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:02:41 +03:00
v3nn7andCodex 3cdf2466ae Test steering items in both hands
Co-Authored-By: Codex <[email protected]>
2026-10-31 23:59:48 +01:00
v3nn7andCodex 88c6720079 Allow steering mounts with offhand item
Co-Authored-By: Codex <[email protected]>
2026-10-31 23:59:42 +01:00
some-du6eandClaude Opus 5.5 0c987c9f63 Native loot hooks: round 3 fixes
A server names no player of its own as a mob's killer, and the drops
context's fields aren't flagged as never read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:21 -06:00
SylviromiandDeepSeek dfcc1eb4dc Native mods: the screen hook's payload, and a view that does not allocate
`native_screen_render_post` (`ScreenEvent.Render.Post`) hands the mod the
mouse in GUI pixels and the frame's partial tick besides the screen view,
as the event's `mouseX`, `mouseY` and `partialTick` do, so a mod can draw a
hover highlight.

The view also carries `menu`, the vanilla menu type of the menu the server
opened (`minecraft:generic_9x3`, `minecraft:furnace`, a mod's own id), and
None for a window the client opened. Every container, station and mod
window draws on the inventory screen, so `id`'s doc says they all report
`inventory` and are told apart by `menu`, `title` and `slots`.

The view is built without allocating: slots are counted the way `Game::slots`
lists them (`slot_count`, with `station_slot_count`, `mount_slot_count` and
`native_screen_slot_count` beside their list versions) instead of being
collected into a `Vec` every frame, and the id comes from the screen
`hud_layers` drew rather than from a second argument beside `self.screen`.

The hook and the fragment say which screens never fire it: the title screen,
which returns before its point, and the prompts, books and sign editors
drawn over gameplay after it, which keep `Screen::Playing`, are skipped, and
draw over what the hook drew. Vanilla fires Render.Post for them; that gap
is now written down.

With no mod installed the body stays `let _ = (...)`: the frame is vanilla's.

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 01:55:58 +03:00
rdbtCVSandClaude Opus 5.5 6ebfcd0ded Spawner minecart: /summon loads its spawner; review fixups
- /summon minecraft:spawner_minecart with no NBT runs BaseSpawner.load on an
  empty compound, as SummonCommand.createEntity does, so SpawnPotentials is
  WeightedList.of(new SpawnData()) rather than empty.
- The save test passes no raw tag and changes the delay after loading, so it
  checks the cart's own save arm.
- The cart model test covers spawner_minecart#main.
- Port ledger: MinecartSpawner's tick, save, load and display state are ported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:55:46 +00:00
Federico TaglianiandClaude Sonnet 5.5 23a34328c7 Superflat: fixed-biome ring search, lenient overrides and biome, fallbacks, no far decoration
More review fixes on the flat generator:
- FixedBiomeSource.findBiomeHorizontal for the stronghold rings of a fixed biome source.
- The ring cache key carries the structure_overrides, not a constant.
- A structure_overrides list naming no existing set lets every set generate; an unknown
  biome is plains.
- A flat generator that cannot be built leaves the world on its flat ground; the column
  queries cover the whole level; far decoration is off for a flat world.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 23:55:37 +01:00
rdbtCVSandClaude Opus 5.5 0ddaee3da8 Native hooks: a villager bonemeals a mod block only when it is a crop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:54:24 +00:00
rdbtCVSandClaude Opus 5.5 75c6194615 lan_e2e: realign the riding entry of the check list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:51:18 +00:00
huiandZCode AI agent (GLM-5.3) 1742791286 Native mods: a consumable's effect of a mod's own kind
The loader dropped an on_consume_effects entry whose type the game
doesn't know silently. It now keeps the kind and the JSON the file
wrote, and the game hands them to native_consume_effect when the item
is consumed, on the server thread, for the acting player. The game's
own effects, sounds, teleports and drops are open to the hook; an
unclaimed kind does nothing, as an unknown kind on any loader.

A bare type name without a namespace is minecraft's own — apply_effects
is minecraft:apply_effects — so only a namespaced kind outside
minecraft's becomes Native; an unknown minecraft: kind is dropped. The
hook sees the item's own consumable component, not a patch on the stack.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 23:50:37 +01:00
some-du6eandClaude Opus 5.5 7f9f86a7ab Native loot hooks: round 2 fixes
The wall-table test's registry has every block's default, a kill by a guest
who left names no player, and the load hook's docs say two loads can overlap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:50:04 -06:00
huiandZCode AI agent (GLM-5.3) 1841925457 Ingredient shapes: compound is the union, intersection the all-of; ingredient_type key; fluid dropped
NeoForge's CompoundIngredient matches if any child does — the union of
their items, not the intersection (that is IntersectionIngredient).
The ingredient_type key is neoforge:ingredient_type, type only as the
older packs' spelling; children is the field name, ingredients as the
older spelling. neoforge:fluid is not among NeoForge's item ingredient
types (its fluid ingredients are a separate registry for fluid stacks,
not items), so it is dropped until a use case arrives with a source.

Review: rdbtCVS's agent in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 23:49:30 +01:00
Claude 4a361d034b native_summon: a root a mod kept out has no id
spawn_loaded_entities hands out the id before native_entity_joins runs,
and a cancelled orb doesn't give it back, so the fallback returned an id
no entity holds. Take it only when an orb holds that id (root_id), and
test both cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QT61YPiPzVNysmVHsH
2026-10-31 22:48:42 +00:00
Ludovic MarieandClaude Opus 5.5 8c936237a8 Random ticks: their own span
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:45:56 +01:00
Ludovic MarieandClaude Opus 5.5 3dbe97809f Random ticks: skip the drawn blocks that can't tick, from a per-section mask
Vanilla reads the block at every drawn position of a ticking section; most
hold stone, dirt or air, and each read is a chain of cache misses (chunk map,
section, palette, words, block facts). Each ticking section now keeps a
4,096-bit mask of the blocks a random tick can do anything for (a randomly
ticking block, or lava), built at its first draw and dropped when the section
changes. The draws, their order and every tick that runs are the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:45:56 +01:00
some-du6eandClaude Opus 5.5 603fda280e Native loot hooks: review fixes
Empty stacks a mod leaves don't drop, the load hook runs outside the
cache's lock, a mob's context names the table that rolled (one helper with
drops::die), the wall-variant table lookup has a test, and the docs say
which breaks the drops hook hears.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:37 -06:00
some-du6eandClaude Opus 5.5 f749bbeef1 Native mods: change vanilla loot tables and drops
native_loot_table_load (horizon_world::loot) hears each loot table's JSON as
it loads, after the data packs' (LootTableLoadEvent, LootTableEvents.MODIFY),
and native_loot_drops hears a broken block's or a dead mob's items with what
dropped them (global loot modifiers, MODIFY_DROPS).

Closes #5522

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:37 -06:00
Ludovic Marie 23cd6895bd Merge pull request #5559 from horizon-rust/fix/main-build-lan-bot-dialog-data
🤖 Agent: fixes #5505 (lan_bot 13/15 failing on main since #4698 always sends minecraft:dialog data). Test-only: the known-pack assertion allows data for the dialog registry. lan_bot 15/15 on this head with vanilla data; fmt ok.
2026-10-31 23:44:07 +01:00
ElyTheCatandCodex 8f785872f8 Remove redundant render upload diff
Keep the PR focused on worker-side side sorting.

Co-Authored-By: Codex <[email protected]>
2026-11-01 02:14:05 +03:30
Federico TaglianiandClaude Sonnet 5.5 79315a5de3 Superflat: stronghold ring seed 0, flat sea level and height, null layers, no creature packs
Review fixes for the structures on a flat generator, from the 26.3 bytecode:
- createForFlat searches the stronghold rings with seed 0.
- FlatLevelSource's sea level is -63 and its generation context starts at Y 0 with depth 384.
- A null layer (not opaque to MOTION_BLOCKING) is skipped by getBaseHeight and
  buildTerrain and reads as air in getBaseColumn; a FillLayerFeature fills it after the
  structures (TOP_LAYER_MODIFICATION).
- spawnOriginalMobs is empty for a flat generator: no generation-time creature packs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 23:42:14 +01:00
Ludovic MarieandClaude Opus 5.5 85d8c01b52 lan_bot: a known core pack still gets the dialogs' data (#5505)
Since #4698 the server sends minecraft:dialog's data even to a client
that knows the core pack (Horizon's client reads the dialogs from the
packet), so every bot join panicked on the configuration step's "no data
with the known pack" assertion and 13 of 15 lan_bot tests failed. The
assertion now expects data for the dialog registry and stays strict for
every other one.

Fixes #5505.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:41:56 +01:00
rdbtCVSandClaude Opus 5.5 d078454256 Send the dialogs a client knows from the core pack without data
#4698 made the minecraft:dialog registry always carry its entries' data,
on the idea that a client does not rebuild dialogs from its known pack.
Vanilla's does (RegistrySynchronization.packRegistry): an entry of a
pack the client knows goes without data, and the dedicated_server
harness saw Horizon send the dialog registry in full where vanilla sends
Optional.empty for each entry. Horizon's own client answers
SelectKnownPacks with no packs, so it keeps getting every entry in full;
a dialog a world's pack adds or replaces is marked from_pack and still
carries its data, so quick actions from a data pack reach every client.

Fixes #5525

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:40:13 +00:00
SylviromiandDeepSeek 3e5b8b3ecc Native mods: draw over the open screen
Adds `screen_render_post`, the point of NeoForge's `ScreenEvent.Render.Post`: once a frame, after the
open screen has drawn itself and its tooltips, a native mod may draw over it. It is handed a
`NativeScreenView`: the screen's id (one per `Screen` variant, from `Game::screen_id`), the open
menu's title (borrowed from the menu, not copied: no per-frame allocation), the container panel in GUI
pixels or the whole window, and the menu's slot count.

The firing site is the end of `hud_layers`'s screen match, where every screen but Win and Options is
drawn. Those two keep their early returns: folding them into the match would draw the HUD and the
prompt path behind them, so the hook fires on their own path, after their drawing, instead. It does
not fire while playing with no screen, and it is a `&self` hook on the window only, so a mod can
read and draw but not change the screen.

With no mod installed the body is `let _ = (...)`: the output stays vanilla's.

The hook is API next (`tools/native-hook-fragments/screen_render_post.json`); `native-hooks.json` and
`NATIVE_HOOKS_API` are untouched.

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 01:38:39 +03:00
rdbtCVSandClaude Opus 5.5 de0c665692 Scaled screenshots: halve the size when video memory runs out, not crash
An 8x screenshot at 200% Resolution Scale with the video memory already full
crashed: the depth buffer's allocation failed, and its view raised a
validation error that only an out-of-memory scope was watching. The target
and depth buffer, and the whole scaled frame, now run inside out-of-memory
and validation scopes; a failure writes no PNG and tries again next frame at
half the size, down to the window's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:37:16 +00:00
ErikKalikas 30350feba8 Merge remote-tracking branch 'upstream/main' into mod-block-entity-draw
# Conflicts:
#	crates/horizon-client-render/src/hud.rs
#	crates/horizon-client/src/game.rs
2026-11-01 00:36:06 +02:00
Aaron Wang 968d157f97 lan_e2e: punch vehicles at full charge, so they break
break_vehicle's ten punches have run two ticks apart since they went
into one script (bd09f8b03). Player.attack scales a hit by its charge,
0.2 + s * s * 0.8, and a fist charges in 5 ticks, so each later punch did
0.4: 10 for the first, then 4 a hit against 2 of decay, 28 after ten of
the more than 40 a vehicle needs (vanilla would take 17). The punches
are now 5 ticks apart, fully charged, twelve of them: the vehicle breaks
on the 8th, and a tick of drift still nets at least 4 a punch.

Fixes #5526.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 18:35:39 -04:00
Ludovic Marie 965fee125c Merge pull request #5545 from horizon-rust/fix/anticheat-ground-ystart
🤖 Agent: fixes #5515. Vanilla 26.3 client 25-min soak: 117 ground flags → 0 with this fix (694 blocks, 4286 standing ticks); anticheat_e2e --check ground passes (no-fall + jesus kicked, 5 legit bots never flagged); pr_ready --quick green except lan_bot 13 failures identical on main (#5505).
2026-10-31 23:33:58 +01:00
[email protected]andGPT-6 cd85269d59 docs: correct parity selection costs after the client split
Document refreshed probe dep-info, conservative selection growth, and existing reachability narrowing without promising the old harness counts.

Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:31:27 +01:00
reservedbytes 88d6988d8e feat(mods): content mods declare their own thrown entity types 2026-10-31 21:30:59 -01:00
levzzz d55bb43ab4 Merge pull request #5467 from Sylviromi/item-sync-custom-data
Single-player/LAN item sync sends minecraft:custom_data
2026-11-01 03:29:49 +05:00
Ludovic MarieandClaude Opus 5.5 26748d7c11 Dedicated servers: /seed and /version need op, no /publish
Commands(CommandSelection.DEDICATED) registers SeedCommand and
VersionCommand with checkPermissions (LEVEL_GAMEMASTERS) and leaves out
PublishCommand and UnpublishCommand (includeIntegrated). Horizon's
executor tree used the integrated rules on --dedicated, so any player
could read the seed of an SMP. The Commands packet already cut them; the
executor and /help now do too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:29:08 +01:00
Vaspyyy 347ca18b31 Merge remote-tracking branch 'origin/main' into feat/stack-blocks-attacks 2026-10-31 22:26:45 +00:00
ElyTheCatandCodex 31456cb738 Move leaf draw ordering into mesh workers
Sort solid cutout quads while the worker-side side buckets are built, so rendering uploads the canonical worker result without a render-thread permutation or duplicate allocation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 01:56:35 +03:30
levzzzandClaude Opus 5.5 550624c5cb Launcher: horizon-mixins a2af5c0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:26:23 +05:00
Federico Tagliani 619c8d000b Merge remote-tracking branch 'origin/main' into feat/superflat-structures 2026-10-31 23:26:03 +01:00
Kacpherek 0bf4904595 Stop riding a remote vehicle that is gone
A rider kept its vehicle id after the vehicle left the level. A respawn into
another level keeps the id when KEEP_ENTITY_DATA is set, and a removed
nautilus or minecart leaves it behind. remote_ride then kept the player in
the riding pose with no vehicle under it: it did not move, sneaking did
nothing, and the seat stayed until a vehicle packet named it again.

Vanilla Entity.baseTick stops riding a vehicle that is removed
(isPassenger() && getVehicle().isRemoved() -> stopRiding(), Entity.java).
Do the same in remote_ride when no minecart, boat, cushion or mob has the
id, as tick_riding already does on the local path (boats.rs).
2026-10-31 23:25:37 +01:00
Federico TaglianiandClaude Sonnet 5.5 b5d2db2e60 Tadpole: AbstractFish goals and swim, TadpoleAi brain
A tadpole is an AbstractFish with a brain: it gets panic 1.25, avoid-player and
the fish random swim goals, SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true),
SmoothSwimmingLookControl(10), water-bound navigation, and the TadpoleAi brain
(panic, look at player, follow temptation, swim/walk/stay-in-water gate).
Its travelInWater is AbstractFish's (0.01 speed, 0.9 drag, -0.005 gravity).

Adds mob_ai parity scenarios tadpole_idle, tadpole_tempted, tadpole_panic.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 23:24:16 +01:00
levzzz 76dc5e3bd0 Merge pull request #5520 from levzzz5154/mods/analog-output
Native mods: a mod block's comparator output
2026-11-01 03:23:22 +05:00
arismoko 25cd95b930 Make server tag fragment stampable 2026-10-31 17:23:19 -05:00
levzzz 56a77b8239 Merge pull request #5468 from Sylviromi/connecting-blocks
Content mods: connecting blocks (cables, pipes) joined by a tag
2026-11-01 03:22:44 +05:00
levzzz 2364090421 Merge pull request #5485 from arismoko/server-hook-tags
Allow server hooks to query block and item tags
2026-11-01 03:22:19 +05:00
chi4aandDeepSeek 4.1 Flash 66dcd828ab e2e: cover the item use a keybind screen releases
agent_control_e2e's use-screen scenario opens the inventory and the chat over
a golden apple in use (survival) and checks the use is released in that tick:
the stack stays at 64 and no effect appears, while eating with no screen still
finishes. With the call sites in game/remote.rs disabled it fails with the
apple at 63, so it covers the bin wiring the interaction-input parity harness
cannot see; the parity rows stay as oracle-side coverage.

Co-Authored-By: DeepSeek 4.1 Flash (DeepSeek Harness) <[email protected]>
2026-11-01 00:21:11 +02:00
[email protected]andGPT-6 69c0f83d85 docs: clarify landing hooks preserve block-specific sounds
Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:20:13 +01:00
[email protected]andGPT-6 e1c1669993 feat(mods): add server-side block contact hooks
Adds step-on, inside and fall-on callbacks with entity and block context; landing damage is adjustable before sounds and hurt.

Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:19:26 +01:00
Kacpherek e6c42b985f Cull back faces of an enderman's carried block
A cross model (flower, fungus) carried by an enderman drew both of its
coplanar sides through the entity pipeline, which does not cull. The two
quads sat at the same depth and flickered against each other (#5421).

CarriedBlockLayer.submit draws the block through BlockModelRenderState,
which uses the item cutout sheet (RenderPipelines.ITEM_CUTOUT). That
pipeline keeps the default back-face culling, so only the side facing
the camera is drawn. Drop the quads whose face normal points away from
the camera after emitting the block model. CULL_BACK would also drop
them, but it turns off the normal lighting this model needs.

Refs #5421
2026-10-31 23:19:20 +01:00
sirrayiandDeepSeek Harness 86165f5754 Native mods: the placement fragment's summary is one JSON string
The hardening edit put a raw newline inside the fragment's `doc_summary`, which
JSON reads as a control character: `catalog_matches_hook_points` failed to
parse it. The line break is the `\n` escape the other fragments use.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:15:22 +00:00
MariuslozoandClaude Opus 5.5 8b5a93cbd0 Keep a horse inventory open while another level ticks (#5331)
tick_lan_mount_menus checked every open mount menu on every level pass,
not only in the player's own level. When a second level ticks (a player
or a force-loaded chunk in the Nether, a portal ticket), its pass found
no such mob and closed the menu the tick it opened. Like the station,
crafting and merchant ticks, it now skips players not in the level in
place (ServerPlayer.tick checks stillValid in the player's level).

lan_e2e mount_other_level force-loads a Nether chunk and checks that the
host's and the guest's horse inventories stay open, opened with shift +
right click and with the inventory key while riding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:10:52 +01:00
sirrayiandDeepSeek Harness 2aec9b416e Native mods: the placement hook's test covers a dispenser too
The issue asks the hook to run for every placer that goes through vanilla's
path, not only the player. `the_level_chooses_the_state_a_block_is_placed_in`
now drives a dispenser's `DirectionalPlaceContext` as `dispense_shulker_box`
builds it — `directional_can_place`, then `place_block` — and asserts the
furnace lands in the hook's state, with the hook handed the dispenser's own
click (its yaw, the front block it replaces).

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:09:58 +00:00
sirrayiandDeepSeek Harness 6c494834f5 Native mods: a placement answer must be the placed block's own state
Review point on #5516: an answer naming another block would place a block the
item isn't bound to, and skip that block's `setPlacedBy` and sound. Vanilla's
`getStateForPlacement` sets properties on the block's own `defaultBlockState`,
so the hook's answer is taken only for a state of the block being placed;
another block's, like an id past the state table, leaves vanilla's state.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:07:45 +00:00
sirrayiandDeepSeek Harness 0be8029be8 Native mods: bone meal on a mod block
A mod block took no bone meal: `BoneMealItem.growCrop` looked the block's
`BonemealableBlock` up in the port's classes and stopped. The level now
answers vanilla's three questions for it, ahead of the `BonemealableBlock` a
content template would give it, so a mod block can refuse, roll and grow:

- `is_valid_bonemeal_target` — `isValidBonemealTarget`. Asked on both sides,
  as vanilla asks it, so a client predicts the use with the mod's answer.
- `is_bonemeal_success` — `isBonemealSuccess`, drawing from
  `native_level_random` so a mod's draws stay in vanilla's order.
- `perform_bonemeal` — `performBonemeal`, with the level's ticks.

The hooks are `native_is_valid_bonemeal_target`, `native_is_bonemeal_success`
and `native_perform_bonemeal` (a fragment, no API number taken), plus
`native_level_random` for a world hook, the stream `native_level_random
(dimension)` hands a server hook.

One insertion point serves every placer: `Sim::grow_crop` is where the
player's `BoneMealItem.useOn`, a dispenser's `BoneMealItem.growCrop` and a
villager's bone meal meet, and the meal is used up and level event 1505 fired
by the callers as before, so a mod block gets vanilla's particles and sound.
The client's copy of the level now forwards the one read-only question
through `DryLevel`, and a mod block's growth particles stay inside it, as
vanilla's `GROWER` type does, where the port knows no type for it.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:05:15 +00:00
arismoko 5b4f72ada5 Supply template blockstate in component overlay fixture 2026-10-31 17:04:44 -05:00
Kacpherek 136dc81b8e Refuse to open a world saved by another series
A world whose Version.Series is not main went straight to the file fixes
and the upgrade in WorldRequest::prepare, which the dedicated server and
the agent launch both reach. Vanilla refuses it: the dedicated server's
Main.java returns before DataFixers.getFileFixer().fix when
summary.isCompatible() is false, and WorldOpenFlows.
openWorldCheckVersionCompatibility shows the incompatible AlertScreen
(LevelSummary.isCompatible, DataVersion.isCompatible).

The title screen already refused such a world. The open path now does
too, so an incompatible world is never upgraded and saved in place.
2026-10-31 23:04:33 +01:00
Kacpherek 108ef4ad6d Back up a world before restoring its level.dat_old
The Recover screen's Attempt to Restore moved the broken level.dat aside
and put level.dat_old in its place without copying the world first.

Vanilla RecoverWorldDataScreen.attemptRestore runs
EditWorldScreen.makeBackupAndShowToast before restoreLevelDataFromOld:
a zip of the whole world folder without session.lock. The restore runs
once that finishes, whether the backup worked or not.

The button now starts the same backup through the backup screen and
runs attempt_restore when it is done, with the backup toast.
2026-10-31 23:04:30 +01:00
[email protected]andGPT-6 022306f5d8 refactor(client): keep the library split without the separate mod API
Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:03:46 +01:00
ElyTheCatandCodex 095b522455 Optimize alpha-tested leaf rendering order
Reorder solid cutout quads front to back during GPU upload while preserving the canonical section mesh and shared index batching. Include the before and after leaf-scene captures used for validation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 01:33:08 +03:30
rdbtCVSandClaude Opus 5.5 02caa53a27 Scaled F2 screenshots: game resolution times a Screenshot Quality option
F2 now saves the frame at the game's resolution (Resolution Scale above
100%) times a new Video Settings option, Screenshot Quality (1x, 2x, 4x,
8x; 1x by default). The frame is drawn once into its own offscreen
target, with the HUD zoomed to the same layout, and capped to the GPU's
texture and buffer limits; if the large targets can't be allocated the
window's frame is saved as before.

Fixes #3030

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:01:44 +00:00
levzzzandClaude Opus 5.5 acfac25699 Native mods: a mod block's comparator output
native_has_analog_output and native_analog_output answer BlockBehaviour.hasAnalogOutputSignal and
getAnalogOutputSignal for a mod's block; Level.setBlock's comparator update follows the answer, and
native_update_output_neighbors is Level.updateNeighbourForOutputSignal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:01:10 +05:00
rdbtCVSandClaude Opus 5.5 2f7dd09e90 Windows import check: uiautomationcore.dll ships with Windows (GPUI Fast's accesskit_windows imports it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:58:38 +00:00
k3yhandClaude Opus 5.5 d5c2efa9a3 A command reaches an open cart's live slots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:57:39 -05:00
arismoko c16cba58d6 Verify content defaults in the item catalog 2026-10-31 16:55:46 -05:00
rdbtCVSandClaude Opus 5.5 4e61981299 Merge #3801's branch (with main) into #3825
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:51:17 +00:00
Ludovic MarieandClaude Opus 5.5 75e8a96ca2 Anticheat ground: a claim on what is under the move's start stands
Entity.move resolves Y first, where the body starts, then X and Z. On the
tick a player walks off an edge, or lands on one and slides off it, the
client is on the ground over air at the move's end. An instrumented
25-minute vanilla client soak on generated terrain matched all 117 of
its ground flags to this (104 walk-offs at the same height, 13 landings),
one per step down any slope or staircase: enough to push a player
running down a long one toward the kick. ground now also holds a claim
when the ground holds at the move's start X and Z at the new height. A
no-fall or jesus has nothing there either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:50:26 +01:00
rdbtCVSandClaude Opus 5.5 60851159b2 Merge main into #5446
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:50:15 +00:00
arismoko 92956bf15f Run component isolation coverage in integration process 2026-10-31 16:47:08 -05:00
arismoko b21c7585be Isolate component registry test process 2026-10-31 16:46:05 -05:00
chi4a 5288309e80 Merge remote-tracking branch 'upstream/main' into fix/item-use-screen 2026-10-31 23:45:53 +02:00
arismoko 0dc6e9e281 Preserve tick-context tag helpers 2026-10-31 16:45:30 -05:00
Kacpherek 67c4b573ad Smooth an experience orb's server corrections over 3 ticks
ExperienceOrb.createInterpolationHandler returns a LinearInterpolationHandler, so a client spreads each server position correction over 3 ticks (doInterpolate in Entity.commonTick) and the orb's own move carries the target along (applyPredictedMovement). The remote path put the orb at the new position at once, so it jumped on every correction; the comment claimed vanilla's NO_OP handler.
2026-10-31 22:45:25 +01:00
Kacpherek 6d2682cf87 Take leads back or snip them only when the click hits the fence knot
LeashFenceKnotEntity.interact runs only when the ray meets the knot (its box from recalculateBoundingBox, 0.375 wide, y + 0.375 to y + 0.875) before the post. A click elsewhere on the fence is FenceBlock.useWithoutItem or LeadItem.useOn, which only ties the player's own mobs (LeadItem.bindPlayerMobs). interact_knot ran the take-back and shears branches for any fence click, so clicking the top of a post with stone took a tied horse's lead instead of placing the stone, and shears snipped every lead.
2026-10-31 22:45:25 +01:00
rdbtCVSandClaude Opus 5.5 72e89aca4a Splitscreen: say Linux's gilrs matches a reconnected pad by model too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:44:53 +00:00
rdbtCVSandClaude Opus 5.5 19c46b3fde Biome modifier ore test: the configured feature in 26.3's flat form
The prepared root holds features without vanilla's old "config" wrapper and
with a block state as an id, so the zinc ore never decoded and the
content-root test failed over real data (CI skips it without .local/).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:43:01 +00:00
chi4aandDeepSeek 4.1 Flash b2ab3d0d78 Release the item use when a keybind opens a screen
Opening the inventory (or chat, or advancements) while an item was in use
left the use running: the key event sets the screen, and the release sentence
of Minecraft.handleKeybinds never runs while a screen is open, so the serving
side kept counting and ate the food behind the GUI. Vanilla releases in the
same tick: the key opens the screen inside handleKeybinds, Gui.setScreen runs
KeyMapping.releaseAll (clearing key.use), and the same call then hits the
isUsingItem release. Add interaction_input::release_on_screen_open and call it
from the inventory/creative, chat and advancements key paths; pause, server
containers, mount inventories and dialogs keep vanilla's later release.

Fixes #4472

Co-Authored-By: DeepSeek 4.1 Flash (DeepSeek Harness) <[email protected]>
2026-10-31 23:42:46 +02:00
rdbtCVS 500c13a0bf Merge branch 'pr-3794' into pr-3801 2026-10-31 21:42:23 +00:00
sirrayiandDeepSeek Harness 09a67d8c48 Native mods: a mod block chooses the state it is placed in
`getStateForPlacement` had no way in for a mod: a block could only be placed
in its default state. `LevelAccess::before_state_for_placement` now asks the
level, once per block whose placement state is computed, with vanilla's
`BlockPlaceContext` (the clicked face, the placer's rotation, the clicked
position, whether the clicked block is being replaced, inside, the item in
hand) and the fluid of the position the block would go in, so a block can
face the player, sit on the clicked face or come waterlogged. The answer is
`Some(Some(state))` to place that state, `Some(None)` to refuse the placement
as vanilla's `null` does, and empty to let the block's own placement run.

`native_state_for_placement` is the hook a native bundle answers with, and
`native_state_with_property` builds the state from the block's default state
and one of its properties (a content block's declared ones included).

`Ctx`, the resolved context, is now `PlacementContext`, public and documented
as what a mod block is handed. Both placers that go through vanilla's path
reach it: the player's `BlockItem.place` and a dispenser's
`DirectionalPlaceContext` (both `Sim::state_for_placement`), and the answer
still takes its survival and entity obstruction checks.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 21:41:50 +00:00
rdbtCVSandClaude Opus 5.5 37328ff5c7 Give unpack_cart_loot its doc comment back
The new cart_slots was inserted between unpack_cart_loot and its doc
comment, so rustdoc merged both onto cart_slots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:41:07 +00:00
rdbtCVSandClaude Opus 5.5 6d1237c04a Merge main into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:39:49 +00:00
rdbtCVS 746bdf8a96 Merge remote-tracking branch 'origin/main' into pr-3794 2026-10-31 21:39:39 +00:00
rdbtCVS 288ba8ca10 Merge branch 'pr-3777' into pr-3794 2026-10-31 21:39:34 +00:00
arismoko 9727fc6c90 Correct component removal test fixture 2026-10-31 16:39:09 -05:00
Kacpherek 70e6033ae1 Merge remote-tracking branch 'origin/main' into fix/split-boats-riding 2026-10-31 22:36:01 +01:00
Ludovic Marie a4873dec44 Merge pull request #5321 from sirrayi/fix/jukebox-hopper-disc
🤖 Agent: new head (author merged main) keeps the hopper→jukebox fix and jukebox_taken (song stops when a hopper takes the disc); verify passes on aa68b6653, merges cleanly with current main (no overlap in jukebox/machines/spill/save)
2026-10-31 22:34:53 +01:00
rdbtCVSandClaude Opus 5.5 f7bdb7449e Merge main into #3777
Main's handlers take a loop handle (EventLoopCtl) and about_to_wait's body is Game::idle: exit_on_error takes the handle, the split guard stays in idle, and run_event_loop stops the narrator after the split's loop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:34:01 +00:00
levzzz eaf3bf704d Launcher: the AppImage path test runs on Unix only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 39696d7a74)
2026-11-01 02:33:31 +05:00
levzzzandClaude Opus 5.5 39696d7a74 Launcher: the AppImage path test runs on Unix only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:33:08 +05:00
Ludovic Marie 6335ad5847 Merge pull request #5495 from horizon-rust/feat/anticheat-ground-enforce
🤖 Agent: on #5365's head 2a25c6815; cargo check clean; same checks pass; ground + combat e2e passed (template-only conflicts since).
2026-10-31 22:32:21 +01:00
Ludovic Marie a04cfca2c1 Merge pull request #5365 from horizon-rust/feat/anticheat-bans
🤖 Agent: rdbtCVS's merge of main (same resolution as mine, arm order only). clippy -D warnings clean; anticheat 20, server 88, lan:: 116 tests pass on the equivalent tree. lan_bot fails 13/15 on main itself (#5505), not this PR.
2026-10-31 22:32:00 +01:00
Federico Tagliani 9e583e5644 Merge remote-tracking branch 'origin/main' into feat/superflat-structures 2026-10-31 22:31:52 +01:00
TeiwazikandClaude Opus 5.5 11843ac18b Mipmaps: put the cutout dither behind a graphics option, off by default
The dither changes vanilla's look for every cutout from 64 texels up, so it now runs only under
Distant Plant Dither (ditherCutoutMips in horizon-graphics.txt). Turning it on or off reloads the
textures, since mips are made when they load. Decoded textures don't hold mips, so the sprite cache
keeps its version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 22:30:41 +01:00
Ludovic MarieandClaude Opus 5.5 6ca8da1ace Anticheat: ground acts by default (kick at 30, ban at the second kick)
The official server's first two hours with the anticheat logging only
(67 players): every player who wasn't flying or walking on water peaked
at a ground level of 7, and that was 4 claims standing still in one
second (a block the server didn't have after a long teleport). The seven
who were (no-fall, fly, jesus: claims every tick while rising or falling
through the air, or walking on water; most also kicked by vanilla's
floating check) peaked at 113 to 3749.

ground now defaults to setback 10, alert 15, kick 30, ban_after 2, and a
claim standing still isn't judged (no fall to skip; hovering is
vanilla's floating kick). Replayed on that log, the others peak at 5
and the seven are kicked, then banned. timer and prediction still only
log: prediction flagged nearly everyone, and timer's slow decay lets lag
bursts add up over a session.

anticheat_e2e's ground check now runs at the defaults with cheat bots
that move, and wants the ban count in the kick line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:29:48 +01:00
Ludovic Marie c348d5da9c Merge pull request #5504 from horizon-rust/chore/stamp-native-hooks-59
🤖 Agent: merging: the native hooks stamp after #5466 (API 59-62), comments, docs and the constant only; stamp tests and fmt checked locally.
2026-10-31 22:29:31 +01:00
rdbtCVSandClaude Opus 5.5 98f26eae9b online_mode harness: compare a service call's last field, so a lookup is a mismatch, not a crash
Fixes #5506.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:29:05 +00:00
arismoko 6d50c26459 Make component acceptance and tests deterministic 2026-10-31 16:28:32 -05:00
Ludovic MarieandClaude Opus 5.5 1a36d8c39e Native hooks: stamp API 59-62 (entities, item bar, mod sounds, nicknames)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:28:23 +01:00
arismoko 708cee4054 Isolate invalid content component patches 2026-10-31 16:27:25 -05:00
Ludovic Marie 0720d6de7e Merge pull request #5466 from horizon-rust/feat/native-nicknames
🤖 Agent: merging: two native helpers for server mods (nicknames in chat and the Tab list, the chat filter's verdict); the SMP mod's proof passed on this head with the fragment stamped. No change without a mod calling them.
2026-10-31 22:26:47 +01:00
Aaron Wang e103983b5d Native mods fire their own advancement criteria triggers
An advancement of a content mod or a data pack can name a trigger of
the mod's (mymod:pipe_connected); the tree keeps it, but only the game's
own events called Game::trigger, so nothing could grant it.

native_trigger_criterion(uuid, trigger, test) is vanilla's
SimpleCriterionTrigger.trigger for one player: each open criterion of
that trigger goes to the mod's test, then to its player predicate, and
those that pass are granted and awarded as the game's own are. The test
reads the criterion's conditions and can check a vanilla item predicate
in them. It reaches the acting player inside their hook, and any player,
in their own level (for_player), where no player is swapped in;
minecraft: triggers are refused.

Fixes #5481.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 17:25:54 -04:00
arismoko d72a6615e9 Validate content component identifiers consistently 2026-10-31 16:24:43 -05:00
rdbtCVSandClaude Opus 5.5 2d5ca3a1a6 Native components: install nothing, and skip registries.json, when no mod registers a type
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:20:47 +00:00
levzzzandClaude Opus 5.5 6f0839a15e Launcher: the macOS and Windows tests build on gpui-fast
Keystroke::parse takes a 'static str; the app comes from gpui_platform::application(), and
AsyncApp::update returns nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:16:39 +05:00
rdbtCVSandClaude Opus 5.5 2a25c68159 Merge main into #5365
Conflicts: game/lan.rs (the anticheat's HostEvent::Ban next to main's ChatFiltered) and horizon-server config.rs (main's [discord] and [chat_filter] blocks around the anticheat's ban_after wording).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:15:16 +00:00
PzmuV1517 b1f3afcd9a Native mods: a mod block decides if it can stay where it is
native_can_survive answers BlockState.canSurvive before the block does: as it is placed (BlockItem.canPlace)
and from the shape update of a block whose template checks it, which breaks it with its drops.
native_may_place_on answers VegetationBlock.mayPlaceOn, the blocks a mod plant can stand on, which its
template's canSurvive asks. Both through LevelAccess, on authoritative servers, as native_update_shape is.
2026-10-31 23:14:07 +02:00
rdbtCVS fff998e85f Merge main into #5426 2026-10-31 21:13:12 +00:00
rdbtCVSandClaude Opus 5.5 3332137ad2 Merge main into #5412
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:12:15 +00:00
rdbtCVSandClaude Opus 5.5 7d8f2f1dcd horizon-prepare: rustfmt's edition-2021 order for check.rs's use line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:11:57 +00:00
rdbtCVSandClaude Opus 5.5 85c781e56e Far terrain client: a reconfiguration frees the old server's slots and rate
Inbox::reset, called on Event::Reconfiguring after clear_lod: requests a proxy's previous backend held are forgotten instead of holding in_flight slots for the 30 s timeout, and the next offer's per_second makes a new bucket.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:11:15 +00:00
rdbtCVSandClaude Opus 5.5 5a2f191b22 fix(skins): a head's texture patch only names a file inside the packs
The profile's texture id comes from a server's NBT; Identifier's characters and
no '.', '..' or empty segment keep it from reading a PNG outside the resource root.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:10:46 +00:00
rdbtCVSandClaude Opus 5.5 29f5ee49ee Merge main into #5411
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:10:22 +00:00
rdbtCVS 6df483cd9c Merge main into #4319 2026-10-31 21:10:02 +00:00
rdbtCVSandClaude Opus 5.5 b42b510cfb Merge main into #4250
The ten lang tables conflicted where both sides added strings: main's table as the
base, plus this branch's account strings, without the "Microsoft account · signed in"
this branch removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:08:52 +00:00
rdbtCVSandClaude Opus 5.5 cf7b5ce636 Merge #4946 (main merged) into #4947
Conflicts: lod.rs (main's wanted-tile heap from #4747; a failed tile stays out of the heap until its RETRY passes, then the heap is rebuilt so it is asked again; main's MAX_LEVEL 22) and remote.rs (send_far_terrain and main's tick_chat_session both run before ClientTickEnd).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:08:25 +00:00
SolenopsisbotandClaude Opus 5.5 0294c117fe --player-dir: one player's own files when several games share a game folder
Two games playing in one game folder at the same time (#4952, the launcher's
`play --account`) fought over its per-player files: one `options.txt`, one
`logs/latest.log` (the second game logged to the terminal only), one generated name in
`horizon-player.txt` (two offline players got one UUID) and one `servers.dat`.

`horizon-client --player-dir DIR` puts this player's own files in DIR: `options.txt` and the
settings files beside it, `logs/`, `crash-reports/`, `horizon-player.txt`, `servers.dat`,
`hotbar.nbt` and `screenshots/`. Its players still share what the game folder holds: saves,
resource and shader packs, mods (the enabled list included, `logging::shared_dir`) and native
mods' `config/`, which no longer follow `options.txt` but the game folder (`Game::game_dir`).
Without the option nothing moves.

Launch API 0.1.1 promises the option (docs/api/launch.md).

Tested: two games started at once in one folder with --player-dir players/Alice and
players/Bob each logged to its own players/<name>/logs/latest.log and saved their worlds in
the shared saves/; a third, without --name, kept options.txt, horizon-graphics.txt and its
generated horizon-player.txt in players/Carol/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:08:11 +11:00
Ludovic Marie 21bddafb20 Merge pull request #5358 from MSedgeMC/fix/fishing-hook-rebound
🤖 Agent: author rebased on main again; same patch (patch-id equal), proven earlier with pr_ready --quick
2026-10-31 22:07:14 +01:00
Ludovic Marie 9114f52751 Merge pull request #5357 from MSedgeMC/fix/nine-slice-validate
🤖 Agent: author rebased on main again; same patch (patch-id equal), proven earlier with pr_ready --quick
2026-10-31 22:07:10 +01:00
Ludovic Marie 0d9f5b929a Merge pull request #5356 from MSedgeMC/fix/flowerbed-display-shift
🤖 Agent: author rebased on main again; same patch (patch-id equal), proven earlier with pr_ready --quick
2026-10-31 22:07:06 +01:00
Ludovic Marie 38d1664ee6 Merge pull request #5354 from MSedgeMC/fix/pose-first-name-wins
🤖 Agent: author rebased on main again; same patch (patch-id equal), proven earlier with pr_ready --quick
2026-10-31 22:07:01 +01:00
rdbtCVSandClaude Opus 5.5 28c4a086be Launcher: the Turkish table has the strings this PR routes through tr()
Main added tr.json after this branch began, so it lacked the 12 keys the i18n
completeness test asks for. They hold the English source until translated, as the
other untranslated entries do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:06:19 +00:00
rdbtCVSandClaude Opus 5.5 c33d9a3c35 Merge main into #4178
The nine lang tables conflicted where both sides added keys: main's table kept as
the base (its order and its translations), plus the keys only this branch has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:06:18 +00:00
rdbtCVSandClaude Opus 5.5 68b5f9e525 native_play_local_sound: check the event with sound_id, as native_play_sound does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:03:25 +00:00
rdbtCVS aa68b6653f Merge remote-tracking branch 'origin/main' into pr-5321 2026-10-31 21:00:30 +00:00
Ludovic Marie ba2106689e Merge pull request #5487 from horizon-rust/ci/playhorizon-checks-opt-in
CI: checks back on GitHub's runners by default, playhorizon's with SELF_HOSTED_CI=all
2026-10-31 22:00:28 +01:00
rdbtCVSandClaude Opus 5.5 53cfb35095 Merge main into #5441
Both sides added an Extra field and its NBT round trip (rabbit MoreCarrotTicks here,
ocelot Trusting on main); kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:00:23 +00:00
rdbtCVSandClaude Opus 5.5 5c3017a630 fix(mobs): a fox egg's trust goes to the baby made on a fox, not to an egg used on a block
Fox.onOffspringSpawnedFromEgg's only caller is Mob.checkAndHandleImportantInteractions
(a fox egg used on an adult fox), so the trust moves from use_spawn_egg to
spawn_egg_offspring. The port map's Fox.clearTrusted and readAdditionalSaveData rows
now say what is ported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:59:05 +00:00
rdbtCVSandClaude Opus 5.5 27fb1b40c6 Merge main into #5410
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:57:32 +00:00
levzzz 8811364a94 Merge pull request #5490 from levzzz5154/docs/launcher-completions
Docs: the launcher reference lists completions
2026-11-01 01:56:26 +05:00
rdbtCVSandClaude Opus 5.5 82bd9f6f37 Merge main into #5403
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:56:22 +00:00
levzzz 2034a32866 Merge remote-tracking branch 'origin/launcher/gpui-fast' into launcher/gpui-fast 2026-11-01 01:55:57 +05:00
levzzzandClaude Opus 5.5 8ef7b133f2 Docs: the launcher reference lists completions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:55:07 +05:00
levzzzandClaude Opus 5.5 124904afa4 Docs: the launcher reference lists completions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:54:47 +05:00
Claude f2d940e4c8 Biome modifiers: test one biome id and a list of biomes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NLEqWLKW2LHgtLer35pT8m
2026-10-31 20:54:36 +00:00
levzzz 26a4cdc5c5 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast 2026-11-01 01:53:36 +05:00
masa-masa-0314 0090d9ba2e Merge remote-tracking branch 'upstream/main' into tmp-5169
# Conflicts:
#	crates/horizon-client/src/net.rs
2026-11-01 04:52:11 +08:00
rdbtCVSandClaude Opus 5.5 a844d55815 Merge main into #5409
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:52:06 +00:00
levzzz ef0dd81a17 Merge pull request #5479 from levzzz5154/fix/pad-creative-pages
Controller: creative tabs on other pages of mods' tabs
2026-11-01 01:51:27 +05:00
rdbtCVSandClaude Opus 5.5 8e6281bb91 Merge #4945 (main merged) into #4946
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:51:26 +00:00
rdbtCVSandClaude Opus 5.5 185c95220a Shadow cache: redraw for packs with custom images
Main now clears a pack's custom images each frame before the shadow pass, which may write them; skipping that pass on a hit would leave them empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:51:14 +00:00
rdbtCVS 4d2331be1f Merge main into #3702
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/gfx/pack_post.rs
2026-10-31 20:51:14 +00:00
Ludovic MarieandClaude Opus 5.5 20480b868a CI: checks back on GitHub's runners by default, playhorizon's with SELF_HOSTED_CI=all
Measured after #5415: with the bots off GitHub's pool it ran 9-12 of its 20 jobs and nothing waited, while 19 jobs
waited for playhorizon's two checks runners (4 CPUs shared with parity), the oldest 18 minutes. The bots stay on
playhorizon-light; the checks runners take main's and same-repository pull requests' checks only when the
repository variable SELF_HOSTED_CI is `all`, for when GitHub's pool is full again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:51:09 +01:00
Guilherme Jansen 4f1ffce47d fix(launcher): preserve running macOS app bundles during updates 2026-10-31 17:49:40 -03:00
rdbtCVSandClaude Opus 5.5 e05059a562 Merge main into #5418
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:49:11 +00:00
levzzz fe2f6b5734 Merge pull request #5478 from levzzz5154/fix/pack-crate-data
Launcher: a mod installed from GitHub keeps its crates' data and README files
2026-11-01 01:48:32 +05:00
rdbtCVSandClaude Opus 5.5 eadf81b7ae Merge main into #5358
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:48:24 +00:00
rdbtCVSandClaude Opus 5.5 7f4784397a Far terrain wire: columns hold main's Spans
#4747 made lod::Column::spans a SmallVec-backed Spans; the decoder builds one directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:48:13 +00:00
rdbtCVSandClaude Opus 5.5 6c1b6d0823 Merge main into #5357
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:47:28 +00:00
rdbtCVSandClaude Opus 5.5 27f3b8aac6 Merge main into #5356
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:46:53 +00:00
rdbtCVSandClaude Opus 5.5 88590156f0 Merge main into #5461
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:46:21 +00:00
AkirabaneandClaude Sonnet 5.5 8424089943 Merge main into feat/rcon
Two documentation conflicts again, where main's management protocol lines grow at the same place as the RCON ones. Main's text is kept, and the RCON lines now sit away from them (before the management protocol's paragraph and its changelog line), so the next merge doesn't meet them.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 21:46:12 +01:00
rdbtCVS 75620fbbc6 Merge main into #3947 2026-10-31 20:45:52 +00:00
rdbtCVSandClaude Opus 5.5 266fcf08fe Boats: test the server's bubble-column countdown (#3947 review)
The countdown of AbstractBoat.tickBubbleColumn moves to Boat::tick_bubble_countdown,
so a unit test checks it fires on the 60th tick over a column, with the column's
direction, and resets when the boat leaves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:45:52 +00:00
rdbtCVS f97c2cd499 Merge main into #5413 2026-10-31 20:45:41 +00:00
rdbtCVSandClaude Opus 5.5 84d1b7ac44 Merge main into #5354
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:45:40 +00:00
Claude 1fedaa1c5a Content mods: place mod features in biomes (biome modifiers)
A content mod's placed features now generate in vanilla's biomes through
NeoForge's biome modifiers (data/<ns>/neoforge/biome_modifier/, and
forge/biome_modifier/ for older mods): add_features, remove_features and
none. When the content root builds they rewrite the biomes' feature
lists, an added feature at the end of its step as NeoForge adds it; the
generator's FeatureSorter then orders them as vanilla does. Biomes no
modifier names keep their text, and a root with no modifiers keeps
vanilla's bundle. An unknown biome, tag, feature, step or type fails the
build with the mod and file named.

Part of #4953, closes #5431.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NLEqWLKW2LHgtLer35pT8m
2026-10-31 20:45:38 +00:00
rdbtCVSandClaude Opus 5.5 9c7dc13f69 Merge main into #5406
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:43:56 +00:00
VaspyyyandClaude Opus 5.5 3541a9b97e Read a stack's own blocks_attacks component
Vanilla's Item.use, getItemBlockingWith and applyItemBlocking read
DataComponents.BLOCKS_ATTACKS from the stack, so a sword given
`[blocks_attacks={}]` blocks like a shield. Horizon only read the item's
default component. BlocksAttacks::of_stack now takes the stack's own
component first (kept in StackData::other), then the item's, and the
local player's raising, use tick, blocking, off-hand intent check and the
remote client's use prediction go through it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSY4AbxZbBtWqJLhFg
2026-10-31 20:43:55 +00:00
Federico TaglianiandClaude Sonnet 5.5 b903c95142 A superflat world runs its structure sets on its flat ground
FlatLevelSource.createState: villages and strongholds (the classic flat
world's structure_overrides) and any set a world's settings allow generate on
the flat layers, with the fixed biome, and are found by /locate. The pipeline's
noise column is the layers (getBaseHeight, getBaseColumn), the terrain stage
fills them, and the structure steps run with no placed features.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 21:43:53 +01:00
levzzz c05fba8302 Merge pull request #5473 from levzzz5154/fix/native-data-rebuild
Launcher: a native rebuild starts without the old build's mod data
2026-11-01 01:43:52 +05:00
rdbtCVSandClaude Opus 5.5 b6044ea5f2 Arena compaction: move LOD stand-in meshes off the closing page too
A tile's previous mesh (kept while near chunks fade in) lives in the arena as well; without moving it, a closing page holding one stayed closed until the fade ended and compaction stalled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:43:03 +00:00
arismoko 3740c979e4 Allow server hooks to query tags 2026-10-31 15:42:53 -05:00
levzzzandClaude Opus 5.5 9d7eb28cd3 Pad: creative tabs of other pages are neither targets nor clicked through
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:42:26 +05:00
levzzz 24e41db95e Merge pull request #5471 from levzzz5154/fix/native-status-digest
Launcher: a native build of the mods turned on reads as up to date
2026-11-01 01:41:20 +05:00
rdbtCVSandClaude Opus 5.5 db22303b9e Merge main into #3466
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:41:20 +00:00
rdbtCVSandClaude Opus 5.5 dc1fc94a4c Merge main into #4945
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:40:44 +00:00
Ludovic Marie d32d582d01 Merge pull request #5380 from sirrayi/audit/ticked-rows
🤖 Agent: merged main again after #5259 and #5275 landed (port-map rows only); port tests pass
2026-10-31 21:40:13 +01:00
Claude 8a6eaac999 native_summon: return experience orbs' ids, and place passengers without a Pos
Follow-up to #5408. A summoned orb spawned but native_summon returned None,
since no command Target lookup covers orbs: return the stack's first
spawned id. Passengers without Pos were not placed and got lost: share
/summon's recursive place helper (place_summoned), which also supplies
the Fire and Rotation defaults.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QT61YPiPzVNysmVHsH
2026-10-31 20:39:58 +00:00
huiandZCode AI agent (GLM-5.3) 42e5a2fb8e Load conditions: an unknown kind nested in the tree leaves the file out
known() now checks the whole subtree, not only the immediate type:
not(not(unknown)) and or(true, not(unknown)) both failed to exclude
the file, because the outer combinator was a known kind and only the
direct child was checked. Both now fail closed, with tests.

Review: Aria's agent swarm (Dyna) in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:39:32 +01:00
huiandZCode AI agent (GLM-5.3) bb0e421674 Recipes parse NeoForge's standard ingredient shapes
An ingredient that is one of NeoForge's standard object shapes now
resolves to the flat item list it matches, where the string parser
dropped the recipe file whole: neoforge:compound and
neoforge:intersection keep the items every sub-ingredient names,
neoforge:difference the base's without the subtracted's, and
neoforge:fluid a fluid's item form, its full bucket (a water or lava
tag names the same still fluid). A kind outside these still fails the
file - a mod's own ingredient types come with their own hook, not the
loader.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:39:32 +01:00
levzzzandClaude Opus 5.5 2da7f0f751 Launcher: packing a GitHub mod keeps its crates' data and text files
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:39:25 +05:00
Ludovic Marie 97ded17573 Merge pull request #5474 from horizon-rust/server-admin/management-ops-ipbans
🤖 Agent: management protocol 6e, ip_bans/* and operators/* applied by the game as vanilla's services: parity harness vs vanilla 26.3 passed (50 exchanges over TLS); clippy, fmt, horizon-server (88) and horizon-server-files (13) tests pass.
2026-10-31 21:39:05 +01:00
levzzz d6eabd8d6a Merge pull request #5469 from levzzz5154/mods/mod-sound-events
Native mods: mods' own sound events in native_play_sound
2026-11-01 01:38:29 +05:00
Ludovic MarieandClaude Opus 5.5 86b5927eaa Merge main into #5380
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:38:14 +01:00
levzzzandClaude Opus 5.5 6b268f8469 Launcher: a native rebuild starts without the old build's native-data
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:37:33 +05:00
AkirabaneandClaude Sonnet 5.5 b49e817821 Merge main into feat/rcon
Two conflicts, both documentation: docs/api/server.md's changelog and docs/server.md section 8, where main added the management protocol's allowlist and bans. Main's text is kept and the RCON lines follow it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 21:36:54 +01:00
[email protected]andClaude Opus 5.5 1e0fe9bb79 Merge upstream/main; the game's unit tests keep their own process
docs/native-mods.md keeps main's API 58 notes, then the API mods section.

With the game module in the horizon-client library, its unit tests ran in the
library's test process and shared process-wide state with the library's tests
(health::HURT_SERVER, the sulfur cube archetypes): six tests failed. The game
module now builds into the library only outside tests; its unit tests stay in the
executable's test build, as before the move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:36:47 +01:00
Ludovic MarieandClaude Opus 5.5 5cc967780f horizon-server: the management protocol changes the IP bans and the operators
ip_bans/{add,remove,set,clear} and operators/{add,remove,set,clear}, as
vanilla 26.3's IpBanlistService and OperatorService make them, through the
game's management request like the allowlist and the bans: an address or an
online player's address, those online from it sent away; an operator's level
and bypass as asked, else op-permission-level and the bypass they had, the
commands online players may run updated. The parity harness changes all four
lists on both servers (50 exchanges).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:36:38 +01:00
Ludovic Marie 46e6854e4f Merge pull request #5275 from sirrayi/fix/frog-amphibious-nav
🤖 Agent: author's rebase on main, same content as reviewed; pr_ready --quick on main + #5242 #5259 #5275 #5321 #5354 #5356 #5357 #5358 passes (clippy, 2389 client/render tests, Python 508)
2026-10-31 21:35:22 +01:00
Ludovic Marie 491bdb78de Merge pull request #5259 from sirrayi/audit/port-map-blocks
🤖 Agent: author's rebase on main, same content as reviewed; pr_ready --quick on main + #5242 #5259 #5275 #5321 #5354 #5356 #5357 #5358 passes (clippy, 2389 client/render tests, Python 508)
2026-10-31 21:35:18 +01:00
levzzz 274cdafa5b Merge remote-tracking branch 'hr/main' into fix/native-status-digest 2026-11-01 01:33:13 +05:00
TeiwazikandClaude Opus 5.5 d36550e82c Shader API: translucent terrain in the shadow pass, and its colour
Translucent terrain is drawn over a copy of the solid shadow map, so a pack can tell a point under
water or stained glass from one in shadow: shadow_map_translucent holds that depth, and shadow_color
the texture colour times the tint of the nearest such surface, with alpha 1 on a fluid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 21:32:52 +01:00
Claude 436d039047 native_explode: a nested blast starts sourceless and gives the outer blast its state back
Called from native_explosion_detonate, native_explode took the outer
blast's source, cause, owner and hive/rail flags. Save and clear them
around server_explode, then restore them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QT61YPiPzVNysmVHsH
2026-10-31 20:32:34 +00:00
levzzz caaab6c1a7 Merge pull request #5454 from Sylviromi/item-bar
Native mods: item_bar hook, the bar drawn under an item (API next)
2026-11-01 01:32:28 +05:00
levzzzandClaude Opus 5.5 d471fdcf34 Native mods: mods' own sound events in native_play_sound
native_play_sound took minecraft: events only. Now an event of another
namespace (mymod:machine_hum) is sent to players by name, as vanilla sends a
sound that isn't in its registry (Holder.direct); they hear it when their
resources define it, as a content mod's sounds.json and .ogg files do.
minecraft: events must still be registered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:29:59 +05:00
arismoko 1761d307fb Reuse identifier validation for native components 2026-10-31 15:28:35 -05:00
arismoko 3ad5a237cd Retain cached wire patches like existing item edits 2026-10-31 15:28:35 -05:00
arismoko 0ff27ffb83 Keep component helpers outside mixin hook source 2026-10-31 15:28:35 -05:00
k3yhandClaude Opus 5.5 25f3de6290 Commands reach a chest or hopper minecart's slots
`/item replace`, `/item modify` and `execute if items` answered "The
target does not have slot container.0" for a chest or hopper minecart.
Vanilla's AbstractMinecartContainer.getSlot is ContainerEntity's
getChestVehicleSlot, which takes any index below the container's size
(27 for a chest minecart, 5 for a hopper minecart), and reading or
writing it unpacks the cart's loot table with no player first.

Slot::Cart gives commands those slots; cart_slots drops the loot table
the same way, so no player_generates_container_loot fires later.

The command execution corpus checks both carts against vanilla: 66/74 of
the items and loot group matched before, 74/74 now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:28:32 -05:00
arismoko 1ca6da70ab Update component validation tests 2026-10-31 15:28:30 -05:00
arismoko daaeda8c8f Expose native item component types 2026-10-31 15:28:30 -05:00
levzzz e31e3bd996 Merge pull request #5408 from Vaspyyy/feat/native-entities
Native mods: entities for server mods (summon, move, display transforms)
2026-11-01 01:28:10 +05:00
Ludovic Marie a52d11c38a Merge pull request #5242 from sirrayi/fix/mobs-bounce-off-bouncy-blocks
🤖 Agent: merged main (mod_effects in the bounce test); pr_ready --quick on main + #5242 #5259 #5275 #5321 #5354 #5356 #5357 #5358 passes (clippy, 2389 client/render tests, Python 508)
2026-10-31 21:27:03 +01:00
Ludovic MarieandClaude Opus 5.5 cfad40a955 Merge #5259 into #5380 (port-map rows: each side's changed rows kept)
Both PRs correct rows of the same port-map tables; the conflicts were
neighbouring rows, each changed on one side only, so every row keeps the
side that changed it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:26:29 +01:00
Ludovic Marie 32567dfc64 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-31 21:26:07 +01:00
Aaron Wang 7bb5aec013 Measure a far teleport from where the entity is headed
setValuesFromPositionPacket measures tooBigToInterpolate from
PositionMoveRotation.of(entity), which is getClientPositionAndRotation:
the interpolation target while one runs. handleEntityPositionSync
measures from the entity's position. MovePacket keeps the two apart.

The mob and armor stand branches of move_entity now share stepped_move.
A mob put somewhere at once also resets its previous yaw and pitch
(snapTo's setOldPosAndRot), so it doesn't spin for a frame, and every
spawn starts with a fresh interpolation handler, a stand's included.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 16:25:51 -04:00
Ludovic Marie 534ddd6add Merge pull request #5463 from horizon-rust/server-admin/management-lists
🤖 Agent: management protocol 6d, allowlist/* and bans/* applied by the game as vanilla's services: parity harness vs vanilla 26.3 passed (42 exchanges over TLS); pr_ready clippy/fmt/python pass; lan_bot's 13 minecraft:dialog failures come from main (#4698), not this PR.
2026-10-31 21:25:31 +01:00
Ludovic Marie bdea7dc089 Merge pull request #5415 from horizon-rust/ci/playhorizon-runners
CI: same-repo checks and the bots on playhorizon's own runners
2026-10-31 21:24:14 +01:00
Ludovic Marie 7650ed1cfd Merge pull request #5462 from horizon-rust/fix/discord-bridge-one-identity
🤖 Agent: horizon-server only; fmt, clippy -D warnings, discord tests 5/5 locally.
2026-10-31 21:23:46 +01:00
huiandZCode AI agent (GLM-5.3) 3379d00ae0 Load conditions: mod IDs, not namespaces; unknown fails closed; the overlay replaces before the gate
mod_loaded now asks which mods are loaded (the mod-content root's new
horizon-mod-ids.json marker), not which namespaces they own — a mod that
also owns a namespace doesn't make that namespace another mod. An
unknown condition kind fails the file through not and or as well, not
just on its own: `not` of an unknown kind is not a pass, and an `or`
over one doesn't short-circuit past it. And the stations, the recipe
book and brewing apply the world's packs first and gate the merged
file, as the crafting loader already did: a false-conditioned pack file
leaves the lower recipe gone too, not still active underneath.

Review: Aria's agent swarm (Dyna) in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:22:30 +01:00
Ludovic MarieandClaude Opus 5.5 c07986d5d6 Discord bridge: post every line under the webhook's own identity
Server owners asked for one identity in the channel (the webhook's name
and avatar, e.g. "HorizonSMP") instead of a per-player name and head on
each post. Drop the per-player username/avatar_url and the Line field
that only fed it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:22:24 +01:00
levzzz cecaedc5ed Merge pull request #5452 from levzzz5154/hooks/stamp-render
Native hooks: stamp API 58 (render hook points)
2026-11-01 01:19:26 +05:00
SylviromiandClaude Opus 5.5 cac5c33c75 item_sync: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:19:05 +03:00
Ludovic Marie 13e0250562 Merge pull request #5457 from horizon-rust/fix/admin-feedback-issuer-only
🤖 Agent: queued for merge once verify passes (Ludovic's ask: keep operators' commands out of other players' chat on the official server; default stays vanilla).
2026-10-31 21:15:33 +01:00
Ludovic MarieandClaude Opus 5.5 dc80c54eb5 Merge main into #5242 (MobWorld gained mod_effects)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:10:36 +01:00
SylviromiandClaude Opus 5.5 079ee9de36 item_sync: a neutral fixture in the custom_data sync test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:10:03 +03:00
[email protected]andClaude Opus 5.5 af806fd1c3 Merge upstream/main into native/api-mods
Conflicts: game.rs keeps the api_mods export beside main's narrator module;
horizon-threads takes main's windows-sys features (the same Windows fix);
native-mods.md keeps main's API 47-57 notes, then the API mods section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:09:44 +01:00
Ludovic Marie 9b74118f85 Merge pull request #5405 from MSedgeMC/fix/tooltip-top-clamp
🤖 Agent: merged main (the verify failure was main's title keyboard test, fixed in #5425). DefaultTooltipPositioner has no top clamp; one line.
2026-10-31 21:09:43 +01:00
Ludovic Marie f6e087405f Merge pull request #5404 from MSedgeMC/fix/wolf-ambient-angry
🤖 Agent: merged main (the verify failure was main's title keyboard test, fixed in #5425). Wolf.getAmbientSound growls on isAngry (anger timer), checked in bytecode.
2026-10-31 21:09:40 +01:00
Ludovic MarieandClaude Opus 5.5 7fbfa2f23d net test: LAN login fixture waits 60 s, not 10
The fixture's server generates a 1024-bit RSA key in a debug build before it answers; on playhorizon's checks
runners (4 CPUs shared with parity, niced) that took longer than the 10 s the test waited for the session's event.
A passing run never waits for the timeouts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:07:25 +01:00
Ludovic MarieandClaude Opus 5.5 96bcfc4ffe docs/ci-runners.md: measured times
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:07:15 +01:00
Ludovic MarieandClaude Opus 5.5 92523df6c2 Telemetry: threads are the machine's, as cores are
available_parallelism is the process's share (its CPU affinity, a cgroup quota): pinned to 4 of 8 CPUs, the game
reported 4 threads and 8 cores, and this_machine_is_described failed on playhorizon's pinned CI runners. On Linux
the threads are the processors /proc/cpuinfo lists, where cores come from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:07:15 +01:00
Ludovic MarieandClaude Opus 5.5 c8a84885b3 CI runners: GitHub's umask (0022) for the checks runners' jobs
A systemd user service's umask is 0002 on Ubuntu, and the launcher's tests check the mode of the files they write.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:07:15 +01:00
Ludovic MarieandClaude Opus 5.5 b7222a864a CI: same-repo checks and the bots on playhorizon's own runners
GitHub's 20 hosted jobs at a time are shared by every pull request: on 2026-10-31 400+ runs queued and every job
waited 20-25 minutes for work of 0.3-4 minutes. Main's pushes and this repository's own pull requests now run
checks' verify and launcher on playhorizon (label playhorizon-ci, two runners); the bots that run main's own scripts
(pr labels, overlap note, cancel-on-close, claim command, parity-scope) on two small runners there
(playhorizon-light). A fork's pull request stays on ubuntu-latest, and the runners' job-started hook refuses one
whatever its workflow says. SELF_HOSTED_CI=off sends everything back to GitHub's runners.

tools/ci/checks-runners.sh installs the runners (systemd user services pinned to CPUs 4-7 in horizon-ci.slice,
shared cargo home, a build folder per runner outside the checkout); docs/ci-runners.md; tests/test_ci_runners.py pins
the fork rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:07:15 +01:00
Ludovic Marie 80e6672f2f Merge pull request #5401 from Teiwazik/fix/pack-meta-trailing-text
🤖 Agent: merged main (the verify failure was main's title keyboard test, fixed in #5425). pack.mcmeta: first JSON value only, as Gson's JsonParser; BOM kept; test.
2026-10-31 21:07:02 +01:00
Aaron Wang 34ff18cdad Armor stands turn between ticks and snap on far moves, as living entities
An armor stand is a LivingEntity, so a vanilla client moves it with the
SteppedInterpolationHandler over its update interval (3 ticks), position
and yaw together, and ArmorStand.tickHeadTurn has the body follow yRot.
Horizon eased stands with the generic three-step lerp and set the yaw at
once from each packet. Stands now share the mobs' stepped handler:
ArmorStand::interpolate takes its step, keeps the yaw continuous and
saves yBodyRotO, and the renderer and the F3+B hitbox turn the body by
Mth.rotLerp(partialTick, yBodyRotO, yBodyRot).

A move of more than 64 blocks now snaps (tooBigToInterpolate), with
nothing left to blend from (Entity.snapTo). TeleportEntity snaps too, as
setValuesFromPositionPacket does; before, only EntityPositionSync did,
so a far teleported mob also slid there over three ticks.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 16:06:36 -04:00
Ludovic Marie 185441dd01 Merge main into #5405 (main's title keyboard test was fixed in #5425) 2026-10-31 21:06:03 +01:00
Ludovic Marie 1f0bfe338c Merge pull request #5417 from reservedbytes/feat/native-hurt-server-dragon
🤖 Agent: merged main (fragment stamped by #5390 → doc edits moved to API 50 section; main's dragon test updated to HurtSource); cargo test dragon + native pass
2026-10-31 21:05:53 +01:00
Ludovic MarieandClaude Opus 5.5 644657ec4d horizon-server: the management protocol changes the allowlist and the bans
allowlist/{add,remove,set,clear} and bans/{add,remove,set,clear}, as vanilla
26.3's AllowlistService and BanlistService make them. The supervisor checks the
parameters and forwards each edit to the running game as a control request
(management <connection> <json>); the game finds the players as
MinecraftPlayerListService.getUser does (by id: the user cache, else the
session service; by name: the user cache, else the profile service, else an
offline player), changes its lists, logs RPC Connection #N lines and sends the
banned or unlisted players away. The reply is the list from its file.

horizon-server-files gains UserList::clear, UserCache::get_by_id and
profiles::find_by_id. The parity harness changes both servers' lists and
compares every answer (42 exchanges); neither server reaches a profile service
there, so names resolve to the same offline players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:05:46 +01:00
Ludovic Marie 85c735f53b Merge main into #5404 (main's title keyboard test was fixed in #5425) 2026-10-31 21:05:32 +01:00
Ludovic Marie fefde53059 Merge main into #5401 (main's title keyboard test was fixed in #5425) 2026-10-31 21:04:18 +01:00
TeiwazikandClaude Opus 5.5 acc8c21f4c Shader API: [shadow], a shadow map of the solid terrain
A pack asks for a shadow map in its manifest and the renderer draws the solid terrain the light
sees into it each frame, from the shadow camera's lists shaders/ packs already use. The material
and post stages read it as shadow_map with shadow_sampler and the prelude's shadow_position, and
the frame gains light_direction, has_shadow, shadow_view_proj, shadow_distance, shadow_distortion,
shadow_resolution and shadow_center_texel. A pack without [shadow] gets a 1x1 map that lights
everything. Translucent terrain and entities don't cast yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 21:03:05 +01:00
levzzzandClaude Opus 5.5 4109e8bec1 Native hooks: stamp API 58 (render hook points)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:02:57 +05:00
Federico TaglianiandClaude Sonnet 5.5 e2b71fa692 The Debug world preset: a grid of every block state
DebugLevelSource: barriers at y 60 and the block states in registry order on
a grid at y 70, one every other block. A Debug world's blocks never change
(Level.setBlock fails; setblock, fill and clone report it), nothing ticks
(scheduled, random, natural spawning), the login packet says isDebug, and
level-type minecraft:debug_all_block_states creates it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 21:02:03 +01:00
levzzz 525dcdccd1 Merge pull request #5434 from levzzz5154/mods/render-hooks
Native mods: render hook points (world drawn, render needs)
2026-11-01 01:01:06 +05:00
Ludovic Marie e5458b8c62 Merge main into #5417 (living_incoming_damage was stamped API 49 by #5390)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/native-hook-fragments/living_incoming_damage.json
2026-10-31 21:00:47 +01:00
Ludovic Marie 26110493d5 Merge pull request #5432 from CocofireHD/fix/sheared-wool-pop
🤖 Agent: Sheep.shear spawnAtLocation(stack, 1): ItemEntity motion + the shear spread, pickup delay; mushrooms keep the constructor motion
2026-10-31 21:00:35 +01:00
Ludovic Marie 83942562f3 Merge pull request #5429 from arismoko/ci/testing-guidance
🤖 Agent: docs only (AGENTS.md test guidance; keeps harnesses as the spec)
2026-10-31 21:00:31 +01:00
Ludovic Marie 726661618f Merge pull request #5424 from Blzs2005/fix/pick-key-in-inventory
🤖 Agent: AbstractContainerScreen.keyPressed: pick-block key clones the hovered slot; test (#5378)
2026-10-31 21:00:27 +01:00
Ludovic Marie 0089ecf6be Merge pull request #5430 from xFedeT/fix/ocelot-trust
🤖 Agent: Ocelot.mobInteract trust (1 in 3, events 40/41), DATA_TRUSTING synced and saved, removeWhenFarAway and the avoid goal; superset of #5423 (closed)
2026-10-31 21:00:22 +01:00
Ludovic Marie 35513ba344 Merge pull request #5305 from TobiasLogic/shaderpacks/scalar-swizzles-unsigned
🤖 Agent: merged main (test union in translate/mod.rs); translate tests pass
2026-10-31 21:00:18 +01:00
Ludovic Marie 296328ac74 Merge pull request #4922 from senjinthedragon/hdr-masks
🤖 Agent: merged main: terrain.wgsl computes this PR's thresholded emission first, then main's native emissive layer mixes over it (mix(emissive, 0.1, mask.a)); gfx shader validation tests pass, clippy clean.
2026-10-31 21:00:13 +01:00
Ludovic Marie 7f96d941a3 Merge pull request #5349 from leifiyoo/narrator/16-keyboard-loading
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client lib/bins/tests clean at #5335, #5343 (options_ui), #5346 (lan_screen) and #5348 (pad_menu on main's EventLoopCtl), then main's Shader Options page in options_ui (rows, fitted labels)
2026-10-31 21:00:09 +01:00
Ludovic Marie 9be3941599 Merge pull request #4957 from masa-masa-0314/feat/enabled-features
🤖 Agent: merged #4948 with main; clippy clean, title tests pass.
2026-10-31 21:00:04 +01:00
Ludovic Marie 284a2deeef Merge pull request #4948 from masa-masa-0314/feat/create-world-screens
🤖 Agent: merged main again (tests.rs: main took the same preparing-step fix); clippy clean, title tests 54 pass.
2026-10-31 21:00:00 +01:00
SylviromiandClaude Opus 5.5 82b9c426a3 Native mods: item_bar hook, the bar drawn under an item (API next)
A mod item with its own fill level (a tank, a battery) had no way to draw
the bar vanilla's Item.isBarVisible/getBarWidth/getBarColor give tools and
bundles. native_item_bar fires where GuiGraphicsExtractor.itemDecorations
picks the bar, after the bundle override, with vanilla's choice in `bar`;
a mod may replace or hide it. Nothing changes when no mod hooks it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:57:11 +03:00
Ludovic MarieandClaude Opus 5.5 0f26235889 Native server mods: nicknames in chat and the Tab list, and the chat filter's verdict
native_set_nickname(uuid, Option<&str>) shows a name in place of a
player's in chat (Player.getDisplayName's text: their messages, /me,
/msg) and in the Tab list (PlayerInfo UPDATE_DISPLAY_NAME); the hover,
the suggested /tell and the insertion keep the real name, as do the
log, commands and the server's events. native_text_allowed(text) asks
the server's chat filter. API number from the fragment's stamp.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:56:38 +01:00
Ludovic Marie 9658ee1fef Merge pull request #5372 from horizon-rust/feat/discord-bridge
horizon-server: [discord] posts the server's chat to a Discord channel, drawn as in game
2026-10-31 20:56:32 +01:00
Ludovic Marie 7e285aa283 Merge main into #5305 (translate/mod.rs tests beside main's)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-shaders/src/translate/mod.rs
2026-10-31 20:54:27 +01:00
Ludovic Marie 6b0139bed5 Merge pull request #5348 from leifiyoo/narrator/15-keyboard-focus
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client lib/bins/tests clean at #5335, #5343 (options_ui), #5346 (lan_screen) and #5348 (pad_menu on main's EventLoopCtl), then main's Shader Options page in options_ui (rows, fitted labels)
2026-10-31 20:53:42 +01:00
Ludovic Marie fdcf903d50 Merge pull request #5347 from leifiyoo/narrator/14-docs
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client lib/bins/tests clean at #5335, #5343 (options_ui), #5346 (lan_screen) and #5348 (pad_menu on main's EventLoopCtl), then main's Shader Options page in options_ui (rows, fitted labels)
2026-10-31 20:53:36 +01:00
Ludovic Marie fe3ef4c6d1 Merge remote-tracking branch 'origin/main' into feat/discord-bridge
# Conflicts:
#	Cargo.lock
2026-10-31 20:52:04 +01:00
Ludovic MarieandClaude Opus 5.5 e386fbc925 Merge #5348's main merge into #5349 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:50:48 +01:00
arismoko 7fbe0efd00 Merge remote-tracking branch 'upstream/main' into content-state-fanout 2026-10-31 14:50:46 -05:00
SylviromiandClaude Sonnet 19ca652907 Merge origin/main into content-connecting-blocks
Co-Authored-By: Claude Sonnet <[email protected]>
2026-10-31 22:49:52 +03:00
Ludovic MarieandClaude Opus 5.5 489a99733a Merge #5347's main merge into #5348 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:48:59 +01:00
Ludovic Marie d172962d9b Merge main into #4922
# Conflicts:
#	crates/horizon-client-render/src/terrain.wgsl
2026-10-31 20:48:41 +01:00
AkirabaneandClaude Sonnet 5.5 9a4d8f2bea Merge main into feat/rcon
Two conflicts, both documentation: docs/api/server.md's changelog and docs/server.md section 8, where main added the management protocol's TLS and reworded its list. Main's text is kept and the RCON lines follow it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 20:48:35 +01:00
Ludovic Marie 256a52eb2f Merge pull request #5343 from leifiyoo/narrator/11-options-lists
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client lib/bins/tests clean at #5335, #5343 (options_ui), #5346 (lan_screen) and #5348 (pad_menu on main's EventLoopCtl), then main's Shader Options page in options_ui (rows, fitted labels)
2026-10-31 20:48:30 +01:00
Ludovic MarieandClaude Opus 5.5 30a0022d7b Merge #5346's main merge into #5347 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:48:24 +01:00
Ludovic MarieandClaude Opus 5.5 9843893c19 Merge #5344's main merge into #5346 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:47:45 +01:00
Ludovic MarieandClaude Opus 5.5 e550e5a609 Merge #5343's main merge into #5344 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:47:10 +01:00
Ludovic Marie 5272f3ad64 Merge pull request #5390 from horizon-rust/chore/stamp-native-hooks-46
Native hooks: stamp API 47-57 from the pending fragments
2026-10-31 20:46:19 +01:00
Ludovic Marie 945958f560 Merge pull request #4698 from stievenw/feat/quick-actions
🤖 Agent: merged main again: the pause additions button (id 14) joins main's keyboard focus (execute_pause_action, key navigation, focus highlight); data.rs keeps main's custom-item identity check. Clippy clean, pause and lan::data tests pass.
2026-10-31 20:46:15 +01:00
Ludovic Marie 8c57c47473 Merge #4948 (main merged) into #4957 2026-10-31 20:44:19 +01:00
Ludovic Marie d5aef59f19 Merge main into #4948
# Conflicts:
#	crates/horizon-client/src/title/tests.rs
2026-10-31 20:44:18 +01:00
Blzs 932dfb1ec2 Merge remote-tracking branch 'origin/main' into fix/pick-key-in-inventory 2026-10-31 20:43:43 +01:00
Ludovic MarieandClaude Opus 5.5 1cab2e7411 Merge main into #5343 (options_ui: main's shader-option tooltips and fitted labels beside tooltip_text)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:43:43 +01:00
Ludovic Marie d0bceaf3b2 Merge pull request #5440 from horizon-rust/server-admin/management-tls
🤖 Agent: management protocol 6c, TLS on by default (PKCS12 keystore as vanilla, or a generated self-signed one): parity harness vs vanilla 26.3 passed over TLS (33 exchanges, TLSv1.3 both), horizon-server's 81 tests and clippy pass.
2026-10-31 20:43:27 +01:00
SylviromiandClaude Opus 5.5 c6b21d17eb Merge origin/main into item-sync-custom-data
Upstream now sends custom_model_data itself; keep its codec and drop
ours, so this branch only adds custom_data to the LAN item sync.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:42:48 +03:00
Ludovic Marie 3ea46f3670 Merge main into #4698
# Conflicts:
#	crates/horizon-client/src/game/screens.rs
#	crates/horizon-client/src/lan/data.rs
2026-10-31 20:42:40 +01:00
Ludovic Marie 30aa273950 Merge pull request #5388 from horizon-rust/feat/chat-filter
Server chat filter: refuse hate slurs in chat, message commands, signs, anvils and books
2026-10-31 20:41:11 +01:00
Ludovic Marie c5106acda6 Merge #5348's main merge into #5349 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/startup_menu.rs
2026-10-31 20:39:58 +01:00
Ludovic MarieandClaude Opus 5.5 448fda90e3 horizon-server: TLS for vanilla's management protocol, on by default
The management server serves wss:// as vanilla's does: a PKCS12 keystore
(management-server-tls-keystore), its password from
MINECRAFT_MANAGEMENT_TLS_KEYSTORE_PASSWORD or the server property, rustls on
ring. Where vanilla stops without a keystore, Horizon generates a self-signed
one in .horizon/ and writes its path and password to [properties], so
management-server-enabled=true is enough; the start logs the certificate's
SHA-256 fingerprint. The WebSocket reads and writes through a Link (plain or
TLS, full duplex). The parity harness now runs both servers over TLS with one
keytool keystore.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:39:44 +01:00
Aaron Wang ccc34ef964 Draw armor stands between ticks, so a falling one moves smoothly
EntityRenderer draws an entity at Mth.lerp(partialTick, xo, x), but armor
stands were drawn at their tick position, so a falling stand jumped once
a tick (20 times a second) while boats, TNT and mobs slid. ArmorStand now
keeps its previous position: ArmorStand::interpolate saves it before each
interpolation step on a server's client, a stand riding a mob or the
player takes it from its vehicle's previous seat (as the mob riders do,
so it stays right while the level is frozen), and the in-frame tick
saves it too. The body, its light, the culling box, the name tag, the
flames and the F3+B hitbox all use the blended position.

Fixes #4416

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 15:39:13 -04:00
Ludovic MarieandClaude Opus 5.5 4e7e1ba84a Anticheat: a player already leaving counts no second strike; no ban list kicks
rdbtCVS's review of #5365: a kick only marks the player as leaving, so
packets still queued (a lagging client's ticks) were handled after it,
flagged again and counted a second strike, banning the player in the
session the first kick warned. flag_to now drops them and counts
nothing once the player is leaving. Without a ban list, anticheat_ban
falls back to the anticheat's kick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:38:42 +01:00
reservedbytes 742e4dc206 fix(mods): a canceled hit forgets its attackers on every damage path 2026-10-31 18:37:46 -01:00
Ludovic Marie 5ec102ddac Merge pull request #4628 from AurelienBelloir/parity/save-parent-vehicle
🤖 Agent: merged main again: save_guest follows main's every-player-file rule with this PR's playerdata API; main's new autosave loop (PlayerList.saveAll) calls playerdata::save without the old vehicle argument. Clippy clean, persist tests pass.
2026-10-31 20:37:44 +01:00
Ludovic Marie 8710682a12 Merge pull request #4619 from masa-masa-0314/feat/experiments-screen
🤖 Agent: merged main; what remains is the Experiments switches committing on Done (Cancel drops them). Main's feature-aware load replaces the Re-Create fix. Client builds.
2026-10-31 20:37:40 +01:00
Ludovic Marie 08e6c0b6f4 Merge pull request #4577 from masa-masa-0314/feat/worldgen-pack-content
🤖 Agent: merged main (most of this stack landed via #5069); what remains is pack item tags reading main's tags/<ns>:<registry>/<path> keys (tag_kind) plus two loot tests; namespace tests pass.
2026-10-31 20:37:36 +01:00
Ludovic Marie a35ad0158f Merge #5347's main merge into #5348 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/pad_menu.rs
2026-10-31 20:37:06 +01:00
Ludovic MarieandClaude Opus 5.5 efc91facea Merge #5346's main merge into #5347 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:36:14 +01:00
levzzz 09c383118e Merge remote-tracking branch 'hr/main' into mods/render-hooks 2026-11-01 00:35:01 +05:00
Ludovic Marie a0e02c41c4 Merge #5344's main merge into #5346 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/lan_screen.rs
2026-10-31 20:34:51 +01:00
Ludovic MarieandClaude Opus 5.5 639a32b0c2 Saving: main's autosave of every player's file uses this PR's playerdata::save
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:34:35 +01:00
Ludovic Marie 4e4ae1050d Merge pull request #5342 from leifiyoo/narrator/10-world-server
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client+render lib/bins/tests clean at #5335
2026-10-31 20:34:28 +01:00
Ludovic Marie 9715dfdad3 Merge pull request #4839 from josegil1909/fix/4829-instances-and-tabs-i18n
🤖 Agent: merged main again (catalogs: main's values win, this PR's new keys kept); launcher i18n tests pass.
2026-10-31 20:34:24 +01:00
Ludovic Marie f4498b8f0c Merge pull request #4747 from sirrayi/lod-engine-overhaul
🤖 Agent: merged main again; the verify failure was main's pt-BR catalog gap (fixed on main).
2026-10-31 20:34:19 +01:00
Ludovic Marie b3ecf336e0 Merge pull request #4792 from PzmuV1517/shader-packs/15
🤖 Agent: merged main up the stack; #4448's atomic vector rewrite carries main's new Preprocessed.warnings; #4790 handles Enter on its shader buttons in main's new keyboard focus path (#4792: shift+Enter resets, as shift-click). Each layer builds; horizon-shaders tests 76 pass.
2026-10-31 20:34:15 +01:00
Ludovic Marie e95ec55c35 Merge pull request #4791 from PzmuV1517/shader-packs/14
🤖 Agent: merged main up the stack; #4448's atomic vector rewrite carries main's new Preprocessed.warnings; #4790 handles Enter on its shader buttons in main's new keyboard focus path (#4792: shift+Enter resets, as shift-click). Each layer builds; horizon-shaders tests 76 pass.
2026-10-31 20:34:11 +01:00
Ludovic Marie 658a187e3d Merge pull request #5341 from leifiyoo/narrator/09-status
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client+render lib/bins/tests clean at #5335
2026-10-31 20:34:07 +01:00
Ludovic Marie f2a7e066a2 Merge pull request #4790 from PzmuV1517/shader-packs/13
🤖 Agent: merged main up the stack; #4448's atomic vector rewrite carries main's new Preprocessed.warnings; #4790 handles Enter on its shader buttons in main's new keyboard focus path (#4792: shift+Enter resets, as shift-click). Each layer builds; horizon-shaders tests 76 pass.
2026-10-31 20:34:03 +01:00
Ludovic MarieandClaude Opus 5.5 7cab1f0414 Merge #5343's main merge into #5344 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:33:59 +01:00
Ludovic Marie a2feb5ee9f Merge pull request #5340 from leifiyoo/narrator/08-ingame
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client+render lib/bins/tests clean at #5335
2026-10-31 20:33:59 +01:00
Ludovic Marie b48d9fb179 Merge pull request #5339 from leifiyoo/narrator/07-menus
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client+render lib/bins/tests clean at #5335
2026-10-31 20:33:55 +01:00
Ludovic Marie 611ab28abe Merge pull request #5386 from horizon-rust/tools/server-bench-pumpkin
🤖 Agent: Server bench against Pumpkin (black box, GPL-3.0 source never read): the runner, its tests, the doc and the 2026-10-31 report. CI green at this head, pr_ready pass (498 Python tests), 24 matrix runs on playhorizon with none failed.
2026-10-31 20:33:51 +01:00
Claude 6b5780b686 Merge remote-tracking branch 'upstream/main' into feat/native-camera-shake 2026-10-31 19:33:10 +00:00
Claude f58fe06685 Native hooks: finite keeps its own doc comment 2026-10-31 19:33:10 +00:00
Ludovic Marie 3ef1e50576 Merge #5342's main merge into #5343 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/options_ui.rs
2026-10-31 20:32:38 +01:00
Federico TaglianiandClaude Sonnet 5.5 b1e4937c9a A rabbit eats ripe carrots
Rabbit.RaidGardenGoal: a MoveToBlockGoal (0.7, 16) to carrots at their maximum
age on #supports_crops ground, which takes one age off them (or the crop when
it had none) and leaves the rabbit full for 40 ticks (moreCarrotTicks, saved
as MoreCarrotTicks, running down by 0 to 2 a tick).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 20:31:50 +01:00
Ludovic Marie 5ecf9f7b91 Merge main into #4628
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
2026-10-31 20:31:42 +01:00
Ludovic MarieandClaude Opus 5.5 a32b67a6ab Merge #5341's main merge into #5342 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:31:01 +01:00
Ludovic Marie 8a4b6cb3c6 Merge pull request #5338 from leifiyoo/narrator/06-driver
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client+render lib/bins/tests clean at #5335
2026-10-31 20:30:52 +01:00
Ludovic Marie fc9a2bd87e Merge pull request #5337 from leifiyoo/narrator/05-recording
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client+render lib/bins/tests clean at #5335
2026-10-31 20:30:47 +01:00
Ludovic Marie 633daa245e Merge pull request #5336 from leifiyoo/narrator/04-mac-linux
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client+render lib/bins/tests clean at #5335
2026-10-31 20:30:44 +01:00
Ludovic MarieandClaude Opus 5.5 487dc2a541 Merge #5340's main merge into #5341 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:30:27 +01:00
Ludovic MarieandClaude Opus 5.5 13c7798c61 Merge #5339's main merge into #5340 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:29:46 +01:00
levzzzandClaude Opus 5.5 1de5955c40 Native mods: render hook points (world drawn, render needs)
native_world_drawn gives a mod the frame once the world is drawn: the device,
queue and encoder, the colour target, the world's depth, the matrices with and
without TAA's jitter, the entity quads with their runs, the atlas and the quad
indices. native_render_needs lets mods ask for the entity runs and the world's
depth without a shader pack; several can ask. Both live in the render crate
(gfx/native_render.rs), and the catalog test accepts hook points there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:29:07 +05:00
Ludovic MarieandClaude Opus 5.5 fb68e2b7c4 Merge #5338's main merge into #5339 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:28:58 +01:00
Ludovic Marie eb717a9bb7 Merge pull request #5335 from leifiyoo/narrator/03-messages
🤖 Agent: narrator stack merged with main (remote.rs chat path, chat_tests.rs union); cargo check client+render lib/bins/tests clean at #5335
2026-10-31 20:28:18 +01:00
Ludovic MarieandClaude Opus 5.5 c14cd1ad9f Merge #5337's main merge into #5338 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:28:14 +01:00
Ludovic Marie 6d2f15b1aa Merge pull request #5387 from horizon-rust/fix/lan-bot-drain-packets
🤖 Agent: rdbtCVS's merge of main (S::Attack hunk now main's); diff = Hub::tick drain + lan_bot ClientTickEnd; lan_bot 15/15 on rebased main.
2026-10-31 20:28:14 +01:00
Ludovic MarieandClaude Opus 5.5 91df1e2cf5 Dedicated server: admin_feedback_to_ops, so an operator's commands can stay out of the other operators' chat
server.toml [server] admin_feedback_to_ops (default true, vanilla's
broadcastToAdmins) is written as horizon-admin-feedback-to-ops; false
keeps the [name: ...] lines to the console's log. Players who aren't
operators never get them either way (g.permission > 0, as vanilla's
isOp, checked by the server_ops_feedback harness).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:27:46 +01:00
Ludovic MarieandClaude Opus 5.5 659587e362 Merge #5336's main merge into #5337 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:27:24 +01:00
Ludovic MarieandClaude Opus 5.5 8f789bde17 horizon-server [discord]: players are named by their account, never a nickname alone
Lines name players only by the event's `name`; a death message the game wrote
with a nickname gets the real name after it (smp-1 is adding /nick).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:27:02 +01:00
Ludovic MarieandClaude Opus 5.5 17f988c72a Merge #5335's main merge into #5336 (main merged, conflicts resolved below)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:26:00 +01:00
[email protected] 0fb25759e1 boot_paint: AddFontMemResourceEx only reads the font count
Clippy (unnecessary_mut_passed) now that boot_paint compiles on Windows.
2026-10-31 20:25:15 +01:00
[email protected] 3888ac4613 Native mods without mixins: API mods that never rebuild horizon-client
A native bundle with [api] (crate and entry) and no [[injection]] is an API mod: its
crate implements horizon_mod_api::Mod and the game asks it right after the item and
block hook points it is named after, once the mixin mods there are done.

- horizon-mod-api: the Mod trait (use_item, use_item_on, right_click_block,
  left_click_block, destroy_block, place_block, tooltip), Action and Host.
- horizon-client: the game module moves into the library and the executable only adds
  the allocator, so a build can link the game from another package; game/api_mods.rs
  dispatches, and a mod turned off in Options > Mods is skipped with no build.
- launcher: API mods skip horizon-mixins; their crate and a generated native-game
  package build the executable, so horizon-client never depends on a mod.
- horizon-mods: the manifest's optional api field and its checks.
- docs: native-mods.md "API mods (no mixins)", stability.md; example in
  launcher/tests/fixtures/native-api-demo.
2026-10-31 20:25:15 +01:00
sirrayiandDeepSeek Harness 7170948484 test(probe): the mob probe feeds without a love cause
`feed_animal` takes the player who feeds an animal into love since this
branch; the probe passes none, so `--examples` builds again.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:24:24 +00:00
Ludovic Marie 7a2576b919 Merge pull request #5420 from horizon-rust/fix/anticheat-sprint-modifier
🤖 Agent: rebased on main c0df3d782, retargeted to main (carries #5387's identical 4-line fix); clippy clean, lan_bot 15/15, lan:: 113 tests pass.
2026-10-31 20:24:08 +01:00
rdbtCVSandClaude Opus 5.5 1edb846397 tooling(port-map): backupStatus points at list_worlds; ocelot trust rows keep their gap
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:22:40 +00:00
sirrayiandDeepSeek Harness f852fa48fa tooling(port-map): the mannequin's description is ported
#5327 landed `Mannequin.updateDescription` (the default label, hidden, or the
server's component under the name tag) and the two synched-data slots beside
the profile, so both rows stop saying missing.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:22:19 +00:00
sirrayiandDeepSeek Harness c96883a23a tooling(port-map): the equipment-break row is ported on both halves
`LivingEntity.entityEventForEquipmentBreak`'s note still said a player's
hands, the saddle and a guest's tool were not broadcast. Main's #5392 covers
the player half (both hands, armour, shield, a guest's included) and #5317
the mobs' half, so the row points at the slot-to-event table itself now and
says where both halves live.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:22:19 +00:00
sirrayiandDeepSeek Harness 97e39839e5 tooling(port-map): the shulker lid's push is the piston push machinery
`ShulkerBoxBlockEntity.moveCollidedEntities` was marked unsure in the
checklist; it is `game/piston_push.rs::Game::push_shulker_entities`, whose doc
names the method and moves every body the sweeping lid box meets along the
facing by the box's size + 0.01. That was the last row of #3516 without a
port.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:22:19 +00:00
sirrayiandDeepSeek Harness fd19d8756e tooling(port-map): five more ticked rows that are ported
`Arrow.updateColor` is `Projectile::arrow_effect_color` (its doc names the
method on the way past), `Cat.CatTemptGoal.canScare` is `CatTempt::can_continue`,
`Entity.applyImplicitComponent`/`applyImplicitComponentIfPresent` are the
component pass `entity_nbt::apply_item_components`, and
`LivingEntity.swapHandItems` is the entity-event-55 arm in
`remote.rs::Game::handle_packet`.

`Fox.canHoldItem` and `Fox.pickUpItem`, checked in the same pass, have no
implementation in the code at all (the generic `equip` gate lists the fox as
one whose pickup is elsewhere, and nothing is), so they stay out.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:22:19 +00:00
sirrayiandDeepSeek Harness 0b1b2083c8 tooling(port-map): the trial spawner's data and a frame's blast are ported
`TrialSpawner.FullConfig.overrideEntityData` is
`mobs/trial_spawner.rs::TrialSpawner::override_entity_data`, reached from the
dispenser's egg by `Game::use_spawn_egg_on_spawner`; and a blast breaking an
item frame (`ItemFrame.shouldDamageDropItem`) is
`game/explosion.rs::Game::explosion_hurt_entities`'s `explosion_breaks_frames`.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:22:19 +00:00
sirrayiandDeepSeek Harness 93f40863a3 tooling(port-map): the shelf's swaps are Game::use_shelf, not a gap
`ShelfBlock.useItemOn` was pointing at the client-side reaction
(`game/remote.rs::remote_use_item_on`) and its two swaps were still `missing`
for want of a citation; all three are `game/shelf.rs::Game::use_shelf`,
whose doc names the method and whose two arms are the single swap and the
powered chain's hotbar swap (`block.shelf.multi_swap`), with the block
entity update through `Containers::container_changed`.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:22:19 +00:00
sirrayiandDeepSeek Harness 134ed5c9a6 tooling(port-map): the ticked rows whose map entry still said missing
Eighty-eight rows of #3512, #3514, #3516 and #3517 are ticked in their
checklist while `tools/port_map/` still calls them `missing`: the pull
request that fixed each one ticked the box and did not touch the map. This
corrects the 63 that cite the vanilla method in port code — 36 through a doc
comment on the port itself, 27 through a mention whose note says so and gives
its file and line. Every `where` names a function that exists.

The 23 ticked rows with no citation at all are left alone and listed in the
pull request: a tick is not evidence enough to flip a verdict.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:22:19 +00:00
MartipopsandClaude Opus 5.5 242065a84d Merge upstream main into feature/launcher-auth-status
Main's #5397 translated the same 17 strings into ja, pt-br and tr that dd722ee7a did, with
other wording. Git merges both without a conflict, which left each of those tables with 17
duplicate keys. Main's wording is kept and this branch's dropped, so the tables differ from
main only by this branch's 26 account strings and the key it removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 15:21:49 -04:00
rdbtCVSandClaude Opus 5.5 a209fab13e Native hooks: finite keeps its own doc comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:21:31 +00:00
Claude 1ec53c7a3f Merge remote-tracking branch 'upstream/main' into feat/native-falling-blocks
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-31 19:21:14 +00:00
Claude 4e72c2b5b5 Merge remote-tracking branch 'upstream/main' into feat/native-entities
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-31 19:21:12 +00:00
Claude b7b36ccbc7 Merge remote-tracking branch 'upstream/main' into feat/native-explode
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-31 19:21:02 +00:00
Ludovic Marie 60f88cb59c Merge main into #4839
# Conflicts:
#	launcher/lang/ja.json
#	launcher/lang/pt-br.json
#	launcher/lang/tr.json
2026-10-31 20:20:22 +01:00
arismoko 9faf959d90 Document testing rules that preserve signal 2026-10-31 14:20:15 -05:00
Ludovic Marie a066b500c0 Merge main into #4747 2026-10-31 20:20:08 +01:00
CocofireHDandDeepSeek Harness f084e54852 Sheared wool pops out of the sheep as vanilla's does
Sheep.shear spawns each wool item with spawnAtLocation(stack, 1.0F): an
ItemEntity a block above the feet whose constructor sets its motion to
(rand*0.2-0.1, 0.2, rand*0.2-0.1), with the default pickup delay, and then
adds its own spread ((f-f)*0.1, f*0.05, (f-f)*0.1) on top. Horizon gave the
drops only that spread and spawned them at the body's centre, so the wool
left the sheep with at most 0.05 blocks/tick upwards and fell almost
straight down.

MushroomCow.shear builds each ItemEntity by hand at getY(1.0), so its
mushrooms get the constructor's motion and no pickup delay; the loop now
tells the two apart instead of applying the sheep's spread to both.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 20:20:03 +01:00
sirrayiandDeepSeek Harness e8a93aa87e fix(mobs): a fox's baby trusts the players who bred it
`Fox.FoxBreedGoal.breed` hands the breeding to the baby as trust, which needs
the player who fed each parent into love: the animals kept the love counter
but not its cause. `Animal.LoveCause` is now kept (`Extra.love_cause`, fed
from the player who hands over the food, saved and read), and
`fox::trust_breeders` gives the baby both players, once each.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:19:52 +00:00
Ludovic Marie e26e78aa6a Merge pull request #5330 from Joansitoh/fix-5027
🤖 Agent: merged main (functions.rs: enabled_packs with main's enabled_features); content_overlay test passes
2026-10-31 20:19:19 +01:00
Federico TaglianiandClaude Sonnet 5.5 0dfe43ea45 An ocelot trusts the player who feeds it raw fish, one time in three
Ocelot.mobInteract: raw fish from within 3 blocks while the tempt goal runs is
used up; one time in three DATA_TRUSTING is set (entity event 41, hearts),
otherwise smoke (event 40). A trusting ocelot neither runs from players nor is
scared while tempted, is saved as Trusting, and no longer despawns when far.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 20:18:45 +01:00
Ludovic Marie 73bdc8ef5a Merge main into #5335
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/chat_tests.rs
#	crates/horizon-client/src/game/remote.rs
2026-10-31 20:18:38 +01:00
rdbtCVSandClaude Opus 5.5 7e6dea3707 Merge main into #5387: main's 825a6bbcf already has the one S::Attack arm
Keeps main's arm (the anticheat drops a cheating attack, a move behind a
kept attack waits) and this branch's Hub::tick drain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:18:24 +00:00
sirrayiandDeepSeek Harness c5f11d1eb6 fix(mobs): foxes trust the players their list names
`Fox.getTrustedEntities` and `Fox.addTrustedEntity` were modelled as "no
trusted players yet": the save wrote an empty `Trusted` list, nothing read it
back, and the avoid-player goal had no trust test, so a fox fled the player
who spawned it and nobody could become trusted (two rows of #3512).

- `Extra.trusted` holds the two `DATA_TRUSTED_ID` slots; `fox::trusted_entities`,
  `add_trusted_entity` (first free slot, then replacing the second),
  `clear_trusted` and `trusts` are the entity's own methods by name.
- The save writes the real list (`Fox.TRUSTED_LIST_CODEC`: int arrays) and
  `mob_from_nbt` clears and rebuilds it, as `Fox.readAdditionalSaveData` does.
- `AVOID_PLAYERS` no longer flees a player the fox trusts, which is what the
  row meant by a bred fox still running from you; `AvoidEntity::player_when`
  takes the avoider so the predicate can ask. A fox spawned from a spawn egg
  trusts the player who used it (`Fox.onOffspringSpawnedFromEgg`).

Tested without prepared game data:
`mobs::fox::trust_tests::a_fox_trusts_two_players_and_the_third_replaces_the_second`.

Still missing from the row: the breeding hand-off (needs a saved love cause,
which the mobs do not keep) and `DefendTrustedTargetGoal`; both are in the
note of the map rows.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:18:19 +00:00
Ludovic MarieandClaude Opus 5.5 381c62a408 Native hooks: stamp API 47-57 from the pending fragments
python3 tools/stamp_native_hooks.py on main 1e16d07: 11 fragments in
first-parent merge order (47 entity_join_level, 48 block_items,
49 native-sidebar, ... 57 chunks_players).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:17:36 +01:00
Ludovic MarieandClaude Opus 5.5 3f063fd0fa Shader Options: shift+Enter resets a focused option, as shift-click does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:17:26 +01:00
Ludovic Marie d43ce2397c Merge the stack below into #4791 2026-10-31 20:17:17 +01:00
Ludovic Marie 5a266f520c Merge the stack below into #4792 2026-10-31 20:17:17 +01:00
Ludovic MarieandClaude Opus 5.5 f8f0c6da2a Shader Options: Enter on a focused shader button does what a click does
Main's keyboard focus (#4638) matches every options action on Enter;
this page's actions join it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:17:02 +01:00
huiandZCode AI agent (GLM-5.3) 617466720a Recipes answer NeoForge's load conditions
A data file with neoforge:conditions loads only when every condition
holds, and is silently absent otherwise, as NeoForge's ICondition leaves
it out of the manager. The gate covers the recipe loaders - crafting's
three kinds and the mods' own types, the stations, brewing, and the
recipe book - and the packs' overlays over them; mod_loaded asks which
mods' data is in the root (minecraft, always, and the content mods
that are on), and neoforge:true, not, and and or combine. A condition
the game does not know leaves the file out: a future shape fails
closed. Ported modpacks already carry such recipes verbatim (the carry
step keeps them), so cross-mod recipes stop loading blind.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 20:16:20 +01:00
Ludovic Marie 1e16d0723a Merge pull request #5425 from horizon-rust/fix/main-build-title-keyboard-test
Main's tests pass again: title keyboard test goes through the preparing screen
2026-10-31 20:16:07 +01:00
AkirabaneandClaude Sonnet 5.5 74862c7996 Merge main into feat/rcon
The one conflict was docs/api/server.md's changelog: main's longer management protocol line and the RCON line are both kept.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 20:15:48 +01:00
Ludovic MarieandClaude Opus 5.5 9e523a894e Title keyboard test: Create New World goes through the preparing screen
#4638's keyboard test expects Enter on Create New World to open the create
screen at once, but 072cc6127 (landed beside it) shows vanilla's
"Preparing for world creation" screen first, as the other title tests
expect. The test now finishes the preparing step before checking.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:15:19 +01:00
BlzsandClaude Opus 5.5 a16ba2fb5d Pick block bound to a key clones the hovered slot in container screens
A key bound to key.pickItem was turned into the middle button only while
playing; behind a screen it was dropped, so pick block on X did nothing in
the inventory or a creative tab. AbstractContainerScreen.keyPressed clicks
the hovered slot with CLONE (button 0) for it, as the middle button does.
Not on the creative search tab, whose box takes the key, nor behind a dialog.

Fixes #5378

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:13:39 +01:00
Ludovic Marie f2b9a33b4b Merge pull request #5334 from leifiyoo/narrator/02-option-hotkey
🤖 Agent: merged main (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397); PR content unchanged since review
2026-10-31 20:12:47 +01:00
Ludovic Marie cb05d3129c Merge pull request #5345 from horizon-rust/server-admin/management
🤖 Agent: management protocol 6b (players, players/kick, server/status|save|stop|system_message, the lists' getters), main merged in after #5329: parity harness vs vanilla 26.3 passed (32 exchanges), horizon-server's 79 tests pass.
2026-10-31 20:12:43 +01:00
Ludovic Marie 66f1c78703 Merge pull request #5333 from leifiyoo/narrator/01-core
🤖 Agent: merged main (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397); PR content unchanged since review
2026-10-31 20:12:39 +01:00
Ludovic Marie c380eaa346 Merge pull request #5332 from Detimes/fix-op-suggestions
🤖 Agent: merged main (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397); PR content unchanged since review
2026-10-31 20:12:35 +01:00
Ludovic Marie 436a2224df Merge pull request #5360 from MSedgeMC/fix/book-line-seek-bias
🤖 Agent: requeued: author rebased on main, same LINE_SEEK_PIXEL_BIAS change
2026-10-31 20:12:30 +01:00
Ludovic Marie 81d4a3461e Merge pull request #5359 from MSedgeMC/fix/remote-shoulder-parrot
🤖 Agent: requeued: author rebased on main, same one-line ParrotOnShoulderLayer change
2026-10-31 20:12:26 +01:00
Ludovic Marie 9dbdda07d5 Merge main into #5330
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/functions.rs
2026-10-31 20:12:13 +01:00
Ludovic Marie c45e90aa1a Merge the stack below (main merged) into #4792
# Conflicts:
#	crates/horizon-client-render/src/options_ui.rs
2026-10-31 20:11:38 +01:00
Ludovic Marie 0f5c41e9c2 Merge the stack below (main merged) into #4791 2026-10-31 20:11:22 +01:00
Ludovic Marie 48bb35bb94 Merge the stack below (main merged) into #4790
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-10-31 20:11:21 +01:00
Ludovic Marie 0d22dea027 Merge the stack below (main merged) into #4453 2026-10-31 20:11:07 +01:00
Ludovic Marie 2d6e8182a1 Merge the stack below (main merged) into #4452 2026-10-31 20:11:06 +01:00
Ludovic Marie 2ab7354733 Merge the stack below (main merged) into #4451 2026-10-31 20:11:05 +01:00
Ludovic Marie 7d5b60277a Merge the stack below (main merged) into #4450 2026-10-31 20:11:04 +01:00
Ludovic MarieandClaude Opus 5.5 96fbc6093c Shader packs: atomic vector rewrites carry main's preprocessor warnings
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:11:03 +01:00
k3yhandClaude Opus 5.5 ad85e99600 Smoke where a bucket's water evaporates in the Nether (#5288)
BucketItem.emptyContents ends an evaporation with ServerLevel.sendParticles
(LARGE_SMOKE, the block's corner, 8, spread 1, speed 0, ALTERNATIVE).
Horizon played the hiss but sent no smoke for a player's bucket, and the
dispenser drew its own smoke one block too wide (nextDouble * 2 - 1 around
the corner instead of nextDouble from it) and sent none to other players.

evaporation_smoke does both: the window's puffs and the packet, now with
its randomization type (QueuedParticle.randomization). A client receiving
LevelParticles now honors ALTERNATIVE and ALTERNATIVE_WITH_SPEED, as
ClientPacketListener.handleParticleEvent does: positions spread over
[0, dist), speeds kept or scaled by nextDouble.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:10:43 -05:00
Claude d356420e76 Merge main into feat/native-shader-params
Resolves the conflicts with the water reflection fields: Frame.mods now
follows reflection_resolution at offset 528.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRonhDhVY6koHuncJx
2026-10-31 19:10:34 +00:00
Ludovic Marie 8f43b2de7c Merge main into #4448 2026-10-31 20:10:31 +01:00
Ludovic MarieandClaude Opus 5.5 6515cc0605 Merge main into #5334 (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:10:26 +01:00
Ludovic MarieandClaude Opus 5.5 83e42596da Sprint modifier: the server's body carries it; the replay keeps the client's
The server never put setSprinting's movement speed modifier on a guest's
body: START_SPRINTING only set a flag. Its movement speed then synced
without the modifier, and a vanilla client, which takes the modifiers as
sent (handleUpdateAttributes), walked while it sprinted: after a speed or
slowness change mid-sprint, until it stopped sprinting. handlePlayerCommand
now sets the modifier as setSprinting does.

The prediction's replay copied all the server's attributes on any
attribute or effect packet, the movement speed included, so it lost the
sprint modifier the client kept and played a sprint as a walk (a false
flag of a vanilla client after spreadplayers). An attribute packet now
changes the replay's sprint modifier only when it carried the movement
speed, as the client's does; effect packets copy only the effects. A
replay that starts again sprints as the client's first tick does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:09:53 +01:00
Ludovic MarieandClaude Opus 5.5 8bd5ff4a92 lan_bot: Hub::tick drains the players' packets; bots end their ticks
#4787 moved the players' packets into Hub::drain_packets, which the game
calls between poll and tick_level; Hub::tick, the whole-tick helper the
lan_bot server uses, kept only poll and tick_level, so 8 of 14 tests saw
none of their packets handled. The bots of
guests_see_each_other_and_the_world_entities now send ClientTickEnd after
each move, as a client does: a second move in one client tick is kicked.

Fixes #5364.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:09:53 +01:00
Ludovic MarieandClaude Opus 5.5 38ab275114 Merge main into #5333 (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:09:45 +01:00
Ludovic MarieandClaude Opus 5.5 c9ed92a2a9 Anticheat: ban at a check's ban_after kick (combat: the second)
A check that kicks now counts each player's kicks, across sessions and
restarts (anticheat-strikes.json). The kick that reaches the check's
`ban_after` bans instead: banned-players.json through the normal ban list
(source "Horizon anticheat", reason "Horizon anticheat: <check>"), the
player sent away with vanilla's ban message. combat bans at its second
kick; timer, ground and prediction keep ban_after = 0 until trusted.

Every kick and ban is logged with its evidence and told to the console
and online operators, so an op can /pardon a wrong one.

Also merges hub.rs's two S::Attack arms: the anticheat's shadowed #5123's
(unreachable pattern), so an attack never stopped the moves queued behind
it. Both apply now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:09:20 +01:00
Ludovic Marie 2f60ce6457 Merge the stack below (main merged) into #4957 2026-10-31 20:09:19 +01:00
Ludovic Marie 75ba9ad839 Merge pull request #5318 from xFedeT/fix/blast-in-water-spares-decorations
🤖 Agent: merged main (sulfur_cube.rs: in_water inside main's tnt_explodes guard); cargo check clean
2026-10-31 20:09:16 +01:00
Ludovic MarieandClaude Opus 5.5 50c8a09d23 Merge fixes: the Data Packs reopen takes main's event loop, and the keyboard test passes the preparing screen
Main now hands title actions a &dyn EventLoopCtl; #4638's keyboard test
predates the preparing read Create New World goes through.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:09:14 +01:00
Ludovic MarieandClaude Opus 5.5 5066c63192 Merge main into #5332 (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:09:07 +01:00
Ludovic Marie c0df3d7821 Merge pull request #5384 from mariuslozo-boop/fix/release-sign-windows
🤖 Agent: merged main (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397); PR content unchanged since review
2026-10-31 20:07:57 +01:00
Ludovic Marie 6bdeeafbe8 Merge pull request #5327 from xFedeT/fix/mannequin-description-immovable
🤖 Agent: merged main (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397); PR content unchanged since review
2026-10-31 20:07:53 +01:00
Ludovic Marie d438663447 Merge pull request #5324 from mariuslozo-boop/fix/windows-crlf-source-tests
🤖 Agent: merged main (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397); PR content unchanged since review
2026-10-31 20:07:49 +01:00
Ludovic Marie f8f251fab5 Merge pull request #5266 from finalpantasy/fix/legacy-forge-metadata
🤖 Agent: merged main after #5397 (launcher strings) and #5369 (perf_bench); port-pack tests pass; reviewed earlier (legacy Forge mcmod.info identity)
2026-10-31 20:07:44 +01:00
Ludovic Marie e3ed152998 Merge pull request #4983 from elythepreciouscat/perf/cull-offscreen-animated-sprites
🤖 Agent: requeue: head moved only by main merges (rdbtCVS / the author).
2026-10-31 20:07:40 +01:00
Ludovic Marie 90f112370e Merge pull request #5382 from mariuslozo-boop/fix/server-props-eol
🤖 Agent: test-only: server.properties tests compare with line_separator(), as Properties::store writes (vanilla BufferedWriter.newLine)
2026-10-31 20:07:35 +01:00
Ludovic Marie f87acc7d25 Merge pull request #4420 from xFedeT/feat/blending-old-chunks
🤖 Agent: requeued after merging main (clean): the red python was main's perf_bench test at the time, fixed on main (72fabfede).
2026-10-31 20:07:31 +01:00
Ludovic Marie 5e466f60dd Merge pull request #5373 from oxi-does-dev/ghast-client-steer
🤖 Agent: client_moves now includes HappyGhast.getControllingPassenger (harnessed adult, not on still timeout); test
2026-10-31 20:07:28 +01:00
Ludovic Marie 033513aa35 Merge pull request #5371 from TobiasLogic/shaderpacks/unfoldable-constants
🤖 Agent: consts calling built-ins naga can't fold (mix, smoothstep...) move to run time like the ?: case; test
2026-10-31 20:07:24 +01:00
Ludovic Marie 4c6b83967e Merge pull request #5375 from oxi-does-dev/jump-mining-harness
🤖 Agent: harness-only: three #4124 jump-mining scenarios in test_first_person_parity, matching 26.3 per the PR; issue kept open
2026-10-31 20:07:21 +01:00
Ludovic Marie c6af9ebb1f Merge main into #5318 (sulfur_cube.rs beside #5317)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/sulfur_cube.rs
2026-10-31 20:06:30 +01:00
Ludovic Marie c25c063045 Merge the stack below (main merged) into #4948 2026-10-31 20:05:17 +01:00
Ludovic MarieandClaude Opus 5.5 a1351237e7 Merge main into #5384 (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:04:59 +01:00
Ludovic Marie 8602b105ce Merge remote-tracking branch 'origin/main' into feat/discord-bridge 2026-10-31 20:04:57 +01:00
Ludovic MarieandClaude Opus 5.5 45499f3811 Server chat filter: refuse hate slurs in chat, message commands, signs, anvils and books
A dedicated server now refuses players' text that holds a hate slur before it
relays it: chat, /msg /tell /w, /me, /say, /teammsg, sign text, anvil names and
book titles. The sender is told in private, logs/chat-filter.log keeps the
original text for operators, and horizon-server's event stream gets a
chat_filtered event instead of chat (so the Discord bridge never forwards it).

The 2nd hit within 10 minutes mutes for 10 minutes, the 3rd bans for 24 hours.
Mutes persist in chat-filter-mutes.json; ops lift them with /unmute <player>
(console: unmute). server.toml's [chat_filter] sets it, on by default.

The new horizon-chat-filter crate holds the settings, the precompiled matcher
for the list (data/slurs.txt, or the owner's words_file) and the escalation.
Text is normalised first: case, accents, look-alike letters, invisible
characters, leetspeak, separators and repeated letters, with care for Niger,
snigger, knight, suspicious and the like.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:04:35 +01:00
Ludovic Marie d4b12650c5 Merge the stack below (main merged) into #4619
# Conflicts:
#	crates/horizon-client-render/src/datapack.rs
#	crates/horizon-client/src/game/functions.rs
#	crates/horizon-client/src/game/world_loading.rs
#	crates/horizon-client/src/title.rs
2026-10-31 20:04:30 +01:00
Ludovic MarieandClaude Opus 5.5 34a3b733cd Merge main into #5327 (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:04:21 +01:00
levzzz 120a5f41e3 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast 2026-11-01 00:04:08 +05:00
Ludovic MarieandClaude Opus 5.5 416b263a30 Merge main into #5324 (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:03:49 +01:00
TobiasLogicandClaude Opus 5.5 181d5f5ed6 Shader packs: four more translation fixes (sampler names, const parameters, arrays of arrays)
- A sampler named like a function (Bloop's `uniform sampler2D texture;` read as
  `texture(texture, uv)`) is the function where it is called, and the sampler elsewhere.
- A local named like a sampler, or like a sampler parameter, is in scope only after its
  initializer (GLSL 4.60 4.2.2): `float depth = texture(depth, c).x` still samples the parameter
  `depth` (Potato's depth-aware blur).
- A parameter's `const` (`const in float x`), which naga's parser refuses, is dropped: it only
  promises the function doesn't write the parameter.
- naga's frontend nests arrays of arrays the other way round (`float x[3][2]` comes out as 2
  arrays of 3; also `float[3][2]`, struct members and parameters), so indexing reads the wrong
  element or fails validation. Two or more dimensions in a type are written reversed, so the
  frontend's reversal restores GL's order; accesses are untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:03:20 +07:00
MSedgeMC 53e6cc2d35 Let a bobber spring back off a wall 2026-11-01 00:32:49 +05:30
MSedgeMC 0e9f0e8f95 Shift wildflowers and pink petals on a golem*s antenna 2026-11-01 00:32:43 +05:30
Ludovic MarieandClaude Opus 5.5 eaa4c16e05 Merge main into #5305 (main's perf_bench, triage-list and launcher-string failures, fixed by #5369 and #5397)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:02:38 +01:00
MSedgeMC b3277770e6 Draw another player*s shoulder parrot 2026-11-01 00:32:34 +05:30
MSedgeMC d1750f6d66 Let the first part keep a name two parts share 2026-11-01 00:32:28 +05:30
MSedgeMC 3b7fd2ea15 Bias a books line seek by vanilla*s two pixels 2026-11-01 00:32:25 +05:30
MSedgeMC b3061d144b Reject a nine-slice border that leaves no centre 2026-11-01 00:32:22 +05:30
Ludovic Marie 4e833c2fe6 Merge pull request #5299 from Qwerner88880/win-features-fix
🤖 Agent: merged main, feature list = union with main's Win32_System_Console; code unchanged since review
2026-10-31 20:02:16 +01:00
Ludovic Marie 0530214eac Merge pull request #4785 from WooCaash/fix/4549-recipe-book-servers
🤖 Agent: merged main again: the verify failure was main's hub.rs unreachable-pattern clippy error, fixed in 825a6bbcf.
2026-10-31 20:02:11 +01:00
Ludovic Marie 07ee94939d Merge pull request #4891 from sirrayi/fix/interact-packet-target
🤖 Agent: requeue: head moved only by main merges (rdbtCVS / the author).
2026-10-31 20:02:07 +01:00
Ludovic Marie 89fe9cfe0c Merge main into #4577
# Conflicts:
#	crates/horizon-client-render/src/datapack.rs
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/functions.rs
#	crates/horizon-client/src/game/integrated.rs
#	crates/horizon-client/src/game/world_loading.rs
2026-10-31 20:01:32 +01:00
arismoko 4efd95dddd Expand content block model state definitions 2026-10-31 14:01:04 -05:00
AkirabaneandClaude Sonnet 5.5 42b500a769 horizon-server: RCON, the vanilla remote console
enable-rcon, rcon.port and rcon.password were read from server.properties but
nothing listened. The supervisor now serves vanilla's RCON (RconThread and
RconClient in 26.3): it listens on server-ip (every address when empty) and
rcon.port, logs in with the password, and runs each command through the same
console request as `horizon-server cmd`, so it answers while the game restarts.

Settings follow RconThread.create (no password or a port outside 1 to 65535
leaves it off with vanilla's warning; a port that can't be bound is a warning).
The packets follow RconClient: login 3, command 2, replies in packets of 4096
characters, -1 for a refused login, "Unknown request <hex>", and "Error
executing: <command> (<reason>)". Differences: packets are read from the
stream, the password is compared in constant time, a connection that hasn't
logged in within 30 seconds is closed, and at most 16 are open.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 20:00:43 +01:00
Ludovic Marie 4ba0479a7d Merge pull request #5317 from sirrayi/fix/mob-body-armor-wear
🤖 Agent: reverted the guest-tool broadcast (#5392 does it with the player half); same mob-armour content as reviewed
2026-10-31 19:59:49 +01:00
Ludovic MarieandClaude Opus 5.5 86f3920a4b Merge main into #5266 (main's launcher strings are complete again after #5397)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:59:45 +01:00
Ludovic Marie 854bce76e4 Merge pull request #4996 from Solenopsisbot/headless/loop-handle
🤖 Agent: merged main; main's new control_command_inner now takes &dyn EventLoopCtl (the land check's E0425); cargo check of bins and tests passes
2026-10-31 19:59:45 +01:00
Ludovic Marie 06da0b9fd6 Merge pull request #5059 from MSedgeMC/fix/xp-orb-unstuck
🤖 Agent: requeued on rdbtCVS's merge of main (Python suite fix)
2026-10-31 19:59:42 +01:00
Ludovic Marie 8acdff48fc Merge pull request #5392 from xFedeT/fix/player-equipment-break-event
🤖 Agent: the player half of onEquippedItemBroken (hands 47/48, armour, shield, guest breaks broadcast); complements #5317 (mobs) without touching creatures.rs
2026-10-31 19:59:37 +01:00
Ludovic Marie 16d7a9e870 Merge pull request #5084 from reservedbytes/fix/sulfur-cube-tnt-explodes
🤖 Agent: requeued on rdbtCVS's merge of main (Python suite fix)
2026-10-31 19:59:32 +01:00
Ludovic Marie c26c4cedca Merge pull request #5131 from Qwerner88880/hooks-chunk-player
🤖 Agent: merged main myself (lan.rs: main's wake_woken_guests before this PR's Option-returning lan_process_events); native_ tests pass locally.
2026-10-31 19:59:27 +01:00
Ludovic Marie 56a6bfa5b5 Merge pull request #5377 from xFedeT/fix/shulker-bullet-hurt
🤖 Agent: ShulkerBullet.hurtServer on a player's blow (sound, 15 crit, destroy), pick radius 0 as Entity.getPickRadius; test
2026-10-31 19:59:23 +01:00
Ludovic Marie 26116c31e4 Merge pull request #4484 from quintyo/fix/recipe-book-close-item
🤖 Agent: merged main; container_screen.rs keeps main's creative-page test beside this PR's recipe-book test (8 pass).
2026-10-31 19:59:20 +01:00
MSedgeMC 1a26cd5643 Let a tooltip sit at the top edge, where vanilla leaves it 2026-11-01 00:29:11 +05:30
MSedgeMC 705b60f684 Growl only for a wolf that is actually angry 2026-11-01 00:29:04 +05:30
levzzz fefa788a34 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast
# Conflicts:
#	launcher/Cargo.lock
2026-10-31 23:58:48 +05:00
TeiwazikandClaude Opus 5.5 03829bb88e Mipmaps: dither a high-resolution cutout's small levels
Grass blades a few texels wide at 2048 average to about the same alpha everywhere by 64, and no scale of that alpha keeps the coverage, so the alpha test passes the whole quad or none of it. From 64 texels up, each level below the base takes the mean alpha through an ordered dither, about as many texels in as the base has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 19:58:40 +01:00
Ludovic MarieandClaude Opus 5.5 6237b86c0b Revert "fix(mobs): a guest's tool breaking reaches the other clients"
This reverts commit 3d5138cfd: #5392 broadcasts the player's equipment
breaks (both hands, armour, shield, a guest's included) through
onEquippedItemBroken, so with both the guest's main-hand break would be
sent twice. This PR stays the mobs' half.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:57:32 +01:00
Ludovic MarieandClaude Opus 5.5 837a0e774a Merge origin/main into server-admin/management
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:57:23 +01:00
Ludovic MarieandClaude Opus 5.5 35f85fd88d Merge origin/main into headless/loop-handle
control.rs: main's control_command_inner takes the loop handle
(&dyn EventLoopCtl) like the rest of the game's methods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:57:02 +01:00
Federico TaglianiandClaude Sonnet 5.5 cb12c27a6e MinecartSpawner: a spawner minecart ticks its BaseSpawner where it stands
A new cart kind (spawner_minecart, made by /summon: there is no item) that
shows a spawner block, drops and is picked as a plain minecart, and saves and
loads the BaseSpawner fields. Its serverTick is the spawner block's, with
broadcastEvent as an entity event on the cart.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:55:19 +01:00
levzzzandClaude Opus 5.5 e36ed13712 Launcher: GPUI Fast instead of GPUI
longbridge/gpui-fast: Zed's GPUI with retained views and cached scroll layers.
The API changes are mechanical: focus and blur take the App, the application
comes from gpui-fast-platform (wgpu on Linux), menus say whether they're
disabled, shadows whether they're inset. oo7 follows to 0.6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:55:12 +05:00
Ludovic Marie 2d39fb84ca Merge pull request #5397 from horizon-rust/fix/launcher-lang-ja-ptbr-tr
🤖 Agent: main's launcher i18n test fails on 17 strings missing in ja/pt-br/tr; added them, every table has the same 500 keys
2026-10-31 19:55:11 +01:00
Ludovic Marie fd1e6c104f Merge pull request #4638 from elythepreciouscat/feat/ui-slider-keyboard-controls
🤖 Agent: merged main (title.rs: focused_button beside main's Experiments fields); client lib, bin and tests build.
2026-10-31 19:55:07 +01:00
Ludovic Marie 50b900579a Merge pull request #5139 from oskal675-design/fix/gateway-exit-storage
🤖 Agent: merged main myself (world_loading.rs: the gateway migration beside main's packed biomes); builds locally.
2026-10-31 19:55:02 +01:00
Ludovic Marie 82149460e3 Merge pull request #4584 from doodlyyousuf/fix/mob-minecart-board
🤖 Agent: merged main again: the verify failure was main's hub.rs unreachable-pattern clippy error, fixed in 825a6bbcf.
2026-10-31 19:54:57 +01:00
Ludovic Marie 3e531ead7b Merge pull request #5394 from abit-foggy/fix/intel-gen12-vulkan-flicker
🤖 Agent: is_broken_intel_vulkan extended to Gen12 device ranges/names (#4599), same Vulkan+Intel gate as Gen11's
2026-10-31 19:54:52 +01:00
Ludovic Marie d7708ced16 Merge pull request #5391 from horizon-rust/docs/no-rebase-after-maintainer-push
🤖 Agent: docs-only CONTRIBUTING rule (merge main, no rebase after a maintainer push); public tree check passed.
2026-10-31 19:54:48 +01:00
Ludovic Marie 3d0bc068f6 Merge pull request #5174 from reservedbytes/feat/mod-mob-effect-hooks
🤖 Agent: merged main myself (native_hooks.rs: the effect hooks beside #5170's damage hook); build and native_ tests pass locally.
2026-10-31 19:54:42 +01:00
Ludovic Marie be872e629d Merge pull request #4413 from ESHAYAT102/feat/native-visual-hooks
🤖 Agent: requeued after merging main: terrain.wgsl's TerrainOut keeps main's water-mirror height at location 9 and this PR's emissive layer moves to 10; horizon-client-render's 767 lib tests pass.
2026-10-31 19:54:36 +01:00
Ludovic Marie 792ee6e67a Merge pull request #5325 from arismoko/native-component-types
🤖 Agent: read the transport slice: custom component IDs behind a OnceLock table, checked in the fallback arm only (vanilla ids unchanged), unnegotiated stacks dropped; two integration tests
2026-10-31 19:54:32 +01:00
Ludovic MarieandClaude Opus 5.5 ca4eeab63e fix(blocks): a hopper taking a jukebox's disc stops the song
JukeboxBlockEntity.removeItem goes through setTheItem(EMPTY), which stops
the song and clears has_record; the hopper extraction path now does the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:54:30 +00:00
sirrayiandDeepSeek Harness 2f6e9fd50a fix(blocks): a hopper can put a disc in a jukebox, and it plays
`JukeboxBlockEntity` holds its disc in a `ContainerSingleItem`, so a hopper
or a dropper reaches it like any other container and a disc that lands
starts the song. Here the disc was a bare `Jukebox.record` that
`BlockEntity.slots` did not expose, so `HopperBlockEntity.getContainerAt`
found no container and nothing could be inserted (the
`JukeboxBlockEntity.setTheItem` and `.canPlaceItem` rows of #3516).

`Jukebox.record` becomes `items: [Option<ItemStack>; 1]` with a `record()`
accessor, which `slots`/`slots_mut`/`contents` now hand out; `Containers`
learns `Kind::Jukebox` and its `canPlaceItem` (a playable disc, into the one
empty slot); and a successful transfer calls the new
`Game::jukebox_inserted`, which starts the song the way the player's own use
does (block state, level event, neighbours and comparator).

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:54:30 +00:00
Ludovic Marie 87c52a279d Merge pull request #5355 from MSedgeMC/fix/spore-blossom-limit
🤖 Agent: ParticleLimit.SPORE_BLOSSOM after the provider (RNG order kept); added a size guard before the scan; test passes
2026-10-31 19:54:28 +01:00
Ludovic Marie 6badfbf436 Merge pull request #5203 from Qwerner88880/hooks-fluid-move
🤖 Agent: reviewed and fixed (see my comment); merged with main, fluid tests and clippy pass locally.
2026-10-31 19:54:23 +01:00
Ludovic Marie 551eb26680 Merge main into #5318 (main's Python tests pass again after #5369)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/wither.rs
2026-10-31 19:53:02 +01:00
Ludovic Marie b42aa496ee Merge remote-tracking branch 'origin/main' into sweepc-4996 2026-10-31 19:52:28 +01:00
Ludovic Marie 0e85ff0175 Merge main into #4698
# Conflicts:
#	crates/horizon-client-render/src/datapack.rs
#	crates/horizon-client/src/game/dialogs.rs
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/lan/data.rs
2026-10-31 19:52:25 +01:00
Ludovic Marie 8a5e34b485 Merge main into #5299 (windows-sys features: main's System_Console beside this PR's Gdi/UI)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-threads/Cargo.toml
2026-10-31 19:51:09 +01:00
RansomandClaude Opus 5.5 46598a2bc5 Native mods: native_play_local_sound plays a client mod's sound, later
A client helper that plays a sound event, vanilla's or the mod's own
from its assets' sounds.json, for this player only and up to a minute
later (SoundEngine.playDelayed), so a mod can time a blast's sound to
its shock wave. Audio's distance delay now goes through the same
play_after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 de92159b98 Native mods: native_spawn_falling_block throws a block from a server mod
A server mod's helper that throws a falling block at a velocity: it
flies, lands and places itself as sand does, or drops as an item, and
can hurt what it lands on as an anvil does. For explosion debris and
collapsing builds. Refused on a client, for another dimension, air, an
unloaded chunk, values that aren't finite, or past 1024 falling blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 3d09e62b52 Native mods: entities for server mods (summon, move, display transforms)
native_summon spawns any entity from its compound as /summon does;
native_move_entity, native_set_display and native_remove_entity move,
pose (transformation eased over ticks) and remove the entities Horizon
keeps without simulating them: displays, markers, interactions. An
item_display with a mod's own item model is a model that moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 39755f90ff Native mods and shader packs: native_shader_params hands a mod's pack eight values
A client hook asked once a frame for eight vec4s that every stage of a
Horizon shader pack reads as frame.mods, so a native mod can tell the
pack it bundles where and when something happened (an explosion's
centre and age for a cloud ray marched in a post pass). Values that
aren't finite read as 0; all are 0 when no mod sets them. The
reference is regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
Ludovic MarieandClaude Opus 5.5 48af991e36 Launcher: the 17 settings and releases strings ja, pt-br and tr lacked
#5226 added the proxy, game arguments, release headers and sponsor strings
to the other languages; ja, pt-br and tr (added beside it) never got them,
so the launcher's every_language_has_every_string_the_code_asks_for test
fails on main and on every PR's launcher check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:49:56 +01:00
TeiwazikandClaude Opus 5.5 01b05967ea Packs: read a pack.mcmeta with a comment after its JSON
A pack with text after the closing brace of its pack.mcmeta was left out of the pack list, because serde_json refuses trailing input. Only the first JSON value is read now, so such a pack shows up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 19:49:11 +01:00
Ludovic Marie dc8ec33576 Merge remote-tracking branch 'origin/main' into sweep/5139-m3
# Conflicts:
#	crates/horizon-client/src/game/world_loading.rs
2026-10-31 19:48:48 +01:00
Ludovic Marie 5d16a611c3 Merge pull request #5233 from iseaprojects/i18n/es-es-complete
🤖 Agent: merged main into the reviewed head (clippy had failed on main's old unreachable S::Attack arm, fixed in 825a6bbcf). es_es.json completed (198/198 keys, placeholders kept, JSON checked); asset only, no code.
2026-10-31 19:48:29 +01:00
Ludovic Marie a2dbd47419 Merge pull request #5253 from reservedbytes/fix/sulfur-cube-burning-arrow
🤖 Agent: merged main into the reviewed head (clippy had failed on main's old unreachable S::Attack arm, fixed in 825a6bbcf). read the diff against vanilla's SulfurCube.hurtServer: a burning arrow or trident lights an explosive cube's long fuse at the hit (tnt_explodes), after the hurt; pending sounds become flags so primed and hit both play; unit tests.
2026-10-31 19:48:22 +01:00
Ludovic Marie dd4194f728 Merge main into #4638
# Conflicts:
#	crates/horizon-client/src/title.rs
2026-10-31 19:48:00 +01:00
[email protected] e2d852088b Windows build: the windows-sys features boot_paint uses
boot_paint.rs draws the boot screen with GDI (Win32_Graphics_Gdi) and reads the
window's size (Win32_UI_WindowsAndMessaging), which horizon-threads didn't turn on.
2026-10-31 19:47:15 +01:00
Ludovic Marie 049c0a54e7 Merge pull request #4447 from PzmuV1517/shader-packs/07
🤖 Agent: merged #4445's new head (86e0d83db, main merged) up the stack; #4790 also merges current main (lib.rs: shader_options beside main's split_profiles); top of stack builds clean (client lib+bin).
2026-10-31 19:45:57 +01:00
Ludovic Marie 008ea17e50 Merge pull request #4446 from PzmuV1517/shader-packs/06
🤖 Agent: merged #4445's new head (86e0d83db, main merged) up the stack; #4790 also merges current main (lib.rs: shader_options beside main's split_profiles); top of stack builds clean (client lib+bin).
2026-10-31 19:45:07 +01:00
Ludovic Marie f71747f7cc Merge main into #4785 (main's hub.rs clippy fix) 2026-10-31 19:44:02 +01:00
Ludovic Marie 21a0572afe Merge main into #4584 (main's hub.rs clippy fix) 2026-10-31 19:43:58 +01:00
sirrayiandDeepSeek Harness 3d5138cfd6 fix(mobs): a guest's tool breaking reaches the other clients
`LivingEntity.onEquippedItemBroken` broadcasts the equipment-break event for
the hand, so every client plays the break and its crumbs. A guest's tool
breaks on the host (`Game::damage_held` acting for the guest), and until now
nothing told the other clients: it was heard only by the window that
happened to be simulating. The break now goes through
`Game::lan_entity_event` with `equipment_break_event(MainHand)` (47).

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:52 +00:00
sirrayiandDeepSeek Harness 92bc5e7728 refactor(mobs): the equipment-break event comes from the vanilla method
`LivingEntity.entityEventForEquipmentBreak` has a client-side inverse here
already (`item_break::equipment_break_slot`); the wear added for #5317 spelled
the event ids out itself. `item_break::equipment_break_event` is that method
by name, `hurt_mob_armor` asks it for the slot's id, and a test pins the two
as inverses of each other over every slot.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:52 +00:00
sirrayiandDeepSeek Harness 009f5d0de4 fix(mobs): every worn piece wears when its mob is hurt
`LivingEntity.doHurtEquipment` covers all the equipment a living entity
wears, not only a horse's body armour: `Game::hurt_mob_armor` now takes
`max(1, damage / 4)` off the helmet, chestplate, leggings and boots too,
Unbreaking and the fire-resistant spare applied, and a piece that reaches
its maximum empties its slot and broadcasts the equipment-break event for
it (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52 for the armour,
65 for the body).

`tools/port_map`: `LivingEntity.entityEventForEquipmentBreak` is `inline`
now, with what it still misses written in its note.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:52 +00:00
sirrayiandDeepSeek Harness 0854972109 fix(mobs): a horse's body armour wears when the horse is hurt
`Horse.hurtArmor` (`LivingEntity.doHurtEquipment`) was not modelled at all:
`Mob::damage_with` had no notion of worn durability, so a horse's armour
kept its full durability however often the horse was hit, and the piece
never broke (the row of #3512).

`damage_with` now reports the hit's dealt amount for a mob wearing body
armour (`MobEvent::ArmorHurt`, which carries the damage kind), and the host
applies it in `game/creatures.rs::Game::hurt_mob_armor` the way the player's
own `hurt_armor` does: `max(1, damage / 4)` with Unbreaking applied, no
wear for a piece that does not wear when hurt or for a fire hit on a
fire-resistant one, and at the item's maximum the slot empties and the
equipment-break event (65, the body slot) goes out. A wolf's armour keeps
its own rule (`Wolf.actuallyHurt`, `hurt_wolf_armor`).

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:52 +00:00
sirrayiandDeepSeek Harness a343318ba3 fix(mobs): frogs swim and prefer the shallows when they path
`Frog.FrogPathNavigation` is an `AmphibiousPathNavigation` whose
`prefersShallowSwimming` override costs the water nodes deeper than ten
blocks under sea level, but `frog.rs::register` never changed the
navigation kind, so a frog pathed as a plain walker: it would not cross
water at all and no frog ever reached a shallow bank (the
`Frog.FrogPathNavigation.createPathFinder` row of #3512).

`NavKind::Amphibious` now carries the preference (`Amphibious {
prefers_shallow }`), which the path finder already had a field for and
always read as false, and a frog sets it. The axolotl, drowned and turtle
keep the plain form.

`tools/port_map/game-012.tsv`: both `Frog.FrogPathNavigation` rows are
`port` now.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:51 +00:00
Ludovic Marie 47f372fee8 Merge pull request #5085 from k3yh/fix/nbt-long-string-save
🤖 Agent: merged main again (nbt.rs: kept both new tests); nbt tests pass
2026-10-31 19:43:25 +01:00
sirrayiandDeepSeek Harness 9b3521e324 tooling(port-map): four more world rows of #3517 are ported
`FlatLevelGeneratorSettings.updateLayers` and `.adjustGenerationSettings`,
`LevelChunk.postProcessGeneration` and `LevelSummary.backupStatus` are all
in the code (`flatgen.rs`, `game/postprocess.rs`, `save.rs`); the checklist
rows describing them as unported are stale.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:14 +00:00
sirrayiandDeepSeek Harness 69c877ce43 tooling(port-map): three rows the audit found wrong, not missing
The checklists of #3512/#3514 assumed these were unported; reading both
sides says otherwise:

- `FallingBlockEntity.getTypeName` is no port at all: the anvil death text
  is its damage type's message id, which takes no block-name argument.
- `Ocelot.OcelotAvoidEntityGoal.canUse` is `tame.rs::OcelotAvoid::can_use`,
  registered last for a wild ocelot.
- `Axolotl.Variant.getSpawnVariant` loses a stale gap: spawn never draws the
  blue variant in vanilla either, and the 1-in-1200 draw is on breeding.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:14 +00:00
sirrayiandDeepSeek Harness a89abcb7b8 tooling(port-map): six more rows of #3514 are ported
Block and item displays, a saved text display, the spear's stab count, a
firework rocket's entity hit and the arrow and stinger counts a player
carries are all in the code already.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:14 +00:00
sirrayiandDeepSeek Harness 8ed534bfa7 tooling(port-map): sixteen more rows of #3516 are ported
The second half of the blocks, redstone and fluids list: the jigsaw and
structure block editors, a spawn egg on a trial spawner, the campfire,
chorus flower and decorated pot a projectile lights or breaks, the wind
burst that opens doors, trapdoors and levers, the flower pot an empty hand
empties, the light block's picked level, and the It Spreads trigger.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:14 +00:00
sirrayiandDeepSeek Harness 8de5fb1857 tooling(port-map): rows of #3512, #3514 and #3517 are ported already too
The same read of the checklists against `main` for the animals, entities and
world lists (#3601). Eighteen rows move from `missing` to `port`, and five
already-`port` or `inline` rows lose a gap note that has since been closed:
schooling-fish followers path to their leader, a copper golem opens doors on
its path, a mule never mates, a tamed wolf's kill is its owner's, and loot
tables apply set_components.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:14 +00:00
sirrayiandDeepSeek Harness 01517c8ec1 tooling(port-map): nineteen rows of #3516 are ported already
Re-read the open rows of the blocks, redstone and fluids list against `main`
(#3601). Nineteen of them are in the code already, so their verdict-map rows
stop saying `missing` (or lose a gap note that has since been closed):

- the whole structure block screen and its behaviour: the mode buttons, size
  detection between corners, save, load, place-with-settings and the redstone
  trigger are `game/structure_blocks.rs`, and the outline box and render mode
  are `horizon-client-render/src/structure_block.rs`;
- `TurtleEggBlock.destroyEgg` is the shared `BlockLandingRegistry::destroy_egg`
  of the fall and step rolls;
- `LeverBlock.useWithoutItem`'s dust puff is `Game::lever_puff` on the client
  that clicked;
- `LightBlock.getCloneItemStack`, `MagmaBlock.stepOn`,
  `RedStoneOreBlock.spawnParticles`, `BeehiveBlock.playerWillDestroy` and
  `TripWireBlock.playerWillDestroy` all have their port.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:14 +00:00
sirrayiandDeepSeek Harness af096e8ce1 test(mobs): the bounce test's MobWorld takes main's universal_anger rule
Main grew `MobWorld.universal_anger` (`NeutralMob.isAngryAtAllPlayers`)
after this branch was written, so the test's MobWorld literal stopped
building. Restores the one-line fix the maintainer's agent had pushed here
before a rebase of mine overwrote it.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:13 +00:00
sirrayiandDeepSeek Harness 6c50703103 fix(mobs): a mob landing on a slime block or bed bounces
`Mob::do_move` rebuilt `Entity.restituteMovementAfterCollisions` by hand and
only ever used the entity's own `bounciness` attribute, so the block's
restitution never reached a mob: a slime block or a bed under a falling mob
stopped it dead where vanilla sends it back up (`Entity.getBlockBounciness`,
the row of #3514).

The port of that method now drives mobs too:
`movement_restitution::restitute_movement`, which the player and dropped
items already use and which `tools/test_restitution_parity.py` compares with
vanilla, is bound to a mob's own fields by `MobRestitution`. That also drops
the hardcoded honey check: which blocks suppress a bounce is the movement
data's `suppresses_bounce` tag, as for every other entity, and the
gravity comparison uses the living entity's effective gravity (0.01 while a
Slow Falling mob descends) instead of the bare attribute.

Mobs still sneak never, so nothing suppresses their own bounce.

`tools/port_map/game-005.tsv`: the `Entity.getBlockBounciness` row loses its
"mobs ignore it" gap.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:43:13 +00:00
Ludovic Marie 1c60198bd3 Merge pull request #5199 from reservedbytes/fix/guest-blast-advancement-cause
🤖 Agent: Requeued at 352c6336a: the commits since my review are main merges only (for #5251's build fix); nothing blocking.
2026-10-31 19:42:56 +01:00
Ludovic MarieandClaude Opus 5.5 e56a64273b CONTRIBUTING: merge main, don't rebase, once a maintainer pushed to your branch
Contributors' agents that rebase and force-push over maintainer fixups
erase them (#5188, #5203). Ask for merges of main instead, and say what
happens if the fixes get erased anyway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:42:54 +01:00
Ludovic Marie e0af08b8b1 Merge pull request #5172 from kacpherek/fix/frame-map-updates
🤖 Agent: Requeued at 38ca64911: the commits since my review are main merges only (for #5251's build fix); nothing blocking.
2026-10-31 19:42:45 +01:00
Ludovic Marie 07dec696b7 Merge pull request #5157 from mulareal/fix-skin-pack-reload-5097
🤖 Agent: Requeued at 190e96938 after reading what came in since (approvals, main merges for #5251's fix); nothing blocking.
2026-10-31 19:42:33 +01:00
Ludovic Marie 94aa6231da Merge pull request #5124 from mulareal/fix-item-slime-bounce-5080
🤖 Agent: Requeued at b4980bc77 after reading what came in since (approvals, main merges for #5251's fix); nothing blocking.
2026-10-31 19:42:16 +01:00
Ludovic Marie f0f5eac5fc Merge pull request #5291 from horizon-rust/fix/ci-native-mods-strings
🤖 Agent: re-read after the author's update: only crates/horizon-mod-registry/Cargo.lock regenerated against main (adds zip and its deps), so mod-registry's --locked clippy passes; the launcher strings reached main elsewhere.
2026-10-31 19:41:59 +01:00
Ludovic Marie f67740ab8d Merge remote-tracking branch 'origin/main' into sweep/5174-m3
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-10-31 19:40:24 +01:00
Ludovic MarieandClaude Opus 5.5 a11de0a95f Merge origin/main into headless/loop-handle (main's Python suite fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:40:09 +01:00
Ludovic Marie dfd94a7e51 Merge pull request #5069 from masa-masa-0314/feat/trade-rebalance
🤖 Agent: merged main again (#5304 fixes main's clippy break that failed this branch's clippy); requeued
2026-10-31 19:39:37 +01:00
Ludovic Marie 8cd2d69e21 Merge pull request #5063 from ludovicthenot/fix/ride-non-mob-vehicles
🤖 Agent: merged main again (#5304 fixes main's clippy break that failed this branch's clippy); requeued
2026-10-31 19:38:46 +01:00
Ludovic Marie cc65cfef36 Merge pull request #5057 from MSedgeMC/fix/conversion-save
🤖 Agent: merged main again (#5304 fixes main's clippy break that failed this branch's clippy); requeued
2026-10-31 19:38:39 +01:00
Ludovic Marie 58ab34373c Merge the branch's new head into #4839
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/ja.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/ru.json
#	launcher/lang/tr.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-10-31 19:38:37 +01:00
Ludovic Marie 5be990276d Merge pull request #5002 from doodlyyousuf/fix/4974-dialog-held-keys
🤖 Agent: merged main again (#5304 fixes main's clippy break that failed this branch's clippy); requeued
2026-10-31 19:38:29 +01:00
Ludovic MarieandClaude Opus 5.5 839399c373 Merge origin/main into fix/ride-non-mob-vehicles (main's Python suite fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:38:23 +01:00
Ludovic MarieandClaude Opus 5.5 a197021889 Launcher: the new strings in pt-BR, Japanese and Turkish
Main added Japanese and Turkish catalogs and new strings since this PR;
the i18n test asks every language for every string any catalog holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:38:11 +01:00
Ludovic Marie b0d667e0c8 Merge pull request #5058 from reservedbytes/fix/death-message-held-item
🤖 Agent: merged main again (#5304 fixes main's clippy break that failed this branch's clippy); requeued
2026-10-31 19:38:09 +01:00
Ludovic MarieandClaude Opus 5.5 77c1778b15 lan_bot: Hub::tick drains the players' packets; one S::Attack arm
#4787 moved the players' packets into Hub::drain_packets, which the game
calls between poll and tick_level; Hub::tick, the whole-tick helper the
lan_bot server uses, kept only poll and tick_level, so 8 of 14 tests saw
none of their packets handled. The bots of
guests_see_each_other_and_the_world_entities now send ClientTickEnd after
each move, as a client does: a second move in one client tick is kicked.

hub.rs's packet loop had two S::Attack arms (the anticheat's, then
#5123's, which keeps the moves queued behind an attack for the next tick):
the second was unreachable, which fails clippy -D warnings on main and
a_move_behind_an_attack_and_its_swing_waits_for_the_next_tick. One arm
does both now (the same hunk as #5365).

Fixes #5364.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:38:02 +01:00
Ludovic Marie 6993869f71 Merge pull request #5314 from elythepreciouscat/perf/terrain-frame-budget
🤖 Agent: read the diff: a software (DeviceType::Cpu) adapter skips the GPU occlusion pass by default, HORIZON_GPU_CULL still forces it either way; hardware unchanged; measured +5 % FPS on llvmpipe; unit test. Merged main, dropping the branch's duplicate #5251 fixes.
2026-10-31 19:37:56 +01:00
Ludovic Marie cd62766252 Merge pull request #5308 from loicpiller/fix/4166-inventory-crafting-shortcuts
🤖 Agent: read the diff: screens read Shift/Ctrl from the OS modifier word (InputWithModifiers.hasShiftDown), the word reconciles stale physical keys and held latches on ModifiersChanged (toggles, pad holds and rebinds kept), Shift-drag distributes a carried stack, pad Y quick-moves, Ctrl+Q reads the same word, horizon-ctl key/release keep it in sync; new lan_e2e checks. Merged main: #5208's per-frame heal reads the same word, so the two agree; cargo check -p horizon-client --lib --bins --tests clean.
2026-10-31 19:37:27 +01:00
Ludovic Marie d4ee991627 Merge pull request #5055 from Teiwazik/fix/pack-signature-reads-directory
Packs: don't hash a large zip's bytes on every overlay check
2026-10-31 19:37:18 +01:00
Ludovic Marie ba51a54f99 Merge pull request #5083 from reservedbytes/fix/wither-skull-dragon
fix(dragon): a wither skull hits the ender dragon's parts and explodes
2026-10-31 19:37:14 +01:00
Ludovic Marie e4e9cfb691 Merge pull request #5113 from TobiasLogic/shaderpacks/smooth-first-value
Shader packs: custom uniforms' smooth starts from the first real value
2026-10-31 19:37:10 +01:00
rdbtCVSandClaude Opus 5.5 171b667268 Merge origin/main: the water mirror's terrain group gets the emission thresholds too
Main's new mirror_group shares the terrain bind group layout, which this
branch extends with the emission-thresholds texture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:36:44 +00:00
Ludovic Marie b9b88227a8 Merge pull request #4967 from MSedgeMC/fix/golem-anger-and-cracks
🤖 Agent: merged main; golem.rs keeps both main's and this PR's test modules; golem tests pass (12).
2026-10-31 19:36:39 +01:00
Federico TaglianiandClaude Sonnet 5.5 9aeae198d1 A player's broken tool, shield or armour is told to every client
LivingEntity.onEquippedItemBroken broadcasts entityEventForEquipmentBreak
(47 hand, 48 off hand, 49 to 52 armour) and each client plays breakItem: the
stack's break sound and the crumbs. The acting guest's breaks go out with
their entity id when their turn ends; the shield's sound is its break_sound
component, not a sound the server plays.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:36:31 +01:00
Ludovic Marie 6415dfee84 Merge remote-tracking branch 'origin/main' into sweep/5169-m3
# Conflicts:
#	crates/horizon-threads/Cargo.toml
2026-10-31 19:35:30 +01:00
Ludovic MarieandClaude Opus 5.5 4102dc1056 Server bench tests: Pumpkin's configuration, its ready line, rounds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:35:15 +01:00
Ludovic MarieandClaude Opus 5.5 0b8417aad0 Docs: Horizon's server against Pumpkin at 0 to 100 bots
The same mixed bots on playhorizon (release builds, CPUs 0-3, three rounds):
Horizon's tick is half Pumpkin's at 100 bots, equal at 60, and 1.8 times
longer at 20 (where it generates 63 % more new chunks a second). Idle
memory, start and median join are behind. The report, how to read it, and
the commands to run it again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:35:15 +01:00
Ludovic MarieandClaude Opus 5.5 e7c2e8962e Server bench: rewrap the Pumpkin paragraph
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:35:15 +01:00
Ludovic MarieandClaude Opus 5.5 a7bed87b6d Server bench: Pumpkin's first start listens on 127.0.0.1 only, without telemetry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:35:15 +01:00
Ludovic MarieandClaude Opus 5.5 aa7f278b7f Server bench: Pumpkin runs, rounds and the join time
tools/server_bench.py --servers horizon,pumpkin --pumpkin <binary>: Pumpkin
(a binary built by the user, run as a black box) gets its own default
configuration with the benchmark's settings, listening on 127.0.0.1 with its
Bedrock and LAN listeners, query, plugins and telemetry off. --repeat runs
every server and bot count again, alternated; a run with 0 bots measures the
idle server; each run records the bots' join time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:35:14 +01:00
levzzzandClaude Opus 5.5 72a14b86ff Launcher: a native build of the mods turned on reads as up to date
A build recorded each mod's digest over its mixins/<id>/ folder (after repinning
its copy), but the Mods page digests the whole .hnmod, horizon-native.json
included. They never matched: every registry native mod showed Needs rebuild
while Build said no build was needed. The build now records the digest the page
reads, taken before repin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:34:31 +05:00
rdbtCVSandClaude Opus 5.5 2880dda4d3 Merge origin/main: fluid quads take main's face shade and still record animated layers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:34:11 +00:00
Ludovic Marie b863e7cb4c Merge pull request #5311 from mariuslozo-boop/tooling/windows-lan-e2e
🤖 Agent: read the diff: lan_e2e/agent_control_e2e run on Windows (junction fallback on WinError 1314, no vscreen), horizon-ctl launch clears HANDLE_FLAG_INHERIT on its std handles before spawning the game (cfg(windows) only); Linux/macOS unchanged; Python tests. Proof on Windows 11 in the body.
2026-10-31 19:33:39 +01:00
Ludovic Marie c27101174a Merge pull request #5312 from sirrayi/fix/chat-normalize-message
🤖 Agent: read the diff against vanilla's ChatScreen.normalizeChatMessage: String.trim, normalizeSpace (Java whitespace) and the 256 UTF-16 unit cut before sending and before the history; unit tests.
2026-10-31 19:33:06 +01:00
RansomandClaude Opus 5.5 4983adec8b Native mods: native_camera_shake lets a client mod shake the camera
A client hook asked each frame for turns in degrees and an offset in
blocks, which add up across mods. Horizon scales the sum by Distortion
Effects (0% turns it off), bounds it to 10 degrees and half a block on
each axis, and multiplies it into the view after the hurt tilt and view
bobbing. It moves what the player sees, not where they aim.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:33:03 +00:00
Ludovic Marie 6491049437 Merge remote-tracking branch 'origin/main' into sweep/5131-m3
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
2026-10-31 19:33:01 +01:00
reservedbytes b7c356a99e feat(mods): native mods change or cancel damage to the ender dragon 2026-10-31 17:32:53 -01:00
abit-foggy 4bb84b9efe fix(render): enable flat-draw workaround on Intel Gen12 Vulkan (#4599) 2026-10-31 08:32:47 -10:00
Ludovic Marie 4dc010f75d Merge remote-tracking branch 'origin/main' into a3-pr-4420 2026-10-31 19:32:46 +01:00
Ludovic Marie aa71cc480e Merge pull request #4326 from DUzzL/fix/issue-4321-creative-armor-swap
🤖 Agent: survival: a replaced armour piece that doesn't fit is thrown from the eyes like Player.drop(stack, false), as the other Inventory.add-else-drop paths; 2 lines after #4231 took the rest; rdbtCVS approved. Parity rerun (lunge_motion failure unrelated).
2026-10-31 19:32:29 +01:00
Ludovic Marie 04d1ee1482 Merge remote-tracking branch 'origin/main' into a3-pr-4319 2026-10-31 19:31:22 +01:00
rdbtCVSandClaude Opus 5.5 8c51ac2208 Merge origin/main (picks up 72fabfede's Python test fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:31:14 +00:00
rdbtCVSandClaude Opus 5.5 0d600c843e Merge origin/main (picks up 72fabfede's Python test fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:30:17 +00:00
Ludovic Marie 653ea779c4 Merge main into #4839
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-10-31 19:29:02 +01:00
Ludovic MarieandClaude Opus 5.5 f3eebb902a Merge main into feat/native-visual-hooks: terrain's emissive layer moves to location 10
Main's water mirror took @location(9) for the camera-relative height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:28:51 +01:00
rdbtCVSandClaude Opus 5.5 21569d4a8d Merge origin/main (picks up 72fabfede's Python test fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:28:46 +00:00
sirrayi 8ec84484b5 Merge main into #4891 once more, for #5148's machine-menu test
Main merged #5148, which appends its stock-layout test to the same place at
the end of `game::tests` where this PR appends its two, so the hunk
conflicted. Both sides are whole `#[test]` functions: kept both, each with
its own closing brace, in main's order after this PR's.

`cargo test -p horizon-client --bins` runs all three green
(`a_mod_screen_on_a_machine_menu_takes_the_stock_layout_out` from #5148, and
this PR's `an_interact_packet_clicks_the_entity_it_names_not_the_nearest` and
`an_interact_packet_cannot_name_a_mob_the_pick_would_refuse`).
2026-10-31 18:28:31 +00:00
Ludovic Marie c6d7e29004 Merge remote-tracking branch 'origin/main' into feat/discord-bridge
# Conflicts:
#	Cargo.lock
2026-10-31 19:28:14 +01:00
oxi-does-dev d1f41bf7e7 #4124: jump-mining parity scenarios in the first-person harness
Mining a block above while jumping into reach: the attack stays held,
the aim is lost on the way down and found again on the next jump, and
the block never finishes breaking. Three new cases (pick, no tool,
stone by hand) over the miss-flicker pattern the report describes.

All 4,199 cases match the pinned 26.3 client, so the client-side
breaking logic (sameDestroyTarget resets, stopDestroyBlock on a miss)
is vanilla; the reported divergence must live elsewhere — the eye-ray
(reach/eye height) or the server's delayed destroy.
2026-10-31 21:26:07 +03:00
Ludovic MarieandClaude Opus 5.5 5a3941068b Particles: skip the spore blossom scan while fewer particles are alive than its limit
The scan over live and queued particles only runs once there are at least
ParticleLimit.SPORE_BLOSSOM of them in all, so a spore blossom costs nothing
extra in the common case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:24:07 +01:00
Ludovic Marie 1b6e55d4f3 Merge pull request #5306 from sirrayi/mods/content-block-state-properties
🤖 Agent: review points at 79ce64782 fixed with tests; opt-in mod field, model-states slice left for #5290
2026-10-31 19:23:57 +01:00
Ludovic Marie eb157cc15e Merge pull request #5319 from readerkid/fix/shulker-box-collision
🤖 Agent: shulker box collision follows the lid and pushes entities as moveCollidedEntities; reviewed the diff
2026-10-31 19:23:53 +01:00
Ludovic Marie 4afc31571c Merge pull request #5329 from horizon-rust/server-admin/management-transport
🤖 Agent: management protocol 6a (WebSocket transport, auth, JSON-RPC errors, rpc.discover): parity harness vs vanilla 26.3 passed (20 exchanges), pr_ready --quick and horizon-server's 77 tests pass.
2026-10-31 19:23:50 +01:00
Ludovic Marie f0dc95398f Merge pull request #5171 from levzzz5154/mods/native-entity-leave
🤖 Agent: merged main (kept #5167's join beside the leave hook), leave hook added on main's new removal paths; native_ 31 passed, clippy clean.
2026-10-31 19:23:46 +01:00
Ludovic Marie 6696029c7e Merge pull request #5220 from ErikKalikas/mod-block-entity-live
🤖 Agent: merged main into the reviewed head (clippy had failed on main's old unreachable S::Attack arm, fixed in 825a6bbcf). read the diff against vanilla's Level.sendBlockUpdated / ChunkHolder.broadcastChanges: native_block_entity_sync puts a loaded position in the block log, so the hub sends the block and its update tag once a tick; lan_bot test; tested in game by arismoko. Merged main, API number moved to a hook fragment.
2026-10-31 19:23:42 +01:00
Ludovic Marie a2716978fa Merge pull request #5287 from Sylviromi/menu-button-v2
🤖 Agent: merged main into the reviewed head (clippy had failed on main's old unreachable S::Attack arm, fixed in 825a6bbcf). read the diff: native_menu_button_v2 fires right after native_menu_button with the clicking player's cursor stack (clickMenuButton(Player, int)); a change is written back and sent with SetCursorItem; API number from a hook fragment. Marked ready.
2026-10-31 19:23:38 +01:00
Ludovic Marie 748bd46de0 Merge pull request #5295 from xFedeT/fix/piglin-admire-seen-brute-pickup
🤖 Agent: merged main into the reviewed head (clippy had failed on main's old unreachable S::Attack arm, fixed in 825a6bbcf). read the diff against vanilla: PiglinBrute.wantsToPickUp (golden axe only), StartAdmiringItemIfSeen(119) with StopAdmiringIfItemTooFarAway(9) and StopAdmiringIfTiredOfTryingToReachItem(200, 200), wasHurtBy clearing the memory and ADMIRING_DISABLED for a player's blow; unit tests.
2026-10-31 19:23:34 +01:00
Ludovic Marie 56d0f44cf2 Merge pull request #4445 from PzmuV1517/shader-packs/05
🤖 Agent: requeued after merging main again (clean merge, now that #4441 is in); shader packs part 5/12, review points fixed, earlier checks unchanged.
2026-10-31 19:23:30 +01:00
Ludovic Marie 040b5d809a Merge pull request #4444 from PzmuV1517/shader-packs/04
🤖 Agent: requeued after merging main again (clean merge, now that #4441 is in); shader packs part 4/12, review points fixed, earlier checks unchanged.
2026-10-31 19:23:26 +01:00
Ludovic Marie 879f3cd540 Merge pull request #4443 from PzmuV1517/shader-packs/03
🤖 Agent: requeued after merging main again (clean merge, now that #4441 is in); shader packs part 3/12, review points fixed, earlier checks unchanged.
2026-10-31 19:23:23 +01:00
Ludovic Marie 2e333474f2 Merge pull request #4442 from PzmuV1517/shader-packs/02
🤖 Agent: requeued after merging main again (clean merge, now that #4441 is in); shader packs part 2/12, review points fixed, earlier checks unchanged.
2026-10-31 19:23:19 +01:00
Ludovic Marie b5e85e66a5 Merge pull request #3670 from dacrab/feat/launcher-appimage
🤖 Agent: Linux AppImage: merged main, scoped $APPIMAGE to $APPDIR, rpath instead of LD_LIBRARY_PATH, MCP/shortcuts use update_target; launcher clippy + tests pass.
2026-10-31 19:23:15 +01:00
Ludovic Marie 8d86472e01 Merge main into #4628
# Conflicts:
#	crates/horizon-client/src/game/dedicated.rs
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/persist.rs
2026-10-31 19:23:10 +01:00
Ludovic Marie f5e106b66f Merge pull request #5164 from arismoko/hook-fragments-workflow
🤖 Agent: Requeued at d8882f9cb: the commits since my review are main merges only (for #5251's build fix); nothing blocking.
2026-10-31 19:23:10 +01:00
Ludovic Marie 9b5b56b68b Merge pull request #5262 from some-du6e/feat/debug-entries-content
🤖 Agent: merged main and resolved debug_keys.rs: main's F3 Density line (#4389) is now debug_entry_lines' chunk_generation_stats arm, unchanged; cargo check -p horizon-client --bins clean. Reviewed earlier: vanilla's DebugEntry* lines and six debug renderers, profile datafixes; nothing extra per frame with F3 closed.
2026-10-31 19:23:07 +01:00
Ludovic Marie 6bd31ce03a Merge pull request #4377 from 4404c/secure-chat-v2/3-client
🤖 Agent: secure chat 3/3 after #4376: the client signs chat and /msg on enforce-secure-profile servers (fixes the official server's 'missing profile public key'); Chat Delay flush marks messages processed, command signing waits for the server's tree, reconfiguration ack as vanilla; tested by 4404c with a real account; main merged by the author.
2026-10-31 19:23:03 +01:00
MartipopsandClaude Opus 5.5 dd722ee7a7 Launcher: main's new strings in Japanese, Brazilian Portuguese and Turkish; tr gets the accounts'
Main's 31c1212bf added 17 strings to seven tables but not to ja, pt-br or the new tr, which
fails the every-language test on main. They are translated here as in the PR to main that
fixes it, so the lines are the same. tr.json also gets this branch's 26 account strings and
drops "Microsoft account · signed in", as ja and pt-br did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 14:23:00 -04:00
Claude 71955880b7 Draw a head profile's texture override instead of its skin
A ResolvableProfile can carry a `texture` id (PlayerSkin.Patch body, a
ClientAsset.ResourceTexture). Vanilla draws a player head with that
resource-pack texture instead of the downloaded or default skin; Horizon
ignored it and drew the profile's default skin. Placed heads and head
items now read the patch, loading non-vanilla namespaces' textures from
the resource packs into a skin slot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01P1ZTfB3AfwKgGnv7UFKYVD
2026-10-31 18:22:56 +00:00
Ludovic MarieandClaude Opus 5.5 5b56e48f99 Keep #5188's head and the move's fixes over the rebase
The rebase dropped 24349e636's fixes to native_move_fluids (inverted put-back
check, a tank marked with no fluid in it, other tanks written away; and a
room limit so a cauldron's thirds never need to go back) and #5188's newer
head. This puts them back on #5188's head, with the move in fluids_move.json
as in the rebase.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:22:37 +01:00
RansomandClaude Opus 5.5 9f2291cb87 Native mods: native_explode sets off an explosion from a server mod
A server mod's helper for Level.explode with no source entity: blocks
break as NativeBlast::Keep, Block or Tnt say (the drop decay rules),
entities are hurt and pushed, fire is optional, and players near hear
and see it. Refused on a client, for another dimension, an unloaded
chunk, or a radius outside 0 < r <= 32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:22:25 +00:00
masa-masa-0314andGLM 5.3 1c04975a48 horizon-threads: restore the windows-sys features the naga vendor pass dropped
boot_paint.rs imports Win32_Graphics_Gdi and Win32_UI_WindowsAndMessaging,
which stopped resolving after #4441's Cargo.lock pass; main does not
compile. Also the test's language map resolves the keyed refusal.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-11-01 02:22:24 +08:00
Ludovic MarieandClaude Opus 5.5 9314523024 Fluid tanks: clamp a bad capacity instead of renumbering the tanks
A tank dropped for an invalid capacity shifted the later tanks' numbers, so
the face and fill lists reached the wrong tanks (rdbtCVS's review). Also fixes
write_tanks' doc: an empty tank is written as minecraft:empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:21:21 +01:00
Federico TaglianiandClaude Sonnet 5.5 f286230b3e A blow from a player pops a shulker bullet
ShulkerBullet.hurtServer: the hurt sound, fifteen crit particles and destroy.
The bullet is pickable and attackable (no pick radius, unlike
AbstractHurtingProjectile), so the crosshair finds it, a guest's attack
packet reaches it, and the blow plays Player.attack's sounds for a target
that is not a mob.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:21:19 +01:00
Ludovic Marie 122729e403 Merge pull request #5369 from horizon-rust/fix/main-build-perf-bench-test
Main's Python tests pass again: perf_bench fixture, trusted reviewers list
2026-10-31 19:21:11 +01:00
Ludovic MarieandClaude Opus 5.5 72fabfede5 tests: main's Python suite passes again (perf_bench fixture, trusted reviewers)
- test_table_distinguishes_missing_scene_load_from_zero: #4407's later
  commit makes table() refuse reports without matching conditions, so its
  fixtures now carry the same full conditions as the contract tests.
- test_the_repos_list_is_the_nightlys: the list names Qwerner88880 since
  f35210d2d; 10c71a65d updated test_nightly_set but not this test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:20:13 +01:00
Ludovic Marie 2750e7932f Merge main into #4484
# Conflicts:
#	crates/horizon-client/src/container_screen.rs
2026-10-31 19:17:31 +01:00
Ludovic Marie ee20fbd1a9 Merge branch 't3794' into t3801 2026-10-31 19:17:11 +01:00
Jose Gil 6d7a62fc65 Merge remote-tracking branch 'hr/main' into fix/4829-instances-and-tabs-i18n 2026-10-31 14:17:10 -04:00
Ludovic Marie 696c5709e7 Merge branch 't3777' into t3794 2026-10-31 19:17:06 +01:00
Martipops 80bd8b2aa2 Merge upstream main into feature/launcher-auth-status
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-10-31 14:17:01 -04:00
masa-masa-0314 887f598427 Merge remote-tracking branch 'upstream/main' into tmp-sync
# Conflicts:
#	crates/horizon-client/src/net.rs
2026-11-01 02:16:52 +08:00
Ludovic Marie a734600373 Merge pull request #4932 from reservedbytes/fix/structure-shape-pass
🤖 Agent: merged main (picks up #5251's Audio fix that failed the earlier verify); the touched crates check clean.
2026-10-31 19:16:23 +01:00
Ludovic Marie b127bd2f65 Merge pull request #4398 from Teiwazik/shader/s5b-mirror-option
🤖 Agent: Shader API [reflection] option and mirror-eye facing culling after #4379; only native shader packs see it; rdbtCVS's points fixed; merged up the stack, shader-api tests and a horizon-client check pass.
2026-10-31 19:16:17 +01:00
Ludovic Marie f7782167d3 Merge pull request #5170 from reservedbytes/feat/native-hurt-server-v2
🤖 Agent: Reviewed; merged main, hook moved to a fragment, canceled hits credit no attacker. native_ tests 30 passed, health 8 passed locally.
2026-10-31 19:16:11 +01:00
Ludovic Marie 03c073e4ff Merge pull request #4379 from Teiwazik/shader/s5-water-mirror
🤖 Agent: Shader API water mirror (reflection binding, half resolution, mirrored terrain matrix tested; the sky limit documented) after #4359; rdbtCVS's points fixed; merged up the stack, Frame layout 528 bytes.
2026-10-31 19:16:05 +01:00
Ludovic Marie d30588e308 Merge pull request #4345 from Teiwazik/shader/s2-frame-motion
🤖 Agent: Shader API frame motion (previous_view_projection, camera_motion, delta_time, eased eye light/water/lava) after #4335; rdbtCVS approved; merged with #4335 and main's #4186 fields, layout tests and reference updated.
2026-10-31 19:14:50 +01:00
Ludovic Marie 7bc0a113d2 Merge pull request #5282 from Ashura006/feat/server-pack-in-list
🤖 Agent: read the diff against vanilla's DownloadedPackSource (DOWNLOADED_PACK_SELECTION): active server/world packs show in the Resource Packs list as required, fixed at the top, and their server/ ids stay out of options.txt; unit test.
2026-10-31 19:14:43 +01:00
Ludovic Marie e508458cf8 Merge pull request #5283 from MSedgeMC/fix/falling-block-hidden
🤖 Agent: read the diff against vanilla's FallingBlockRenderer.shouldRender: a falling block whose state equals its cell's is not drawn; one block read per falling block per frame; unit test.
2026-10-31 19:14:37 +01:00
Ludovic Marie d70fc3ec76 Merge pull request #5211 from mulareal/fix-bed-sleep-wakeup-5121
🤖 Agent: re-read the author's rebase onto main (only context in lan.rs changed). read the diff against vanilla: model light probed at the pose's eye height (crouch render offset undone, Joansitoh's review addressed), setPosToBed and xRot 0 on entering and while sleeping, stopSleeping's findStandUpPosition + teleport on wake for the local player and guests. CI failures were main's Audio build break (#5251).
2026-10-31 19:14:32 +01:00
Ludovic Marie d22c3c59cf Merge pull request #5089 from MSedgeMC/fix/locked-containers
🤖 Agent: LockCode.canUnlock (spectator or the main-hand item) and createMenu's refusal (action bar + block.chest.locked, both halves of a double chest) match vanilla; the lock stays in the loader's source tag, so saves keep it; approved by rdbtCVS's agent
2026-10-31 19:14:27 +01:00
Ludovic Marie 0c5ce8e7c8 Merge pull request #5243 from apeirs/fix/uuid-target-any-entity
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff against vanilla: a UUID target resolves through the level's entities (ServerLevel.getEntity), not only mobs; the scoreboard holder lookup shares it; execute on owner keeps only living owners (EntityReference.getLivingEntity). Corpus cases added to command_exec; CI parity runs them. CI failures were main's Audio build break (#5251).
2026-10-31 19:14:22 +01:00
Ludovic Marie f351b64659 Merge pull request #5252 from reservedbytes/fix/sulfur-cube-immune-types
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff against the 26.3 tag sulfur_cube_with_block_immune_to: the eight missing damage types added to the cube's immune list (fireworks left to #4997); unit tests. CI failures were main's Audio build break (#5251).
2026-10-31 19:14:17 +01:00
Ludovic Marie bbed9b695a Merge pull request #5184 from TobiasLogic/shaderpacks/terrain-normals-everywhere
🤖 Agent: read the diff: flat draws now exist wherever the device supports them, but frames without a pack keep per-section draws off Metal/Intel Gen11 (min_sections usize::MAX), so only a pack's terrain and shadow pass draw flat; gives packs gl_Normal and mc_Entity on NVIDIA/AMD, measured black-box. CI green, parity runs on the head merged into main.
2026-10-31 19:14:12 +01:00
Ludovic Marie 58a3beedc3 Merge pull request #5160 from TobiasLogic/shaderpacks/no-face-shading
🤖 Agent: read the diff: with a shaders/ pack (oldLighting unset) terrain and fluid vertices drop the baked face shade, measured black-box against Iris with a probe pack (no source read); without a pack, lighting::cardinal as before. CI green, parity runs on the head merged into main.
2026-10-31 19:14:05 +01:00
Ludovic Marie bdce08d7ed Merge pull request #5148 from sirrayi/fix/mod-screen-on-machine-menus
🤖 Agent: read the diff: while a mod screen is shown on a hopper or 3x3 menu, the stock grid no longer answers first in slot_position, so the mod's slots and inventory_y apply; unchanged without a mod screen. Tested in game by arismoko; CI green, parity runs on the head merged into main.
2026-10-31 19:13:59 +01:00
Ludovic MarieandClaude Opus 5.5 a5fcf75992 horizon-server [discord]: transparent ground, never chat_filtered, in the template
The picture has no box: the glyphs' shadows keep them readable on Discord's
light and dark themes (Anthony's feedback). A chat_filtered event (the server's
chat filter) is never forwarded nor accepted in discord.events. [discord] is a
real section of init's template (webhook "" = off), so the settings reference
lists its keys; docs/api/server-reference.md regenerated. Alt text and fallback
text are cut after defusing, within Discord's limits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:13:38 +01:00
sirrayiandDeepSeek Harness 79ce64782a Content mods: the state tables fan out per combination, and a declared
default no longer needs the template's properties

Both points arismoko raised by reading at e7f4440f0.

1. The NBT tables and the collision overrides carried one row per *template*
   state, not per combination, so `facts.json` and the tables disagreed about
   how many states a block has.

   `nbt` (`states` lists, `cached_shapes` arrays) now emits a row per
   combination in the order `states` numbers them in — a template state's
   combinations are one run, and `Rename::id` maps the template state to the
   first of it — with the declared properties on each row (the `properties`
   compound `BlockState::to_nbt` writes; without them `BlockTable::check`
   fails with "state identities differ" even once the lengths agree).

   `collision_shapes` wrote `first + i`, so only the first combination of each
   template state got a shape and the rest kept the table's -1. It now covers
   every combination of every template state, all pointing at that state's one
   shape: the geometry is the template's, the properties are the block's.

   `structure-blocks.json` declared `states` as the template's states alone;
   it now counts them times the fan-out, so the declared total and the lists
   agree.

2. A `default_state` key the block declares itself was looked up on the
   template anyway, so `{"template": "minecraft:stone",
   "state_properties": {"lit": ...}, "default_state": {"lit": "true"}}` was
   refused with "template default has no state properties" — stone has one
   state and no properties at all. `combinations` already checks a declared
   key's value, and only a key the template has narrows which of its states is
   the default, so the lookup now happens only when such a key is present.

Three tests, each checked red before green: with the old `nbt` the table gets
4 rows where `facts.json` has 6; with the old `collision_shapes` the block's
last state maps to -1; with the old count `structure-blocks.json` says 4 where
its own lists hold 6; and with the old `default_state` guard the property-less
template is refused.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 18:13:15 +00:00
Ludovic Marie f0761a306d Merge remote-tracking branch 'origin/main' into t3777
# Conflicts:
#	crates/horizon-client/src/game/integrated.rs
2026-10-31 19:13:05 +01:00
MSedgeMC 04a8b278aa Cap spore blossom air particles at vanilla*s limit 2026-10-31 23:42:35 +05:30
Ludovic MarieandClaude Opus 5.5 60b6ddeafc LOD: the heightmap level borrows its columns like the rest
Main's heightmap tiles built owned columns; with this PR's zero-copy
meshing the tile and edge columns are references, so the heightmap
columns are built first and borrowed. Spans replaces Vec in the
heightmap and its test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:12:29 +01:00
Ludovic MarieandClaude Opus 5.5 d346e90232 Merge main (#5130's item handler beside the fluid handler)
Both modules declared the same native_simulates; the fluids now use the item
handler's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:10:40 +01:00
Ludovic Marie ea6373755c Merge main into #4747 2026-10-31 19:09:19 +01:00
MariuslozoandClaude Opus 5.5 38c4ec4642 horizon-release-sign: test the signing script on Linux and macOS only
the_openssl_script_signs_what_the_launcher_checks runs
tools/ci/sign_release.sh with `bash`. On Windows, Command looks in
System32 before PATH, so with WSL installed it starts WSL's bash.exe,
which can't open the script's Windows path and the test fails. The
script only ever runs on the ubuntu-latest `sign` jobs of launcher.yml
and nightly-build.yml, so skip it on Windows the way the test already
skips a machine without OpenSSL 3. Linux and macOS run it as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:08:28 +01:00
MartipopsandClaude Opus 5.5 695574db09 Launcher accounts: a refresh saves to the accounts file it is given; pt-BR and Japanese strings
refresh_account and renew_for_game_with take the accounts.json a renewed profile is saved to.
The commands pass the launcher's own (Accounts::path()); the tests pass one that is never
there, so they no longer read or write the developer's launcher data. A new test checks that
a refresh saves the services' newer profile and leaves an unchanged one unwritten.

Main added pt-br.json and ja.json after this branch's strings: both get its 26 account
strings, and lose "Microsoft account · signed in", which the code no longer asks for (the
other tables dropped it already).

With main merged: profile_heads' test uses Accent::VIOLET (main made Accent a hue), and
docs/launcher-commands.md is regenerated for main's `completions` command, which main had
not written into it (the reference test failed on main too).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 14:08:21 -04:00
Alexander Krylov f86d564821 Merge remote-tracking branch 'upstream/main' into ru-strings
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
#	launcher/src/main.rs
2026-10-31 21:07:37 +03:00
Ludovic Marie 0839397021 Merge the stack below (main merged) into #4957 2026-10-31 19:07:15 +01:00
huiandZCode AI agent (GLM-5.3) a5fcdd7f7c Native mods: move a block's fluids to another block
native_move_fluids moves up to its max between two blocks, one fluid,
native_move_items' shape: what the target won't take goes back into the
source, past the fill sides' leave. The source's tanks just gave the fluid
up, so they have the room for exactly what left them, and a cauldron
levels back up by the thirds it gave, which a drain hands out whole.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 19:06:21 +01:00
TobiasLogicandClaude Opus 5.5 e11dae108d Merge main into #5305 (#4441 and #5264 changed translate/mod.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:05:26 +07:00
Ludovic MarieandClaude Opus 5.5 c63f053e28 Merge main; leave hooks for main's new item and orb removals
Merges origin/main (#5167's join hook beside this leave hook). Main's blast
on experience orbs, a mob picking up a whole stack and native_set_item_entity
emptying a stack now tell native_entity_leave_level too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:05:24 +01:00
MariuslozoandClaude Opus 5.5 5187bab995 horizon-server-files: compare stored properties with the platform line separator
Properties.store ends every line with BufferedWriter.newLine, which writes
System.lineSeparator(): "\r\n" on Windows (checked with JDK 25 there).
store() already does the same through line_separator(), but
stores_sorted_and_escaped and defaults_match_vanilla_init_settings
expected "\n" and failed on Windows. The first now expects the platform
separator exactly; the second reads the stored lines as "\n". Nothing
changes on Linux or macOS, where the separator is "\n".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:05:23 +01:00
JoansitohandClaude Sonnet 5.5 fe76e51588 Data packs: content_overlay follows the selected pack order
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:04:50 +01:00
rdbtCVSandClaude Opus 5.5 374171dac6 Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:04:35 +00:00
Ludovic MarieandClaude Opus 5.5 8445b8d3e7 Create World preparing: keep the error screen, block typing, fix doc placement
Review points from rdbtCVS on #4948: a failed read now clears the flag
before failed() so the error screen stays up with Create as its back
target; type_text is blocked during the read like the key paths; the
docs of finish_preparing, title_action and world_worldgen_overrides sit
on their functions again, and set_data_packs' orphan doc is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:04:34 +01:00
Ludovic Marie 2b8c6e90b3 Merge pull request #5310 from rdbtCVS/ci/single-verify-job
🤖 Agent: read the workflow: verify runs its python, fmt/clippy and test steps in one job again (one runner per run instead of four, the runner queue was 300 deep); the required check is still verify, the only one branch protection requires; no game code.
2026-10-31 19:04:17 +01:00
Ludovic Marie 63bd80498b Merge pull request #5297 from sirrayi/fix/dispenser-egg-entity-data
🤖 Agent: read the diff against vanilla: the dispenser's spawn egg now runs the same EntityType.updateCustomEntityTag step (apply_spawn_egg_entity_data, with its guards) a player's egg does.
2026-10-31 19:04:12 +01:00
TobiasLogicandClaude Opus 5.5 498c33fe7d Shader packs: consts that call mix, smoothstep and the like run in main
naga's constant evaluator lacks mix, smoothstep, reflect, the pack/unpack functions and a few
more built-ins, so a const initialised with one fails the program ("Not implemented as constant
expression: Mix built-in function"; 66 times in Bliss). GL drivers fold them.

A global const that calls one now joins the globals our main initialises (as const ... ? ... : ...
already does), and a local const that calls one loses its const and is evaluated in place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:04:02 +07:00
Ludovic Marie 162b972ac3 Merge the stack below (main merged) into #4948
# Conflicts:
#	crates/horizon-client/src/game/world_loading.rs
#	crates/horizon-client/src/title.rs
2026-10-31 19:03:23 +01:00
Ludovic MarieandClaude Opus 5.5 f2b2d3c1c0 Merge main into #5220 (main's clippy fix 825a6bbcf)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:02:49 +01:00
huiandZCode AI agent (GLM-5.3) c1efef9859 horizon-threads: turn on the windows-sys features boot_paint uses
boot_paint.rs imports Win32::Foundation, Win32::Graphics::Gdi and
Win32::UI::WindowsAndMessaging, none of which were in the dependency's
feature list, so every Windows build of main fails at horizon-threads
(E0433, the items are configured out). Linux CI never compiles this
file, so it stays green while Windows builds, including the launcher's,
are broken. The same file passed the font count mutably where the API
takes it by const - Linux clippy cannot see that either.

(The hub.rs duplicate S::Attack arm this pull request first also fixed
has since landed on main by itself.)

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 19:02:43 +01:00
Ludovic Marie 2020e78b2c Merge remote-tracking branch 'origin/main' into t3702 2026-10-31 19:02:16 +01:00
Ludovic Marie 737540db27 Merge pull request #5067 from eymenwsmc/main
🤖 Agent: merged main (clean); every launcher language has the same 483 strings; approved as code owner
2026-10-31 19:02:15 +01:00
Ludovic Marie 2313de06a8 Merge remote-tracking branch 'origin/main' into t3466 2026-10-31 19:02:14 +01:00
Ludovic MarieandClaude Opus 5.5 48439d8a5d Merge main into #5233 (main's clippy fix 825a6bbcf)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:01:59 +01:00
Federico TaglianiandClaude Sonnet 5.5 cf1e7d3246 names: then_some
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:01:44 +01:00
Ludovic Marie ad04330b99 Merge pull request #5241 from horizon-rust/chore/trusted-qwerner
🤖 Agent: Ludovic's decision (2026-10-31), one line in .github/trusted-reviewers.txt; merged main (verify had failed on main's Audio break, fixed by #5251).
2026-10-31 19:01:14 +01:00
Ludovic Marie 6bb01ed6c9 Merge pull request #5277 from arismoko/native-stop-flush
🤖 Agent: read the diff: queued native mod messages are flushed after native_server_stopping while the host and players are still there, on the integrated and dedicated servers; in-game proof on both paths in the body.
2026-10-31 19:01:08 +01:00
Ludovic Marie d39bbb7b4c Merge branch 'sweepB/k4577' into HEAD
# Conflicts:
#	crates/horizon-client-render/src/datapack.rs
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/functions.rs
#	crates/horizon-client/src/game/integrated.rs
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/world_loading.rs
#	crates/horizon-world/src/loot.rs
2026-10-31 19:01:04 +01:00
Ludovic MarieandClaude Opus 5.5 5c8560ecbc Merge main into #5253 (main's clippy fix 825a6bbcf)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:00:56 +01:00
Ludovic Marie df36444423 Merge pull request #5268 from horizon-rust/feat/native-sidebar
🤖 Agent: its owner merged main into the reviewed head (95cdad8b3, unchanged). read the diff: helpers only (per-player sidebar objective, Tab header/footer, tick stats, sleep-count opt-out for AFK players, yaw/pitch on NativeServerPlayer), nothing per tick; the hook fragment is stamped after merge. pr_ready green and the SMP proof on a dedicated server with two clients, per the body.
2026-10-31 19:00:53 +01:00
Ludovic Marie 64b84a7801 Merge pull request #5254 from reservedbytes/fix/sulfur-cube-knockback-sync
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff against vanilla: SulfurCube.knockback sets needsSync before setDeltaMovement; unit test.
2026-10-31 19:00:25 +01:00
Ludovic Marie c2af3b13b9 Merge main into #4577
# Conflicts:
#	crates/horizon-client-render/src/datapack.rs
#	crates/horizon-client/src/game/lan.rs
2026-10-31 19:00:16 +01:00
Ludovic MarieandClaude Opus 5.5 62d1ce8e1b Merge main into #5287 (main's clippy fix 825a6bbcf)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:00:12 +01:00
Ludovic MarieandClaude Opus 5.5 90d357679b Merge main into the AppImage launcher; scope $APPIMAGE, rpath instead of LD_LIBRARY_PATH
- appimage_path: only when this binary runs from inside $APPDIR, so a launcher started by another
  AppImage's program never overwrites that program's file.
- AppRun no longer exports LD_LIBRARY_PATH (the game and every other child inherited it):
  patchelf sets the binary's and libraries' run paths; CI installs patchelf.
- Agent (MCP) configs and desktop shortcuts start update_target(), not the copy in the mount.
- The AppImage is built from $BIN like the archives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:00:08 +01:00
Ludovic Marie 06f08d851d Merge pull request #5264 from TobiasLogic/shaderpacks/infinity-nan-texelfetch
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff: x / 0.0 and sqrt of a negative literal go through a private hz_zero global naga can't fold (GL's run-time inf/NaN), texelFetch1D/2D/3D map to texelFetch; unit test. Shader-pack translator only.
2026-10-31 18:59:59 +01:00
Ludovic Marie bfbe5ba649 Merge pull request #3986 from DUzzL/fix/issue-3971-bots-fight-behaviour
🤖 Agent: merged main again (the earlier CI failure was main's broken Audio initializer, fixed by f80c0360d); clippy -D warnings and the horizon-bots tests pass on the merge. Approved by rdbtCVS; bots-only, no game code.
2026-10-31 18:59:31 +01:00
Ludovic MarieandClaude Opus 5.5 932b14d591 Merge main into #5295 (main's clippy fix 825a6bbcf)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:59:18 +01:00
Ludovic MarieandClaude Opus 5.5 10c71a65de Trusted reviewers test: the list now names Qwerner88880 too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:57:55 +01:00
levzzz a58ad8b2ee Merge remote-tracking branch 'hr/main' into qw-5131 2026-10-31 22:57:43 +05:00
Ludovic MarieandClaude Opus 5.5 ad64e4376f Merge main into #5241
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:57:15 +01:00
DetimesandClaude Sonnet 5.5 8a4397551e Dedicated server: complete /op, /deop, /pardon, /pardon-ip and /whitelist from the server's own lists (#5316)
A guest's completion request was answered over the host process's registries, whose player list on a
dedicated server is the one local player "Player". The server now answers with its real online players and,
as vanilla's providers do (OpCommand, DeOpCommands, PardonCommand, PardonIpCommand, WhitelistCommand), from
the operators, banned names and addresses, and the whitelist.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 17:56:42 +00:00
Ludovic Marie bff326d927 Merge branch 'a3-4444' into a3-4445 2026-10-31 18:56:26 +01:00
Ludovic Marie 723ecbaa7f Merge branch 'a3-4443' into a3-4444 2026-10-31 18:56:26 +01:00
Ludovic Marie 86a257635f Merge branch 'a3-4442' into a3-4443 2026-10-31 18:56:25 +01:00
Ludovic Marie 6173f174b1 Merge remote-tracking branch 'origin/main' into a3-4442 2026-10-31 18:56:01 +01:00
Federico TaglianiandClaude Sonnet 5.5 0a02e784e6 A mannequin keeps and syncs its description and immovable flag
Mannequin.DATA_IMMOVABLE and DATA_DESCRIPTION (after Avatar's two and the
profile), updateDescription (default entity.minecraft.mannequin.label, none
for hide_description, or the saved component) and isEffectiveAi: an immovable
mannequin never travels. The client draws the description under the name
(ClientMannequin.belowNameDisplay) within 10 blocks.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 18:55:26 +01:00
Martipops 8bbf67da6f Merge upstream main into feature/launcher-auth-status 2026-10-31 13:55:01 -04:00
Ludovic MarieandClaude Opus 5.5 2518193044 Merge main into #5266 (main's launcher i18n strings are in now)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:54:21 +01:00
arismoko 2238e66444 Carry private native item components over the wire 2026-10-31 12:52:56 -05:00
Ludovic MarieandClaude Opus 5.5 0aa1e5e37c Merge origin/main into fix/nbt-long-string-save
nbt.rs tests: keep this branch's long-string test and main's root-name test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:52:25 +01:00
levzzz bcc3cc5e53 Merge pull request #5130 from Qwerner88880/hooks-item-handler
Native mods: read and take a block's items through a side, and move them (API next)
2026-10-31 22:52:22 +05:00
Ludovic Marie 193eec30a1 Merge pull request #5285 from horizon-rust/feat/official-server-from-links
Official server: play.playhorizon.dev from links.json; launcher and game move old entries, count follows SRV
2026-10-31 18:51:38 +01:00
Ludovic MarieandClaude Opus 5.5 466d68c465 horizon-server: [discord] posts the chat to a Discord channel, drawn as in game
A new [discord] table in server.toml (webhook, events, enabled) starts a forwarder
on the supervisor's typed event stream: chat, joins, leaves, deaths (and kicks).
Lines of each 2 s are drawn as one PNG of the in-game chat box (half-black box,
§ colours, bold, 1-pixel shadows, wrapped at the chat's 320 px) with Pixelify
Sans (SIL OFL, already in horizon-theme), never Mojang's font. At most one post
every 2 s, longer when Discord's rate-limit headers or a 429 say so; a burst
past 20 lines is counted, not drawn; plain text when drawing fails.

No pings: every post sets allowed_mentions {"parse": []}, and every @ (and <@,
<# ...) gets a zero-width joiner, in the alt text and the fallback text too.
One player's lines carry their head (mc-heads.net by UUID) as the avatar;
their address is never sent. Only the webhook's host is accepted (Discord),
the API and panel show it as "(set)", and the API can't change [discord].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:51:04 +01:00
ErikKalikasandClaude Opus 5.5 91ed167997 Native mods: keep the block entity draw helpers for mods (dead_code)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:50:44 +02:00
Ludovic MarieandClaude Opus 5.5 043d3ff075 Launcher: live test of the official count through SRV (ignored by default)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:50:36 +01:00
Ludovic Marie 2776df4716 Merge pull request #5226 from josegil1909/fix/launcher-settings-and-headers-i18n
🤖 Agent: read the diff: remaining launcher strings (proxy, game arguments, releases, patch notes, sponsor, mod verdicts) go through tr() with entries in the seven catalogs; no behaviour change.
2026-10-31 18:50:25 +01:00
Ludovic Marie 91229df006 Merge pull request #5227 from MSedgeMC/fix/shears-crop-growing-plant
🤖 Agent: read the diff against vanilla's ShearsItem.useOn: shears on a growing plant head below age 25 set getMaxAgeState with block.growing_plant.crop, a block_change game event and one wear; bodies and max-age heads untouched. New use_item_on cases; CI parity runs them.
2026-10-31 18:50:21 +01:00
Ludovic Marie 50a45cb4be Merge pull request #4327 from MSedgeMC/fix/4124-dig-clock
🤖 Agent: a resumed dig keeps the first START's clock (ServerPlayerGameMode destroyPos/destroyProgressStart, 0.7 split) on the server and the client; dig_clock, dig_stop and instant_digging LAN checks pass per the author; elythepreciouscat's remote_mining fix included; rdbtCVS approved; verify, clippy, tests green. Parity runs on the merged head.
2026-10-31 18:50:16 +01:00
Ludovic Marie 19e9621e03 Merge pull request #5228 from MSedgeMC/fix/stem-pick-block
🤖 Agent: read the diff against vanilla's StemBlock/AttachedStemBlock.getCloneItemStack: pick-block on a stem gives its seed; unit test.
2026-10-31 18:50:12 +01:00
Ludovic Marie 0a541d4134 Merge pull request #5229 from MSedgeMC/fix/player-head-name
🤖 Agent: read the diff against vanilla's PlayerHeadItem.getName: a profile with a name titles the head block.minecraft.player_head.named in tooltips and command feedback; custom name still wins; unit test.
2026-10-31 18:50:08 +01:00
Ludovic Marie e2a58b30ff Merge pull request #3763 from spidercraft/fsr-frame-interpolation
🤖 Agent: FSR 3.1 frame interpolation (MIT, AMD FidelityFX 1.1.4) ported to WGSL, still unused (skip-release); rdbtCVS's div_ceil point fixed in 58b2c98d3; naga-validated, GPU tests by the author; verify green.
2026-10-31 18:50:03 +01:00
Ludovic MarieandClaude Opus 5.5 1e7bb94391 Merge origin/main into feat/trade-rebalance (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:50:01 +01:00
Ludovic Marie 5baa329c3b Merge pull request #5045 from Teiwazik/fix/pack-list-reads-no-whole-zip
🤖 Agent: verified: Zip::open now keeps the file and reads only the central directory onward (as Java's ZipFile), entries read at their offsets with no shared cursor; parsing unchanged; tests cover lazy reads, long directories and broken END counts. Thanks!
2026-10-31 18:49:58 +01:00
Ludovic Marie 5cdf4cc329 Merge pull request #5009 from reservedbytes/feat/mod-mob-effect-icons
🤖 Agent: verified against Hud.getMobEffectSprite (mob_effect/ prefix, namespace kept) and MissingTextureAtlasSprite.generateMissingImage (magenta where y<8 xor x<8). Thanks!
2026-10-31 18:49:54 +01:00
Ludovic Marie 60a576f19c Merge pull request #5236 from TobiasLogic/shaderpacks/if-conditions
🤖 Agent: read the diff: the shaders/ preprocessor's #if evaluates float literals (integer / and % kept without one), and an unreadable condition is a warning reading false instead of skipping the program; unit tests. CI green, parity runs on the head merged into main.
2026-10-31 18:49:49 +01:00
Ludovic Marie f2bbd60357 Merge pull request #5232 from TobiasLogic/shaderpacks/sky-cloud-attributes
🤖 Agent: read the diff: sky, celestial and cloud lightmap coords, the sky normal, cloud face normals (w 0, the game's own clouds unchanged) and blockEntityId -1 by default, as a shaders/ pack reads them in Iris (observed with a probe pack, no source read). CI green, parity runs on the head merged into main.
2026-10-31 18:49:45 +01:00
Ludovic Marie cca4b0bfa7 Merge pull request #4456 from reservedbytes/fix/terrain-sampling
🤖 Agent: None now samples terrain as vanilla (linear clamped sampler, sampleNearest unless RGSS; terrain.fsh), and distant tiles repeat their sprite per block, which also fixes RGSS/Anisotropic stripes on main since #4843 (checked in game by the author). rdbtCVS's change request was addressed (dismissed with his re-review). verify green.
2026-10-31 18:49:40 +01:00
Ludovic Marie 5e0dba5db4 Merge pull request #4441 from PzmuV1517/shader-packs/01
🤖 Agent: shader packs 1/12: multi-line macro calls, struct varyings, property continuations; naga 30.0.1 vendored unchanged (MIT/Apache, notices added) with fixes on top; review points fixed. CI green.
2026-10-31 18:49:35 +01:00
Ludovic Marie 703a0a75f4 Merge pull request #4220 from elythepreciouscat/perf/opengl-indirect-multidraw
🤖 Agent: GL backend: indirect execution without compute shaders, batched indirect draws with the same strides/offsets; rdbtCVS checked fmt/clippy on third_party/wgpu-hal by hand and approved. CI green.
2026-10-31 18:49:31 +01:00
Ludovic Marie d7f8774e6e Merge pull request #4111 from senjinthedragon/hdr-glow-2
🤖 Agent: HDR tiers for lightning, beacons, crystals, fireworks, orbs; marks follow their draws on classic and OIT paths; checked in game by the author; rdbtCVS approved. CI green, parity pending.
2026-10-31 18:49:27 +01:00
Ludovic Marie ef423d9520 Merge pull request #5137 from Blzs2005/fix/slider-video-modes
🤖 Agent: read the diff: video modes are listed once per monitor instead of at every slider step (about 28 ms each on Windows); with_option keeps the cached list. Unit test; approved by rdbtCVS; CI green, parity runs on the head merged into main.
2026-10-31 18:49:23 +01:00
rdbtCVSandClaude Opus 5.5 d2f7950962 Merge origin/main (picks up #5251's and #5304's build fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:49:14 +00:00
Ludovic MarieandClaude Opus 5.5 3159ecb0cc Merge origin/main into fix/ride-non-mob-vehicles (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:49:09 +01:00
Ludovic MarieandClaude Opus 5.5 0e11d408de Merge origin/main into fix/conversion-save (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:48:09 +01:00
leifiyooandClaude Opus 5.5 6c62ae8b75 Point the narrator doc at keyboard focus navigation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:48 +01:00
leifiyooandClaude Opus 5.5 3c46c26016 Document keyboard focus navigation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:48 +01:00
leifiyooandClaude Opus 5.5 bc791418ca List two more narrator differences found end to end
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:48 +01:00
leifiyooandClaude Opus 5.5 2a80bd539d Navigate the loading title with the keyboard too
The title shown while resources load takes Tab, the arrows and Enter the
same way and hands its focus to the game's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:48 +01:00
leifiyooandClaude Opus 5.5 27f9488135 Navigate menus with Tab, the arrows and Enter as vanilla does
Screen.keyPressed's focus navigation on the targets the controller's focus
mode already reaches: Tab and Shift+Tab in tab order (reading order),
arrows with the ported nextFocusPathInDirection step, Enter, Space and
keypad Enter press the focused widget. Sliders follow canChangeValue,
text fields keep their caret keys, a list is one Tab stop and the world
list selects its focused row. The focused widget is the narration's
focused one, the keyboard's own pointer events don't reach the narrator,
and the real mouse takes the pointer back where it left it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:48 +01:00
leifiyooandClaude Opus 5.5 9171f3b8cf Let horizon-ctl's keys and typing reach the screen narration
The control socket's key and type commands skip the window's events, so
the open screen never heard them (afterKeyboardAction): the chat's typed
message wasn't narrated in scripted runs. They now go through the same
narrate_key as the window's keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:47 +01:00
leifiyooandClaude Opus 5.5 bbbdd3d163 Narrate game rules and command block switches as CycleButtons
The shared button drawer takes the narration a button records
(ui::button_as), so the game rule switches say their name, value,
description and default as BooleanRuleEntry's custom narration does,
and the command block's output, mode, condition and trigger buttons say
"<name>: <value>" and the value a click moves to. The rules list is a
ContainerObjectSelectionList: its rows are narrated with their place,
the category headers by their label, and the rule tooltips the list
draws are no hint of the rows' widgets. The command box and the search
box are numbered where vanilla adds them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:47 +01:00
leifiyooandClaude Opus 5.5 741cc76070 Narrate options lists, key binds, pack lists and dialogs as vanilla does
Options pages narrate their list as a ContainerObjectSelectionList: the
row under the mouse or focused, its position, the widget in it and its
header rows; cycle buttons the value a click moves to, key binds
narrator.controls.*, tooltips as hints. The language and pack lists are
object lists, the pack screen and its confirm screen name themselves, and
dialogs narrate their title, warning button, checkboxes, cycle buttons,
edit boxes and button tooltips.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:47 +01:00
leifiyooandClaude Opus 5.5 a60214a258 Document the narrator
docs/narrator.md: the statuses and hotkey, the voices per platform (flite
needs libflite on Linux, as vanilla), where the code lives, how a screen
records its narration, silent scripted runs with horizon-ctl narration,
and the known differences from vanilla. Linked from the docs index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:47 +01:00
leifiyooandClaude Opus 5.5 80496802e2 Leave inactive edit boxes out and keep the book button's focus
An EditBox that can't be edited isn't active, so World Options' port box
is not counted while LAN is off (updatePortControlsState). The recipe
book's button moves when the book opens; a stable key keeps the focus
the click gave it, so it is narrated as vanilla's focused ImageButton.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:47 +01:00
leifiyooandClaude Opus 5.5 6fd6a9badb Narrate the recipe book as RecipeBookComponent does
The book is one narratable, hovered whenever it is visible, that narrates
the recipe button, search box, filter or tab findNarratableWidget picks
one level in, with no position of its own. The book button before it is
an ImageButton without a message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:47 +01:00
leifiyooandClaude Opus 5.5 37313e8314 Narrate World Options' switches and lock, and the stats lists
World Options' switches are CycleButtons (their value and the one a
click moves to), the difficulty lock a LockIconButton ("Difficulty lock
button. Locked/Unlocked"), the category headers FocusableTextWidgets,
and the port box is numbered after the LAN switch where it is added.
The statistics' general and mob lists are ObjectSelectionLists naming
the hovered row, the item list a ContainerObjectSelectionList of sort
buttons and items, as StatsScreen's are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:47 +01:00
Ludovic MarieandClaude Opus 5.5 5dd9748bac Merge origin/main into fix/4974-dialog-held-keys (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:47:20 +01:00
leifiyooandClaude Opus 5.5 2de29753fd Narrate the world, server, superflat and create-world screens' widgets
The world list, the server list with its LAN header and games, and the
superflat layer and preset lists narrate their rows as their entries'
getNarration; the create-world tabs as a TabNavigationBar, its cycle
buttons and switches with the value a click moves to, and the edit boxes
by their messages. The title screen's and pause menu's icon buttons say
their own messages and tooltips in the order the screens add them, and
the multiplayer screens name themselves (joinForNarration for the confirm
and disconnected ones). A world search narrates the filtered list at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:46:52 +01:00
leifiyooandClaude Opus 5.5 e9ba68068b Run the screens' narration while the narrator can speak, whatever its status
Screen.shouldRunNarration is GameNarrator.isActive: with the narrator off
its narration still runs and is marked said, so the click that turns it on
in the options says only what it changed 200 ms later. Widgets can now
record text widgets, cycle buttons with their own narration, and lists'
rows by name; a shared drawer's record can be named or moved after the
fact, and the drawers hear which widget the narrator has focused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:46:52 +01:00
MariuslozoandClaude Opus 5.5 784633788d Let a source-reading test pass on a Windows CRLF checkout
property_setters_refuse_no_change searches native_hooks.rs, read with
include_str!, for function heads written with "\n". Git for Windows'
default core.autocrlf checks .rs files out with CRLF, so the head is
never found and the test fails there. Read the source with its line ends
normalized, as the launcher's mojang_downloads_only_for_an_owner already
does (9cdfc5bd4). The test still finds the same code on an LF checkout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:46:52 +01:00
leifiyooandClaude Opus 5.5 4a28f60aa6 Narrate the remaining in-game screens as vanilla's do
Stats, advancements, world options (its lock popup, restrictions and game
rules), Play with Friends, server links (with the warning button and its
tooltip), the link and custom screen confirms, the pack prompt and the
code of conduct (title and text, its checkbox) name themselves as their
vanilla screens' getNarrationMessage does; the game mode switcher has no
title. The stats tabs narrate as a TabNavigationBar. The command block
screen has no title and takes the suggestions' usage while they show;
its boxes and the rules' and port's boxes carry their EditBox messages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:46:51 +01:00
leifiyooandClaude Opus 5.5 6f67cb3080 Narrate the chat, level loading, connect, saving and credits screens
The chat screen says only its title, how to send (with the restricted
note) and the message typed, as ChatScreen.updateNarrationState does, and
its link prompt joins the question and the link as ConfirmLinkScreen.
The level loading screens (the startup menu's, single player's start, a
world opening, a server's terrain) have no title and no navigation and
say the server's progress every 2 s; the connect screen has no title;
the saving screen says its title alone. The win screen narrates its
narrator components, the poem's lines and the credits' names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:46:51 +01:00
leifiyooandClaude Opus 5.5 3c407d1c42 Narrate the container screens, books and the sign editor by their titles
A container screen narrates its menu's title (the creative inventory's
is empty, a merchant's is its name without the level); slots are never
narrated. The book screens say their title and page indicator (the view
also the page's text), their page box or title box and page buttons; the
sign editor its title and Done. The creative, recipe book and anvil
boxes carry their EditBox messages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:46:51 +01:00
leifiyooandClaude Opus 5.5 8d221f0055 Narrate the title screens, options pages, pause menu and death screen
The window's input reaches the screen's narration before the screen takes
it, as MouseHandler and KeyboardHandler call afterMouseMove,
afterMouseAction and afterKeyboardAction; a controller's focus step
counts as an arrow key. The startup menu narrates its screens and hands
its narration to the game with the title, and Ctrl+B schedules the open
screen's narration (updateNarratorStatus). The title screen's modes, each
options page, the pause menu and the death screen name themselves as
vanilla's screens do, joinForNarration for the confirm and alert ones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:46:51 +01:00
Ludovic MarieandClaude Opus 5.5 a190c13824 Merge origin/main into headless/loop-handle (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:46:35 +01:00
Ludovic Marie c667170a42 Merge the stack below (main merged) into #4792 2026-10-31 18:46:18 +01:00
Ludovic Marie b21c70d4eb Merge the stack below (main merged) into #4791 2026-10-31 18:46:17 +01:00
Ludovic Marie c63ec770c2 Merge main into #4790
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-10-31 18:46:17 +01:00
leifiyooandClaude Opus 5.5 9ddec4a549 Narrate the open screen from its recorded narration as Screen does
ScreenNarrator reads each drawn frame's screen and narratables: a new
screen narrates at once (init's triggerImmediateNarration), then stays
quiet for 2 s, or until the next key after a keyboard; mouse moves,
clicks and keys schedule the narration that Gui.update runs when due.
updateNarrationState picks the widget as findNarratableWidget does, with
its position and usage, lists' rows and container lists' row widgets, and
a click focuses what it hits as mouseClicked does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:45:47 +01:00
leifiyooandClaude Opus 5.5 c635604a89 Record each frame's screen narration beside its widgets
Drawers say which screen they draw (begin_screen: its id, title and
narration overrides) and the buttons, sliders and edit boxes they draw
record what they narrate, as Screen.updateNarrationState reads its
NarratableEntries. Nothing is recorded or built unless the narrator asks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:45:47 +01:00
leifiyooandClaude Opus 5.5 7983abb95e Turn the macOS speech thread's run loop over while it idles
NSSpeechSynthesizer posts to the run loop of the thread that speaks; the
narrator's speech thread now runs its own each time it idles, and idles
every 50 ms however many messages arrive in between.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:45:47 +01:00
leifiyooandClaude Opus 5.5 0308ba799f Port NarratorMac: NSSpeechSynthesizer on the narrator
The narrator speaks through AppKit's NSSpeechSynthesizer with vanilla's own
queue: a message waits while another is said, an interrupt stops the speech
but keeps the queue, and clear() empties it. The speech thread runs no run
loop for the delegate, so idle() polls isSpeaking and runs vanilla's
didFinishSpeaking when the speech has ended.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:45:47 +01:00
leifiyooandClaude Opus 5.5 b309894c94 Add horizon-ctl narration: the narrator's status and recent messages
A read-only command beside stats and messages: the Narrator option's
status, whether text-to-speech is there, and the last 32 messages the
narrator was asked to say, so scripts can check narration without
listening (HORIZON_NO_AUDIO gives an active, silent narrator).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:45:46 +01:00
leifiyooandClaude Opus 5.5 93adc0997c Port NarratorLinux: flite's cmu_us_kal16 voice on the narrator
The narrator loads libflite and libflite_cmu_us_kal16 at run time as JNA
does, splits each message at punctuation and plays the pieces on its own
"Narrator #0" thread; clear() bumps the batch so queued pieces are skipped
while the one playing finishes, as vanilla's executor does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:45:46 +01:00
leifiyooandClaude Opus 5.5 19c2a6ff82 Narrate chat, overlays and the game's messages where vanilla does
ChatListener: player and disguised chat are said once they show, Chat
Delay included, with the chat type's narration decoration ("Steve says
hi", from the server's chat_type registry, chat.type.text.narrate when
it lacks the type); system chat as saySystemChatQueued when the Chat
option lets it through; the action bar's system messages as
saySystemQueued. Single player and LAN take the same path: the window
gets them from its server thread as packets.

Also: the jukebox's Now Playing and the creative hotbar's saved message
(saySystemNow), the player list's names as it comes up, ConnectScreen's
"Joining the world" every 2 s and LevelLoadingScreen's "Ready to play"
as it closes, a LAN world found in the server list's view, the selected
command suggestion (once per entry, at once when Tab asked), debug chat
(showDebugChat) and the world options' publish messages. Leaving a
server and a reconfiguration clear the narrator (Minecraft.disconnect,
clearClientLevel). Only the window's Game narrates, never a server
thread's in the same process.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:45:46 +01:00
Ludovic MarieandClaude Opus 5.5 8017536dc0 Merge origin/main into fix/death-message-held-item (main's clippy fix #5304)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:45:42 +01:00
rdbtCVSandClaude Opus 5.5 5108719261 Merge origin/main into xp orb unstuck
entities.rs: main's ExperienceOrb::hurt sits before this branch's box_at.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:45:37 +00:00
Ludovic MarieandClaude Opus 5.5 150ca81725 Merge main into #5314; main's own build fixes (#5251) replace this branch's copies
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:45:03 +01:00
sirrayi 5c766a673c The knot comment says why a packet does not reach a knot
The comment claimed a knot is a block under the crosshair rather than an
entity id. On a server it is both: `knot_ids` (`game/lan_entities.rs`)
gives every fence knot a `LeashFenceKnotEntity` id, so a guest's Interact
naming one arrives with an id that `interact_knot` cannot use, because it
starts from the block pick.

The comment now says that instead of stating a false reason. No behaviour
changes: knot ids were not resolved before this PR either.

rdbtCVS's open nit at 44c54ba8b. Also rewords the same claim in the body.
2026-10-31 17:43:46 +00:00
Ludovic Marie 1a031d96b2 Merge the stack below (main merged) into #4792 2026-10-31 18:43:45 +01:00
Ludovic Marie abdd14296a Merge the stack below (main merged) into #4791 2026-10-31 18:43:43 +01:00
Ludovic Marie 0e35217901 Merge the stack below (main merged) into #4790 2026-10-31 18:43:41 +01:00
Ludovic Marie 8ffa149c44 Merge the stack below (main merged) into #4453 2026-10-31 18:43:38 +01:00
Ludovic Marie e55eaa035a Merge the stack below (main merged) into #4452 2026-10-31 18:43:35 +01:00
Ludovic Marie 74b19a8d77 Merge the stack below (main merged) into #4451 2026-10-31 18:43:32 +01:00
sirrayi d9429e6731 Merge main into #4891 again, for 825a6bbcf
The previous merge point (`06485bfef`) is ten commits behind main, and it
still has what `825a6bbcf` fixed: two `S::Attack` arms in `Hub::process_inbox`,
the second unreachable, which is what makes `clippy -D warnings` fail on this
branch as it did on main. Merging current main takes that fix.

No conflicts, and no file this PR changes is resolved by hand here.
2026-10-31 17:43:31 +00:00
Ludovic Marie 75b0939fa4 Merge the stack below (main merged) into #4450 2026-10-31 18:43:29 +01:00
Ludovic Marie fd94b69918 Merge the stack below (main merged) into #4448 2026-10-31 18:43:27 +01:00
Ludovic Marie 2dd60d922d Merge the stack below (main merged) into #4447 2026-10-31 18:43:23 +01:00
Ludovic Marie b5abcf04ea Merge the stack below (main merged) into #4446 2026-10-31 18:43:21 +01:00
Federico TaglianiandClaude Sonnet 5.5 c24000a4ab A blast whose source is in water spares paintings, item frames and cushions
BlockAttachedEntity.ignoreExplosion: the explosion's direct source entity
in water makes the entity ignore it, before the mobGriefing rule. Primed TNT,
TNT minecarts, creepers, sulfur cubes, withers, fireballs, wither skulls and
end crystals report whether they are in water. An armour stand is not a
BlockAttachedEntity and keeps its own rule.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 18:42:49 +01:00
ErikKalikas a8a17b12d0 Merge remote-tracking branch 'upstream/main' into mod-block-entity-draw 2026-10-31 19:42:38 +02:00
ErikKalikasandClaude Opus 5.5 4d9dbdb565 Native mods: draw mod block entities on the client (BlockEntityRenderer.submit)
native_block_entity_submit runs once a frame for each mod block entity in
view, as vanilla's block entity renderers do (64 blocks, frustum, chunk
fade-in), with a pose stack and helpers that add to the entity batch:
quads, model-element boxes baked as FaceBakery does, and block models
(submitBlockModel). Content mods' textures/entity join the atlas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:42:38 +02:00
rdbtCVSandClaude Opus 5.5 47690b73bd Merge origin/main (picks up #5304's hub.rs build fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:41:26 +00:00
Ludovic Marie abb630c564 Merge main into #3825
# Conflicts:
#	crates/horizon-client/src/game/integrated.rs
2026-10-31 18:41:25 +01:00
Ludovic Marie 9660021979 Merge remote-tracking branch 'origin/main' into a3-pr-4413b
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-10-31 18:40:56 +01:00
leifiyooandClaude Opus 5.5 8c5933402f Cycle the narrator with Ctrl+B (Cmd+B on macOS) as KeyboardHandler does
A press or repeat of B with the edit modifier (hasControlDownWithQuirk)
moves the narrator to its next status when the narrator is there, the
Narrator Hotkey option is on and no focused edit box takes the keys (chat,
command block, anvil name, world options' boxes, a dialog's text input,
the options' searches and the title screens' fields). The change is
announced with its toast and saved, on the startup menu, the title, the
menus and in game, and the key still reaches the screen.
on_narrator_status_changed marks where vanilla calls the screen's
updateNarratorStatus, for screen narration later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:40:25 +01:00
leifiyooandClaude Opus 5.5 2fd9fe63b4 Add the narrator crate: GameNarrator, SAPI speech and screen narration state
horizon-narrator ports vanilla's narrator core:
- GameNarrator's status rules (NarratorStatus, queued vs. interrupting
  messages, the status announcement and its toast keys)
- com.mojang.text2speech's NarratorWindows on SAPI's SpVoice, on a speech
  thread of its own so the game never waits on it
- ScreenNarrationCollector and Screen's narration timing (delays, the
  suppression after init, findNarratableWidget)

HORIZON_NO_AUDIO gives an active but silent narrator for scripted runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:40:25 +01:00
leifiyooandClaude Opus 5.5 004df578ef Show the narrator's toggle toast wherever the player is
updateNarratorStatus's SystemToast (NARRATOR_TOGGLE) goes up with
"Narrator Enabled" and the status, "Narrator Disabled", or "Not Available"
when there is no narrator. As SystemToast.addOrUpdate, a toast still up
takes the new text and its display time starts again instead of a second
one stacking. It takes a free ToastManager slot beside the advancement,
tutorial and pack toasts and draws over every screen: the title (its own
toast keeping the first slot), the options, loading and in game, F1 hiding
it. The startup menu has its own until it hands the title to the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:40:25 +01:00
leifiyooandClaude Opus 5.5 b7a765b39c Make the narrator and narrator hotkey options work on the options screens
The Narrator button cycles Off, Narrates All, Narrates Chat and Narrates
System, reads "Not Available" and is inactive while the narrator isn't
(Options.narrator's caption, OptionsSubScreen.addContents), and a change
runs GameNarrator.updateNarratorStatus: the speech is cleared and
"Narrator : <status>" said. Chat Settings shows it where vanilla's
ChatOptionsScreen does. The Narrator Hotkey switch is a plain toggle with
its macOS tooltip there. options-set narrator announces a new status too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:40:25 +01:00
leifiyooandClaude Opus 5.5 e4fdd84c66 Open the narrator with the client and keep it in step with the options
The client opens horizon-narrator where Minecraft's constructor makes its
GameNarrator (never for a server), runs checkStatus (logged: Horizon has no
native message box), and destroys it when the window's event loop ends.
Whenever options load or change, on the title's startup menu and in game,
the narrator takes the narrator option's status and the voice volume
(getFinalSoundSourceVolume(VOICE): master times voice).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:40:25 +01:00
rdbtCVSandClaude Opus 5.5 79d05b573e Merge origin/main (picks up #5304's hub.rs build fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:40:21 +00:00
Ludovic MarieandClaude Opus 5.5 8fa56fc67a Merge main into #5308 (#5208 landed beside it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:39:57 +01:00
ElyTheCatandCodex 026069b8a1 Skip software occlusion culling by default
Co-Authored-By: Codex <[email protected]>
2026-10-31 21:09:52 +03:30
ElyTheCatandCodex 403ec1317f Fix current client build regressions
Co-Authored-By: Codex <[email protected]>
2026-10-31 21:09:48 +03:30
Ludovic Marie c5b7854540 Merge main into #3947
# Conflicts:
#	crates/horizon-client/src/game/boats.rs
2026-10-31 18:38:00 +01:00
Ludovic MarieandClaude Opus 5.5 802a3fa668 Merge #3794's branch; a model match only where ids change and one player waits with it; P1 with controllers off takes no pad
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:37:57 +01:00
sirrayiandDeepSeek Harness e7f4440f05 Content mods: a block declares its own state properties (#5290)
A content block's states were exactly its template's, copied row for row, and
`default_state` could only overlay values on properties the template already
had. So a pipe could not have a connection per side, and a machine could not
have `facing` and `lit`.

A block file may now declare `state_properties` — a boolean, or an enum of
values — and the block's states become its template's states times their
combinations, appended after the template's run so a block's states stay one
contiguous run in registry order.

The combination order puts every property at its declared default first, and
`Rename` carries how many states a block gives each of its template's, so a
template state still moves to exactly one state: the block's default. The
`facts.json` defaults and `render-definitions.json` mappings are unchanged.

Refused: a name its template already has, a bad name or value, a
`default_state` value the property does not have, and more combinations than
a world holds (2^20) — the last before the product is built.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 17:36:25 +00:00
Ludovic MarieandClaude Opus 5.5 b41861991e horizon-server: the management protocol's players, server and list methods
players, players/kick, server/{status,save,stop,system_message} and the four
lists' getters (allowlist, bans, ip_bans, operators), as vanilla's
PlayerService, ServerStateService and the list services answer them: players as
PlayerDtos, bans with their source, reason and ISO-8601 end, operators with
their level, the status's empty player list left out as vanilla's codec leaves
out its default. Kicks, saves and system messages go through the game's
console (tellraw, title actionbar for an overlay). server/status answers while
the game restarts, started false.

The parity harness now gives both servers the same lists and checks these too
(32 exchanges), the notifications vanilla sends meanwhile set aside.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:36:16 +01:00
Ludovic MarieandClaude Opus 5.5 65a8b2aff0 horizon-server: vanilla's management protocol, its transport (JSON-RPC over a WebSocket)
management-server-* in server.properties ([properties] in server.toml) turns on
vanilla 26.3's management protocol, served by the supervisor so it answers while
the game restarts. As net.minecraft.server.jsonrpc does: the secret as a bearer
token or in the minecraft-v1 subprotocol from an allowed origin (401 bodies
included), JSON-RPC errors and batches as Connection handles them, by-name and
positional parameters as IncomingRpcMethod checks them, a generated secret
written back, and a wrong secret or TLS setting stopping the start.

This first part: the WebSocket (std only, websocket.rs) and rpc.discover
(vanilla's OpenRPC document, management_schema.rs). The other methods answer an
internal error saying they aren't in Horizon yet; they come next.

tools/test_management_protocol_parity.py sends the vanilla server and Horizon's
the same messages and handshakes and compares the answers, and rpc.discover with
the data generator's json-rpc-api-schema.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:36:16 +01:00
Ludovic MarieandClaude Opus 5.5 cc008d9526 Merge main into #5262; the F3 Density line moves into debug_entry_lines
Main's DebugEntryChunkGeneration line (#4389) was drawn inline in
draw_debug_overlay_under, which this branch replaced by debug_entry_lines:
it is now that function's chunk_generation_stats arm, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:35:49 +01:00
Ludovic MarieandClaude Opus 5.5 5d6526f8f9 Merge main into the panorama repair: the six-face check moves to horizon-prepare's check.rs
Main moved the launcher's completion checks into horizon_prepare::missing_extras, which
--check and COMPLETIONS share, so lacks_panorama and its test live there now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:35:11 +01:00
Ludovic MarieandClaude Opus 5.5 a84512a757 Merge origin/main (one Attack arm fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:34:23 +01:00
Ludovic MarieandClaude Opus 5.5 d6b5b84976 Launcher: the strings this PR translates in ja and pt-br too
Main added Japanese and Portuguese (Brazil) since this PR's keys were
written; the i18n test wants every key in every language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:33:45 +01:00
Federico TaglianiandClaude Sonnet 5.5 e1c055ce3f Merge origin/main into feat/blending-old-chunks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 18:33:34 +01:00
Ludovic Marie 6ea61a542e Merge main into #4178 2026-10-31 18:32:58 +01:00
Federico Tagliani f7d289cd0b Merge remote-tracking branch 'origin/main' into feat/blending-old-chunks 2026-10-31 18:32:35 +01:00
Ludovic MarieandClaude Opus 5.5 133a655baf Merge origin/main into main (Turkish)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:32:28 +01:00
NazaandClaude Opus 5.5 5501d1d355 A chat message that fails validation counts as processed, not shown
#5149's handle_chat_message_error had no tracker to tell. With one, a signed message that
failed validation never reached it, and the client's last-seen window drifted from the
server's. It now marks the signature as ChatListener.handleChatMessageError does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:32:14 -03:00
Ludovic Marie b8cf85f8c3 Merge pull request #5304 from horizon-rust/fix/main-build-hub-attack-arm
Main builds again: one Attack arm in the LAN guest inbox
2026-10-31 18:31:13 +01:00
sirrayiandDeepSeek Harness 2b9d830a3f tooling(port-map): ChatScreen.send's two rows
`ChatScreen.handleChatInput` and `ChatScreen.normalizeChatMessage` had no
verdict at all: the ledger reads them as unported rather than as the port they
are. Both point at what the message becomes on the way to the server now.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 17:31:04 +00:00
sirrayiandDeepSeek Harness f630fd43bb ChatScreen.normalizeChatMessage: the message a chat screen sends
A chat line went out as typed: Horizon sent the raw box text, where vanilla
sends `ChatScreen.normalizeChatMessage(s)` — `String.trim`, then
`StringUtils.normalizeSpace` (one space for every run of whitespace), then
`StringUtil.trimChatMessage` (the first 256 UTF-16 code units, the length
`ServerboundChatPacket`'s `stringUtf8(256)` decodes).

Two player-visible differences:

- `send_chat` cut the message with `chars().take(256)`, so 256 emoji passed as
  512 UTF-16 units and the server refused the packet; a cut at 256 units is
  what vanilla does (129 emoji become 128).
- runs of spaces stayed: `hello   there` reached other players with its three
  spaces, and a line of nothing but spaces was sent, where vanilla sends
  nothing and remembers nothing in the history.

The normalization sits in `Game::handle_chat_input` (ChatScreen.handleChatInput),
so the chat box, the chat drafts and the control socket's `chat` all send what
a vanilla chat screen would. `java_whitespace` is reused from `ui.rs`, so
U+00A0 stays (Java whitespace excludes the no-break spaces) and U+3000 and
Java's U+001C..U+001F separators collapse.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 17:31:04 +00:00
sirrayiandDeepSeek Harness 9ef1541c02 fix(mobs): a dispensed spawn egg applies its entity_data too
`EntityType.updateCustomEntityTag` was ported for a player's egg
(`block_entity_item::spawn_egg_entity_data` then
`creatures.rs::Game::apply_spawn_egg_entity_data`) but not for the
dispenser's, whose `dispense_spawn_egg` stops after
`entity_nbt::apply_item_components`: a configured egg
(`/give @s pig_spawn_egg[minecraft:entity_data={…}]`) dispensed onto a block
spawned an unconfigured mob, which is the half of the
`EntityType.updateCustomEntityTag` row that pointed at
`game/dispenser.rs`.

`apply_spawn_egg_entity_data` becomes `pub(super)` and the dispenser calls it
after the components, as the player's path does.

`tools/port_map/game-005.tsv`: that row is `port` now.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 17:30:49 +00:00
Ludovic MarieandClaude Opus 5.5 825a6bbcf9 lan: one Attack arm in the guest inbox (main's clippy fails)
The anticheat's Attack arm and #5123's arm that keeps moves queued behind
an attack both landed in process_inbox; the second was unreachable, so
clippy -D warnings failed on main and #5123's behaviour was lost. The
anticheat arm now also holds the queued move when it forwards the attack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:29:49 +01:00
rdbtCVSandClaude Opus 5.5 4db04d87d5 CI: run verify's checks in one job again
f2a99d623 split verify into python, clippy and tests jobs plus a gathering
verify: four GitHub-hosted runners per checks run instead of one. With every
pull request push and every merge to main starting checks, that filled the
organisation's shared runner pool, and every workflow's jobs (labels, overlap
notes, other pull requests' checks) queued behind it for tens of minutes.

verify runs the public-tree check, the Python tests, rustfmt, clippy and the
Rust tests one after another again, on one runner. A pull request's
public-tree check still reads only its own commits (--range base..head).

Closes #5302

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:29:41 +00:00
Ludovic MarieandClaude Opus 5.5 95122e4ff8 Merge #3777's branch; a pressed pad skips players with controllers off; say splitscreen has no rumble yet
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:29:40 +01:00
TobiasLogicandClaude Opus 5.5 61187eb91e Shader packs: scalar swizzles, matrix column components and uint literal overloads translate
naga's GLSL frontend refuses three things GL drivers take, and the program was skipped:

- a swizzle of a scalar (GLSL 4.20: `noise(p).x`, `s.xx`; Kappa's weather map);
- a matrix column's component written to (`uv[1].y = ...`);
- `min`, `max` or `clamp` of a `uint` and integer literals (`max(n, 1)`): naga finds no best
  overload, GL takes the uint one.

compile now rewrites the spot naga points at and parses again (at most 256 times): one swizzled
component is dropped, more become a vector constructor of the scalar, a column's component
becomes an index, and the call's integer literals get a `u`. Error locations map through the
rewritten text, which keeps its lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:29:22 +07:00
Ludovic Marie 3b1e813d9e Merge pull request #5300 from horizon-rust/fix/mod-registry-lockfile
Mod registry: refresh its Cargo.lock (Railway deploys failing)
2026-10-31 18:29:19 +01:00
Ludovic Marie b783268374 Merge main into #4967
# Conflicts:
#	crates/horizon-client/src/mobs/golem.rs
2026-10-31 18:28:50 +01:00
Ludovic Marie 097cc8e882 Merge pull request #5100 from kacpherek/fix/trackers-blocks
🤖 Agent: merged main (#5251's build fix: the verify failure was main's Audio compile break); requeued
2026-10-31 18:28:34 +01:00
Ludovic Marie eec161a4cc Merge pull request #4011 from DUzzL/fix/issue-4001-boat-drowning
🤖 Agent: merged main. Main's 45c36d872 had added the client side of the same rule (RiddenBoat in motion/fluids.rs), so I kept main's type and logic and this PR now fills it for served players (lan_actions.rs → boats.rs riding_boat_fluid_box), where the air supply and drowning are decided; port map row updated. cargo check -p horizon-client --bins passes, fmt clean. Approved by rdbtCVS.
2026-10-31 18:28:30 +01:00
Ludovic MarieandClaude Opus 5.5 b4956c8a93 Mod registry: refresh its own Cargo.lock for horizon-mods' new zip dependency
horizon-mods gained the zip crate, but the registry's separate lockfile
wasn't updated, so Railway's `cargo build --locked` refused to update it
and every deploy of horizon-mod-registry failed (one failure mail per
merge to main). Minimal update: only the new packages are added.

Checked: the Dockerfile's exact build (same files copied, --release
--locked) finishes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:28:26 +01:00
Ludovic Marie ee98c8e5ab Merge pull request #5208 from nathaniel-ramos-dev/fix/stuck-sneak-on-focus-loss
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff: on focus loss a held (non-toggle) sneak/sprint/attack/use mapping is released before the re-sync, toggles keep their latch (ToggleKeyMapping.setDown/release); each frame a Shift/Control the keyboard's modifier state reports up clears its stale physical key and held latch. CI failures were main's Audio build break (#5251).
2026-10-31 18:28:26 +01:00
Ludovic Marie 8fd9690bff Merge pull request #4176 from Domanffe/feat/launcher-accent-hue
🤖 Agent: merged main (clean) on top of Domanffe's 2b2c2893e, which fixes dimaniojk's two findings (drag off the strip, the CLI's hue); earlier CI failures were main's broken Audio initializer. Launcher-only; cargo fmt clean. Approved by rdbtCVS.
2026-10-31 18:28:21 +01:00
Ludovic Marie 8f7ec89520 Merge #4975's head (a7ce858e0) into the trade_rebalance branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/datapack.rs
2026-10-31 18:28:20 +01:00
Ludovic Marie c2606393fc Merge pull request #4389 from xFedeT/feat/f3-density-line
🤖 Agent: F3 Density line as NoiseBasedChunkGenerator.addDebugScreenInfo under the chunk_generation_stats entry, sampled on a new feet block from the server thread's generator; main merged (conflicts with the per-entry F3 and explorer maps resolved, both kept), horizon-client checks clean; rdbtCVS approved.
2026-10-31 18:28:17 +01:00
Ludovic Marie be1b857a80 Merge pull request #4335 from Teiwazik/shader/s1-material-behind
🤖 Agent: Shader API: the material stage gets opaque_depth/opaque_color copies taken before translucent terrain and MaterialInput.screen (fragment position over resolution, as Teiwazik showed); rdbtCVS approved after merging #4186's tags; main merged. Only native shader packs see it.
2026-10-31 18:28:13 +01:00
Ludovic Marie 1b789e5f10 Merge pull request #3985 from DUzzL/fix/issue-3978-skip-software-adapter
🤖 Agent: merged main into the branch (main's broken Audio initializer made the earlier CI fail, fixed on main by f80c0360d); cargo check -p horizon-client-render passes. Change read: two adapter passes, GPUs first, software only with a warning. Approved by rdbtCVS.
2026-10-31 18:28:08 +01:00
Ludovic Marie 73fe322f3f Merge pull request #4440 from masa-masa-0314/fix/function-tick-order
🤖 Agent: functions tick before the levels (MinecraftServer.tickChildren) and an item added this tick first ticks on the next (EntityTickList), so a fresh drop reports Age 0; the freeze dimaniojk found is fixed (only items with a later id are skipped); type_specific/player checks ServerPlayer and input.sneak on the server. rdbtCVS approved; main merged.
2026-10-31 18:28:03 +01:00
readerkidandopencode d2e17a70c0 Shulker boxes: grow the lid's hitbox and push what it meets (#4882)
The lid animated but the collision queries always answered "no box": a
closed box collided as its plain block and an opening one never grew. The
collision view now reads the boxes' lid progress, the level ticks those
lids on its server pass (not only the client-only animation), and an
opening lid applies moveCollidedEntities to the bodies the game moves.

Vanilla: ShulkerBoxBlock.getCollisionShape / getShape (isClosed),
ShulkerBoxBlockEntity.getBoundingBox / getProgressDeltaAabb /
moveCollidedEntities.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 10:27:59 -07:00
Ludovic Marie d148bcb28b Merge pull request #4958 from MSedgeMC/fix/mooshroom-variant-component
🤖 Agent: merged main (port map: main's suspicious stew rows kept, this PR's variant rows). Mooshroom variant component as MushroomCow.get; test fails without it.
2026-10-31 18:27:58 +01:00
Ludovic Marie c5705fe8c9 Merge pull request #5237 from horizon-rust/perf/spawn-lookups
🤖 Agent: read the diff: placement checked before the spawn-list lookup (both pure reads, no RNG), species placement cached in MobData, LocalMobCapCalculator's lists flattened; draws and positions unchanged; A/B on playhorizon −18 % spawn_natural. CI parity runs on the head merged into main.
2026-10-31 18:27:52 +01:00
Ludovic Marie 16ed874dad Merge main into #4891
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-31 18:27:50 +01:00
Ludovic Marie 685cf4a18c Merge pull request #4407 from SlaVKsVolks/contribute/horizon-benchmark-report-20261030
🤖 Agent: perf_bench --table prints the missing-value marker when a report has no scene-load measurement, keeps a measured 0.0 and the mean of measured scenes; regression test covers all three. Tooling only; rdbtCVS approved; CI green. Marked ready from draft.
2026-10-31 18:27:48 +01:00
Ludovic Marie ae8f6c2448 Merge pull request #5129 from AurelienBelloir/test/lists-lock-fork-retry
🤖 Agent: requeued at the same head: the merger stopped only on rdbtCVS's approving review.
2026-10-31 18:27:44 +01:00
Ludovic Marie c0b65f1d24 Merge pull request #5072 from oskal675-design/feat/launcher-shell-completions
🤖 Agent: verified: horizon-launcher completions <shell> via clap_complete over the existing command tree, no network or file writes, test per shell; approved as code owner. Thanks!
2026-10-31 18:27:39 +01:00
Ludovic Marie c072a3e493 Merge pull request #5073 from mulareal/fix-entity-shadow-cull-4965
🤖 Agent: verified: vanilla's ENTITY_SHADOW / OIT_ENTITY_SHADOW pipelines take RenderPipeline.Builder's default cull(true); the shadow quads wind +Y (test added), so the underside is culled. Thanks!
2026-10-31 18:27:34 +01:00
Ludovic Marie 06485bfef8 Merge pull request #5090 from k3yh/fix/nbt-root-name
🤖 Agent: verified against NbtIo.readUnnamedTag (StringTag.skipString): a file root's name that isn't modified UTF-8 reads as empty; keys and truncated names still fail. Proof: CI parity. Thanks!
2026-10-31 18:27:30 +01:00
rdbtCVSandClaude Opus 5.5 d86d2ec555 Regenerate the mod-registry lockfile: main's --locked clippy rejects it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:27:07 +00:00
Ludovic MarieandClaude Opus 5.5 cc080bf72a Launcher: Cargo.lock for horizon-protocol
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:26:49 +01:00
Ludovic MarieandClaude Opus 5.5 4ba9e95bf9 Merge main; native damage hook as a fragment; canceled hits credit no attacker
Merges origin/main, moves the living_incoming_damage hook to
tools/native-hook-fragments (main's API stays as is), and drops the attackers
a canceled hit noted, so the next hit of that damage type isn't credited to
them (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:26:40 +01:00
Ludovic MarieandClaude Opus 5.5 da45330894 Merge main; splitscreen seats follow the owner's Allow Cheats, a failed join leaves P1 playing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:26:05 +01:00
Ludovic MarieandClaude Opus 5.5 78e288212a Merge origin/main into fix/long-string-disk-write
nbt.rs: the Writer keeps both flags, this branch's empty_long_strings
and main's canonical_nan (both on for the disk writer, off for the
network one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:24:17 +01:00
Ludovic MarieandClaude Opus 5.5 c7b8559015 Launcher accounts: status workers wait for the lock; a refresh saves the renewed profile
rdbtCVS's two review points on #4250:
- The launcher's own status workers (off the UI thread, showing "Waiting for
  account access…") take the lock without the 3 s bound, so a worker queued
  behind another account's renewal no longer shows a false "Could not check
  sign-in". account list and MCP keep the bounded wait.
- refresh_account saves a renewed profile (new name or skin) to accounts.json
  as launch() does, and the UI reloads accounts and profile heads when a
  status arrives and the saved list changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:24:16 +01:00
loicpillerandClaude Opus 5.5 54ded421ce fix(input): read Ctrl+Q and agent releases from the same modifier state
Q over a slot and Q in play decided whether to throw the whole stack from
physical_keys, while every other screen check reads Control from the OS
modifier word (has_control_down). The two could disagree after a lost or
lagging key event. drop_whole_stack now takes the modifier word; a latched
sprint toggle still never counts as Control.

The control socket's `release` cleared keys, physical keys and modifiers
but not the sneak/sprint toggle mappings, so a sneak pressed through
`key shift down` came back on the next sync_toggle. It now releases both
mappings as their key's release does (a toggled one stays latched) unless a
controller still holds the action, then resyncs the held keys.

Also:
- `key` reports an unknown action, with its name, from the single early
  check; the later match arm that repeated it could no longer run.
- lan_e2e check_recipe_shift reads the recipe buttons before the tab loop,
  so a book without a 35x27 tab fails on its assertion and target dump
  instead of a NameError.

Validation on macOS arm64:
- cargo test --locked -p horizon-client --bin horizon-client: 359 passed,
  3 ignored (including stack_drop_requires_ctrl_even_with_sprint_latched,
  now driven through modifiers::set_key).
- cargo clippy --locked -p horizon-client --lib --bins --tests
  -- -D warnings, cargo fmt --all --check, lan_e2e.py syntax and
  tools/check_public_tree.py: passed.

Still to run with prepared resources: lan_e2e --check recipe-shift
--check modifier-release, and Ctrl+Q in an inventory and in play.

Refs #4166

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:23:58 +01:00
loicpillerandClaude Opus 5.5 da36881cc7 fix(input): keep synthesized Shift clicks and agent releases on the modifier word
Screens now read Shift from the OS modifier word (has_shift_down), but two
synthesized shift-clicks still only inserted ShiftLeft into physical_keys:
the controller's Y quick-move in an inventory (pad_menu.rs) and horizon-ctl's
`station ... shift` click (station_control.rs). Both became plain pickups.
They now go through modifiers::hold/restore, which sets the SHIFT bit with
the physical key and afterwards puts back exactly what was held before:
a Right Shift already down, or an OS word set without a physical key, stays.

The control socket's `key <mod> up` returned before releasing the modifier
when a server dialog or the sign editor was open, or with no world, leaving
SHIFT set so every later inventory click became a QUICK_MOVE. The release
that already ran after `tap` whatever the handler did now runs after `up`
too (released_modifier), so no early return can keep a modifier down.

Tests: modifiers::a_held_click_restores_the_previous_state and
control::taps_and_ups_always_release_their_key.

Validation on macOS arm64:
- cargo test --locked -p horizon-client-input --lib: 64 passed.
- cargo test --locked -p horizon-client --bin horizon-client control::tests:
  passed.
- cargo clippy --locked -p horizon-client-input -p horizon-client --lib
  --bins --tests -- -D warnings, cargo fmt --all and
  tools/check_public_tree.py: passed.

Still to run on a machine with prepared resources: controller Y on a slot
in the survival and creative inventories, and lan_e2e --check
modifier-release --check clicks.

Refs #4166

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:23:58 +01:00
loicpillerandOpenAI Codex gpt-6.1-sol f014dd11ed fix(input): reconcile screen modifiers without releasing other bindings
Read Shift and Control for screens from the OS modifier word instead of
inferring them from cached physical key events. This follows Minecraft
Java 26.3 InputWithModifiers.hasShiftDown/hasControlDown and prevents a
missed key release from turning subsequent inventory clicks into transfers.

Reconcile lost releases only for physical modifiers that were recorded as
held and are still assigned to the affected sneak/sprint mapping. Preserve
rebound non-modifier keys, controller-held actions and keyboard/controller
toggles. Include controller releases delayed until the next simulation tick.
This avoids cancelling crouch bound to C when the player presses Alt, or
cancelling a controller hold because the keyboard does not hold Shift.

Keep synthesized modifier presses and releases consistent with the OS word.
Releasing one side preserves the bit while the other side is still held.
Validate control key actions before mutating state. Run tap cleanup after
the command handler even if it returns early or fails, and clear the modifier
word on the control socket's release command. This prevents key shift tap
or key shift down followed by release from leaving later clicks modified.

Add five input tests covering modifier press/release, left/right holds,
rebindings, missing physical keys, controller holds and controller toggles.
Add the modifier-release LAN check: after a Shift tap or release, a normal
inventory click must pick up ten diamonds and closing must preserve the
server's count. Remove the obsolete pad-toggle getters introduced by the
previous broad reconciliation, since ToggleKeys already handles toggles.

Validation on macOS arm64 on main 9069aea3e:
- cargo test --locked -p horizon-client-input --lib: 63 passed.
- cargo clippy --locked -p horizon-client-input --all-targets -- -D warnings:
  passed.
- cargo clippy --locked -p horizon-client --lib --bins --tests -- -D warnings
  -A unreachable-patterns: passed. The allowed lint is main's duplicate
  S::Attack arm in lan/hub.rs, a file this branch does not touch.
- cargo fmt --all --check, Python harness syntax, git diff --check and
  tools/check_public_tree.py: passed.

Prepared Minecraft resources are absent here. The new modifier-release
check and final game-level verification remain pending on the dedicated
machine; prior LAN results must not be treated as validation of this change.
Rebuild binaries from this commit, then run:
  python3 tools/lan_e2e.py --check modifier-release --check clicks \
    --check shift-double-click --check recipe-shift --check shift-drag \
    --check crafting --check wheel --out .local/validation-4166-review
Also rerun pr_ready.py with the appropriate main remote as its base.

Manual checks still required: hold rebound crouch/sprint while pressing
and releasing Alt; repeat with controller holds and toggle modes; confirm
lost Shift releases still recover with the default sneak binding. Verify
Shift taps and release before normal inventory clicks. Do not use the
deprecated in-frame path; keep game, launcher and XDG homes in fresh
scratch directories as required by AGENTS.md.

Refs #4166

Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-31 18:23:58 +01:00
loicpillerandOpenAI Codex gpt-6.1-sol 4f1c3ce532 fix(inventory): distribute carried stacks during Shift drags
A carried stack already starts QUICK_CRAFT in Clicks::press, but
inventory_press only initialized its hovered-slot collection without Shift.
Holding Shift therefore prevented the distribution gesture from completing.

Start the drag with either modifier state. Keep PICKUP_ALL restricted to
non-Shift double-clicks, and leave the Shift drag initially empty so a
press/release without any dragged slot retains its QUICK_MOVE behavior.
Follow Minecraft Java 26.3 AbstractContainerScreen.mouseClicked,
mouseDragged and mouseReleased; use the existing menu and server click path.

Add three LAN checks driven by real pointer events, rather than click-slot:
- shift-drag: spread 20 diamonds across three crafting slots as 6/6/6 with
  two carried, or 1/1/1 with seventeen carried, with and without Shift.
- shift-double-click: transfer chest stacks of 8, 13 and 21 diamonds while
  keeping a carried stack of 32; spaced clicks transfer only the first pile.
- recipe-shift: place two diamond-block recipe sets from 20 diamonds using
  the recipe book, then Shift-click the result to produce two blocks and
  retain two diamonds.

The latter two behaviors already worked and receive regression coverage.
Resolve pointer targets from the current UI and include movement inside the
starting slot so the drag actually traverses all selected slots. Check
carried stacks, grid contents and server inventory counts after closure.

Evidence reported by the dedicated-machine agent before this rebase:
Shift drags failed on the old base and passed after the fix; clicks,
shift-double-click, recipe-shift, shift-drag, crafting and wheel passed.
The container_screen, menu_click, place_recipe, recipe_book and
recipe_book_view differential harnesses were also reported passing.
Vanilla expectations came from the pinned sources and Java oracles; the
vanilla graphical client was not exercised. Those game runs cannot be
repeated on this checkout because its prepared resources are absent.

Refs #4166

Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-31 18:23:50 +01:00
Ludovic MarieandClaude Opus 5.5 976cd3be1b Merge origin/main into the trade_rebalance branch
Conflicts: datapack.rs takes both sides' kinds (trade_set,
villager_trade and dialog); game.rs saves through main's
write_level_for_role, then each placed player to their own file;
lan.rs syncs dialog ids, then sends the world's own biome table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:23:49 +01:00
Federico TaglianiandClaude Sonnet 5.5 c7ddfb8ebc Piglin brutes pick up a golden axe; piglins admire a loved item they only see
PiglinBrute.wantsToPickUp takes only a golden axe, through Mob.aiStep's
looting loop. StartAdmiringItemIfSeen sets ADMIRING_ITEM (119 ticks) for a
loved item in sight, and StopAdmiringIfItemTooFarAway(9) and
StopAdmiringIfTiredOfTryingToReachItem(200, 200) end it. A blow from a
player disables admiring for 400 ticks (ADMIRING_DISABLED).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 18:23:35 +01:00
Ludovic Marie f25edbe119 Merge main into #4785 2026-10-31 18:22:40 +01:00
Ludovic Marie 9831e6d146 Merge main into #4932 2026-10-31 18:22:36 +01:00
Ludovic MarieandClaude Opus 5.5 593ca10b34 Shadow cache: skip the shadow pass on a hit instead of copying cached textures
Only the shadow passes write the shadow targets, so while the key matches they
still hold the map (and the A/B side shadowcomp left current): no cache
textures, no copies, and no copy into shadowcolor buffers made without
COPY_SRC/COPY_DST (a wgpu validation panic with any pack using shadowcolor).
pass-timing leaves this PR (unrelated).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:22:12 +01:00
Ludovic Marie 86e0d83dbb Merge branch 'a3-4444' into a3-4445 2026-10-31 18:21:44 +01:00
Ludovic Marie 8de268aad8 Merge branch 'a3-4443' into a3-4444 2026-10-31 18:21:42 +01:00
Ludovic Marie de0508a004 Merge branch 'a3-4442' into a3-4443 2026-10-31 18:21:41 +01:00
Ludovic MarieandClaude Opus 5.5 0c3831d711 Merge main into shader-packs/02 (zip 9.1.3 from #3832 beside this part's half and png)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:21:40 +01:00
Ludovic Marie 9069aea3e2 Merge pull request #3826 from martipops/split/10-profile-page
🤖 Agent: read the diff at 7fc284b (rdbtCVS's main merges + Escape-as-Cancel fix): the splitscreen profile page as a model with its store and tests in horizon-client-render, not wired into play yet; dimaniojk's paging finding fixed. Part of the splitscreen series whose earlier parts merged.
2026-10-31 18:21:05 +01:00
Ludovic Marie 136796628c Merge pull request #5267 from reservedbytes/fix/content-identity-items
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff: an items-only content mod now writes the identity marker, and the identity hashes the mods' item and mob effect entries per registry; a root without mod entries keeps its block-only hash (unit test). Fails safe with the content-mods mismatch message across versions.
2026-10-31 18:21:00 +01:00
Ludovic Marie 6825d43665 Merge pull request #5247 from horizon-rust/perf/spans-mob-phases
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. two spans (spawn_natural, tick_creature_entities) inside tick_creatures, timed only when spans are on; no behaviour change. CI failures were main's Audio build break (#5251).
2026-10-31 18:20:56 +01:00
Ludovic Marie e540cdbbaa Merge pull request #5256 from reservedbytes/fix/spear-no-knockback
🤖 Agent: merged main into the reviewed head (the earlier clippy/tests failures were main's Audio build break, fixed by #5251); no other change. read the diff against vanilla: spear is in #minecraft:no_knockback, so LivingEntity.hurtServer skips dealDefaultKnockback for a stab; Damage::no_knockback now lists Spear; unit tests. CI failures were main's Audio build break (#5251).
2026-10-31 18:20:52 +01:00
Ludovic Marie fb1a9bbecf Merge pull request #5035 from haruki55go/patch-1
🤖 Agent: change request addressed (ja wired in, tests pass); approved as code owner
2026-10-31 18:20:48 +01:00
Ludovic Marie 8d40bd70c6 Merge pull request #5066 from Sylviromi/content-creative-tabs
🤖 Agent: merged main (both sides added tests at the end of mod_content_tests.rs: kept both); cargo check of the lib and tests passes; review points fixed by the author (02a7c1553 cherry-picked); marked ready
2026-10-31 18:20:43 +01:00
Ludovic Marie bddb77819d Merge pull request #5244 from arismoko/pickup-invalidation
🤖 Agent: re-read: the new head only merges main into the reviewed pickup fix (guest pickup keeps item ids, re-finds the item after the native hook).
2026-10-31 18:20:39 +01:00
Ludovic MarieandClaude Opus 5.5 dbc8cfc208 Turkish: wire tr into the launcher and the game's own strings
Launcher: Language::Turkish (code tr, Türkçe, picked from a tr-* system
locale), tr.json brought up to main's strings (25 native-mods strings
translated, 5 the launcher no longer asks for dropped, one {version}
placeholder fixed). Game: tr_tr.json joins the bundled Horizon strings.
Built on #5035's Japanese wiring so the two don't conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:20:36 +01:00
Ludovic Marie 9e48cf7cc8 Merge pull request #5167 from levzzz5154/mods/native-entity-join
🤖 Agent: finished by me (94027c278): merged main, API number as a fragment, the canceled-join crash fixed (just_spawned_item), and a same-level End gateway trip no longer joins again. native_ tests pass.
2026-10-31 18:20:35 +01:00
Ludovic Marie 6d38e45e5b Merge pull request #5061 from Sylviromi/native-mod-review-followups
🤖 Agent: the review follow-ups from #3996–#4289 (screen only for the block's own menu, entity menus clear the block target); review points answered; marked ready
2026-10-31 18:20:28 +01:00
Ludovic Marie 62bf449a12 Merge pull request #5103 from k3yh/fix/region-header-rewrite
🤖 Agent: merged main into the branch (no conflicts left); requeued
2026-10-31 18:20:21 +01:00
Ludovic Marie 9d3722fb1c Merge pull request #5110 from k3yh/fix/lz4-java-decoder
🤖 Agent: requeued on rdbtCVS's merge of main (mergeable)
2026-10-31 18:20:17 +01:00
Ludovic MarieandClaude Opus 5.5 adda4f12c8 Merge main into #5241 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:20:15 +01:00
Ludovic Marie 6930dfd845 Merge pull request #5181 from Joansitoh/fix/4889-flat-world-item-icons
🤖 Agent: read the diff: Superflat layer and preset icons draw the item's GUI model (as CreateFlatWorldScreen's renderItem), clipped exactly to the list; other screens unchanged. Approved by rdbtCVS; CI green, parity runs on the head merged into main.
2026-10-31 18:20:12 +01:00
Ludovic Marie c1c609a058 Merge pull request #3502 from socksprox/feat/launcher-hide-partner-everywhere
🤖 Agent: one partner_hidden setting hides the sidebar card and the Servers banner; the 3-minute seen gate now counts on every page. rdbtCVS approved; launcher CI green.
2026-10-31 18:20:07 +01:00
Ludovic Marie 3c62df0a8b Merge pull request #3192 from kacpherek/fix/launcher-windows-update-no-relocated-copy
🤖 Agent: rename-aside Windows self-update with rollback replaces self_replace's self-deleting copy that Defender flags; start-up clean-up removes only this exe's .old/.update leftovers. Read in full; rdbtCVS approved; CI's launcher job builds Windows. A real Windows update after release is the remaining check.
2026-10-31 18:20:02 +01:00
Ludovic MarieandClaude Opus 5.5 2cefa54830 Merge main into feat/native-visual-hooks: native_visuals and native_emissive move to API 46
Main's #? took API 45 for native_block_entity_update_tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:18:07 +01:00
Ludovic MarieandClaude Opus 5.5 baf451aa0b /ride: force the ride as RideCommand.mount does, and fire started_riding
startRiding(vehicle, true, true) skips canAddPassenger, so a boat under
water still takes the rider; Horizon's boats seat at most max_passengers,
so a full one still refuses. Horizon's minecarts carry no mobs yet, said
in a comment. A player put in a boat or minecart now fires
minecraft:started_riding (sendEventAndTriggers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:18:05 +01:00
Naza 454cecac23 Merge branch 'secure-chat-v2/2-chat-keys' into secure-chat-v2/3-client 2026-10-31 14:17:17 -03:00
Naza 787c70882b Merge branch 'secure-chat-v2/1-signing' into secure-chat-v2/2-chat-keys 2026-10-31 14:17:16 -03:00
NazaandClaude Opus 5.5 2f570b013a Tests: the odd-length hex helper for this branch's keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:17:16 -03:00
Naza c00106cc04 Merge branch 'secure-chat-v2/2-chat-keys' into secure-chat-v2/3-client 2026-10-31 14:16:51 -03:00
Naza 95a121e96e Merge branch 'secure-chat-v2/1-signing' into secure-chat-v2/2-chat-keys 2026-10-31 14:16:50 -03:00
NazaandClaude Opus 5.5 ea371be23b Tests: one hex helper beside #5149's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:16:50 -03:00
rdbtCVSandClaude Opus 5.5 95d98ae933 Merge origin/main into hdr light masks
Both sides added a field to Hdr (masks, calibration) and a line beside mod hdr; kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:16:40 +00:00
Ludovic MarieandClaude Opus 5.5 b136900fd6 Merge main into #5233 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:16:37 +01:00
Naza d604ac139f Merge branch 'secure-chat-v2/2-chat-keys' into secure-chat-v2/3-client 2026-10-31 14:15:56 -03:00
Naza 88e226f7ec Merge branch 'secure-chat-v2/1-signing' into secure-chat-v2/2-chat-keys 2026-10-31 14:15:54 -03:00
Ludovic Marie b28aaa7217 Merge branch 'a3-pr-4379' into a3-pr-4398
# Conflicts:
#	crates/horizon-shader-api/tests/schema.rs
2026-10-31 18:15:52 +01:00
NazaandClaude Opus 5.5 8b73bac6cb Merge main: this branch's tests beside #5149's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:15:52 -03:00
Ludovic MarieandClaude Opus 5.5 855206f914 Merge origin/main; official count follows SRV, game moves old entries too
#5235 keeps the address in horizon_telemetry::server_status::OFFICIAL_ADDRESS:
it is now play.playhorizon.dev, with OFFICIAL_PREVIOUS beside it; the
launcher's defaults and the game use both. The launcher's official player
count pings the address from links.json through its SRV record
(horizon_protocol::server_redirect + server_status::players_at): port 25565
on that host is another server. The telemetry server, which looks no SRV
up, defaults to the SRV target (OFFICIAL_STATUS_ADDRESS). The game moves
servers.dat entries for an earlier address once, like the launcher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:15:22 +01:00
Ludovic Marie f32c8836ad Merge main into #4584 2026-10-31 18:14:09 +01:00
Ludovic MarieandClaude Opus 5.5 641919b93d Board carts only like vanilla: rideable, moving, no rider
pushAndPickupEntities only runs for a rideable cart moving at
horizontalDistanceSqr >= 0.01; an activator rail ejects mob passengers
too; a player can't board a cart a mob already rides (isVehicle).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:14:08 +01:00
Ludovic MarieandClaude Opus 5.5 6ff589d5fe Boats: test the bubble-column wobble (from DUzzL's review of #3947)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:13:12 +01:00
NazaandClaude Opus 5.5 6317f1bfaf Merge main; log what secure chat can't sign or randomise
A failed signature and failed OS randomness are logged instead of hidden, and the
ChatListener.flushQueue ledger row points at flush_chat_queue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:13:01 -03:00
Ludovic Marie 94283c8441 Merge branch 'a3-pr-4359' into a3-pr-4379
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
#	crates/horizon-shader-api/src/inputs.rs
#	docs/api/shaders-reference.md
2026-10-31 18:12:50 +01:00
Ludovic MarieandClaude Opus 5.5 07a7721fd6 Merge main into #5262 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:12:40 +01:00
Ludovic Marie 43d8f2ac93 Merge main into #3947 2026-10-31 18:12:27 +01:00
Ludovic Marie 8c53719432 Merge commit 'refs/pull/5035/head' of https://github.com/horizon-rust/horizon into sweepc-5067 2026-10-31 18:12:06 +01:00
Ludovic MarieandClaude Opus 5.5 d876fa1212 Merge main into #3825; splitscreen windows: the review's three fixes
- audio.rs: main's shared-output fields (the device tracker's, f80c0360d).
- A guest in Window 1 leaves its F11 to P1: the game's window follows P1's option.
- disable() shows Window 1 again and says so (window1_shown), so a later
  split into Window 2 hides it again.
- Clicks, key presses and the wheel from a window the focused player isn't
  in no longer reach that player; releases still pass, so no key sticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:11:32 +01:00
Ludovic Marie fb20c464f1 Merge branch 'a3-pr-4349' into a3-pr-4359
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_material.rs
#	crates/horizon-shader-api/src/stages.rs
2026-10-31 18:10:33 +01:00
Ludovic MarieandClaude Opus 5.5 d0f81cc039 Merge main into #5220; native_block_entity_sync takes its API number from a fragment
#4935 landed as API 45, and API numbers now come from
tools/native-hook-fragments (stamp_native_hooks.py after the merge): the
hand-written API 44 and its docs paragraph become
native-block-entity-sync.json. lan_bot keeps both new tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:10:25 +01:00
Ludovic Marie aab2caeb7e Merge remote-tracking branch 'origin/main' into pr3702 2026-10-31 18:09:49 +01:00
masa-masa-0314andGLM 5.3 dbfc38af70 Create World: the preparing read loads the chosen packs and takes no input
Review fixes on the preparing screen:

- The worldgen data was read from the bundle alone, so the packs the Data
  Packs and Experiments screens picked never reached the generator (the
  cached vanilla `GeneratorData` came back). The read goes through
  `shared_data_with` now, with the overrides the chosen packs build
  (`creation_worldgen_overrides`, the worldgen companion of
  `applyNewPackConfig`'s reload), and a failure reports on the title's
  error screen instead of only logging — the Create screen's options stay
  (`failed` keeps the mode as the back target).
- Escape and text input are ignored while `preparing` is up
  (`GenericMessageScreen.shouldCloseOnEsc` is false): Escape during the
  read used to leave Create and `finish_preparing` then replaced it with
  a fresh screen, losing the player's options.
- The confirm screen's Back/Escape set `reopen_data_packs`, but nothing
  read it: the game side polls it now and reopens the Data Packs screen
  with the selection it had (`onAbort`).
- The preparing assertion in the navigation test is unconditional, and
  offer_data_packs (with and without a feature pack), the confirm
  screen's Proceed and Back, and preparing's input blocking have tests.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-11-01 01:09:40 +08:00
Ludovic MarieandClaude Opus 5.5 43a8eddf0a Launcher: official server address from links.json, moved lists follow it
The official server moves to play.playhorizon.dev (SRV to port 24454).
launcher/links.json now carries `official_server` and
`official_server_previous`, read through the existing Links mechanism with
the same built-in defaults, so a later move is a links.json edit, no release.

server_list::add_official offers the address from links, and when the
address differs from the one the marker file recorded, rewrites entries for
a previous address to the new one (player's name, picture and fields kept),
once. No duplicate when the new address is already listed; a player who
removed the server is not offered it again. The Home list re-runs it when
links.json arrives. The game's own built-in copy and docs/smp.md follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:09:26 +01:00
Ludovic Marie 90030ddcdb Merge remote-tracking branch 'origin/main' into sweepc-5063 2026-10-31 18:08:30 +01:00
Ludovic MarieandClaude Opus 5.5 dd00a69445 Arena compaction: keep an emptied page closed until trim, never move into system memory
The page stays closed after its last mesh moves, so the frame's uploads can't
refill it before trim returns it (trim clears closing). Pages record whether
their buffer went to system memory: moves only go to video-memory pages
(alloc_moved), and sparsest empties a system-memory page first. Tests for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:08:21 +01:00
Ludovic Marie d291364f56 Merge main into #4250 2026-10-31 18:08:06 +01:00
Ludovic MarieandClaude Opus 5.5 ed68b03b4e Merge origin/main into fix/conversion-save (main's build fix #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:08:03 +01:00
Ludovic MarieandClaude Opus 5.5 16305ab9c3 Merge origin/main into mods/native-entity-leave; API number as a fragment, gateway fix
- The hook asks for its API number with tools/native-hook-fragments/entity_leave_level.json
  (#5161); NATIVE_HOOKS_API, the histories and native-hooks.json are main's again.
- An End gateway keeps its traveller in the level (teleportSameDimension), so an item
  going through one no longer reports leaving it (arismoko's and rdbtCVS's point).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:07:59 +01:00
Ludovic MarieandClaude Opus 5.5 ac676e7319 Merge origin/main into fix/trackers-blocks (main's build fix #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:07:59 +01:00
Ludovic MarieandClaude Opus 5.5 681124a485 Merge main into #5266 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:07:00 +01:00
Ludovic MarieandClaude Opus 5.5 8f38acad12 Merge main into #5253 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:06:06 +01:00
Ludovic Marie d6665585e5 Merge branch 'a3-pr-4345' into a3-pr-4349
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
2026-10-31 18:05:58 +01:00
Sylviromi 9971d320d3 Merge remote-tracking branch 'origin/main' into menu-button-v2 2026-10-31 20:05:43 +03:00
Ludovic MarieandClaude Opus 5.5 72645234c5 Merge #4335's updated branch (main with #4186) into shader/s2-frame-motion
#4186's precipitation/temperature/downfall/wetness come first in Frame (they are on main);
this branch's frame-motion fields follow at 416..512. Reference regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:05:36 +01:00
Ludovic MarieandClaude Opus 5.5 03e50c439d Merge main into #5267 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:05:04 +01:00
rdbtCVSandClaude Opus 5.5 fc0645c6be Merge origin/main (picks up #5251's compile fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:04:49 +00:00
rdbtCVSandClaude Opus 5.5 b7a7aa8fc3 Merge origin/main (picks up #5251's compile fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:04:44 +00:00
rdbtCVSandClaude Opus 5.5 352c6336ad Merge origin/main into fix/guest-blast-source
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:04:40 +00:00
rdbtCVSandClaude Opus 5.5 7fc284b46a Merge origin/main (picks up #5251's compile fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:04:34 +00:00
Ludovic Marie 0ce359e5a4 Merge main into #4176 2026-10-31 18:04:26 +01:00
Ludovic MarieandClaude Opus 5.5 fa4017bdf6 Merge main into #5208 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:03:13 +01:00
rdbtCVSandClaude Opus 5.5 71245c0aaf Merge origin/main into the far terrain server branch
lan.rs keeps both the encoder's mod update tag and the far terrain service.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:03:00 +00:00
Ludovic Marie 8125648039 Merge remote-tracking branch 'origin/main' into sweepc-5067 2026-10-31 18:02:36 +01:00
levzzz 1aedc168c7 Merge remote-tracking branch 'hr/main' into qw-5131
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
2026-10-31 22:02:14 +05:00
Ludovic MarieandClaude Opus 5.5 846f2b7c9e Merge main into #4178 (home.rs: main's linked player counts, the playing label through trf)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:02:09 +01:00
MariuslozoandClaude Opus 5.5 03ec7ec551 Windows: run lan_e2e and agent_control_e2e without symlink privilege or vscreen
Three things kept tools/lan_e2e.py (and agent_control_e2e.py) from running
on Windows:

- The prepared resources were linked into the scratch home with os.symlink,
  which needs Developer Mode or an administrator (WinError 1314). The new
  lab.link_dir falls back to a directory junction there, which needs
  neither; shutil.rmtree removes a junction without touching its target.
- agent_control_e2e.game_env ran `vscreen up`, which doesn't exist on
  Windows. Windows has no virtual screen, so it takes plain lab.game_env,
  as macOS does.
- `horizon-ctl launch` never returned when its output was captured:
  Windows hands a child every inheritable handle of its parent, so the game
  kept the caller's stdout pipe open until it quit. horizon-ctl now clears
  the inherit flag on its own standard handles before it starts the game
  (horizon_threads::keep_stdio_from_children). Linux and macOS are
  unchanged.

Fixes #5274

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:02:03 +01:00
Ludovic MarieandClaude Opus 5.5 433ba6ecc3 Merge main into #5243 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:01:44 +01:00
Ludovic MarieandClaude Opus 5.5 b323704770 Merge origin/main into headless/loop-handle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:01:32 +01:00
Ludovic MarieandClaude Opus 5.5 9c32365c4d Merge main into #4011: use main's RiddenBoat for served players
Main's 45c36d872 added RiddenBoat for the client's own fluid box. The
served player's tick (lan_actions.rs) now fills the same type from the
ridden boat, so the server-side air supply follows it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:01:14 +01:00
MSedgeMC baaf6b5d4e Draw no falling block that sits inside its own state 2026-10-31 22:30:48 +05:30
Ludovic MarieandClaude Opus 5.5 3f52f4988f Merge origin/main into content-creative-tabs
Both sides added tests at the end of mod_content_tests.rs: keep main's
and append this branch's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:00:38 +01:00
Ludovic MarieandClaude Opus 5.5 94027c278e Merge origin/main into mods/native-entity-join; API number as a fragment, review fixes
- The hook asks for its API number with tools/native-hook-fragments/entity_join_level.json
  (#5161); NATIVE_HOOKS_API, the histories and native-hooks.json are main's again.
- A mod canceling an item's join no longer crashes a throw: spawn_item's callers
  touch the new item only if it joined (just_spawned_item), not whatever item is
  last (throw_from_eyes unwrapped it; allays, shearing and /give's overflow
  edited an unrelated item).
- An End gateway keeps its traveller in the same level (teleportSameDimension), so
  the item doesn't join again on arrival (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:00:35 +01:00
mularealandAntigravity 190e96938b gfx: upload atlas patches on skipped frames and refresh skins after pack reload
Fixes #5097

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 18:00:31 +01:00
Ludovic MarieandClaude Opus 5.5 2426dbb368 Merge main into #5252 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:00:22 +01:00
mularealandAntigravity b4980bc779 Sync item entities immediately after velocity jumps and slime bounces (#5080)
Co-Authored-By: Antigravity <[email protected]>
2026-10-31 18:00:02 +01:00
Federico Tagliani 12a1295e21 Merge remote-tracking branch 'origin/main' into feat/blending-old-chunks 2026-10-31 17:59:33 +01:00
Federico TaglianiandClaude Sonnet 5.5 a52547637f Merge origin/main into feat/blending-old-chunks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 17:59:33 +01:00
Ludovic Marie 52b5043167 Merge remote-tracking branch 'origin/main' into pr3466 2026-10-31 17:59:28 +01:00
mularealandAntigravity 12cdd1c6fe Beds: sample sleeper light at eye height, zero sleeping pitch, and stand beside bed on wake (#5121)
Co-Authored-By: Antigravity <[email protected]>
2026-10-31 17:59:20 +01:00
levzzz b3c3bc2843 Merge remote-tracking branch 'hr/main' into qw-5188 2026-10-31 21:59:11 +05:00
levzzz 4c7b07a6ea Merge remote-tracking branch 'hr/main' into qw-5130 2026-10-31 21:59:10 +05:00
Ludovic MarieandClaude Opus 5.5 acf4da15a4 Spans: time natural spawning and the mobs' ticks inside tick_mobs
tick_mobs is half of a crowded server's tick, and its two big parts
(NaturalSpawner and the entity ticks) move independently with the players'
spread. Two nested spans let the slow-tick log and horizon-report tell them
apart; with spans off they cost one relaxed load each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:59:02 +01:00
rdbtCVSandClaude Opus 5.5 2d79156569 Far terrain: the span-cap test trips the cap, the encoder refuses past MAX_BIOMES
The forged reply was rejected on its empty biome palette and out-of-range
spans before the cap; it now names one biome and keeps every span inside
HEIGHTS, and asserts the cap's error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:58:58 +00:00
Ludovic MarieandClaude Opus 5.5 0784050d6f Merge main into #5254 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:58:50 +01:00
Ludovic Marie a58349cd55 Merge remote-tracking branch 'origin/main' into feat/native-sidebar 2026-10-31 17:58:17 +01:00
Ludovic MarieandClaude Opus 5.5 5a44e816c6 Merge origin/main into fix/region-header-rewrite
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:57:40 +01:00
Ludovic MarieandClaude Opus 5.5 1b9ed487ae Merge main into #5256 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:57:23 +01:00
PzmuV1517 a2a5dd5dc6 Merge main into the shader options 2026-10-31 18:57:07 +02:00
PzmuV1517 e0f952d26a Merge main into the shader options 2026-10-31 18:57:07 +02:00
PzmuV1517 98398d9c04 Merge main into the shader options 2026-10-31 18:57:06 +02:00
rdbtCVSandClaude Opus 5.5 7fddcb2ec5 Merge origin/main (with #5251's build fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:55 +00:00
rdbtCVSandClaude Opus 5.5 5e8c60d4d3 Merge origin/main (with #5251's build fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:52 +00:00
rdbtCVSandClaude Opus 5.5 a7ce858e0e Merge origin/main (with #5251's build fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:45 +00:00
reservedbytes e3308bf498 feat(mods): native mods give their mob effects behaviour 2026-10-31 15:56:34 -01:00
SylviromiandClaude Opus 5.5 dfd7903066 Native mods: menu_button_v2, a mod menu button with the clicking player's cursor stack
Vanilla's AbstractContainerMenu.clickMenuButton(Player, int) reaches the player's carried stack;
native_menu_button(pos, entity, id) does not, so a mod's widget outside the slots (a tank taking a
bucket) cannot take or give what the player carries. native_menu_button_v2 fires right after it with
`carried: &mut Option<ItemStack>`; a change is written back and sent to the player (SetCursorItem).
Single player's player is a guest of its own host, so one path covers it and LAN guests.
native_menu_button is unchanged (frozen). The API number comes from the fragment when stamped.

Closes #5246

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:56:18 +03:00
Ludovic MarieandClaude Opus 5.5 0ade40a885 Merge main into #5264 (main builds again after #5251)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:56:05 +01:00
Ludovic Marie a430b573f2 Merge remote-tracking branch 'origin/main' into a3-pr-4413 2026-10-31 17:55:55 +01:00
Ludovic Marie a8763ea259 Merge remote-tracking branch 'origin/main' into a3-pr-4335 2026-10-31 17:55:53 +01:00
Ludovic Marie 73274bb060 Merge remote-tracking branch 'origin/main' into a3-pr-4389
# Conflicts:
#	crates/horizon-client/src/game/debug_keys.rs
#	crates/horizon-client/src/mod_content_tests.rs
#	crates/horizon-client/src/vanillagen.rs
#	tools/lan_e2e.py
2026-10-31 17:55:34 +01:00
Ludovic MarieandClaude Opus 5.5 068791f1cc Merge origin/main into #4484 (the book click after main's native screen press)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:55:19 +01:00
Ludovic Marie 670454eeb8 Merge main into #3986 2026-10-31 17:55:09 +01:00
rdbtCVSandClaude Opus 5.5 38ca649113 Merge origin/main into fix/frame-map-updates
Keep main's framed tuple and check for a filled map inside it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:54:28 +00:00
rdbtCVSandClaude Opus 5.5 d4d5c08c38 Restore #5251's compile fixes that the merge commit dropped
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:53:26 +00:00
Ludovic MarieandClaude Opus 5.5 d07eeca212 Launcher: offer Japanese, and bring pt-br up to date
Wire ja.json into Language (code ja, name 日本語), translate the native
mods strings ja.json and pt-br.json lacked and drop the five strings the
launcher no longer asks for, so every language has every string again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:53:23 +01:00
Ludovic Marie 51827488ed Merge remote-tracking branch 'origin/main' into a3-pr-4440 2026-10-31 17:52:54 +01:00
rdbtCVSandClaude Opus 5.5 94df2b6183 Merge origin/main: keep locked_message beside the vehicle menus
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:52:52 +00:00
Ludovic MarieandClaude Opus 5.5 c9fdb5f790 Merge origin/main into #4958 (port map: main's stew rows, this PR's variant rows)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:52:35 +01:00
Ludovic Marie fa23bd1019 Merge remote-tracking branch 'origin/main' into pr5035 2026-10-31 17:52:28 +01:00
masa-masa-0314andGLM 5.3 6d88762c62 Integrated server: packed_biomes gets the resources root back
The world-save commit dropped the argument; the bin did not compile.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-11-01 00:52:23 +08:00
Ludovic Marie 2c613da8c5 Merge main into #3985 2026-10-31 17:51:36 +01:00
arismoko d8882f9cb4 Merge remote-tracking branch 'upstream/main' into hook-fragments-workflow 2026-10-31 11:51:31 -05:00
arismoko 208bb011e8 Merge remote-tracking branch 'upstream/main' into pickup-invalidation 2026-10-31 11:51:30 -05:00
rdbtCVSandClaude Opus 5.5 2138e38349 Merge origin/main: keep both the wither skull and firework damage kinds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:51:25 +00:00
masa-masa-0314andGLM 5.3 a04d8f11e5 World save: a vanilla-only enabled_features list leaves the tag out
lenientOptionalFieldOf("enabled_features", DEFAULT_FLAGS) omits the tag
when the set is vanilla's default, so a `["minecraft:vanilla"]`-only list
(a world created with no experiment picked) does not reach level.dat;
Horizon wrote it. The round-trip test the body promised is here too: a
picked experiment's list writes and reads back equal, a vanilla-only
selection writes no tag.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-11-01 00:51:14 +08:00
Ludovic MarieandClaude Opus 5.5 356ccbd3d3 Launcher: drop the strings main's native mods pages no longer use
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:50:53 +01:00
arismoko 8d7ccfd5f4 Merge remote-tracking branch 'upstream/main' into native-stop-flush 2026-10-31 11:50:44 -05:00
Ludovic MarieandClaude Opus 5.5 dd8c07b537 Re-Create keeps the world's feature packs
for_recreate filtered the saved selection against the world's own packs
only, so a world with trade_rebalance on was re-created without it.
Vanilla's recreateWorldData keeps the DataConfiguration. rdbtCVS's review
point 3 on #4619.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:49:19 +01:00
rdbtCVSandClaude Opus 5.5 0e3d146470 Merge main: lz4-java's decompressor inside the block-at-a-time decode
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:47:50 +00:00
levzzz b3e0f1d476 Merge main; the fluid hooks as an API fragment 2026-10-31 21:46:57 +05:00
rdbtCVSandClaude Opus 5.5 7174827f0f Merge the branch's own main merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:46:29 +00:00
rdbtCVSandClaude Opus 5.5 a9387ea367 Merge main: keep both region tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:46:01 +00:00
Alexander KrylovandHermes Agent 2b2c2893ef Launcher: the accent a drag leaves off the strip, and the CLI's hue
Review fixes on top of the hue strip:

- A drag released outside the strip now writes settings.json too. on_mouse_up
  only fires while the element is hovered, so the accent stayed in memory and
  was lost on restart; on_mouse_up_out covers the release off it, and
  hue_dragging keeps a click elsewhere on the page from saving.
- The contrast test sweeps every half degree from 0 to 359.5 on both bases
  instead of two hand-picked hues, so the guarantee is tested where it is
  claimed.
- `settings accent <value>` names the degrees a value may be, not only the
  seven names.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-31 19:45:59 +03:00
rdbtCVSandClaude Opus 5.5 d54c7b2629 Merge origin/main into feat/ui-slider-keyboard-controls
Resolve title.rs (Outcome::Connect gained `lan`), handle main's new
graphics_ui::Action::Calibrate in the wheel and key paths, and let
Escape close Video Settings on the first press.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:45:57 +00:00
rdbtCVSandClaude Opus 5.5 02e7c4b24a Merge origin/main: rocket damage now goes through Damage::fireworks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:49 +00:00
Ludovic Marie 08b6bee394 Merge remote-tracking branch 'origin/main' into pr5035 2026-10-31 17:43:36 +01:00
masa-masa-0314andGLM 5.3 73d42592e3 Experiments: the switches commit on Done, and Cancel drops them
ExperimentsScreen's switches wrote `options.data_packs` on every flip, so
Cancel and Escape left the flips in place — vanilla's ExperimentsScreen
only flips its own list and writes the selection back in `onDone`. The
switch now flips the screen's flag only; Done rebuilds the selection the
way `onDone` does (the other packs keep their order, the selected
features are appended in reverse screen order), and Cancel drops it.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-11-01 00:43:29 +08:00
iseaprojectsandDeepSeek V4.1 Flash 01770ad0d7 Merge origin/main into i18n/es-es-complete
main added three strings to every language (`finding_friend`,
`opening_world`, `cancelling_world`); es_es keeps its 198 translations
and gains those three, for 201/201 keys in en_us order.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-31 12:42:28 -04:00
rdbtCVSandClaude Opus 5.5 acc42f9953 Read dialog_open before the player borrow in the motion tick (E0502)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:41:28 +00:00
Ludovic Marie df22152c88 Merge branch 'pr4376' into pr4377 2026-10-31 17:41:01 +01:00
Ludovic Marie 67f328dfc5 Merge remote-tracking branch 'origin/main' into sweepB/4638
# Conflicts:
#	crates/horizon-client/src/title.rs
2026-10-31 17:40:30 +01:00
finalpantasyandCodex 60e865877d fix(port-pack): read legacy Forge mcmod.info identity
Handle root-array and modList metadata with synthetic ZIP regressions.
Preserve modern metadata precedence and existing fallback behavior.

Co-Authored-By: Codex <[email protected]>
2026-10-31 12:39:40 -04:00
rdbtCVSandClaude Opus 5.5 ac13cb84f5 Merge main: dialog content ids, sync_dialog_ids, packed_biomes takes the resources
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:39:26 +00:00
levzzz 9b1f9dbf83 Merge main; the chunk and player hooks as an API fragment 2026-10-31 21:39:15 +05:00
Ludovic MarieandClaude Opus 5.5 4441194650 Merge main into secure-chat-v2/2-chat-keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:38:33 +01:00
Ludovic MarieandClaude Opus 5.5 0bf6a93f90 Merge origin/main into fix/reconnect-keys
Conflicts: main's lan login fallback (an auth failure only warns on LAN) kept
with this branch's keyed reasons; reconnect tests take both the lan flag and
the reason's key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:38:11 +01:00
some-du6eandClaude Opus 5.5 daed0b5e2d F3 debug entries: draw the lines and renderers Horizon never had
Ports the 26.3 DebugEntry* classes the debug options screen listed but
the overlay never drew: memory, targeted block/fluid/entity state and
tags, chunk render/source stats, entity and particle stats, player
speed, light levels, heightmaps, biome, local difficulty, day count,
spawn counts, sound mood and cache, post effects and GPU utilization,
in DebugScreenEntryList's order (by identifier) with vanilla's text and
priority/line/group placement. The integrated server reports the lines
that read the server level.

Renderers: water levels, heightmap, collision boxes, block and sky light
levels, solid faces. The list's scrollbar can be dragged, and
debug-profile.json gets vanilla's three datafixes by DataVersion.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 10:37:23 -06:00
Ludovic MarieandClaude Opus 5.5 d9bbb2fbb0 Launcher: pt-BR strings for the keys #4839 and main added
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:37:23 +01:00
rdbtCVSandClaude Opus 5.5 32cb2a76be Merge origin/main (brings #4955's block_update/random_tick oracle fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:36:31 +00:00
rdbtCVSandClaude Opus 5.5 618660d072 Far terrain server: an unreadable request takes a token too, as the doc says
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:36:11 +00:00
Ludovic Marie 233f42335c Merge remote-tracking branch 'origin/main' into sweepB/4839
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
#	launcher/src/home.rs
2026-10-31 17:36:10 +01:00
Ludovic Marie caefd3bb28 Revert "Fill an empty flower pot with a plant on right-click"
This reverts commit 8d75d8953e.
2026-10-31 17:35:38 +01:00
Ludovic Marie 9afcbb3609 Revert "Drop flight when leaving spectator near the ground"
This reverts commit 09e52b9de3.
2026-10-31 17:35:38 +01:00
TobiasLogicandClaude Opus 5.5 af63e5f439 Shader packs: x / 0.0, sqrt of a negative literal, and texelFetch2D translate
`x / 0.0` and `sqrt(-1.0)` are GL's infinity and NaN at run time (Mellow's vec4(-1.0 / 0.0),
Bloop's sqrt of negative constants). naga's frontend folds them into constants and refuses the
result, so the program was skipped. They now divide by `hz_zero`, a private float the frontend
can't fold: `x / hz_zero`, and `hz_zero / hz_zero` for the square root.

texelFetch1D/2D/3D (GL_EXT_gpu_shader4's names, Bliss) become texelFetch, as texture2D and
the other old names already do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:35:19 +07:00
rdbtCVSandClaude Opus 5.5 98e1976db1 Merge origin/main; Escape on the profile list counts as Cancel
Conflicts in options_ui.rs (main's HDR calibration page beside the profile
page) and lib.rs's re-export list: kept both. Escape on the list now sets
choice = Some(None), as the Cancel button does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:34:29 +00:00
rdbtCVSandClaude Opus 5.5 dc2c9e7b46 Merge origin/main into fix: entity_nbt conflict (conversion times beside the block-state helpers)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:34:21 +00:00
ElyTheCatandCodex 0f25a86b37 Merge origin/main into animated sprite culling
Co-Authored-By: Codex <[email protected]>
2026-10-31 20:03:20 +03:30
masa-masa-0314andGLM 5.3 725a91b23f Data packs: pack tag overlays reach TagLoader's ids
Two readers of the packs' tag files never saw any file:

- `PackOverlay::tag_kind` stripped `":{registry}/"` from a path that
  `split_once(':')` had already freed of the colon, so it matched nothing
  and every pack tag merged into `LootData` (item, enchantment,
  instrument, entity_type, damage_type) was silently lost. It strips
  `{registry}/` now, with the key shapes `content_id` writes fed through
  it in a test.
- `load_tag_lists` (horizon-items) read the same key shape through
  `overlay.kind("tags/{kind}")` and hit the same nothing: a pack's item
  and block tags never applied. It reads `tag_kind(kind)` now.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-11-01 00:30:56 +08:00
Ludovic MarieandClaude Opus 5.5 ad930b9bb1 Merge main into native-visual-hooks; renumber to hook API 45, test module last in shader_packs.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:28:54 +01:00
levzzz b8e9257861 Merge remote-tracking branch 'hr/main' into qw-5130 2026-10-31 21:28:40 +05:00
rdbtCVSandClaude Opus 5.5 d7883994eb Predicates: a server's player matches input.sneak and its game mode
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:27:27 +00:00
Ludovic MarieandClaude Opus 5.5 a405d45b3c ExperienceOrb.unstuckIfPossible: search a cube of the orb's size around its centre
AABB.ofSize(center, size, size, size), not the box grown by its size on
every side, which let a buried orb jump three times as far as vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:24:35 +01:00
rdbtCVSandClaude Opus 5.5 f0518ca0e1 Merge origin/main: --attack takes several targets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:23:56 +00:00
rdbtCVSandClaude Opus 5.5 cc6a70672e Chat ack on reconfiguration only when there is an offset, as sendChatAcknowledgement does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:22:40 +00:00
Ludovic MarieandClaude Opus 5.5 85b378f227 End gateway migration: a side file it can't read no longer stops the End from opening
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:22:02 +01:00
arismoko a5cca57316 Flush native mod messages during server shutdown 2026-10-31 11:21:50 -05:00
Ludovic MarieandClaude Opus 5.5 f3a8781d9b Merge origin/main into fix/gateway-exit-storage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:21:44 +01:00
ElyTheCatandCodex e74a62b51e Match vanilla secure chat command handling
Keep command signing disabled until the server command tree arrives, count chat length in UTF-16 units, and acknowledge delayed chat during configuration transitions.

Co-Authored-By: Codex <[email protected]>
2026-10-31 16:20:52 +00:00
reservedbytes 89551109f9 fix(combat): a spear stab deals no default knockback 2026-10-31 15:20:16 -01:00
reservedbytes 16fd39cbe4 fix(mobs): a burning arrow lights a sulfur cube's fuse 2026-10-31 15:20:15 -01:00
reservedbytes 3351ca5d40 fix(mobs): a sulfur cube with a block shrugs off its whole immune tag 2026-10-31 15:20:15 -01:00
SylviromiandClaude Sonnet 1b8dbdea3a client: send custom_data and custom_model_data in integrated-server/LAN item sync
network_stack (integrated-server and LAN inventory sync) encoded custom_name
and item_name from StackData.other but skipped minecraft:custom_data and
minecraft:custom_model_data, so item models' tints/selectors and client-side
custom_data reads saw nothing for a synced stack. Push both components, built
from the saved tag the same way vanilla's DataComponentPatch carries them;
item_sync's decode side already mapped both back.

Co-Authored-By: Claude Sonnet <[email protected]>
2026-10-31 19:18:41 +03:00
rdbtCVSandClaude Opus 5.5 59f37f92fb Merge origin/main into shader/s1-material-behind
MaterialInput gets main's tags, then screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:16:53 +00:00
Ludovic MarieandClaude Opus 5.5 95cdad8b3a Native sidebar: line holders the sidebar shows (not '#', which vanilla hides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:16:46 +01:00
ErikKalikasandClaude Opus 5.5 16f7efa54d Merge mod-block-entity-sync (upstream/main); renumber native_block_entity_sync to API 44
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:16:22 +02:00
arismoko fd60c3dc7a Merge remote-tracking branch 'upstream/main' into hook-fragments-workflow 2026-10-31 11:15:59 -05:00
levzzz 1992ad03ba Merge main; the block items helpers as an API fragment 2026-10-31 21:15:53 +05:00
Ashura006andCursor Agent fcfcc67c01 Show server resource packs in the Resource Packs list
Vanilla's DownloadedPackSource puts active server packs in the selection
as required, fixed at the top (pack.source.server). Horizon already
applied them but never listed them; they also stay out of options.txt.

Fixes #5218

Co-Authored-By: Cursor Agent <[email protected]>
2026-10-31 19:15:46 +03:00
rdbtCVSandClaude Opus 5.5 9ec5a13f6b Merge origin/main into launcher auth status
launcher/lang/*.json: keep both sides' new strings (main's text where a key is in both).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:15:13 +00:00
rdbtCVSandClaude Opus 5.5 44c54ba8b8 rustfmt creatures.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:12:16 +00:00
rdbtCVSandClaude Opus 5.5 a9015a5525 Renderer: a software-only first try asks again with non-conformant adapters
Review thread: wgpu ranks a CPU adapter last, so the retry can find hasvk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:11:58 +00:00
Ludovic Marie d1207de12b Merge remote-tracking branch 'origin/main' into feat/native-sidebar
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 17:11:44 +01:00
rdbtCVSandClaude Opus 5.5 08c9aa50f2 Merge origin/main into the accent hue branch
launcher/src/main.rs: keep both new Launcher fields (hue_area, language_menu).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:11:32 +00:00
ElyTheCatandCodex f6fb532782 Fix remaining keyboard focus review findings
Co-Authored-By: Codex <[email protected]>
2026-10-31 19:40:55 +03:30
Ludovic MarieandClaude Opus 5.5 f35210d2da Trusted reviewers: add Qwerner88880
Ludovic (2026-10-31) asked to add x3 кто (Discord vladislav_hagen,
GitHub Qwerner88880, the native-mod hooks author).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:10:35 +01:00
apeirsandCursor 917b185ba1 Commands: a reused UUID still resolves, and on owner stays living
A kill leaves the old id in the UUID map, so the first match could be an entity the level no longer holds. on owner follows getLivingEntity, which does not return a marker or a display.

Co-authored-by: Cursor <[email protected]>
2026-10-31 17:10:31 +01:00
rdbtCVSandClaude Opus 5.5 35f41e670c Merge origin/main into split/09-windows
Kept PadRouter's state_of with main's stick_axes, both Flags fields, and filled
main's audio device-tracking fields in Audio::share.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:09:57 +00:00
MSedgeMC 486464c829 Drop a saved anger at a player a golem cannot attack
NeutralMob.readPersistentAngerSaveData hands the saved anger back through
Mob.setTarget, and setTarget runs asValidTarget, which turns a target the
mob cannot attack into no target at all. IronGolem.canAttack refuses every
player when a player built the golem. Horizon's world load wrote the target
directly, so a player-built golem reloaded from a save kept its builder as
its target, and its melee goal never re-checks canAttack.

The golem rule becomes Mob::built_by_player, used by both the restore and
spares_players, with tests pinning the targets a built golem does and does
not take and the crack sound it already plays.
2026-10-31 21:36:54 +05:30
rdbtCVSandClaude Opus 5.5 d309aed786 Merge origin/main into split/07-controllers
Conflicts: integrated.rs keeps both Flags fields (split_players, load);
pad/hardware.rs keeps the PadRouter split and moves main's stick_axes
mapping into state_of. A shared Audio gets main's device-tracking fields
and leaves reopening the output to the game's own Audio.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:06:04 +00:00
rdbtCVSandClaude Opus 5.5 59e106cf25 Merge origin/main into split/06-two-players
main's audio device tracking (#default_seen, devices_seen, device_ticks) added fields
the shared Audio built by share() now fills.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:05:26 +00:00
reservedbytes 8baff760da fix(mods): items-only content mods join the content identity check 2026-10-31 15:04:49 -01:00
MSedgeMC 9cef07df2f Drop the orb scan's unused upper-corner binding 2026-10-31 21:34:30 +05:30
MSedgeMC 79a182d6af Let the stem name auto-deref, as clippy asks 2026-10-31 21:34:26 +05:30
rdbtCVSandClaude Opus 5.5 ac8614cd4a Merge origin/main into shadow-cache
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:03:24 +00:00
ElyTheCatandCodex 8bac1883f2 Keep the remote dig target after a break
Preserve the client's destroyBlockPos after a completed prediction so a replacement at the same position can resume the retained target, matching the isDestroying split.

Co-Authored-By: Codex <[email protected]>
2026-10-31 21:31:50 +05:30
MSedgeMC 80443df31e Fold the destroyPos guard onto one line, as rustfmt wants
CI's stable rustfmt put the `if !one_hit && let Some(play) = ...` back on a
single line; the local check accepted it split.
2026-10-31 21:31:50 +05:30
MSedgeMC 73d6388a0e Write destroyPos only for a dig that takes more than one hit
handleBlockBreakAction writes destroyPos in the branch that keeps digging,
after the creative destroy and the instant-mine check, so a creative or
one-hit START leaves no block behind for a later STOP to break.

Reported in review: destroy_pos was set for every accepted START.
2026-10-31 21:31:49 +05:30
MSedgeMC 5035b06b97 Keep a dig's progress across a missed pick, as vanilla does
MultiPlayerGameMode.stopDestroyBlock opens with `if (!isDestroying) return`,
so it zeroes the progress once, on the stop that ends a running break; every
later missed pick is a no-op, the dig identity survives, and the client keeps
accumulating until the progress passes 1.0 and the STOP it sends is measured
from the first START.

Clearing the accumulated progress on every miss restarted the dig each jump,
so the client never reached 1.0 and the server's retained clock was never
read. The LAN regression now breaks the stone in 100 ticks with the dig
accumulating to 27 ticks across falls.
2026-10-31 21:31:48 +05:30
MSedgeMC 12cbedcd1b Add the dig_clock LAN regression and fix its probe
The verifier asked for the server-thread regression this change needs: a
survival guest under a block it can only reach near the jump apex, holding
attack and jumping, with the block's dig clock measured from the first
START.

It currently fails: the block survives 308 ticks of held attack. The
client aborts on every fall and the STOP that would read the retained
clock is only sent when the client's own progress completes, which a
five-tick in-reach window cannot reach. Reporting rather than papering
over it.

tests/test_remote_mining.py compiles an extracted copy of
stop_remote_mining against a stub, so the stub needs the new flag and its
assertion must stop expecting a stop to clear the target, which vanilla's
stopDestroyBlock does not do.
2026-10-31 21:31:47 +05:30
MSedgeMC 5b8b65a1cc Split the running break from the target, and test the STOP decision
A click on the block a stopped break still names has to start the dig
again, so the server re-anchors its clock, while a held button carries the
same target on and keeps it. That is vanilla's "isDestroying &&
sameDestroyTarget" guard: the target test stays pos plus item, and the
running flag is what the start guard adds.

The STOP decision moves into a small pure function over the retained
destroy_pos, the elapsed ticks and the block's per-tick progress, so the
0.7 break, the delayed arming from the same anchor and the cases that must
not inherit a clock are covered without a Game.
2026-10-31 21:31:46 +05:30
MSedgeMC 84dc805438 Let a resumed dig run on the START that named the block
Vanilla keeps ServerPlayerGameMode.destroyPos and destroyProgressStart after
an abort or a STOP that arrives out of reach: only a START writes them, the
abort clears isDestroyingBlock alone, and tick does not check distance. On
the client, stopDestroyBlock keeps destroyBlockPos and destroyingItem, so
sameDestroyTarget still matches after a fall, no second START is sent, and
the server's clock keeps running. That is the "break it a bit each jump"
behaviour the report asks for.

Horizon had the formula but not the state: the ABORT and too-far STOP
branches cleared the dig, guest_stop_digging required an active dig, and
the client's own target died on every stop, so each landing re-sent START
and re-anchored dig_start.

The server now retains destroy_pos (written only by an accepted START,
cleared by a respawn) and measures a STOP from the START that named the
block. The client splits isDestroying out of the target, so the target
survives a stop while a running break is still the thing that suppresses a
new START.
2026-10-31 21:31:45 +05:30
rdbtCVSandClaude Opus 5.5 58b2c98d35 frame_gen: size the optical flow field as OpticalFlow sizes its vectors (div_ceil)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:01:17 +00:00
TobiasLogicandClaude Opus 5.5 7042bc4d65 Shader packs: #if takes float values, and a condition it can't read is a warning
GL drivers evaluate a float in #if as a number (I Like Vanilla's #define AMBIENT_CEL_AMOUNT 0.0
tested with #if AMBIENT_CEL_AMOUNT != 0), and Iris loads such packs; Horizon refused the line and
skipped the program. The evaluator now computes in f64: float literals count, a fraction is true,
and / and % stay integer division unless a float literal appears.

A condition that still can't be evaluated already reads as false, but it was a diagnostic, so the
whole program was skipped (Pastel). It is now a warning in the loader's notes, and the program
loads with that branch off, as Iris runs those packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:01:04 +07:00
rdbtCVSandClaude Opus 5.5 b02bd359c1 Merge origin/main into pr4178
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:00:39 +00:00
rdbtCVSandClaude Opus 5.5 c58e27ef3f Merge origin/main into triage-3466
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:58:53 +00:00
Ludovic MarieandClaude Opus 5.5 79dc2964db Native mods: a sidebar and Tab list lines for one player, tick times, AFK players out of the sleep count (API 43)
For a public server's SMP mod: native_sidebar shows one player a sidebar of
up to 15 lines (a text, and one where the score would be) that only their
client holds, native_tab_list sets the lines over and under their Tab
list, native_tick_stats reads /tick query's rate and average tick time,
and native_sleep_ignored leaves a player out of players_sleeping_percentage's
count until they are in bed. Server players now carry yaw and pitch, for
telling who is away from the keyboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:49 +01:00
MSedgeMC 5a4e55917e Title a player head after its profile
PlayerHeadItem.getName answers block.minecraft.player_head.named with the
profile's name, so a head given for someone reads their name rather than
"Player Head". Horizon showed the plain item name in the tooltip and in
command feedback.
2026-10-31 21:25:04 +05:30
MSedgeMC 1f354fcf65 Pick the seed a stem grew from
A stem has no block item, so vanilla's pick-block returns the seed it grew
from. Horizon fell back to the block's own item, found air and picked
nothing, so middle-clicking a grown pumpkin or melon stem did nothing.
2026-10-31 21:24:57 +05:30
MSedgeMC 92639ca113 Let shears crop a growing plant's head
ShearsItem.useOn on a growing-plant head below its top age plays
block.growing_plant.crop, sets the head to age 25, fires a block change,
wears the shears and succeeds. Horizon had no such arm, so shears on kelp
or a vine did nothing.

The arm sits with the pumpkin carve and the beehive shear and reuses their
helpers.
2026-10-31 21:24:35 +05:30
Jose Gil 31c1212bf7 fix(launcher): localize proxy, game arguments, release headers, and sponsor section 2026-10-31 11:53:09 -04:00
arismoko bd26b09bdd Keep item pickups stable across native hooks 2026-10-31 10:48:22 -05:00
iseaprojectsandDeepSeek V4.1 Flash 5e04d2051f i18n: complete the Spanish (es_es) strings
`es_es.json` had 13 of the 198 keys `en_us.json` ships, so the other 185
fell back to English on every Spanish screen. Translate them, keeping the
13 existing strings and every `%s`/`%.2f` placeholder.

The file also serves `es_ar`, `es_cl`, `es_ec`, `es_mx`, `es_uy` and
`es_ve`, which `lang.rs` aliases to it.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-31 11:46:47 -04:00
Nathaniel Ramos fe617e8370 Heal a stale Shift or Control from the modifier state too
The same lost key release that latched sneak also leaves the physical
modifier down, and a screen reads the physical state: every inventory
click becomes a shift-click, every throw a whole-stack drop.
2026-10-31 23:45:57 +08:00
ErikKalikasandClaude Opus 5.5 0ea18b9878 Native mods: sync a mod block entity to clients (sendBlockUpdated)
native_block_entity_sync(pos) puts the position in the level's block log,
as Level.sendBlockUpdated marks ChunkHolder.blockChanged: the hub sends the
block and the block entity's update tag to the players who see its chunk,
once per position a tick. NATIVE_HOOKS_API 24. A lan_bot test checks the
chunk's tag, one BlockEntityData for two syncs, and none without a tag.

Refs #4615

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:41:58 +02:00
Teiwazik 2c952190f4 Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 16:38:22 +01:00
Teiwazik 9bd31bf94d Merge branch 'shader/s4-material-surface' into shader/s5-water-mirror 2026-10-31 16:38:19 +01:00
Teiwazik c127524e08 Merge branch 'shader/s3-post-history' into shader/s4-material-surface 2026-10-31 16:38:17 +01:00
Teiwazik 00af468123 Merge branch 'shader/s2-frame-motion' into shader/s3-post-history 2026-10-31 16:38:15 +01:00
Teiwazik 9452f0ac11 Merge branch 'shader/s1-material-behind' into shader/s2-frame-motion 2026-10-31 16:38:13 +01:00
Teiwazik 3f0b8f293a Merge remote-tracking branch 'origin/main' into shader/s1-material-behind 2026-10-31 16:38:11 +01:00
TobiasLogicandClaude Opus 5.5 70f8a8ab4e Shader packs: the sky's and clouds' light and normals, and blockEntityId, as Iris gives them
Measured with a probe pack in Minecraft 26.3 + Iris 1.11.7 against Horizon on the same world and
camera:

- gbuffers_skybasic, gbuffers_skytextured and gbuffers_clouds get full light, lightmap
  coordinates (240, 240); Horizon gave (0, 240).
- gbuffers_skybasic's normal is (0, 0, 1); Horizon gave (0, 1, 0).
- Clouds get their face's normal (down for the bottom, out for the sides); Horizon gave up for
  every face. The cloud mesh now carries it (w 0: the game's own clouds still shade by colour).
- blockEntityId is -1 outside block entities; Horizon gave 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:30:05 +07:00
Nathaniel Ramos 5f25673546 Heal a held sneak or sprint from the modifier state
A release does not always reach the mapping: a screen reads presses only, and
an unfocused window is given no release at all. The latch then keeps the player
sneaking or sprinting until the key is tapped again, which is what a short
alt-tab did on Wayland. The modifier state is what the keyboard itself asserts,
and winit reports it before any screen or gameplay handler runs, so a per-frame
check settles the question instead of waiting for a release that already got
away.

A mapping that toggles, or one a pad latched, is left alone: set_down(false,
needs_toggle) is a no-op for those, and a toggle the player asked for is meant
to outlive the key. A mapping rebound away from its modifier is skipped too,
since the modifier says nothing about it.
2026-10-31 23:20:20 +08:00
huiandZCode AI agent (GLM-5.3) 962288e85f Fluids: a cauldron never exceeds the ask, a shrunk tank table keeps its tanks, no overflow
A cauldron's drain now hands out only whole thirds that fit within the
amount asked, as IFluidHandler.drain's maxDrain: asking 1 or 500 of a full
one takes one third (334) or none, so a caller moving into the room it has
never receives more than it can hold. Its fill compares each level's whole
third against what is left of the amount instead of summing, so a huge
amount can't overflow. A save with more tanks than the block now declares
reads the tanks that still exist instead of all empty, which the next write
would have made permanent.

Review: Aria's agent swarm (Dyna) in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 16:19:00 +01:00
arismoko 47cf6820c4 Explain how to start stamp PR checks 2026-10-31 10:18:09 -05:00
Ludovic MarieandClaude Opus 5.5 a129533651 Natural spawning: fewer lookups per chunk and per attempt
- A position's placement (isSpawnPositionOk) is tested before its spawn list
  is looked up again (canSpawnMobAt: structures reaching it, the zoomed biome,
  a string-keyed list). Both only read the level, so their order cannot change
  the answer, and the few block reads of the placement turn most positions
  away. The placement is read from a table by species, not by an id built
  with format! after a linear search (from server-tick-2's
  perf/spawn-placement-first).
- LocalMobCapCalculator's players near a chunk are runs of one list, not a
  Vec per chunk, and the counts per player a Vec by player index, not a hash
  map: thousands of chunks ask every tick on a full server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:57:33 +01:00
reservedbytes a2a79d9cfc fix(mobs): a knocked sulfur cube syncs at once 2026-10-31 13:57:08 -01:00
Teiwazik a74b7d507b Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 15:55:11 +01:00
TeiwazikandClaude Opus 5.5 995dedc504 Shader API: say what the water mirror's sky is
The mirror draws the vanilla gradient, not a pack's own sky stage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 15:55:10 +01:00
Teiwazik a83a978c3f Merge branch 'shader/s4-material-surface' into shader/s5-water-mirror 2026-10-31 15:54:37 +01:00
Teiwazik fa562a57bf Merge branch 'shader/s3-post-history' into shader/s4-material-surface 2026-10-31 15:54:36 +01:00
ElyTheCatandCodex 0a7c5e6ced Reset native post history after frame gaps
Co-Authored-By: Codex <[email protected]>
2026-10-31 15:54:36 +01:00
TeiwazikandClaude Opus 5.5 e83286d60f Shader API: keep fluid faces out of the section mesh fixture's alpha
The fixture exports vanilla's colours, whose alpha is 255: the fluid face in
the high nibble is Horizon's own, as the emission in the low one is. A test
now checks the material stage's decode against the mesher's encoders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 15:54:17 +01:00
Nathaniel Ramos 44ba5682be Release held sneak and sprint when the window loses focus
A short alt-tab (under 500 ms, so no pause screen opens) leaves the player
sneaking until Shift is tapped again. The latch in `toggles` survives the focus
loss and #4139's `sync_toggle` puts ShiftLeft back into `keys`, while an
unfocused window never receives the key release that would clear it.

Release each mapping that does not toggle, so a held sneak, sprint, attack or
use cannot survive a focus change, and pass the option through for the ones
that do: `set_down(false)` is a no-op for a toggling mapping and for a pad
latch, so the latched state #4139 was protecting still crosses the alt-tab.
2026-10-31 22:49:33 +08:00
reservedbytes c9a8724b61 fix(explosion): a guest hit by a blast remembers its source for fall_after_explosion 2026-10-31 13:40:53 -01:00
JoansitohandClaude Sonnet 5.5 eb62069295 Superflat screens: draw layer and preset icons from the item's GUI model
The layer list and the preset list drew the flat item/<name> sprite, so a block
showed as the first texture of its model. They now resolve the item and draw it
as GuiGraphics.renderItem does (isometric for blocks, buckets for water and lava),
clipped exactly to the list; the sprite stays as the fallback.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:21:04 +01:00
oxi-does-dev d08c7e7cde #4107: the rider's client steers a harnessed Happy Ghast
HappyGhast.getControllingPassenger makes the first passenger control
a harnessed adult (wearing body armor, not on a still timeout), like
AbstractHorse's does for a saddled horse. client_moves only knew the
equine half, so on a Java server the client never moved the ghast and
never sent MoveVehicle: WASD did nothing.

Verified against the pinned 26.3 bytecode:
rea_vanilla HappyGhast.getControllingPassenger / isOnStillTimeout.
2026-10-31 17:20:22 +03:00
sirrayi 576737b0f5 Merge remote-tracking branch 'origin/main' into lod-final 2026-10-31 14:18:43 +00:00
sirrayiandDeepSeek Harness 42ffa89bca LOD: pin the result-retention rule with a test
The frontier-starvation fix (accept a result newer than the one shown, even
while a newer job is pending) had no test: it ran inside `receive`, which only
the worker channel can reach. `accept_mesh` is that rule on its own, so the
test drives it directly — an older-but-newer-than-shown revision fills the
frame and leaves the tile waiting, the same revision is not redrawn, the
newest settles it, and a canceled result or one for a tile that left the view
is dropped.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 14:18:38 +00:00
TobiasLogicandClaude Opus 5.5 6d7323af8c Shader packs: face normals and mc_Entity for terrain on every GPU
A shaders/ pack's terrain program reads each quad's face normal and mc_Entity only in flat draws
(PackAtlas::terrain_inputs(flat)); otherwise it gets (0, 1, 0) and -1. Flat draws existed only
on Metal and Intel Gen11's Vulkan driver, so on NVIDIA and AMD every pack lit all terrain as if
it faced up and knew no block from block.properties. Flat draws now exist wherever the device
supports them; frames without a pack keep per-section draws outside Metal and Gen11
(flat::for_vanilla), so only a pack's terrain and shadow pass draw flat there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:13:00 +07:00
Jose Gil 4e97410874 fix(launcher): deduplicate ping keys in catalogs and add Save translation 2026-10-31 10:05:48 -04:00
Kacpherek 09e52b9de3 Drop flight when leaving spectator near the ground
Switching from spectator to creative always left the player flying. In
ServerPlayerGameMode.changeGameModeForPlayer a flying player who leaves
spectator mode stops flying when isInRangeOfGround holds: no collider
touches the player's box and Entity.getAvailableSpaceBelow(1.0) is under
one block.
2026-10-31 15:05:07 +01:00
Kacpherek 73dd47046c Let explosions destroy and push experience orbs
Orbs had no health and ignored blasts. Vanilla gives an orb 5 health
(ExperienceOrb.health) and ServerExplosion.hurtEntities calls
ExperienceOrb.hurtServer with the blast damage, which takes it off as an
int and discards the orb at 0 or below; pushFromExplosion then moves the
orbs that stay, from the eyes at 0.85 of the 0.5 high box. The health is
saved and loaded as "Health". Fire and lava damage are not ported yet.
2026-10-31 15:05:07 +01:00
Kacpherek 054124dd6f Lift entities when farmland or a path turns to dirt
Farmland that dried out or lost its support, and a path with a block over
it, became dirt and left whoever stood on it sunk a sixteenth into the
new block. FarmlandBlock.turnToBaseBlock and PathBlock.turnToDirt call
Block.pushEntitiesUp (Block.java): every entity touching the collision
the new state adds is teleported up so it rests on it. The level now
reports the swap and the game lifts the players, guests, mobs and items
the same way.
2026-10-31 15:05:07 +01:00
Kacpherek 0c312c3c96 Crack turtle eggs under walking mobs
Only the player broke an egg by stepping on it. TurtleEggBlock.stepOn
(TurtleEggBlock.java) runs for every entity on the ground that does not
step carefully: canDestroyEgg refuses turtles and bats and needs mob
griefing for other living entities, then the level random rolls 1 in 100
and decreaseEggs plays the break sound (0.7, 0.9 + random * 0.2) and
removes one egg or the block. A mob now rolls that in its block effects
and the game cracks the egg.
2026-10-31 15:05:07 +01:00
Kacpherek 12744874c8 Snuff a candle from a wind burst with the candle sound
A wind charge put a lit candle out with the fire-extinguish sound and no
block-change game event. AbstractCandleBlock.onExplosionHit calls
extinguish(null, ...) (AbstractCandleBlock.java): setBlock lit=false with
flags 11, the candle.extinguish sound at 1.0/1.0 and a block_change game
event; the smoke is a level.addParticle, which a server level ignores.
Use the world's extinguish_candle, which does exactly that.
2026-10-31 15:05:07 +01:00
Kacpherek c780e0890c Open a fence gate from a wind burst without turning it
A wind charge opened a gate by running the click path, which swings the
gate's facing towards the player when it faces them. Vanilla's
FenceGateBlock.onExplosionHit (FenceGateBlock.java) only toggles OPEN of
an unpowered gate with setBlockAndUpdate, plays the open or close sound
at pitch random * 0.1 + 0.9 and posts block_open or block_close with the
old state as context and no entity.
2026-10-31 15:05:07 +01:00
Kacpherek 0eda94e97a Break a pitcher crop under a walking ravager
A ravager only trampled wheat, carrots, potatoes, beetroots and
torchflowers; PitcherCropBlock.entityInside (PitcherCropBlock.java) does
the same for a pitcher crop: with mobGriefing a server level destroys the
block with drops for any Ravager inside it.
2026-10-31 15:05:07 +01:00
Kacpherek d50a163baf Keep an op's spawn egg data on a trial spawner
Using an op's spawn egg with entity data on a trial spawner only set the
mob id and dropped the data. TrialSpawnerBlockEntity.setEntityData runs
TrialSpawner.overrideEntityToSpawn(TypedEntityData), whose
FullConfig.overrideEntityData / TrialSpawnerConfig.withSpawning build each
config's spawn data from the egg's data (TypedEntityData.loadInto), then
the state goes to inactive.
2026-10-31 15:05:07 +01:00
Kacpherek 8d75d8953e Fill an empty flower pot with a plant on right-click
Horizon had only the empty-hand take-out of FlowerPotBlock, so no
potted plant could be made. FlowerPotBlock.useItemOn (FlowerPotBlock.java):
an empty pot clicked with a block item that has a potted variant
(getFullPot) becomes that pot, sends the block_change game event,
awards the pot_flower statistic and consumes one item; any other item
tries the empty hand.
2026-10-31 15:05:07 +01:00
Kacpherek e4f7a58689 Describe the boat's bubble column push without a stale citation
The doc named the Rust BubbleColumn::push in vanilla's Class::method form, which the port ledger reads as a vanilla citation that 26.3 doesn't have.
2026-10-31 15:04:55 +01:00
Kacpherek aaaf867e46 Send framed map updates only for a framed filled map
ServerEntity.sendChanges (ServerEntity.java) updates a frame's map only when the framed item is a MapItem; lan_map_updates took any framed stack with a map_id component, so /give stone[map_id=1] in a frame got map packets. That check followed a NeoForge-patched decompile, which replaces the test with if (true).
2026-10-31 15:01:47 +01:00
levzzzandClaude Opus 5.5 7b0c59cc0a Native mods: entities leaving a level, for item entities and orbs
native_entity_leave_level(dimension, entity, kind) fires as vanilla's
onTrackingEnd: wherever an item entity or orb is removed (picked up by a
player or a guest, merged, despawned, burnt, blown up, killed, taken by a
hopper or a mob, gone through a portal) and when its chunk unloads,
including a level closing empty. Single removals go through
remove_dropped / remove_orb; the ticks and sweeps compare IDs before and
after. Not told at a server stop, as in vanilla. API 41.

Part of #5162 and #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:00:32 +05:00
SylviromiandClaude Opus 5.5 d76b3b7343 Native mods: a mod screen over a hopper or dispenser menu keeps its layout
machine_screen() answered Hopper or Dispenser by the menu's type even when
a mod drew its own screen over it, so the player inventory and the menu's
slots sat where HopperScreen puts them (inventory at y 51), not where the
mod's screen said. It now answers none while a mod screen shows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:54:15 +03:00
PzmuV1517 1a4f4e13cf Shader Options: shift-clicking a slider clicks, shortened labels keep the pack's colours 2026-10-31 15:54:14 +02:00
PzmuV1517 f2d68b69ab Shader Options: a held slider's handle follows the pointer 2026-10-31 15:54:14 +02:00
PzmuV1517 a837c014c5 Shader Options: include guards aren't options, empty profiles and unlisted saved values are dropped 2026-10-31 15:54:14 +02:00
PzmuV1517 28993a4ccc Shader Options: the pack's comments, shift-click to reset an option, labels that fit
Hovering an option, a screen or the profile button shows what the pack's lang file says of it
(option.NAME.comment, screen.NAME.comment, profile.comment). Shift-click puts one option
back at the pack's default. A label wider than its button shortens the option's name, never
its value, and the whole label shows on hover.
2026-10-31 15:54:14 +02:00
PzmuV1517 14284620a9 Shader Options: sliders, and const bool options
The options a pack lists in sliders are sliders, each step one of the option's values. const
bool declarations (const bool shadowHardwareFiltering = true;) are switches too; as they are
as often the pack's own settings, they show where a screen names them, not with *.
2026-10-31 15:54:14 +02:00
PzmuV1517 48ddbebda0 Shader packs: a Shader Options page for the picked pack's options
The options a pack declares (switches, values with their lists, consts) on the screens
shaders.properties lays out, with the names its lang files give them, its profiles, and
Reset. Choices are saved to shaderpacks/<pack>.txt and applied to the pack's sources as
they are read; leaving the page loads the pack again with them.
2026-10-31 15:54:14 +02:00
PzmuV1517 fe3083e11c Shader packs: the out-argument fix is tested in CI, BACKENDS=1 fails on a backend error 2026-10-31 15:54:01 +02:00
PzmuV1517 d8ce624a9a Shader packs: snapshots need loads and stores in one function, firstPersonCamera follows the view, every strip's triangles are drawn 2026-10-31 15:54:01 +02:00
PzmuV1517 0d6a564287 Shader packs: up to 64 images, a pass's copy of an image it samples and stores is taken just before it 2026-10-31 15:54:01 +02:00
PzmuV1517 ed70681a0f Shader packs: gl_InstanceID is 0 in a folded vertex stage, shadow_water has no cutout test 2026-10-31 15:54:01 +02:00
PzmuV1517 888fc0fafd Shader packs: relativeEyePosition points from the eye to the camera, uint indices on split vectors 2026-10-31 15:54:01 +02:00
PzmuV1517 0a19224cfe Shader packs: images a GPU can't make are left out, a resize rebinds every pass 2026-10-31 15:54:01 +02:00
PzmuV1517 7d4377297d Shader packs: SSBO and CUSTOM_IMAGES wait for their bindings, three sizes make a 3D image 2026-10-31 15:54:01 +02:00
PzmuV1517 a48662db3c Shader packs: a pass whose buffers differ in size is skipped, images bind in every program 2026-10-31 15:54:01 +02:00
PzmuV1517 e472d6f7e7 Shader packs: shaders.properties sees the pack's options, images wait for compute programs 2026-10-31 15:54:01 +02:00
PzmuV1517 b8f4137e2c Shader packs: 1D stand-ins, samplers check a custom texture's type, pack_textures is tested 2026-10-31 15:54:01 +02:00
PzmuV1517 0920c36d1f Shader packs: a local size gl_WorkGroupSize can't take fails the program, image clears copy in slices
local_size_x = 8 * 2 gave gl_WorkGroupSize 8, and a size that is not a number 1, silently;
such a program now fails with a message. Without clear_texture, images clear from a zero
buffer of at most 4 MiB, copied a slice of rows at a time, instead of one as large as the
largest image.
2026-10-31 15:54:01 +02:00
PzmuV1517 23364b2d9a Shader packs: else stays with the pack's if, snapshots skip loops and keep their own texture
A geometry stage's write wrapped as if (hz_primary) took the else of an unbraced if; it is
now a whole if (!hz_primary) {} else write. A barrier after a shared atomic no longer goes
between an if and its else.

An image whose load can run after the same invocation's store (a loop around both, or a
function holding both called in a loop or more than once) is no longer read from the copy
taken before the dispatch. That copy has a texture of its own, so a pass sampling last
frame's copy later in the frame still finds it.
2026-10-31 15:54:01 +02:00
PzmuV1517 8429a924ed Shader packs: a terrain or shadow program that writes only images is drawn
It was skipped as writing no buffer; the sink was only given to fullscreen programs. A program
drawing geometry has the depth buffer, so it now draws with no colour target, its images
written as GL's would be.
2026-10-31 15:54:01 +02:00
PzmuV1517 ba64c55746 Shader packs: geometry stages are tested, take unsized inputs, images keep a layout on the line before
A unit test folds a small shadow program with a geometry stage and checks its triangle count.
It found that inputs declared without a size (in vec3 v[]) were unsized arrays naga refuses;
they have the triangle's three elements. An image declared with its layout on the line before
no longer gets a second one.
2026-10-31 15:54:01 +02:00
PzmuV1517 62a673ec2c Shader packs: eye uniforms are the player's in third person, compound assignments on atomic vectors
eyePosition, playerLookVector and playerBodyVector came from the camera, which a third-person
view backs away from the player (and turns around in front view); they are the camera
entity's eye and rotation now, and relativeEyePosition is the eye from the camera.

A shared vector split for atomic functions took sums += v to hz_get_sums() += v, which does
not compile; compound assignments write through the setter. Array sizes of several tokens
are kept whole. Tests cover components, swizzles, whole reads and writes, arrays and a
program compiling end to end.
2026-10-31 15:54:01 +02:00
PzmuV1517 026faad742 Shader packs: images clear before the shadow programs write them, buffers stay within the device's limits
Clearing image.* happened after the shadow and shadowcomp programs ran, wiping what they
stored; it now opens the frame. A bufferObject.* larger than the device binds is made at the
largest size it takes, with a warning, and its per-pixel size no longer overflows.
2026-10-31 15:54:01 +02:00
PzmuV1517 de054759fb Shader packs: the hand doesn't spin with nausea, and the buffer and image parsers are tested
gbufferModelView holds the nausea spin the world turns with; the hand's projection now takes
it out again, as vanilla's hand never spun. Tests cover bufferObject.*, image.*,
iris.features.* and buffer blocks with and without a binding.
2026-10-31 15:54:01 +02:00
PzmuV1517 83c30ba172 Shader packs: draw passes sample their copies too, and a sky program that writes nothing hides vanilla's
A composite or deferred program that samples colortexN and stores to colorimgN now samples
a copy taken before it runs, as compute programs did; it sampled an empty texture. Whether
vanilla's sky and sun are drawn depends on the pack having the program, not on it building a
pass.
2026-10-31 15:54:01 +02:00
PzmuV1517 8f6c9f7d0c Shader packs: work group sizes a compute program can't use are refused, buffer sizes bounded
workGroups needs three numbers and workGroupsRender two (or three, the last taken as 1);
another count fails the program with that message instead of running it 1x1. size.buffer.*
factors must be finite and positive, and every size resolves to at most 8192.
2026-10-31 15:54:01 +02:00
PzmuV1517 ec259a221a Shader packs: a custom texture over the device's limits is skipped with a note
Its size is checked against the device's largest texture of its dimension and its texels
against that size before anything is created, so a pack asking for too much loses that
texture instead of failing validation at upload.
2026-10-31 15:54:01 +02:00
PzmuV1517 49500aacd2 Shader packs: gl_WorkGroupSize is the program's local size
naga's HLSL writer has no WorkGroupSize built-in, so on DirectX 12 the two Rethinking Voxels compute programs that read it were refused. It becomes the constant the program's layout(local_size_*) declares, as GLSL defines it.
2026-10-31 15:54:01 +02:00
PzmuV1517 053f5b8714 Shader packs: compute programs see the order their packs were written for
GL leaves undefined what one invocation reads of what others of the same dispatch write, and packs lean on how desktop drivers happen to run it. Rethinking Voxels' light lists changed from frame to frame on Apple GPUs, so glass, which reads its light volume directly, flickered.

- An image a compute program both loads and stores, with every load before every store, no atomic function on it and neither coherent nor volatile, is loaded from a copy taken before the dispatch: neighbouring work groups' light lists, reprojection in place and the volume shift read the values they expect.
- A barrier follows each statement of a compute main that applies an atomic function to shared memory, directly or through a function, unless main returns early: light weights raised with atomicMax were read back before every invocation had raised them.
2026-10-31 15:54:01 +02:00
PzmuV1517 60d3a679bc Shader packs: draw geometry stages, and what Rethinking Voxels needed
- Flat terrain draws a program with a geometry stage without its index buffer: the vertex stage reads it (set 5) and the draw has an instance for each triangle the geometry stage may emit.
- Programs that write only images draw into a sink that keeps nothing.
- The pack's images are cleared before the shadow pass, not after it, and a pass that samples an image it stores samples last frame's copy. Images an atomic function uses are made for atomics (Metal dropped them otherwise), and images bind in vertex stages where the device lets them.
- HORIZON_SHADER_PACK_DUMP=<image> logs how many of a pack image's texels are set, for debugging.

Rethinking Voxels r0.1-beta9 voxelizes the world in its shadow pass and lights it with its colored voxel lights.
2026-10-31 15:54:01 +02:00
PzmuV1517 92b62f0f0e Shader packs: translate geometry stages of terrain's shadow program
A shadow program of terrain with a geometry stage (shadow.gsh): wgpu has none, so its vertex, geometry and fragment stages become one vertex stage and the fragment stage (translate_geometry). Each vertex invocation stands for one corner of one triangle the geometry stage may emit: it reads its input triangle's three vertices itself (GeometryInputs says how), runs the pack's vertex stage on them, then the geometry stage, and keeps its corner.

- The shadow pass has the cutout test (GREATER 0.1) unless alphaTest.<program> says otherwise: leaves cast the shadow of their texels, not of their blocks.
- Images declared without a format take the one image.* gives them.
- gl_VertexID became hz_VertexID: the compatibility inputs' prefix gl_Vertex matched it first.

The game gives no GeometryInputs yet, so such programs are still skipped.
2026-10-31 15:54:01 +02:00
PzmuV1517 6e385e741f Shader packs: Iris's camera uniforms
cameraPositionInt and cameraPositionFract (and their previous values) are the camera's whole blocks and the rest, exact however far from the origin; eyePosition, relativeEyePosition, playerLookVector and playerBodyVector are the eye and where it looks, the camera being the eye in first person. The previous camera position is kept in double precision for them.
2026-10-31 15:54:01 +02:00
PzmuV1517 eb54435bef Shader packs: real_packs prints a program's bindings with PRINT_PROGRAM 2026-10-31 15:54:01 +02:00
PzmuV1517 af942d8ae1 Shader packs: read storage buffers and custom images
- bufferObject.<n>: a storage buffer of n bytes (or bytes per pixel of the render size). Buffer blocks are bound in set 4 and lose their access qualifiers so that every stage binds them alike.
- image.<name>: 2D and 3D images of the pack's, with their format, size (or the render size scaled), sampler name and whether they clear before each frame.
- iris.features: SSBO and CUSTOM_IMAGES are supported.
- A 3D image (iimage3D) is declared as 3D.
- naga: atomicAdd and the other atomic functions on buffers and shared memory, imageAtomic*.

Nothing binds them yet.
2026-10-31 15:54:01 +02:00
PzmuV1517 e75b7ff982 Shader packs: run compute programs, buffer sizes and the pack's own sky
- Compute programs dispatch before their composite-style program, over workGroups or workGroupsRender times the target's size; their images bind the side colortexN reads, and a pass that samples and stores one buffer samples a copy taken first. The MC_GL_KHR_shader_subgroup_* macros are defined when the device has subgroups.
- size.buffer: a pass draws at the size of the buffers it writes, and those buffers clear apart from the blit.
- A gbuffers_skybasic or gbuffers_skytextured that writes nothing still hides vanilla's sky or sun and moon, and the world's background is colortex0's clear colour when the pack sets it: vanilla's fog colour was added to Photon's sky, and hid its stars.

Photon v1.4 at 2560x1440 on an M5: 8 to 50 FPS (GPU 125 to 21.5 ms), its sky light from the compute program, quarter-resolution clouds.
2026-10-31 15:54:01 +02:00
PzmuV1517 e944268c3e Shader packs: translate compute programs, read size.buffer, options in shaders.properties
- Compute programs (<name>.csh, then _a to _z) are translated and carried before their composite-style program: images (colorimgN in its own format), workGroups and workGroupsRender, shared memory, barriers and subgroups. Compute-only programs are noted as not supported yet. Nothing dispatches them yet.
- size.buffer.<buffer>: a size of its own, relative to the render size (floats) or in pixels (integers).
- shaders.properties and the program switches see the pack's options at their defaults (switches on, value options): Photon sizes its cloud buffers only under #if CLOUDS_TEMPORAL_UPSCALING == 4.
- naga: subgroup operations, memory barriers, and barrier() as a work group barrier.
2026-10-31 15:54:01 +02:00
PzmuV1517 e65496594f Shader packs: draw custom textures, and what Photon needed to render in game
- The custom textures texture.* names are uploaded and bound to the samplers they name: one texture per dimension of a unit (Photon's 3D scattering LUT under depthtex0), and the lightmap and block atlas by resource location. A sampler with nothing to read gets a stand-in of its own dimension instead of failing its pipeline.
- Outputs past the last buffer RENDERTARGETS names go nowhere, as in GL, instead of an attachment the pass lacks (a crash).
- A shader the driver's compiler refuses (an internal error) skips its program as a validation error does.

Photon v1.4 now loads all 27 overworld programs and draws its lighting, shadows, fog and volumetric clouds.
2026-10-31 15:54:01 +02:00
PzmuV1517 85382019cf Shader packs: struct fields and array elements passed as out arguments no longer crash Vulkan
naga's SPIR-V writer panicked on Photon's deferred4 ("Expression is not cached"): the GLSL frontend passed a pointer into the middle of a variable for f(s.field), which only Metal's writer takes. The naga pin moves to a fix that spills such arguments through a local and writes them back after the call, as GLSL's copy-out says.

real_packs with BACKENDS=1 hands every translated module to the SPIR-V, HLSL, GLSL and MSL writers with wgpu's settings, so this is caught on any machine.
2026-10-31 15:54:01 +02:00
PzmuV1517 9d410bc31f Shader packs: colortex buffers bound as images can take atomics
An R32I or R32UI buffer a program binds as colorimgN is made with atomic storage usage, as the pack's own images already are: Metal drops texture atomics on a texture made without it, so imageAtomicMin on colortex9 did nothing.
2026-10-31 15:54:01 +02:00
PzmuV1517 fcd2161287 Shader packs: atomic functions on a component of a shared integer vector
atomicAdd(sums[i], n) and atomicMax(lights[k].w, w) on a shared ivecN or uvecN are GLSL that naga can't express (it has no atomic vectors): such a vector, or array of vectors, becomes an array of its components, a component an element of it, and the whole vector is read and written through functions of ours. Rethinking Voxels' light lists use them.
2026-10-31 15:54:01 +02:00
PzmuV1517 dd028c594f Shader packs: compute-only programs
A prepare, deferred, composite or shadowcomp program of compute stages only (.csh without .vsh and .fsh) runs its stages in order where the program would draw. shadowcomp programs also run the compute stages they carry.
2026-10-31 15:54:01 +02:00
PzmuV1517 3a0de65fa4 Shader packs: IS_IRIS and Iris feature flags, sized array types in global initializers
- IS_IRIS is defined: packs take Iris's extensions (compute programs, images, custom textures) only where it is, and Rethinking Voxels draws an error screen without it. iris.features.optional defines IRIS_FEATURE_<name> for the features Horizon has; a required one it lacks is noted.
- A global initialized with the size on its type (ivec3[6] offsets = ivec3[](...)) kept only the size as its type.
- naga: constant shifts drop the bits shifted out, as GLSL says (1 << 31).
2026-10-31 15:54:01 +02:00
PzmuV1517 63bd8e0474 Shader packs: a device without compute, storage writes or clear_texture skips what needs them
The pack's renderer asks the adapter what it can do instead of assuming Metal or desktop Vulkan. Without compute shaders compute programs are skipped; without storage writes from fragment or vertex shaders the programs that write images or buffers there, and geometry stages, are skipped; an image whose format can't be a storage texture is left out with the programs using it; without clear_texture images are cleared by copying zeros. Each leaves a note, and the bind group layouts made outside the error scope (storage buffers, geometry indices) only name stages the device supports, so none of it can stop the game.

HORIZON_SHADER_PACK_WITHOUT=compute,fragment-storage,vertex-storage,clear tries the fallbacks on a device that has it all.
2026-10-31 15:54:01 +02:00
PzmuV1517 0bfcf5f954 Shader packs: shadow.culling, voxelDistance and geometry stage writes once per triangle
- shadow.culling=false draws every section within the shadow distance and every face of it; reversed does so within voxelDistance (default 160) and culls beyond as before. Horizon drew only the faces turned to the light, and a pack voxelizing the world in its shadow pass lost the others.
- A geometry stage's image and buffer writes run in one of the invocations that stand for its triangle; in an expression the others read the value instead. The instances drawn follow from the strips EndPrimitive ends.
- at_midBlock for flat terrain: the offset to the centre of the face's block, in 64ths.
- firstPersonCamera.
- naga: imageAtomic* give the texel's value before the operation.
2026-10-31 15:54:01 +02:00
PzmuV1517 19f8944958 Shader packs: bind storage buffers and custom images, and atomics on them
- Storage buffers are zeroed once and kept across frames, bound in set 4 to every program that declares the buffer block.
- The pack's images are made at their size, cleared before each frame when asked, and bound as images by name and as textures by their sampler name.
- Images an atomic function uses bind for atomics; the device asks for texture atomics, storage buffers in vertex shaders and texture clears where the GPU has them.

Rethinking Voxels r0.1-beta9 loads 10 programs and draws; its shadow pass (a geometry stage) and compute-only programs remain.
2026-10-31 15:54:01 +02:00
PzmuV1517 77a69f342f Shader packs: the view bob is in gbufferModelView, not gbufferProjection
Packs invert only gbufferProjection's perspective terms when they rebuild positions, so a bob inside it moved every rebuilt position, and the lighting with it, while walking. The bob and the nausea spin are now part of the model-view, where GL drew them; the product is the same, and the hand's projection no longer adds the bob a second time.
2026-10-31 15:54:01 +02:00
PzmuV1517 01664d49f5 Shader packs: matrix elements in custom uniforms are m.column.row
gbufferModelViewInverse.1.0 read row 1, column 0: the transpose. Packs turn a view direction to the world as inverse.0.0 * x + inverse.1.0 * y + ..., so Photon's sun direction (from sunPosition) turned with the camera: the sky lit as another time of day when looking around, swung with view bobbing, and the sun was missing.
2026-10-31 15:54:01 +02:00
PzmuV1517 3e1641b754 Shader packs: a directive ends a continued property value 2026-10-31 15:53:50 +02:00
PzmuV1517 6485363dc2 Shader packs: a pack that defines its own inverse() keeps it 2026-10-31 15:53:50 +02:00
PzmuV1517 6edd81d368 Shader packs: a continued property value stops where its branch does
shaders.properties is preprocessed before its lines join: a value continued with \ just before #else (Photon's moon_phase_brightness) swallowed the directive and the other branch, so the uniform was never defined. Directives now apply while lines join, and lines of branches they turn off are dropped.
2026-10-31 15:53:50 +02:00
PzmuV1517 17639892ec Shader packs: a raw texture's sizes are bounded and checked
Each dimension of a TEXTURE_* entry is at most 16384, and the bytes it needs are counted
without overflowing, so a pack asking for a huge volume gets a note instead of a wrapped
length check.
2026-10-31 15:53:50 +02:00
PzmuV1517 a59d378bf4 Shader packs: struct varyings, Vulkan type names as identifiers, naga's GLSL gaps
Photon translated 45 of its 88 composite-style programs and none of its overworld terrain, sky set-up or final pass; now all 88 translate and every dimension loads its full set.

- A struct passed between stages (flat out CloudsParameters p) keeps the pack's declaration as a private global and travels as its members packed into vectors, copied by our main.
- sampler, texture2D and Vulkan GLSL's other texture and sampler type names are free names in desktop GLSL (sampler2D sampler); they are renamed unless called.
- naga's GLSL frontend lacked textureGather, sampled implicit levels in vertex shaders, broke struct constructors with array members, vecN(bool) and vector % scalar: naga is patched with the fixes until a release has them.
- real_packs prints every loader note with SHOW_ERRORS.
2026-10-31 15:53:50 +02:00
PzmuV1517 6b0f0261e9 Shader packs: read custom textures (texture.*)
texture.<stage>.<name> and texture.noise name the files a pack binds to its samplers: PNG images, and raw 1D, 2D and 3D data with their size, format and type, each with its .mcmeta (blur, clamp). Post carries them, with a note for each that can't be read; nothing draws them yet.
2026-10-31 15:53:50 +02:00
PzmuV1517 914e5d0103 Vendor naga 30.0.1 unchanged
Shader packs need fixes to naga's GLSL frontend that no release has yet. They go on top of this
copy in the commits that need them, as wgpu-hal's fix did, instead of a git dependency.
2026-10-31 15:53:50 +02:00
PzmuV1517 d085cabe41 Shader packs: outputs narrower than vec4, inverse(), alphaTestRef and the cutout test
- A fragment output declared float, vec2 or vec3 (int and uint too) is written as the vec4 of its kind, alpha 1: wgpu's targets take four components.
- inverse() of a mat2, mat3 or mat4 is a function of ours (the adjugate over the determinant): naga writes it neither for Metal nor for WGSL.
- alphaTestRef is a constant of the program: its alpha test's reference.
- Solid and cutout terrain draw together, so terrain gets the cutout test (alpha > 0.1) unless alphaTest.* says otherwise.
- The naga pin moves to the fixes for pointers into vectors passed as out arguments and field selection on a matrix column.
2026-10-31 15:53:50 +02:00
PzmuV1517 c29967b1aa Shader packs: macro calls whose arguments span lines
A function-like macro called across lines (Photon's clamp01(\n ... \n)) was left unexpanded. Its lines now join the line the call starts on; the lines after it stay, empty, so line numbers still map back.
2026-10-31 15:53:49 +02:00
masa-masa-0314andGLM 5.3 3bdd07a71a Reconnect: the disconnect is classified by its key, not its text
The auto-reconnect's `allowed` matched English keywords ("ban", "server
closed", "online mode"...) against the disconnect's resolved text. The
texts arrive resolved in the player's language — a vanilla server's
"Server closed" is `服务器已关闭` to a zh_cn client, so a legitimate retry
was refused, and Horizon's own refusals (NOT_SIGNED_IN, the invalid
address) could only stay English for the matcher to see them.

The disconnect's translation key now travels with the text: the net
thread's reasons carry it (`Reason { text, key }`) from the server's
component's `translate` field, the session-service refusals' inner keys,
and Horizon's own keys (new `horizon.multiplayer.not_signed_in`,
`invalid_address`, `connected_friend_direct/relay` in all six bundles;
the friend connection's status texts and the local world-open progress
line move to keys too). `allowed` decides from a key list first — the
keyword scan stays as the fallback for plain reasons (OS errors,
servers' literal kicks in the server's own language), whose behavior is
unchanged.

NOT_SIGNED_IN is localized now that its classification is key-based, and
`load_language`'s no-language fallback picks Horizon's bundled strings up
so the net thread's keys resolve there too.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 21:51:34 +08:00
arismoko f30e87e791 Scope native hook stamp workflow permissions 2026-10-31 08:40:36 -05:00
levzzzandClaude Opus 5.5 2af60d68a2 Native mods: entities joining a level, for item entities and orbs
native_entity_join_level(dimension, entity, kind, pos, loaded_from_disk,
canceled) fires as vanilla's PersistentEntitySectionManager.addEntity:
from spawn_item, add_orb, /summon and chunk loads. loaded_from_disk is
true only for an entity that comes back with its chunk; portal and
gateway arrivals and /summon go through the chunk-load queue, so they are
marked there as fresh adds. A cancel takes the entity back out, and a
/summon of it fails with commands.summon.failed.uuid. API 41.

Part of #5162 and #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:40:14 +05:00
arismoko ed4501ac13 Open native hook stamp pull requests after merges 2026-10-31 08:40:04 -05:00
huiandZCode AI agent (GLM-5.3) c5f62fe73c Native mods: a block's fluids, read, filled and drained in millibuckets
native_block_entity_fluids declares a mod block's tanks, as its container
declares its slots: each tank's capacity, the tanks each face reaches, and
which a fill may reach and a drain may take. The tanks' contents live in
the entity's saved compound under `fluids`, so they survive restarts and
the block's own tick reads them there.

native_fluid_tanks reads them through a side, native_fluid_insert fills
(first into tanks holding the same fluid, then into empty ones) and answers
what did not fit, native_fluid_extract drains and answers what it took.
Both also work a cauldron: it holds thirds of a bucket, a lava one only
whole, and powder snow none, as it is no fluid. Water and lava are the
fluids; a tank holds one at a time.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 14:32:22 +01:00
sirrayiandDeepSeek Harness c1a49d50e6 Server: the interact packet's entity keeps the crosshair pick's filters
Review found that the by-id path dropped what `mob_hit` applies: a mob in its
death animation, the mob the player rides and the camera entity are never
clicked by the pick, so naming one in a packet must not reach the feed, shear,
leash or name paths either. `named_mob_pickable` is that rule, shared by both
paths and unit-tested on its own, since the pick's copy cannot be tested
without a world.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 13:21:08 +00:00
sirrayiandDeepSeek Harness 70bf1afb05 Server: an Interact packet's entity is the entity it clicks
ServerGamePacketListenerImpl.handleInteract resolves packet.entityId(), checks
it and the range, and hands that same entity to Player.interactOn. Horizon aimed
the guest at the named entity and then picked one again from the view, so a mob
that moved between the eye and the target took the click, and a block in the way
stopped an interaction the packet had already validated (#4651).

GuestUse::Entity now carries the packet's id and location, and the interact_
helpers use it as it is; the local player still picks from the crosshair, the way
Minecraft.hitResult does.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 13:21:08 +00:00
sirrayiandDeepSeek Harness 40fa42e239 Mod screens on hopper menus get their own slots back
`slot_position` asks the hopper's and dispenser's stock layout before its
native-screen arm, so a mod screen on a `minecraft:hopper` or
`minecraft:generic_3x3` menu never reached the slots it declares, and its
`inventory_y` was ignored: the mod drew its screen while every slot stayed on
the stock grid (#5141, item from #4953).

Only that layout stands back, and only while a mod screen is shown; the rest of
those menus was already the mod's, since `draw_native_screen` owns the
background and `screen_height` the height. With no mod screen the stock grid
answers exactly as before.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 13:15:25 +00:00
ElyTheCatandCodex 11d5d87d1f Fix incremental LOD result retention
Co-Authored-By: Codex <[email protected]>
2026-10-31 16:42:38 +03:30
oskal675-designandCodex GPT-6.1 12af64aab1 Keep End gateway exits only in block entity NBT
Migrate legacy side files before loading End chunks and retain gateway source chunks while resolving new exits.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 18:03:26 +05:00
BlzsandClaude Opus 5.5 3a88b6339f Options sliders: list the monitor's video modes once per monitor, not at every drag step
Settings apply at every pointer move of a dragged slider, and since the
Exclusive Fullscreen rows that listed the monitor's video modes each time:
about 28 ms on Windows, so the handle lagged and jumped. The list is now
kept until the window's monitor changes, in game and on the startup menu.
horizon-ctl options-set keeps it too (with_option rebuilt Settings without it).

Fixes #5133

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 13:59:46 +01:00
Federico Tagliani a9dc723f17 Merge remote-tracking branch 'origin/main' into feat/blending-old-chunks 2026-10-31 13:52:48 +01:00
ElyTheCat 5bd465312c Merge remote-tracking branch 'origin/main' into review-4747-fixes
# Conflicts:
#	crates/horizon-client-render/src/mesher.rs
2026-10-31 16:20:18 +03:30
TobiasLogicandClaude Opus 5.5 f62ae6ea53 Shader packs: terrain colours without the game's face shading
With a shaders/ pack, Iris gives terrain, translucent terrain and fluids their colour without
vanilla's face shading: packs light faces themselves (oldLighting, off unless the pack turns it
on). Horizon baked the shading in, so packs shaded faces twice: a probe pack's colours per face,
Horizon over Iris, were 0.8 north and south, 0.6 east and west, 1.0 up. Sections now mesh
without it while a shaders/ pack runs (Mesher::shader_pack_lighting), and remesh when that
changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:46:33 +07:00
SylviromiandClaude Opus 5.5 c1eb16913a Native mods: using an entity forgets the last clicked block
A click on a block that opened nothing left use_target set, so a menu an
entity opened next (a chest minecart's generic_9x3) could take the machine's
screen when the machine declared the same menu (arismoko on #5061). Using an
entity now clears it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:41:21 +03:00
Federico TaglianiandClaude Sonnet 5.5 0a68ecabad Old chunks: the starts they saved come back with their pieces
A jigsaw start (villages, outposts, bastions, ancient cities, trial chambers,
trail ruins) is loaded from its saved Children (PoolPiece::from_saved), as
StructureStart.loadStaticStart does, so the neighbours place the parts that
cross into them and the beardifier sees them. Another structure's pieces
have no loader: it is the generator's own start when that has the same name
and the same pieces' box, else the name and box alone. Decoded once per chunk.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 13:40:17 +01:00
Federico Tagliani 9eac3f03a3 Merge remote-tracking branch 'origin/main' into feat/blending-old-chunks 2026-10-31 13:33:46 +01:00
Federico TaglianiandClaude Sonnet 5.5 b103e5821b F3 Density line: no in-frame fallback
The window's server thread publishes the generators; end.locator is only
set on the deprecated in-frame path, which is not extended.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 13:29:47 +01:00
Federico TaglianiandClaude Sonnet 5.5 b2c52b5036 F3 Density line: the cache is a type with a test
DensityCache holds DebugEntryChunkGeneration's lastPos and result; the test pins
the sampling on a new block or dimension, the cleared line without a
generator, and the clear when another world opens.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 13:22:28 +01:00
huiandClaude Opus 5.5 815de4119d Native mods: hook points for chunks and players coming and going
native_chunk_load, with whether the chunk is new, and native_chunk_unload,
including a level that closes for being empty. native_player_logged_in and
native_player_logged_out come in pairs however a player leaves: a disconnect, a
kick, the server stopping or failing. At a stop they're told before the save,
still listed, and a last message goes out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 13:19:58 +01:00
Federico TaglianiandClaude Sonnet 5.5 50b64cd002 F3 Density line: nothing drawn or kept without a generator
DebugEntryChunkGeneration adds its group only in the server-level branch. With no
generator (a remote server, a LAN world joined, the server thread not there
yet) the cached line of the last world was still drawn; it is cleared
there, and when another world opens.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 13:10:10 +01:00
TobiasLogicandClaude Opus 5.5 22e5ef2029 Shader packs: custom uniforms' smooth starts from the first real value
smooth() starts from the first value it sees. The load-time dry run that finds unknown names
evaluates every custom uniform with the built-ins at zero, so each smooth() started there:
Photon's biome_temperature (smooth over 1000 s) read -0.97 on the plains where Iris reads 0.6 at
once, and took minutes to get there. Smoothing now restarts after the dry run, and on frames
before the camera's biome is known (its chunk not loaded yet).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:03:07 +07:00
AurelienBelloirandClaude Opus 5.5 fe752c7ac5 horizon-server: the lists test waits for a fork-held lock descriptor to close
edits_the_lists_while_stopped failed now and then on Linux CI with "a server
is already running in /tmp/horizon-server-lists-<pid>". The folder's lock is
an flock taken with try_lock; while another test in the same binary spawns a
process (tar, id, the engine), the child holds a copy of the lock's
descriptor between its fork and exec, so the lock is briefly taken and
try_lock answers WouldBlock. one_supervisor_per_folder already retried for
that window (4513af899); the lists test did not, neither for its own lock nor
for the edits, which take the lock inside lists::run.

The retry moves into supervisor::retried, a test-only helper (50 tries, 20 ms
apart), used by one_supervisor_per_folder and by the lists test for its lock
and for the edits that must succeed. The edits that must fail while the test
holds the lock are unchanged, and so is lists::run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 13:02:58 +01:00
ElyTheCatandCodex 0c651136a5 Fix server-side recipe book placement
Use the active world recipe tables when handling remote recipe placement, and let the server own remote grid and ghost updates instead of predicting them on the client.

Co-Authored-By: Codex <[email protected]>
2026-10-31 15:32:41 +03:30
k3yhandClaude Opus 5.5 0cffa9fa03 Read LZ4 region chunks as lz4-java reads them (#5064)
LZ4BlockInputStream decompresses each block with lz4-java's
LZ4JavaSafeFastDecompressor and calls it corrupted unless the decompressor
reads exactly the block. That decompressor has its own end conditions: a
sequence whose literals leave less than 8 bytes of output, or of input
past them, must be the last and end the output, and an offset of 0 fills
the match with zeros. lz4_flex's decoder disagrees both ways, so Horizon
loaded some streams lz4-java refuses (with whatever they decoded to) and
refused some it loads.

decompress_lz4_java ports that decompressor, fed from a buffer kept as
LZ4BlockInputStream keeps compressedBuffer (it may read past the block,
up to the buffer's end), and decode_lz4 requires it to read the whole
block. lz4_flex still compresses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:57:33 -05:00
Federico TaglianiandClaude Sonnet 5.5 d847c697f6 Persistent forwards the block entities decoration made in a final chunk
The game's generator is Persistent<VanillaTerrain>, which forwarded
final_writes but not final_block_entities, so the trait default (none)
replaced the chests and spawners of structure pieces reaching into a chunk an
older version saved. Test through Persistent. Also adapts saved starts to
the starts_at wait/try split on main.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 12:50:15 +01:00
Federico Tagliani c6a324b8fb Merge remote-tracking branch 'origin/main' into feat/blending-old-chunks 2026-10-31 12:48:27 +01:00
Federico TaglianiandClaude Sonnet 5.5 6c8584a70d F3 Density line: sampled when the feet block changes, without the query worker
DebugEntryChunkGeneration.display keeps lastPos and result and recomputes
only when the feet BlockPos changes; F3 sampled every frame. The line reads
the pipeline's samplers only, so it now goes through the generator and no
longer takes the shared query worker (no try_lock, no frame without the line).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 12:48:13 +01:00
Federico Tagliani 27bd4f8313 Merge remote-tracking branch 'origin/main' into feat/f3-density-line 2026-10-31 12:46:01 +01:00
huiClaude Opus 5.5ZCode AI agent (GLM-5.3)
7fc7fc4735 Native mods: read and take a block's items through a side, and move them
native_container_slots, native_extract_item and native_move_items follow the
rules hoppers use; a move puts a refused item back, so none is lost.
native_dimension names the level for a machine's tick, which isn't told it.

native_insert_item goes one item at a time and tells the container once. It
no longer overfills an empty slot, leaves a client's copy alone, and can't
reach a machine's slots during its own tick, where what went in was lost.

Hoppers change with it, as vanilla has them: a shulker box doesn't go into a
shulker box, a stack's max_stack_size component sets its limit, and comparators
read it. A comparator's fill ratio is clamped to full, where vanilla's
count-at-most-limit invariant keeps it: a stack a mod wrote over its own
limit can't push the signal past 15. A slot whose block entity went away
keeps the item, rather than losing it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 12:43:04 +01:00
k3yhandClaude Opus 5.5 9f0d819f6b Rewrite the whole region header on a chunk write (#5064)
RegionFile.write and clear end in writeHeader, which writes both header
sectors from memory. The constructor has already zeroed the entries it
found invalid there (overlapping the header, empty, past the end of the
file), so a write leaves them zero on disk. Horizon wrote only the
touched slot's offset and timestamp, so stale invalid entries stayed in
the file for other tools to read.

write_header replaces write_header_entry and writes both sectors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:35:15 -05:00
SylviromiandClaude Opus 5.5 ae1eb14b17 Creative tabs: look the title up as a lang key (the code for the previous commit)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:31:25 +03:00
SylviromiandClaude Opus 5.5 4c41466e2e Creative tabs: the title may be a lang key
A mod usually has its own itemGroup.<mod id> already (reservedbytes on
#5066): "title" is looked up as a lang key first, English text when it is
none. itemGroup.<mod>.<name> still overrides it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:29:45 +03:00
ElyTheCat 9be1573e70 Fix creative page releases and vanilla tab items
Co-Authored-By: Codex <[email protected]>
(cherry picked from commit 02a7c1553a70590c0d049a0dd0c4cfe22a6a64df)
2026-10-31 14:19:38 +03:00
StievenW 7066b93c96 fix(lan): conditionally clear sync data when packing files
Only call `forget_sync_data` if the generated overlay contains files.
This prevents unnecessary data clearing when no file updates are
required for the LAN overlay.
2026-10-31 18:16:47 +07:00
ElyTheCat f3831f7038 Consume remote menu click targets
Co-Authored-By: Codex <[email protected]>
(cherry picked from commit 4b17a2d6c9597dc328416f1771f325ed49bf8171)
2026-10-31 14:11:57 +03:00
Ludovic MarieandClaude Opus 5.5 f9030e2da9 Far terrain client: requests leave from the game thread, failed tiles are asked again
Review fixes (kacpherek on #4947):
- a refused, lost, timed-out or unreadable tile comes back as no columns,
  and Lod asks for it again 2 s later instead of keeping it empty;
- sampling threads queue their requests and the game thread sends them
  each tick with its other packets, so they no longer take the
  connection's writer; requests queued when the far terrain ends are
  dropped with their slots;
- docs and the e2e: horizon-lod-distance is off unless set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:10:17 +01:00
T3Code 221f941c63 Keep native shader test module after implementation 2026-10-31 17:09:37 +06:00
Ludovic MarieandClaude Opus 5.5 55ed606b37 Far terrain on servers: Horizon clients ask the server for it
The last step: a Horizon client of a dedicated server or of a LAN world
(not of its own server thread, which hands its generator over directly)
draws far terrain when the server offers it. Its far terrain's samplers
each send one tile request and wait for the reply, which the network
thread hands straight to them (lod_net::Inbox), keeping to the server's
limits (requests waiting, tiles a second) so a well-behaved client is never
refused. LOD Distance stops at the server's distance. A new dimension, a
reconfiguration or a disconnect cancels the waiting samplers at once.

tools/server_lod_e2e.py checks it on a real dedicated server: with
horizon-lod-distance=16, a client with LOD Distance 32 gets the server's
far terrain, capped at 16 chunks, and draws far tiles; with 0, none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:07:15 +01:00
Ludovic MarieandClaude Opus 5.5 4b7fc5ca8c Far terrain server: replies on the game thread, charged refusals, caught panics
Review fixes (kacpherek, dimaniojk on #4946):
- the worker hands finished replies back to the game thread, which sends
  them before the tick's flush_all, so they keep their place among the
  game's packets;
- a slot frees when the reply leaves, and replies wait while the player's
  connection has more than 1 MiB pending;
- every request takes a token, refusals included; unreadable or over-rate
  requests are strikes, and 64 strikes disconnect the player;
- a panic while sampling drops that level's sampler and refuses the tile,
  so every job still gets a reply;
- only clients with the horizon brand (the ones offered it) are served;
- a level with no far terrain refuses instead of leaving the client waiting.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:06:58 +01:00
k3yhandClaude Opus 5.5 83df5969b8 Skip a file root's name unread, as vanilla does (#5064)
NbtIo.readUnnamedTag skips the root's name with StringTag.skipString
and never decodes it, so a file whose root name isn't modified UTF-8
loads in vanilla. Horizon decoded it and refused the whole file; for
level.dat that meant the world opened with fresh defaults (game mode,
cheats, time) saved over the real ones.

read_named and read_named_streaming read the root name leniently: a
name that isn't modified UTF-8 is read as empty. Every other string,
compound keys included, is still checked, as DataInput.readUTF does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:05:31 -05:00
MSedgeMC 554451b083 Keep a locked container locked
LockCode.canUnlock lets a spectator or a player whose main hand matches the
lock's item predicate open a container; BaseContainerBlockEntity.createMenu
refuses anyone else with an action-bar message naming the container and one
chest-locked sound. Horizon kept the tag on disk and read it nowhere, so a
locked chest opened for anyone, /data get did not show the lock, and
/setblock could not make one.

The lock now lives where the loader already put it, one place reads and
writes it, and the open funnel refuses with vanilla's message and sound.
2026-10-31 16:32:57 +05:30
Ludovic MarieandClaude Opus 5.5 557f0da93d Far terrain from a server: serve horizon:lod tiles
The second step: a dedicated server, or a LAN world, offers far terrain to
Horizon clients while they configure (vanilla clients hear nothing) and
answers their tile requests. `horizon-lod-distance` in server.properties
(Horizon's own key, 64 chunks by default, 0 off) bounds how far; each
player may have 8 tiles waiting and ask for 64 a second (a burst of 256),
and a tile past the distance, over the limits or of the wrong shape is
refused at once. One "Far terrain server" thread, started at the first
request and behind the ticks (horizon_threads::behind_server), samples
them from the player's level for every player in turn, and sends each
reply to its player itself. Clients don't ask yet (the next step).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:00:42 +01:00
Ludovic MarieandClaude Opus 5.5 fc9cf63829 Far terrain wire: off by default, versioned offer, bounded replies
horizon-lod-distance now defaults to 0: far tiles reveal the generator's
terrain beyond view distance, which helps crack a seed. The offer starts
with a protocol version. A reply over 512 KiB or 16 384 spans, with spans
out of order, overlapping, empty or out of the height range, is an error
before it allocates; the server refuses such a tile instead of sending it.
(Real tiles measure 4-6 spans a column.)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:00:41 +01:00
T3Code efc875c37e Merge current main after native shader toggle 2026-10-31 16:59:54 +06:00
T3Code a4bc24abdd Tie opt-in native shader packs to mod toggle 2026-10-31 16:59:47 +06:00
Ludovic MarieandClaude Opus 5.5 8a7bc780ed Far terrain from a server: the horizon:lod wire format
The first of three steps that let a Horizon client get its far terrain
(LOD) from a dedicated server or a LAN world, whose generator and seed it
doesn't have: the offer a server sends Horizon clients in configuration,
the tile request, the reply (columns with biomes by name, block states by
id, or a refusal), the per-player check of a request's distance and shape,
the token bucket both sides pace with, and the `horizon-lod-distance`
property's reading (64 chunks by default, 1024 at most, 0 off). Nothing
sends or reads them yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:54:50 +01:00
SenjinandClaude Opus 5.5 60c91c304f HDR: what shines is marked with improved transparency on too
Lightning, additive quads and translucent particles draw in the OIT stages
there, and only the OIT entities got a mark after them. The firework mark
also takes the same base as the draw it follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:51:42 +01:00
k3yhandClaude Opus 5.5 d516b77daa Write a string too long for modified UTF-8 empty on disk (#5064)
NbtIo.write goes through StringFallbackDataOutput: a writeUTF that would
exceed 65535 encoded bytes logs "Failed to write NBT String" and writes
an empty string, and the rest of the tree is written. Horizon's
write_named failed instead, and since a save batch's records are encoded
before any is written, one block entity with such a string (a long
command block command, a book) made the whole batch fail: every chunk
changed since the last save was lost, not only that one.

The disk writer (write_named) now writes such a string empty; the
network writer still refuses it, as vanilla's does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:50:27 -05:00
ElyTheCatandCodex 9b9b30a0f5 Reset pause focus when reopening the menu
Co-Authored-By: Codex <[email protected]>
2026-10-31 14:18:03 +03:30
AurelienBelloirandClaude Opus 5.5 0111493fc8 Save a minecart's mob passengers with its ride and climb to it as root
Entity.getRootVehicle follows getVehicle up through any vehicle, and
PlayerList.remove removes getPassengersAndSelf of that root, the same
entities Entity.save wrote under Passengers for
ServerPlayer.saveParentVehicle. The ride saved a minecart without its mob
passengers while remove_vehicle_tree removed them, so they left the world
unsaved, and vehicle_root climbed a mob stack to a boat but not to a cart.

- vehicle_of: the mob, boat or minecart a mob rides; parent_vehicle_root
  climbs it to the root, so a stack in a cart has the cart as root.
- vehicle_tree_nbt puts the cart's mob passengers under Passengers, as
  for a boat (add_passengers, out of put_mob_passengers).
- remove_vehicle_tree removes vehicle_tree over the living mobs, the ones
  put_mob_passengers saves.
- Unit tests over the pure parts: a lone rider saves the horse, two
  players on one boat save nothing, a mob in a boat rides under it, a
  cart saves and takes its mobs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:39:30 +01:00
T3Code 2f297750d7 Hide empty native settings pages and use full-width toggle 2026-10-31 16:36:28 +06:00
AurelienBelloirandClaude Opus 5.5 5fa3563773 Merge remote-tracking branch 'origin/main' into parity/save-parent-vehicle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:32:15 +01:00
StievenW 879eb9264c refactor(game): ensure sync data is forgotten during LAN and remote transitions
Ensure `forget_sync_data` is called unconditionally when packing files
for LAN overlays and when disconnecting from a remote session. This
prevents stale resource synchronization data from persisting across
different connection states.
2026-10-31 17:30:29 +07:00
SylviromiandClaude Opus 5.5 b43e2d1493 Native mods: ask the block's container for its menu when a screen opens
The window's world has no mod containers (the server tick installs them), so
mod_menu_at was always none there and no mod screen opened. Ask
native_block_entity_container for the clicked block instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 13:28:06 +03:00
mularealandAntigravity 6988570a37 gfx: cull back faces of entity shadows
Co-Authored-By: Antigravity <[email protected]>
2026-10-31 11:27:44 +01:00
oskal675-designandCodex GPT-6.1 80e46f61b4 Add launcher shell completion generation
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 15:26:59 +05:00
masa-masa-0314andGLM 5.3 089d775600 Experiments: the trade_rebalance flag applies its built-in data pack
A world with the `trade_rebalance` feature flag now plays the flag's
built-in pack: the overridden villager trade tags and trades, the
librarian enchantment trades and the changed chest loot, as vanilla's
server does. `MinecraftServer.configurePackRepository` force-enables a
feature pack whose requested features the world enables
(`enableForcedFeaturePacks`) and drops a selected pack asking for
missing ones: `datapack::enabled_ids` now takes the world's
`enabled_features` and does both, so the pack's files reach the world's
content overlay (`/datapack list` shows it as enabled).

The content tables then merge the pack over the resource root:
`Trades::load` takes the packs' `villager_trade`/`trade_set` files and
their tags (`replace` clears, `TagLoader`), and `Enchantments::load_with`
takes the packs' enchantment tags, so the librarian trades'
`#minecraft:trades/<biome_common>` pools resolve. Along the way:

- `PackOverlay::tag_kind` stripped a leading `:` that is not part of
  the `tags/<ns>:<registry>/<path>` key shape, so it never matched and
  every pack tag overlay silently no-opped; loot tables' tag overlays
  were affected too.
- `/loot give ... loot <table>` rolled the resource root's table when
  the table had a random sequence (`self.res.items()`), not the world's
  own — the world's tables are used for the sequence branch now.
- The world's flags feed the worldgen overrides and the biome table
  (`packed_biomes`, `level_shapes`, the preload reads them from the
  world's level.dat), and the integrated open path passes them through.
- Fixes the stacked world-save commit leaving `integrated.rs` calling
  `packed_biomes` without its `resources` argument (the bin did not
  compile).

Vanilla reference: `MinecraftServer.configurePackRepository`,
`enableForcedFeaturePacks`, `TagLoader.load`,
`SimpleJsonResourceReloadListener`, the built-in
`data/minecraft/datapacks/trade_rebalance` pack.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 18:22:07 +08:00
SylviromiandClaude Opus 5.5 223fc6b6b6 Native mods: a mod screen only for the menu its block opens
The first try (key == target) never held for a mod block: `holds` knows only
vanilla block entities, so every mod screen stayed shut. The screen now opens
when the menu is the one the clicked block's container declares
(`mod_menu_at`), which a chest menu from a command over a stale target is not.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 13:21:01 +03:00
MSedgeMC 22ea093e76 Fold the two expressions rustfmt wants on one line
CI's stable rustfmt collapses the free-position map and the collision-shape
map; the local check accepted them split.
2026-10-31 15:46:45 +05:30
ElyTheCatandCodex ba89dc137e Merge main to resolve renderer mesh conflict
Co-Authored-By: Codex <[email protected]>
2026-10-31 13:43:46 +03:30
EymenWSMC 5659ae6842 Create tr_tr.json 2026-10-31 13:10:07 +03:00
EymenWSMC 5b8bef7b6b Create tr.json 2026-10-31 13:09:13 +03:00
T3Code 2fba1602ac Merge main and assign native visual hooks API 41 2026-10-31 16:08:28 +06:00
MSedgeMC 23b8c8de7a Lift an experience orb out of the block it spawned in 2026-10-31 15:28:32 +05:30
MSedgeMC d38e8d4ecb Save a mob halfway through converting 2026-10-31 15:28:25 +05:30
Neferr de01121cae C:/Program Files/Git/ride mounts boats and minecarts
RideCommand.mount took only a mob as the vehicle. A player or a mob now
mounts a boat with a free seat, and a player any minecart no other player
is in, through the same state as a click. /ride <player> dismount goes
through stop_riding, and Entity.getVehicle names a minecart as one.

Part of #4968.
2026-10-31 10:57:39 +01:00
reservedbytes ea26d1a145 feat(mods): native mods change or cancel damage 2026-10-31 08:53:28 -01:00
TeiwazikandClaude Opus 5.5 5994eb0aa4 Packs: don't hash a large zip's bytes on every overlay check
The overlay's key hashed each enabled zip in full, on the assumption that one
file reads in a moment: with a 9 GB pack that read 9 GB at every launch.

A zip over 250 MiB, vanilla's limit on a server pack, is now keyed by its
length, modified time and central directory. Smaller ones keep their bytes, so
a server pack's touched time still doesn't rebuild the overlay, and an edit
that a zip's empty checksums wouldn't show is still seen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 10:51:29 +01:00
SylviromiandClaude Opus 5.5 a82bb75544 Native mods: follow-ups from the reviews of the block entity and screen hooks
- A mod screen opens only when the clicked block holds the menu: use_target
  outlives its use, so a menu opened later (a command's, another mod's) got the
  last clicked machine's screen, and its clicks and tooltips (arismoko on #4106
  and #4289).
- native_screen_tooltip runs over an empty slot, as its doc says, so a mod can
  say what an input takes (arismoko on #4289).
- A mod block whose template has no block entity gets none, with a warning:
  breaking it never removed the block entity, which stayed in the save
  (reservedbytes and DUzzL on #3996).
- A GUI texture that doesn't decode is named in the log, not skipped silently
  (reservedbytes on #4106).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:50:03 +03:00
reservedbytes 1ec0d72ba5 fix(mobs): sulfur cubes follow the tnt_explodes rule 2026-10-31 08:48:54 -01:00
ElyTheCatandCodex 2e0198ab3b Renderer: optimize animated sprite layer culling
Co-Authored-By: Codex <[email protected]>
2026-10-31 13:17:36 +03:30
reservedbytes 44d5b972fa fix(dragon): a wither skull hits the ender dragon's parts and explodes 2026-10-31 08:45:10 -01:00
reservedbytes 8516e8d3f9 fix(combat): name a player's renamed held item in death messages 2026-10-31 08:45:02 -01:00
SylviromiandClaude Opus 5.5 a138c139ac Creative tabs: block items once in search, nine-sliced page buttons, lang titles
From reservedbytes' test on #4331: a block's item was added to search twice
(blocks' items are already among the plan's items); the page buttons stretched
the 200-wide button sprite into 20 px and lost their sides (now blit_sprite, as
vanilla's Button draws); turning the page while on search, saved hotbars or the
inventory keeps that tab; a mod tab's title comes from itemGroup.<mod>.<name>
in its lang files, the file's title being the English fallback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:43:07 +03:00
SylviromiandClaude Opus 5.5 74ff545e60 Content mods: creative inventory tabs, paged as NeoForge pages them
A content mod declares tabs in data/<id>/horizon/creative_tab/*.json: a
title, an icon and its items. They come after vanilla's, ten to a page
(the top row's first five columns, then the bottom row's), with < and >
buttons above the screen; search, saved hotbars and the inventory show on
every page. Its new items join the search tab under their English names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:39:48 +03:00
SylviromiandClaude Opus 5.5 32170a7dd3 Content mods: a connecting block's thickness
"thickness" (2 to 14 pixels) gives a connecting block its own outline and
collision, built as PipeBlock builds its shapes (pipe_shape, checked against
the chorus plant's 64 states). The shapes go through the same per-state path
as a block's "shapes", so a block sets one or the other.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:38:19 +03:00
SylviromiandClaude Opus 5.5 25215182b1 Content mods: connecting blocks (cables, pipes) joined by a tag
A block declared with template minecraft:chorus_plant and connects_to: a
block tag is vanilla's PipeBlock without the chorus plant's rules: no
support needed, and each of its six sides joins the same block or a block
in the tag, on placement and on every neighbour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:33:06 +03:00
TeiwazikandClaude Opus 5.5 095342d9b7 Zip: read the directory on open, not the whole archive
Listing the packs opened every zip in resourcepacks/ twice, once for its
pack.mcmeta and once for its pack.png, and each open read the whole file
into memory on the render thread. With 22 GB of packs in the folder, enabled
or not, opening the options screen froze the game for five to seven minutes.

A Zip now keeps the file open and holds only the central directory on, with
the END record normalized as before, the way Java's ZipFile works. Entries
are read at their positions, so clones and threads don't share a cursor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 10:17:31 +01:00
NazaandClaude Opus 5.5 ead550fcdd Port ledger and docs: the key pair's callers, Chat Delay in the harness
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:56:10 -03:00
Naza c4ec4be1f3 Merge branch 'secure-chat-v2/2-chat-keys' into secure-chat-v2/3-client 2026-10-31 05:55:58 -03:00
NazaandClaude Opus 5.5 e3a2719f50 Merge main; messages flushed from the Chat Delay queue count as processed
flush_chat_queue now goes through accept_chat, as ChatListener.flushQueue runs each handler.
Without it a flushed signed message never reached the last-seen tracker, and vanilla's
server disconnected the client at its next message (checksum mismatch). The harness now
sets Chat Delay, holds a message back and flushes it. send_command parses over chat_tree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:55:57 -03:00
Haruki bb76b9a73e Add Japanese translation (ja) 2026-10-31 17:46:36 +09:00
NazaandClaude Opus 5.5 516327ba3d Port ledger: the key pair rows say what this branch calls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:44:28 -03:00
apeirs 3a10d0c3dd Commands: a UUID target finds any entity, not only mobs
EntitySelector.findEntities with a UUID takes ServerLevel.getEntity,
whatever the entity is. target_with_uuid kept only mobs, so /tp, /kill
and /data get entity with a display's, armor stand's or item's UUID
found nothing; the scoreboard's holder lookup kept mobs and items. Both
now go through level_entity.

Fixes #4966
2026-10-31 09:24:40 +01:00
masa-masa-0314andGLM 5.3 bb3835926c Data packs: /datapack follows the world's enabled features
`DataPackCommand` gates feature packs on the world's flags: a pack whose
requested features are not enabled is not available, cannot be enabled
("required flags are not enabled in this world"), and a feature pack can
never be disabled — the flag stays on for the world's life. Horizon
refused every enable and allowed every disable regardless. The command
now reads the world's `enabled_features`: with the flag on, a feature
pack enables and its data loads; without it, it stays hidden and the
refusal names the missing flag.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:17 +08:00
masa-masa-0314andGLM 5.3 088731f316 World save: every player saves to their own file, as PlayerList.saveAll
Vanilla's server save runs `PlayerList.saveAll`: every connected player
is saved to `playerdata/<their own uuid>.dat` — there is no world-owned
player file. The integrated server's save now does the same for its
placed guests (`playerdata::save`), on every save and when a player
leaves, and the owner joins through `PlayerDataStorage.load` on their
own uuid first — the world's own player state is only the fallback for a
first join. Previously the owner's state was stored into the level and
then dropped (`write_without_player`), so position and inventory were
lost on every reload, and the stale single-player file shadowed the
newest-file fallback on load.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:17 +08:00
masa-masa-0314andGLM 5.3 36ab1a21cf World save: the integrated server writes the world's own player
The integrated server's owner plays as the world's own player
(`Owner`): on leave their state is stored into the host's `Level`
(`playerdata::store`), but the server saves with `write_without_player`
(it runs as `dedicated`) and the window's own save is skipped while it
is connected, so the state never reached a file and position and
inventory were lost on every reload. The integrated role now writes the
world's own player file (`players/data/<singleplayer_uuid>.dat`) with
the level, as single player does (`PlayerDataStorage.save`); a real
dedicated server keeps saving its players on their own.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:17 +08:00
masa-masa-0314andGLM 5.3 a4a0b5539b Minecarts: the new behavior turns and climbs
Review of the port against the decompile found three deviations that
stalled carts at corners and broke climbing:

- `stepAlongTrack` ends with `setDeltaMovement(movement)` — the movement
  turns onto the exit's direction at the same speed. Without it the
  velocity never turned, so a corner's exit selection kept the old
  direction and the cart stopped.
- `TrackIteration`'s slope, halt and boost speeds apply once per tick
  and carry their flags across the iteration; the port re-applied them
  on every rail.
- the player-input push does not replace the natural slowdown and the
  speed cap (vanilla applies both), and a stuck step
  (did-not-move-but-would-have) zeroes the movement.

Also `isDecending` reads the step's movement (the exit's direction),
not the stale velocity, for the hill's Y projection.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:17 +08:00
masa-masa-0314andGLM 5.3 9464d531e3 Minecarts: minecart_improvements picks the smoother physics
`AbstractMinecart` chooses its behavior by the world's features: with
`minecart_improvements` the new behavior runs — the slide on slopes
grows with the speed (`max(0.0078125, h * 0.02)`) so a descending cart
keeps its pace, the empty cart slows half as fast (0.975 over 0.96),
the speed cap is a vector normalization instead of a per-axis clamp,
a powered rail boosts by `+0.06` along the movement or pushes a resting
cart 0.2 away from the signal's side, an iteration carries leftover
distance across rails, a hill projects the Y onto the slope, and a V of
opposing slopes rests a slow cart (`restAtVShape`). Without the flag
the old physics runs as before.

Stacked on #4975 (← #4957 ← #4948 ← #4619 ← #4577), whose
`enabled_features` the gate reads.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:17 +08:00
masa-masa-0314andGLM 5.3 072cc61277 Create World: the preparing screen and the experimental-features warning
Vanilla reads the world generation data behind a loading screen before
the Create New World screen opens, again after its Data Packs or
Experiments screen applied a selection, and warns before applying a
selection that asks for experimental features. Horizon showed none of
that.

- `createWorld.preparing` (`GenericMessageScreen`: the menu background
  and the centred message on a black box with a 1-pixel border and 12
  pixels of padding, no progress): Create New World reads
  `vanillagen::shared_data` on a worker while the title shows it, and
  opens the create screen when the read is done; the Data Packs screen's
  Done and the Experiments screen's Done re-run the read the same way
  (`applyNewPackConfig`).
- `ConfirmExperimentalFeaturesScreen`: when the Data Packs screen's Done
  picks a feature pack, the warning comes up first — the title, the red
  message wrapped to 310 and centred, a 100-wide Details button, Proceed
  and Back in the footer, the whole block centred, Escape declining. Its
  `DetailsScreen` lists the picked packs bold with the flags they ask
  for (`selectWorld.experimental.details.entry`). Proceed applies the
  selection, Back reopens the Data Packs screen on the selection it was
  opened with (`tryApplyNewDataPacks`'s `onAbort`).

Stacked on #4619, whose Experiments screen and feature-pack plumbing the
confirm screen builds on.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:16 +08:00
masa-masa-0314andGLM 5.3 5acff3836c Create World: the world's enabled features persist in level.dat
Vanilla's level.dat carries `WorldDataConfiguration`'s `enabled_features`
flat in the Data compound (`FeatureFlags.REGISTRY`'s codec: a list of the
whole set's namespaced ids, `minecraft:vanilla` included, the tag omitted
when the flags are vanilla's default) — Horizon wrote nothing, so a world
made with experiments told vanilla nothing and forgot its flags itself.

`Level.enabled_features` is written with the selection (the flags of the
picked feature packs over vanilla) and read back from level.dat, so a
vanilla-made world keeps its flags too. The behaviors behind the three
flags are still separate work; this is the data they will gate on.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:16 +08:00
masa-masa-0314andGLM 5.3 6975e9c9e1 Experiments: the More tab's button opens, and a world keeps its selection
Review fixes on #4619:

- The More tab's `options.press` guard caught button 30 before the
  Experiments arm could, so the button did nothing; the guard lets 30
  through now.
- Create World's `open_world` overwrote the `datapacks.json`
  `datapack::install` had just written, losing the unselected world
  packs' `disabled` entries, so a pack the player left off came on. The
  write now only happens when `install` did not run (only Experiments
  used), and it creates the world folder and logs failures instead of
  dropping the error.
- `level.dat`'s `DataPacks.Disabled` also lists the world packs the Data
  Packs screen held back, as vanilla's `DataPackConfig` does.
- The completion registries' pack list gains the feature packs, so a
  pack enabled through Experiments completes its worldgen ids.
- The feature packs on the Data Packs screen compared against the
  resource pack format and drew red; they compare against the data pack
  format now.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:16:16 +08:00
rdbtCVSandClaude Opus 5.5 228b7d3491 Fix packed_biomes call after merging main; restore world_worldgen_overrides's doc comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:16:16 +08:00
10fa933647 Create World: the Experiments screen, the feature packs and dropped packs
Vanilla's More tab offers Game Rules, Experiments and Data Packs; the
Experiments button was drawn disabled ("Experiments have no screen yet").
It now opens vanilla's ExperimentsScreen (`title/experiments.rs`): every
feature pack the resources root ships under `data/minecraft/datapacks`
(`ServerPacksSource`, `PackSource.FEATURE`), each with a 44-wide on/off
switch at the right of its row, its description grey underneath, the red
warning wrapped to 310 and centred, Done and Cancel in the footer —
vanilla's `HeaderAndFooterLayout` + `SwitchGrid` geometry. Switching a
pack adds or removes its id in the world's data pack selection, which the
Data Packs screen and the world's DataPacks then see; Done keeps the
other packs and rejoins the picked feature packs last, reversed
(`onDone`), Cancel and Escape leave the selection as it was.

The feature packs also join the world's repository, so a picked pack
reaches the game:

- `datapack::feature_packs` scans the resources root's feature packs and
  `screen_packs` offers them on the Data Packs screen (available, off),
  as `PackSelectionScreen` does;
- a world remembers its selection in `datapacks.json` (the pack stack's
  own file) and `level.dat`'s `DataPacks` (`DataPackConfig`);
- `pack_stack`, `packed_biomes` and `world_worldgen_overrides` resolve
  the ids against the resources root, and a feature pack stays off
  unless the world enabled it (vanilla starts `PackSource.FEATURE`
  unselected), so existing worlds behave exactly as before;
- `/datapack` shows an enabled feature pack with the feature source and
  can disable it again, while `enable` still refuses one
  (`commands.datapack.enable.failed.no_flags`) — the three flags'
  behaviors are not implemented yet, but the packs' data files
  (trade_rebalance's loot tables and tags) reach the content tables
  through the pack stack.

Dropping a pack on the window (`PackSelectionScreen.onFilesDrop`): a
zip, a pack folder, or a folder holding exactly one pack (`PackDetector`)
is copied into the create screen's pack folder, where the Data Packs
screen's existing watcher lists it within a second; anything else is
refused and logged.

Stacked on #4577, whose unified pack list the resolution goes through.

Verified headless: a world with `trade_rebalance` in `datapacks.json`
lists it enabled ("[trade_rebalance (feature)]"), disables it with
`/datapack disable`, and the other feature packs stay available.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
Co-Authored-By: ZCode Agent <[email protected]>
2026-10-31 16:16:15 +08:00
masa-masa-0314andGLM 5.3 ccb19c6cfe Biomes: the table survives world switches, and tags nest
Review fixes on #4577:

- The window's Game on the integrated-server path kept `world_biomes`
  unset: the chunks it draws carry the packs' biome ids past the built-in
  list, but nothing named them. `open_integrated` builds the world's
  table now, as `finish_open_world` does for the load path.
- The integrated path's own encoders read `res.biomes()` past the world's
  table: `lan_host`'s chunk encoder and `/execute if biome`'s naming go
  through `world_biomes!` now, so a packed biome keeps its name and its
  place on the wire (a guest still needs the table sent over the
  connection; a remote server join clears the last world's table so no
  stale names linger).
- A tag in a sub-directory of its registry never matched: `content_id`
  attached the namespace to the file's last segment
  (`tags/worldgen/biome/has_structure/minecraft:village_plains`), while
  the lookups build `tags/{registry}/{ns}:{path}`. The store key is the
  tag id now (`tags/worldgen/biome/test:sub/mytag`), `PackOverlay`
  gained `tag_kind` for the registry walks, and the readers match.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 16:15:13 +08:00
masa-masa-0314andClaude Opus 4.6 7eabfedc8d Data packs: one pack list builds the biomes, the worldgen and the commands
`WorldgenOverrides` and `Biomes::with_packs` both take the world's packs,
but the client built the biome table and the worldgen from different
lists: `content_overlay` reads `datapacks.json` (empty until a world
writes it) while the world's worldgen uses `datapack::discover` with
`enabled_ids`. A pack's biomes therefore never reached the table
`WorldgenOverrides::finish` tests against, so it dropped every new biome
("cannot add biomes yet") and the generator then failed on a material
rule naming one ("unknown material biome").

`world_loading::packed_biomes` builds the table from the same list the
worldgen is built from, and the completion registries read the same
overridden ids instead of `DataStore`'s files, so `/locate biome` accepts
and completes a packed biome.

Verified with Terralith 2.6.5 as the world's only pack: the world opens,
`/locate biome terralith:alpine_highlands` finds it, and `/locate biome
#c:is_lush` reports `terralith:cave/underground_jungle`.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 16:15:13 +08:00
masa-masa-0314andClaude Opus 4.6 6ea12f710f Data packs: the world's chunks and the game read one biome table
`Biomes::with_packs` (the built-in table with a world's pack files over
it, as `RegistryDataLoader` layers registries) had no callers, so a
packed biome had no client data and `Biomes::id` resolved it to plains —
every packed biome rendered, spawned and saved as the fallback.

The table is built when a world opens, from the same pack overlay the
content tables use, and every biome lookup goes through it
(`world_biomes!`, a field-level borrow for the loops that also take
`&mut self`). `world_loading::make_streaming` builds the same table for
the generator, so the ids the terrain names and the ids the client draws
agree.

A world with more than 128 biomes needs the palette's 8 bits, which the
earlier commit gives it.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 16:15:13 +08:00
masa-masa-0314andClaude Opus 4.6 ce83c14926 Commands: a world's packs complete and match their biomes and tags
`command-registries.json` lists the game's own ids, so `/locate biome`
refused a packed biome before searching and offered no packed biome or
tag in its completions. The packs' `worldgen/biome`, `structure`,
`structure_set`, `template_pool` and `processor_list` ids now join those
registries, and so do their worldgen tag ids.

The tag lookup behind `#tag` (`/execute if biome`, `/locate biome #tag`)
read only `data/<ns>/tags/worldgen/biome` from the resource root; it now
reads the world's packs too, in resource-stack order, with a `replace`
file dropping what the lower sources put in (`TagLoader.load`). That also
makes a pack's structure tags work.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 16:15:13 +08:00
masa-masa-0314andClaude Opus 4.6 a5d6d8af2a Biomes: the palette holds a world's biomes too
`Strategy::biomes(7)` tops out at 128 biome ids; vanilla ships 67 and a
pack like Terralith adds a hundred more (`Biomes::with_packs`), which the
global palette's 8 bits hold.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 16:15:13 +08:00
Teiwazik c7ec497a49 Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 08:52:52 +01:00
TeiwazikandClaude Opus 5.5 dbd6d4b88f Water mirror: format the plane scan's key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:52:52 +01:00
Teiwazik e44bda259f Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 08:52:12 +01:00
TeiwazikandClaude Opus 5.5 a121b4e0f3 Water mirror: format the plane scan's key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:52:11 +01:00
Kacpherek 334391253b Merge remote-tracking branch 'origin/main' into fix/split-boats-riding
# Conflicts:
#	crates/horizon-client/src/game/boats.rs
2026-10-31 08:41:28 +01:00
TeiwazikandClaude Opus 5.5 7a34d500ab Shader API: [reflection] option's doc says what the code does
The mirror is drawn while the option is not 0, so a negative number turns it on too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:34:11 +01:00
Teiwazik d948ff386e Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 08:33:51 +01:00
TeiwazikandClaude Opus 5.5 16f513b823 Water mirror: mirror the sky's rays on the right side, scan for the plane less
The sky reads inverse_view * ray, so its mirror is F * inverse_view; the column operation used for view_proj * F left a pitched camera's sky wrong: looking down 40 degrees mirrored a sky looking down. A test compares the rays.

A scan for the water plane reads up to 60 000 blocks on the game thread; it now runs when the camera moves 8 blocks or every 5 seconds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:33:50 +01:00
Teiwazik 287aac04ba Merge branch 'shader/s4-material-surface' into shader/s5-water-mirror
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
2026-10-31 08:33:31 +01:00
TeiwazikandClaude Opus 5.5 360f52d328 Shader API: flowing water keeps its slope in MaterialInput.normal
A normal within 0.99 of an axis (about 8 degrees) snapped to it, which flattened flowing water's gentlest slope (about 6 degrees). The snap now reaches about 3.6 degrees, still enough for distant block faces, and the doc says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:33:10 +01:00
TeiwazikandClaude Opus 5.5 07cd25f819 Shader API: a gap between frames forgets the frame before
NativeFrame::write skips title frames, so the first world frame after a title screen, a pause or a world load reprojected from a stale camera with a delta_time of seconds. Past half a second the frame counts as the first. The easing is a pure function, tested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 08:32:50 +01:00
Teiwazik 9e867eacf6 Merge branch 'shader/s2-frame-motion' into shader/s3-post-history 2026-10-31 08:32:50 +01:00
Teiwazik c20250bf75 Merge branch 'shader/s3-post-history' into shader/s4-material-surface 2026-10-31 08:32:50 +01:00
Teiwazik a67502d44d Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-31 08:31:23 +01:00
Teiwazik 5b79796812 Merge branch 'shader/s4-material-surface' into shader/s5-water-mirror
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
#	crates/horizon-client-render/src/terrain.wgsl
2026-10-31 08:31:22 +01:00
Teiwazik f8184ab1fb Merge branch 'shader/s3-post-history' into shader/s4-material-surface 2026-10-31 08:30:29 +01:00
Teiwazik ff2bf4e670 Merge branch 'shader/s2-frame-motion' into shader/s3-post-history 2026-10-31 08:30:27 +01:00
SolenopsisbotandClaude Opus 5.5 9ec42d2f2b Headless: no section meshes while there is no renderer
Like a server's streaming, a headless game's doesn't mesh sections it can't draw. Measured
on a single-player world after loading: 0 sections meshed instead of 21287, 853 MB resident
instead of 1117 MB, and about 15% of a core in steady state instead of 20%.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:17:22 +11:00
rdbtCVSandClaude Opus 5.5 7c94ba52d0 Merge origin/main; the native visual hooks are API 40
Main took API 39 (#4677). Both sides added Game helpers at the same
place; keep both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:00:51 +00:00
rdbtCVSandClaude Opus 5.5 ff6c8b6a48 Merge origin/main; the native visual hooks are API 39
Main took API 37 (#4799) and 38 (#4725) while this was being checked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:55:57 +00:00
rdbtCVSandClaude Opus 5.5 ff96502356 Merge origin/main; renumber the native visual hooks to API 37
Main reached NATIVE_HOOKS_API 36. native_visuals and native_emissive
(API 8 and 9 on this branch) now share API 37, in the module doc and
docs/native-mods.md. The emissive layer moves to the terrain shader's
@location(9), after main's fade, local and visibility.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:55:29 +00:00
StievenW 652da84cc2 feat(ui): implement quick actions and refactor pause screen layout
Refactor the pause screen button layout to correctly handle the
`pause_screen_additions` tag and the `minecraft:server_links` fallback.
The new implementation uses a helper function `pause_grid` to calculate
the grid origin and ensures that the custom additions button (ID 14)
is rendered as a full-width row, properly pushing down subsequent
menu options.

Additionally, implement `Minecraft.getQuickActionsDialog` to support
opening the quick actions dialog via the `key.quickActions` binding.

Update port maps to reflect the new implementation details for
`PauseScreen` and `Minecraft` quick actions.
2026-10-31 13:32:14 +07:00
Muhammad Asif eafecb1e48 Release held keys when a server dialog opens
A DialogScreen is a screen in vanilla, so KeyMapping.releaseAll runs.
Horizon kept Screen::Playing, dropped key and right-button releases, and
left W and use held after the dialog closed.
2026-10-31 06:17:54 +00:00
rdbtCVS da79a2ba26 Merge remote-tracking branch 'origin/main' into pr-3986
# Conflicts:
#	crates/horizon-bots/src/main.rs
2026-10-31 06:07:00 +00:00
SolenopsisbotandClaude Opus 5.5 a3c5b5b57a --headless: a client with no display, window or GPU
`horizon-client --headless` (`horizon-ctl launch --headless`) runs where no display server
is: winit's event loop needs one even for --offscreen, so the game runs its own loop
(game/headless.rs) through the EventLoopCtl handle, one frame a tick. Each step is
Game::idle, which runs the frame through the RedrawRequested path with WindowId::dummy();
handle_window_event takes that id when there is no window. No renderer is made and audio
is silent; the loading screen is skipped (synchronous startup). It counts as offscreen for
everything else. Quitting saves the world as a window's game does (Game::shut_down, now
shared with winit's `exiting`).

Measured on a Ryzen 7 3700X with no DISPLAY: a single-player world opens through the
server thread, the control socket moves, commands and menus work, no GPU device is opened,
about 1.1 GB resident, and in steady state the client's own thread uses 1.6% of a core.
Screenshots and clicks at GUI points answer "no renderer yet" until vision (#4951).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:01 +11:00
rdbtCVS 5ff1007a29 Merge branch 'fix/issue-4001-boat-drowning' of https://github.com/DUzzL/horizon into pr-4011 2026-10-31 05:57:02 +00:00
rdbtCVS f1cb24b213 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/boats.rs
2026-10-31 05:47:06 +00:00
rdbtCVS 304457a084 Merge remote-tracking branch 'origin/main' into pr4178
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-10-31 05:35:10 +00:00
ElyTheCatandAntigravity addeaf1c58 Renderer: cull offscreen animated sprites
- Track active animated sprite layers per section and cull animation ticks and GPU uploads for sprites not present in visible or shadow sections.
- Mask OPAQUE_LAYER (bit 15) when collecting animated layers from mesh quads so opaque animated blocks (magma, lava, prismarine, etc.) are animated.
- Include active animated layers from visible LOD tiles (and any fading LOD tiles).
- Unit tests for inactive sprite culling and opaque animated face layer collection.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 09:00:41 +03:30
Jose Gil 589d016ad2 fix(launcher): localize launch summary and ping notice, fix polish text 2026-10-31 01:21:14 -04:00
Jose Gil 0c3bb45dc9 feat(launcher): localize Instances modal, Export, Launch, Shortcut, and Home tabs 2026-10-31 01:21:14 -04:00
reservedbytes b56f94b2b7 fix(render): terrain samples block textures like vanilla 2026-10-31 04:16:34 -01:00
rdbtCVSandClaude Opus 5.5 0240b305c9 Launcher on Windows: remove an interrupted update's copy too
An update that stopped between copying the new binary and moving it into
place left <name>.update-<pid> in the install folder, and the start-up
clean-up only matched .old- files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:16:05 +00:00
SolenopsisbotandClaude Opus 5.5 a4d481311f Game: its event handlers take a loop handle, not winit's loop
A headless client (#4951) has no display, so it cannot run winit's event loop. The game's
methods used winit's ActiveEventLoop only to stop the loop and to say when to wake next:
they now take `&dyn EventLoopCtl` (game/event_loop.rs), which winit's loop implements.

window_event and about_to_wait keep their bodies, now the inherent handle_window_event and
idle, and the ApplicationHandler methods forward to them. The control socket's and the pad
menu's synthetic input call handle_window_event. Nothing changes for a window's game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:14:36 +11:00
rdbtCVS 2f8def5b52 Merge remote-tracking branch 'origin/main' into pr-3986
# Conflicts:
#	crates/horizon-bots/src/main.rs
2026-10-31 05:10:45 +00:00
reservedbytes d8a9baec80 feat(mods): mod mob effects show their icons 2026-10-31 04:07:43 -01:00
rdbtCVS 1a1aae69ec Merge remote-tracking branch 'origin/main' into pr-3192 2026-10-31 05:05:20 +00:00
rdbtCVSandClaude Opus 5.5 c3557288e6 Merge main; keep only the survival drop from the eyes
#4231 (on main) already returns the replaced piece in creative and
discards it there when the inventory is full. What this branch still
adds: survival throws the piece that no longer fits like Player.drop
(stack, false) instead of spawning it motionless at the eyes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:58:36 +00:00
rdbtCVS a3cbfd71d0 Merge remote-tracking branch 'origin/main' into pr-3985 2026-10-31 04:55:48 +00:00
rdbtCVSandClaude Opus 5.5 d929bb58a8 Merge main; move the software-adapter skip into open_backend
Main split open_device into open_with_retry and open_backend (#4064). The
CPU-adapter check now sits right after request_adapter and fails the
backend, so the non-conformant retry doesn't pick the same adapter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:55:42 +00:00
rdbtCVS 852f627737 Merge remote-tracking branch 'origin/main' into pr-4011
# Conflicts:
#	crates/horizon-client/src/game/boats.rs
2026-10-31 04:54:41 +00:00
rdbtCVS ad3f5ed3d7 Merge remote-tracking branch 'origin/main' into pr-4111 2026-10-31 04:54:09 +00:00
some-du6eandClaude Opus 5.5 9a4e65ef6e Test that far terrain past the render distance loads fine first
Right past the render distance, far terrain showed as big flat-topped
boxes with grey stone sides: coarse ancestors standing in while the
sampling order filled a ring ahead first and refined broad tiles every
other job. Main now samples only the plan's leaves, nearest first; this
pulls that order into `sample_order` (no change) and tests it:

- At render distance 12, cells are 1 block wide to 256 blocks out and
  2 to 512 (Reduced: 1 to the near terrain's edge, 2 to 256).
- Loading one job at a time, the ring within two chunks of the near
  terrain reaches that detail all around after 270-301 jobs, all fine
  tiles (7500-8500 density lattice points). The old order took
  54000-119000 for the part in view alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:53:01 -06:00
MSedgeMC 584f41373e Report a mooshroom's colour as its variant component
MushroomCow.get answers with minecraft:mooshroom/variant, red or brown, and
applyImplicitComponents reads it back, so a mob spawned from an egg keeps
its colour. finalize's species_variant_component listed wolf, cat, frog,
pig, cow, chicken and zombie nautilus but not the mooshroom: the component
was never reported, variant_name fell through to the frog's table and
variant_index could not recognise "brown".

The save format is untouched: a mooshroom still stores Type, not variant.
2026-10-31 10:20:29 +05:30
rdbtCVSandClaude Opus 5.5 d22bb9e177 Skip only items added this tick in tick_items, not every fresh one
The ticks == 0 guard never let a dropped item reach ItemEntity::tick, the
only place ticks counts up, so every drop froze: no gravity, pickup delay,
merging or despawn. An item now waits one tick only when its id is past
the last one the tick saw after its functions ran.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:49:50 +00:00
rdbtCVS 0ddc638be0 Merge remote-tracking branch 'origin/main' into pr-4440 2026-10-31 04:47:39 +00:00
rdbtCVSandClaude Opus 5.5 9950e27f87 Fix packed_biomes call after merging main; restore world_worldgen_overrides's doc comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:47:33 +00:00
rdbtCVS 9acc6a9b96 Merge remote-tracking branch 'origin/main' into pr4178
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
#	launcher/src/main.rs
2026-10-31 04:47:19 +00:00
rdbtCVS 69cc8ab35b Merge remote-tracking branch 'origin/main' into pr-4577 2026-10-31 04:46:10 +00:00
rdbtCVSandClaude Opus 5.5 9c42d07257 cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:46:04 +00:00
rdbtCVSandClaude Opus 5.5 3466915d77 Merge origin/main into fsr-frame-interpolation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:45:29 +00:00
rdbtCVSandClaude Opus 5.5 c1ba8b5e66 cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:45:14 +00:00
rdbtCVS 252ac76369 Merge branch 'pr4335' into pr4345 2026-10-31 04:45:02 +00:00
rdbtCVS 3dbcf3c6c6 Merge remote-tracking branch 'origin/main' into pr4335
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-10-31 04:44:54 +00:00
SenjinandClaude Opus 5.5 975666d3d5 Merge remote-tracking branch 'origin/main' into hdr-glow-2
Main's OIT_ENTITY shares the layer's high bits with the HDR tier: the overlay
consumers mask 0xffff, vs_lightning masks the tier, and a test pins that a tier
keeps the material.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:28:39 +01:00
ElyTheCat fca2bb2b79 Merge remote-tracking branch 'origin/main' into feat/ui-slider-keyboard-controls 2026-10-31 07:55:22 +03:30
ElyTheCatandAntigravity f4531d6e0d UI: fix missing click sounds for keyboard Space/Enter; add focused-widget wheel
- options_ui: Space/Enter on FocusId::Cycle now emits Event::ButtonClick
  before cycling (was silent before)
- options_ui: wheel on a keyboard-focused slider steps it; wheel on a
  keyboard-focused cycle button cycles it (Horizon improvement; Vanilla 26.3
  does not intercept the wheel for sliders at all)
- graphics_ui: same wheel improvement for the graphics settings page — when
  can_change_value is set, wheel steps the focused slider or cycles the focused
  cycle item instead of scrolling the list
- title.rs: confirm_key_size returns (Action, bool); the bool is true for any
  enabled focused button press, false for disabled buttons or no-ops
- game.rs: title keyboard confirm plays the click sound from the bool, not just
  on Action::Play
- startup_menu.rs: extract .0 from confirm_key_size return
- Tests: options_keyboard_space_enter_emits_button_click_for_cycle_and_default
  and title_screen_keyboard_navigation_and_activation updated and passing

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 07:41:20 +03:30
masa-masa-0314andGLM 5.3 34be9125df Biomes: the table survives world switches, and tags nest
Review fixes on #4577:

- The window's Game on the integrated-server path kept `world_biomes`
  unset: the chunks it draws carry the packs' biome ids past the built-in
  list, but nothing named them. `open_integrated` builds the world's
  table now, as `finish_open_world` does for the load path.
- The integrated path's own encoders read `res.biomes()` past the world's
  table: `lan_host`'s chunk encoder and `/execute if biome`'s naming go
  through `world_biomes!` now, so a packed biome keeps its name and its
  place on the wire (a guest still needs the table sent over the
  connection; a remote server join clears the last world's table so no
  stale names linger).
- A tag in a sub-directory of its registry never matched: `content_id`
  attached the namespace to the file's last segment
  (`tags/worldgen/biome/has_structure/minecraft:village_plains`), while
  the lookups build `tags/{registry}/{ns}:{path}`. The store key is the
  tag id now (`tags/worldgen/biome/test:sub/mytag`), `PackOverlay`
  gained `tag_kind` for the registry walks, and the readers match.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 11:47:25 +08:00
masa-masa-0314andClaude Opus 4.6 6eada864b1 Data packs: one pack list builds the biomes, the worldgen and the commands
`WorldgenOverrides` and `Biomes::with_packs` both take the world's packs,
but the client built the biome table and the worldgen from different
lists: `content_overlay` reads `datapacks.json` (empty until a world
writes it) while the world's worldgen uses `datapack::discover` with
`enabled_ids`. A pack's biomes therefore never reached the table
`WorldgenOverrides::finish` tests against, so it dropped every new biome
("cannot add biomes yet") and the generator then failed on a material
rule naming one ("unknown material biome").

`world_loading::packed_biomes` builds the table from the same list the
worldgen is built from, and the completion registries read the same
overridden ids instead of `DataStore`'s files, so `/locate biome` accepts
and completes a packed biome.

Verified with Terralith 2.6.5 as the world's only pack: the world opens,
`/locate biome terralith:alpine_highlands` finds it, and `/locate biome
#c:is_lush` reports `terralith:cave/underground_jungle`.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 11:47:05 +08:00
masa-masa-0314andClaude Opus 4.6 48856738b0 Data packs: the world's chunks and the game read one biome table
`Biomes::with_packs` (the built-in table with a world's pack files over
it, as `RegistryDataLoader` layers registries) had no callers, so a
packed biome had no client data and `Biomes::id` resolved it to plains —
every packed biome rendered, spawned and saved as the fallback.

The table is built when a world opens, from the same pack overlay the
content tables use, and every biome lookup goes through it
(`world_biomes!`, a field-level borrow for the loops that also take
`&mut self`). `world_loading::make_streaming` builds the same table for
the generator, so the ids the terrain names and the ids the client draws
agree.

A world with more than 128 biomes needs the palette's 8 bits, which the
earlier commit gives it.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 11:47:05 +08:00
masa-masa-0314andClaude Opus 4.6 d2145ac820 Commands: a world's packs complete and match their biomes and tags
`command-registries.json` lists the game's own ids, so `/locate biome`
refused a packed biome before searching and offered no packed biome or
tag in its completions. The packs' `worldgen/biome`, `structure`,
`structure_set`, `template_pool` and `processor_list` ids now join those
registries, and so do their worldgen tag ids.

The tag lookup behind `#tag` (`/execute if biome`, `/locate biome #tag`)
read only `data/<ns>/tags/worldgen/biome` from the resource root; it now
reads the world's packs too, in resource-stack order, with a `replace`
file dropping what the lower sources put in (`TagLoader.load`). That also
makes a pack's structure tags work.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 11:47:04 +08:00
masa-masa-0314andClaude Opus 4.6 a3dc4c74ab Biomes: the palette holds a world's biomes too
`Strategy::biomes(7)` tops out at 128 biome ids; vanilla ships 67 and a
pack like Terralith adds a hundred more (`Biomes::with_packs`), which the
global palette's 8 bits hold.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-10-31 11:47:04 +08:00
NazaandClaude Opus 5.5 353f09034d Merge main: keep marking messages processed where the chat takes them, with its chat trust
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:21:20 -03:00
StievenW 213c523481 fix: close dialogs when player dies
- close_dialogs() is called when entering Screen::Dead so quick_actions
  and other dialogs don't stay open over the death screen
2026-10-31 10:08:10 +07:00
StievenW ebab79bba9 fix: pause_screen_additions returns single dialog tag instead of Tag::List
- Return first valid dialog's tag instead of Tag::List (show_dialog can't parse List)
- This fixes pause_screen_additions button actually opening the dialog
2026-10-31 10:01:11 +07:00
reservedbytes fb7f8a7656 fix(worldgen): placed templates update their shapes and neighbours in the live level 2026-10-31 01:51:32 -01:00
StievenW ed4a44cbe8 style: rustfmt fix indentation for comment 2026-10-31 09:51:09 +07:00
StievenW 96d56102e2 resolve merge conflict: combine forget_sync_data with upstream main's Title::new.with_user 2026-10-31 09:45:41 +07:00
646b213087 Fix recipe book crafting on servers.
Predict craftable PlaceRecipe placements on the remote client, look up
ghost recipes by display id, and clear the ghost when the server updates
the open menu so red ghost slots do not stick after a successful place.

Fixes #4549

Co-Authored-By: Composer <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-31 03:38:04 +01:00
SenjinandClaude Opus 5.5 6c9d809951 HDR: tiers and masks from a round in the gallery
Judged in tools/hdr_gallery.py's gallery under the tuning standard. Flames, lava,
crying obsidian, the froglights, the jack o'lantern and the larger amethyst shine all
over; brewing stand, enchanting table and the smaller buds go bright; candle cakes and
the ender chest don't shine (the cake's frosting would in daylight). The bright-parts
mask goes to 0.32 of the way from a sprite's darkest pixel to its brightest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:29:44 +01:00
SenjinandClaude Opus 5.5 aad1fdfd80 HDR: light sources shine where their own sprite is bright
The tier windows judge a pixel by a fixed brightness, tuned on vanilla's
textures: a resource pack with a lighter torch handle lifted the handle
too. When HDR output turns on, a small pass now measures every block
sprite's darkest and brightest opaque pixel (gfx/hdr_stats.rs), and
terrain marks a light source's pixel only when it is as bright as its
sprite's threshold, set between the two: for most light sources near
their brightest (their bright parts shine), for those marked WHOLE at
their darkest (they shine all over). The tier windows still decide how
much. Nothing is measured with HDR off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:29:44 +01:00
StievenW 1ccbf8939f style: minor comment clarity for pause_screen_additions 2026-10-31 09:28:54 +07:00
StievenW 0aaa1d6b16 fix: address review findings from dimaniojk
- Restore Bug Report and Feedback icons with click handlers (open confirm_trusted_link)
- Fix dialog leak between worlds: clear sync data on world close (forget_sync_data)
- pause_screen_additions: match vanilla behavior (single button opening dialog_list)
- Fix clippy warnings: use ? operator with as_ref()
- Fix tests: update pause_buttons/pause_icons calls for new signature
2026-10-31 08:55:51 +07:00
sirrayi 4f26b7543b Merge remote-tracking branch 'origin/main' into lod-engine-overhaul-v2 2026-10-31 01:39:16 +00:00
sirrayiandomp 210c6ee10c LOD: keep completion, removals and cutout incremental
Completing a subtree used to mark every active descendant dirty, so the
final chunk of a cold view remeshed the whole view. A completion that does
not change a tile's own drawn state now touches no mesh.

Cutout publication waited for every mesh job in flight, so continuous
loading could defer it indefinitely. It now tracks the active tiles whose
meshes must stop clipping the changed columns; a retired waiter hands its
wait to its active descendants, and later batches do not delay it.

An active tile index (`unmeshed`) replaces the all-active scan in removal
gating, dispatches coalesce while the pool is saturated, and the neighbours
behind a split tile's shared face are no longer remeshed. A saved summary
without completed detail no longer releases a covered empty stand-in.

Co-Authored-By: omp (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 01:39:10 +00:00
levzzzandClaude Sonnet 5.5 45a8b80d13 Secure chat: key pair only on online-mode logins; unsigned-session signable commands keep vanilla's signed packet
handleLogin prepares the key pair only when the login packet says the server
is in online mode, and tick asks shouldRefreshKeyPair only once there is a
chat session. sendCommand sends ServerboundChatCommandSignedPacket whenever
the command has signable arguments, with no signatures when there is no
session (ArgumentSignatures.signCommand drops null signatures).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:32:36 +05:00
levzzz cb8fe4dc2b Merge branch 'fix/pr-4376' into fix/pr-4377 2026-10-31 06:29:42 +05:00
levzzz 5c1d6be44c Merge remote-tracking branch 'hr/main' into fix/pr-4377 2026-10-31 06:29:42 +05:00
levzzz 65b1d9a122 Merge branch 'fix/pr-4375' into fix/pr-4376 2026-10-31 06:27:02 +05:00
levzzz 1ba7b1a3dc Merge remote-tracking branch 'hr/main' into fix/pr-4376 2026-10-31 06:27:02 +05:00
levzzz 43a2732250 Merge remote-tracking branch 'hr/main' into fix/pr-4375 2026-10-31 06:25:46 +05:00
Federico Tagliani de798d23b5 Merge remote-tracking branch 'origin/main' into feat/blending-old-chunks 2026-10-31 01:37:45 +01:00
Federico Tagliani 7749d48fb7 Merge origin/main into feat/f3-density-line 2026-10-31 01:37:32 +01:00
Federico TaglianiandClaude Sonnet 5.5 085561fcec Decoration into a final chunk waits for the one writing into it
Two decorations reaching the same chunk an older version saved ran on their own
snapshots and the last write won; a chunk's features each see what the
one before placed. The proto chunks already exclude each other by being
checked out: the final chunks a running decoration writes into now do too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:33:57 +01:00
Federico Tagliani 01c5be5f35 Merge remote-tracking branch 'origin/main' into feat/blending-old-chunks 2026-10-31 01:18:53 +01:00
sirrayiandomp 41490a413d LOD: keep completion, removals and cutout incremental
Completing a subtree used to mark every active descendant dirty, so the
final chunk of a cold view remeshed the whole view. A completion that does
not change a tile's own drawn state now touches no mesh.

Cutout publication waited for every mesh job in flight, so continuous
loading could defer it indefinitely. It now tracks the sequence of the batch
that replaces the affected tiles; later batches do not delay it.

An active tile index (`unmeshed`) replaces the all-active scan in removal
gating, dispatches coalesce while the pool is saturated, and the neighbours
behind a split tile's shared face are no longer remeshed. A saved summary
without completed detail no longer releases a covered empty stand-in.

Co-Authored-By: omp (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 00:18:35 +00:00
Federico TaglianiandClaude Sonnet 5.5 04b63e8553 Old chunks: /locate and maps see the starts the chunk saved
starts_at returned nothing for a chunk an older version saved, so the
explored part of an upgraded world had no structures to find. It now
returns the starts in the chunk's structures.starts (name and bounding box,
no pieces: they are not rebuilt).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:08:15 +01:00
Federico TaglianiandClaude Sonnet 5.5 471e559ae3 Decoration into an old chunk carries the block entities it made
A chest or spawner of a structure piece reaching into a chunk an older
version saved had only its block copied; the generated block entity, with
its rolled contents, now goes with it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:06:43 +01:00
Federico TaglianiandClaude Sonnet 5.5 8e75d8ac19 Old chunks: one scan per region, the other workers wait for it
region_bits computed outside the lock with no in-flight guard, so every
worker that asked for a region meanwhile decoded its chunks again.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:54:12 +01:00
Federico Tagliani db899c8276 Merge remote-tracking branch 'origin/main' into feat/blending-old-chunks 2026-10-31 00:52:41 +01:00
Federico TaglianiandClaude Sonnet 5.5 b387b911e4 F3 Density line: skip it while a long query holds the worker
The F3 overlay is built on the window thread every frame; locking the
shared query worker froze the frame behind /locate biome or /place jigsaw.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:51:35 +01:00
Federico Tagliani ce2e70d916 Merge origin/main into feat/f3-density-line 2026-10-31 00:51:23 +01:00
sirrayi 7b2f4bf999 Merge remote-tracking branch 'origin/main' into lod-engine-overhaul-v2 2026-10-30 23:42:56 +00:00
sirrayiandomp c5b195907f LOD: incremental frame path and parallel zero-copy meshing
The far-terrain scheduler evaluated the whole quadtree on the frame thread
for every finished sample, cloned every active tile's columns into every
mesh job, and handed a whole selection batch to one worker.

Selection is now incremental: a finished sample re-evaluates only its
ancestors (O(tree depth)) and marks only the tiles whose faces or clipping
actually change. The sample queue is a nearest-first priority heap rebuilt
when the view moves a chunk, not scanned and sorted every frame. Mesh jobs
carry one immutable snapshot and borrow columns by reference instead of
cloning them; a pool of workers meshes tiles in parallel with a priority
queue and revision checks. Bounds are tracked while emitting vertices on the
worker, so the frame thread no longer walks every vertex of every uploaded
tile. Removals wait until every overlapping replacement has its mesh, so
fade-out cannot open a hole, and both worker pools are stopped under their
own wait locks.

Selection stays an exact match for Plan::select after every sample, across
camera moves and coverage changes (test); edge-neighbour dirtying, active
descendant redraws and removal gating have their own tests. Column spans are
inline (SmallVec), removing almost all per-column allocations on the
sampling workers.

Co-Authored-By: omp (DeepSeek V4.1 Flash) <[email protected]>
2026-10-30 23:42:42 +00:00
ElyTheCat b0ed32ad9a Merge remote-tracking branch 'origin/main' into feat/ui-slider-keyboard-controls
# Conflicts:
#	crates/horizon-client/src/game/screens.rs
#	crates/horizon-client/src/title.rs
#	crates/horizon-client/src/title/tests.rs
2026-10-31 02:46:26 +03:30
martipops 9e3aaa3400 Merge branch 'split/08-join-with-controller' into split/09-windows
# Conflicts:
#	crates/horizon-client/src/game/options.rs
2026-10-30 19:04:33 -04:00
martipops 1e13f55868 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-30 19:03:22 -04:00
martipops 3779a7c5d0 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 19:03:22 -04:00
martipopsandClaude Opus 5.5 c6661cfe72 Merge upstream main into split/06-two-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:03:22 -04:00
ElyTheCatandAntigravity 4a06ae4d47 UI: Add keyboard navigation for Title, Pause, Death, and Confirm screens; refine slider parity
- Keyboard Navigation & Focus Parity (Java Edition 26.3 Screen / AbstractWidget):
  - Title Screen: Tab cycling, Arrow key 2D spatial navigation, Space/Enter activation, and button focus highlighting.
  - Create World & Select World: Tab / Arrow navigation, bidirectional option cycling, Space/Enter activation.
  - Pause & Death Screens: Tab cycling, Arrow navigation, button activation, and focused button rendering.
  - Confirm & Choice Screens: Tab / Arrow navigation between Yes/No/Cancel options, Space/Enter activation, Esc cancel.
  - LAN / World Options Screen: Full keyboard navigation and activation for all controls and modals.
- Slider & Wheel Parity Refinements:
  - Per Java 26.3 AbstractSliderButton parity, sliders do not intercept mouse wheel; mouse wheel in lists scrolls the list. Blocked rows fall through to scroll.
  - Derive slider steps directly from slider definitions via slider_steps and slider_position / set_slider, removing duplicate hardcoded ranges.
  - Added unit tests verifying stepping equals set_slider across all stepped options.
  - Dialog sliders: Arrow keys step by declared step / range ratio rather than fixed 0.01.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 02:30:51 +03:30
martipopsandClaude Opus 5.5 626de8923b Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:59:08 -04:00
martipopsandClaude Opus 5.5 5726f5a551 Merge upstream main into split/10-profile-page
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:38:30 -04:00
martipops cd48c77ff2 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-30 18:37:16 -04:00
martipops c7dbf8f290 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-30 18:37:16 -04:00
martipops 858e8b3ba8 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 18:37:16 -04:00
martipops a2fcdeef3b Merge branch 'split/02-audio' into split/06-two-players 2026-10-30 18:37:16 -04:00
martipopsandClaude Opus 5.5 a12892258f Merge upstream main into split/06-two-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:36:45 -04:00
ElyTheCatandAntigravity 41da822ff8 fix(gl): restore clippy allow on UniformLocation and clarify multi-draw requirements
- Re-add clippy::clone_on_copy allow for UniformLocation in command.rs.
- Format indirect_execution expression in adapter.rs to satisfy rustfmt.
- Note in gfx.rs comment that multi_draw requires indirect first instance
  support (OpenGL 4.2 + GL_ARB_shader_draw_parameters) in addition to 4.0 indirect draws.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 02:00:38 +03:30
martipopsandClaude Opus 5.5 f68140d0e5 Splitscreen profile page: show the last page there is after a Forget
Forgetting the only profile on the last page left the page empty until
Previous was pressed: the page is now kept to the pages the list has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:20:44 -04:00
martipopsandClaude Opus 5.5 dbb23c1eca Splitscreen windows: don't retry a window that failed to open, pace on a shown one
- A window that failed to open was tried again every frame, each time
  re-laying out every player. Its players now play in Window 1 and the
  window is forgotten.
- Vsync and the frame pacing's refresh rate follow the first shown
  window, not a hidden Window 1; Window 1's visibility is kept in a
  field, so Wayland (which can't report it) isn't told every frame.
- Only free windows remember where they were: a monitor's rect made a
  later free window with its number open monitor-sized.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:19:50 -04:00
martipops fd4b82f92d Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-30 18:18:16 -04:00
martipopsandClaude Opus 5.5 2d9d1f2264 Splitscreen: keep a lone P1's controller output between frames
route_pads released P1's pad hardware every frame, so with P1 alone the
reader lent for P1's turn lost its rumble and DualSense link a frame
after they started. It now releases only when nothing is lent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:17:39 -04:00
martipopsandClaude Opus 5.5 2317bebacf Merge split/07-controllers into split/08-join-with-controller
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:17:39 -04:00
martipopsandClaude Opus 5.5 aad4fd03f4 Splitscreen: hold back a trigger press that gave a controller its player
A controller first pressed with a trigger got a player, and the held
trigger then attacked or used: only buttons were held back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:15:30 -04:00
martipops a7802c004c Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 18:14:30 -04:00
martipops 3d94aa69d1 Merge branch 'split/02-audio' into split/06-two-players 2026-10-30 18:14:20 -04:00
martipopsandClaude Opus 5.5 7215229f71 Splitscreen: let a seat's name in only through memory
A LAN player who logged in under a registered seat's name (P2) passed
the integrated server's gate and the full-server check and held the
owner's permissions given to that seat. The gate now admits a seat's
name only from the memory connection (MEMORY_ADDRESS, port 0).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:14:20 -04:00
StievenW 49df6375ae Merge branch 'feat/quick-actions' of https://github.com/stievenw/horizon into feat/quick-actions 2026-10-31 04:44:55 +07:00
StievenW 776f8a40b1 test: fix pause_buttons test calls with 5th argument
- Add additions_count=0 to test calls matching the new function signature
- This fixes the CI compilation failure in verify job
2026-10-31 04:44:43 +07:00
martipopsandClaude Opus 5.5 9945e72c81 Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:39:33 -04:00
martipopsandClaude Opus 5.5 1af012a921 Launcher auth: let a status read give up behind a long operation
A refresh or a launch check holds the account lock across network calls.
Status reads (account list, the account page) now wait at most 3 s for
it, then say another operation is running, instead of waiting out a
network timeout. Writers still wait.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:38:37 -04:00
Stieven c020499a73 Merge branch 'main' into feat/quick-actions 2026-10-31 04:26:46 +07:00
StievenW 2d88cea471 style: rustfmt fixes for screens.rs 2026-10-31 04:14:08 +07:00
martipopsandClaude Opus 5.5 134b6f1a42 Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:02:13 -04:00
martipopsandClaude Opus 5.5 af5ac52ace Launcher auth: answer review nits
Translate the account status messages, print a failed `account refresh`
once, fetch a profile head again a minute after its skin download failed,
and check every account's sign-in at most once a minute on page switches
(an account added meanwhile at once), so switching pages no longer reads
the keychain each time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:57:48 -04:00
StievenW 673a4f8f88 chore: remove horizon_official_server.txt (should not be committed) 2026-10-31 03:56:47 +07:00
StievenW e0a1e12b64 fix: pause_screen_additions button closes pause menu before opening dialog
- When clicking a pause_screen_additions button, first close the pause screen (return to Playing + capture mouse)
- Then show the dialog
- This matches vanilla behavior where pause menu closes when opening a dialog from it
2026-10-31 03:53:26 +07:00
T3Code 5fb61a983b Allow native rims around loaded ordinary blocks 2026-10-31 02:53:08 +06:00
StievenW 63be0b4b5d fix: pause_screen_additions button position and click handling
- Fix button Y position: pause_screen_additions buttons now appear at row 3 (y0 + 122),
  above Options/World Options, matching vanilla behavior
- Fix click handler to use correct Y position for additions buttons
- Update pause_icons to account for additions height
- Button label uses external_title (falls back to title if not set)
2026-10-31 03:46:06 +07:00
StievenW 69461a9acc feat: add pause_screen_additions support to pause menu
- Add pause_screen_additions() to read dialogs from the minecraft:pause_screen_additions tag
- Display dialogs with external_title and pause:true as buttons in the pause menu
- Clicking these buttons opens the corresponding dialog

fix: clear held keys when opening a dialog to prevent stuck movement
2026-10-31 03:29:15 +07:00
NazaandClaude Opus 5.5 0d2cb928ba Merge the chat key branch and main: one signature cache, messages marked when the chat takes them
The client uses #4214's SignatureCache and its handling of a bad packed id, instead of its
own cache. markMessageAsProcessed runs where the chat takes a message, past any Chat Delay,
with whether it was shown; handleDeleteChat now calls LastSeenTracker::ignore_pending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:24:45 -03:00
StievenW ea29fb0433 feat: quick actions implementation 2026-10-31 03:13:17 +07:00
NazaandClaude Opus 5.5 98dfd26b67 Merge the signing branch and main: the chat keys start where the session now loads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:12:34 -03:00
NazaandClaude Opus 5.5 7e8b9c1154 Merge main: SignatureCache::unpack is #4214's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:12:18 -03:00
DUzzL 50a501c334 Discard the replaced armour in creative when the inventory is full 2026-10-30 21:03:47 +01:00
ElyTheCatandAntigravity e74b415d72 UI: Add keyboard navigation, arrow controls, and wheel scrolling for Horizon graphics settings and extras
- GraphicsUi (Horizon Graphics, Advanced, HDR):
  - Add full keyboard navigation (Tab/Shift+Tab, ArrowUp/ArrowDown, ArrowLeft/ArrowRight)
  - Slider keyboard controls (ArrowLeft/ArrowRight step discrete and continuous settings)
  - Selection key (Space/Enter) toggles can_change_value on sliders, cycles buttons, triggers actions
  - Escape cancels can_change_value before leaving the screen
  - Mouse wheel scrolling on sliders steps value; wheel on cycle buttons cycles mode
  - Visual parity: track highlighted when focused and !can_change_value, handle highlighted when hovered or can_change_value
  - Auto-scroll keeps focused list elements in view
- OptionsUi:
  - Route wheel and key_modifiers to GraphicsUi when on Horizon Graphics pages
  - Support mouse wheel cycling on Act::Extra toggle switches (Extras page)

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 23:07:04 +03:30
ElyTheCatandAntigravity ac08e26060 UI: Add keyboard navigation, arrow control, and wheel scrolling for sliders
- AbstractSliderButton / OptionInstanceSliderButton parity:
  - ArrowLeft / ArrowRight adjust slider values when in can_change_value mode
  - Space / Enter toggles can_change_value focus state
  - Escape cancels can_change_value before closing the options screen
  - Mouse wheel scrolling over a slider steps its value
  - Render state parity: track highlighted only when focused and !can_change_value,
    handle highlighted when hovered or can_change_value
- Dialog sliders: support ArrowLeft / ArrowRight nudge and mouse wheel scrolling
- General UI keyboard navigation: Tab/Shift-Tab cycle and 2D spatial Arrow navigation

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 22:43:42 +03:30
Muhammad AsifandOhMyAgent 4ef0559617 Let mobs board minecarts they walk into
OldMinecartBehavior.pushAndPickupEntities also picks up living entities
in the cart's box, 0.2 wider on each side. Horizon only pushed other
carts, so a cow shoved into a minecart stayed on the ground. Eligible
mobs now startRiding an empty cart on contact; players still click to
board, iron golems still push, and a cart that already has a rider
pushes instead. Entity.canRide refuses a warden and a boarding cooldown.

sync_riders treated only boats and mobs as vehicles, so a boarded cart
would drop its rider the next tick. Riders sit on the cart's passenger
attachment, get out when it breaks, and are saved under Passengers.

Vanilla: OldMinecartBehavior.pushAndPickupEntities, Entity.startRiding,
AbstractMinecart passenger attachment, Entity.positionRider.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-10-30 23:42:50 +05:00
DUzzL b094aa114f Pin the creative armour swap with tests and a pure swap plan 2026-10-30 19:37:54 +01:00
AurelienBelloirandClaude Opus 5.5 7136fd7e75 Save the ridden vehicle tree in the player's file and take it on leave
ServerPlayer.saveParentVehicle writes RootVehicle {Attach: UUID of the
immediate mount, Entity: the root vehicle saved with its passengers},
only while the root has exactly one player passenger
(Entity.hasExactlyOnePlayerPassenger). PlayerList.remove then, for such
a player, stops riding and removes the root and its passengers from the
world (UNLOADED_WITH_PLAYER), so the ride leaves with the player and
ServerPlayer.loadAndSpawnParentVehicle spawns it again on the next login.
PlayerList.removeAll does this for each player in turn on shutdown.

Horizon only wrote Attach and left the vehicle in the world, so a vanilla
reader got no vehicle back and the horse or boat stayed behind.

- persist.rs: vehicle_root, vehicle_tree_nbt (mob stack, boat with its
  mobs, minecart), parent_vehicle_tag with the one-player check, and
  remove_vehicle_tree.
- lan.rs: take_parent_vehicle on a guest leaving (HostEvent::Left) and
  save_leaving_guests for lan_close, stop_integrated and stop_dedicated;
  the tree goes in the guest's saved compound, the guest unrides.
  store_owner writes the full RootVehicle for the integrated owner.
- playerdata::store takes the RootVehicle compound; save no longer takes
  a vehicle. sync_level writes the tree for the local player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:06:19 +01:00
DUzzL a1c065740f Boat passengers' fluid box follows the boat
Entity.getFluidInteractionBox asks the vehicle, and
AbstractBoat.modifyPassengerFluidInteractionBox keeps only the part of the
passenger's box above the boat's top, unchanged when the boat is underwater and
absent when the boat reaches the passenger's top. The rider's fluid interaction
now uses that box instead of clearing the eyes afterwards, so the body
immersion, in_water and the eyes all follow vanilla and a boat floating on water
no longer drowns its passenger (#4001).
2026-10-30 18:56:49 +01:00
DUzzL 9784820d3c Boat passengers no longer drown while the boat floats on water
Entity.updateFluidOnEyes finds no fluid on the eyes of a rider whose eyes
are inside a boat that is not underwater. The server-side tick of a LAN
guest ignored that, so a boat bobbing in water drowned its passenger.
2026-10-30 18:56:47 +01:00
StievenWandNemotron AI agent 99bf176372 feat: make Quick Actions datapack-driven
Quick Actions menu (key.quickActions / G) now reads dialog definitions
from enabled datapacks (data/namespace/dialog/*.json with type
multi_action). Each action entry becomes a button; clicking runs the
action id as a command.

Also exposes DataPack::list and DataPack::read as pub so the game can
scan datapack dialog files.

Co-Authored-By: Nemotron AI agent <[email protected]>
2026-10-31 00:22:03 +07:00
StievenWandNemotron AI agent d3b5a9c5ac feat: add client-side Quick Actions screen (key.quickActions / G)
Adds a non-pausing Quick Actions menu opened by the G key (key.quickActions,
already defined in keybinds). The screen shows buttons for quick player
actions and releases the mouse while open. Camera action works; emote,
quick chat and gesture are placeholders pending datapack-driven content.

Co-Authored-By: Nemotron AI agent <[email protected]>
2026-10-31 00:16:45 +07:00
Eshayat Al-WasiuandT3Code efd3dcaf86 Add bounded native emissive overlays for texture-matched materials
Co-Authored-By: T3Code (GPT-6 Sol) <[email protected]>
2026-10-30 23:04:10 +06:00
Михаил РоговцевandCodex c1ac65d5fa Inventory: handle recipe book clicks before container presses
Co-Authored-By: Codex <[email protected]>
2026-10-30 18:39:54 +03:00
Михаил РоговцевandCodex c13cf61609 Inventory: prevent recipe book clicks from throwing carried stacks
Co-Authored-By: Codex <[email protected]>
2026-10-30 18:39:39 +03:00
levzzzandClaude Sonnet 5.5 fa9f76b7af Merge hr/main into fix/split-boats-riding
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 20:19:27 +05:00
levzzzandClaude Sonnet 5.5 36bb4da136 Merge hr/main into fix/split-boats-riding
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:54:56 +05:00
levzzzandClaude Sonnet 5.5 eddbccd13b Merge hr/main into fix/split-boats-riding
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:53:30 +05:00
Federico TaglianiandClaude Sonnet 5.5 5d1038557f Chunks beside old ones are decorated, their features spilling into the old chunks
A chunk with a final neighbour (a chunk of an older world, loaded from the
save) used to be left undecorated: no trees, ores or structure pieces in the
strip along the old chunks. The streaming now decorates it with the final
neighbour's blocks in the region and writes what the features changed into
the final chunk, as vanilla's features write into the chunks around them.
The structure starts of an old chunk are not made again from the seed.
test_blending_parity.py --phase full compares the decorated chunks around
old ones with vanilla.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 15:47:43 +01:00
masa-masa-0314andZCode Agent 9c23d27666 Functions: tick before the levels' entities, and drop Age: 0 a tick early
`MinecraftServer.tickChildren` runs `commandFunctions` before any
`ServerLevel.tick`, and `EntityTickList.ensureActiveIsNotIterated` gives
an entity added while the list iterates its first tick on the next tick.
Together they mean a tick function that polls for the drop a block just
made still finds it with `Age: 0` — the pattern data packs use to hook a
block break. Horizon ticked its functions inside the entity pass, after
the items: the drop had counted its age up already and the poll found
nothing.

The functions now run at the top of the tick, before the levels, with
the world's saved data read first (a load tag's `data modify storage`
must land after the save is restored, not before). An item entity added
this tick first ticks the next one.

`type_specific/player` also matches players only, as
`PlayerPredicate.matches` starts with `instanceof ServerPlayer`, and
honours `input` (`InputPredicate`; `input.sneak` is `Input.shift`, the
held key, not the sneaking pose).

Verified with the Vein Miner data pack: its tick-driven chain mining
takes both ores it is asked to, and its `veinminer:sneaking` predicate
reports false while the player is not sneaking.

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-30 22:47:12 +08:00
Federico Tagliani 5b5503fce0 Merge main into feat/blending-old-chunks 2026-10-30 15:07:50 +01:00
Federico TaglianiandClaude Sonnet 5.5 a58d441ce9 Chunks an older version saved: the game reads them for Blender.of
SavedOldWorld finds the saved chunks with blending_data (or DataVersion
below 4997) per region, reads their border columns and the biomes they
keep, and the overworld generator blends the new chunks around them with it.
tools/old_world_e2e.py plays a world made with the old_world_fixture
example, with and without blending (HORIZON_DISABLE_BLENDING, vanilla's
DEBUG_DISABLE_BLENDING).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 15:03:07 +01:00
Eshayat Al-WasiuandT3Code fd46f59c7d Add bounded native visual hook for block rims and colored lights
Co-Authored-By: T3Code (GPT-6 Sol) <[email protected]>
2026-10-30 19:50:02 +06:00
SlaVKsVolksandOpenAI Codex GPT-6.1 Sol e0b4037a39 fix(bench): distinguish absent scene load measurements
Show a dash when no scene-load measurements exist; preserve measured zero and the mean of available scenes.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-30 10:42:54 -03:00
Federico TaglianiandClaude Sonnet 5.5 83767f883e Blender and BlendingData: terrain, biomes and carving next to old chunks
Blender.of, BlendingData (calculated from an old chunk's border columns),
blendOffsetAndFactor, blendDensity, blendBiome and the carving filter, with
fastutil's entry order for the weighted sums. The generator takes an
OldWorld (the chunks an older version saved) and blends biomes, the density
graph's blend_alpha/blend_offset/blend_density and the carving mask with it.
tools/test_blending_parity.py compares the terrain around old chunks of
another seed block for block and quart for quart with vanilla.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 14:15:25 +01:00
Teiwazik 2cefbebb64 Merge branch 'shader/s5-water-mirror' into shader/s5b-mirror-option 2026-10-30 14:14:50 +01:00
Teiwazik 45d19d1f87 Merge remote-tracking branch 'origin/main' into shader/s5-water-mirror
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-10-30 14:14:49 +01:00
TeiwazikandClaude Opus 5.5 77fab15086 Shader API: [reflection] option, and facing culling for the mirror's eye
A pack can let players turn its water reflections off: [reflection] option
names an option the mirror is drawn only while it is on (a bool on, a number
above 0); without it, the mirror is drawn whenever the material reads it.
The sample pack shows it.

While mirroring, solid quads were all drawn, since the mirror's eye sees the
undersides the camera does not. Sides::visible_from keeps the quads that face
any eye from the camera straight down to its mirror below the plane, which
differ only in y, for the sections and the distant tiles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 14:11:12 +01:00
Federico TaglianiandClaude Sonnet 5.5 8d772a91d0 F3 shows the Density line of the noise settings' debug functions
NoiseBasedChunkGenerator.addDebugScreenInfo: DebugEntryChunkGeneration
adds 'Density N: .. T: .. V: .. C: .. E: .. D: .. W: .. PV: .. PS: ..' (the
overworld's debug_functions, three decimals) at the feet where the game
runs the level's generator: a single-player world on its server thread.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 13:53:24 +01:00
Ashura006andCursor 2fe8dd0029 ui: shorten Shadow Cache description to fit one Advanced Graphics line.
Co-authored-by: Cursor <[email protected]>
2026-10-30 15:44:25 +03:00
Ashura006andCursor fd0deacd54 clippy: drop needless return in pass-timing control command.
Co-authored-by: Cursor <[email protected]>
2026-10-30 15:24:32 +03:00
TeiwazikandClaude Opus 5.5 9b53e24b33 Shader API: the material stage's water mirror
Water can reflect only what is on screen, which loses the sky over a lake and
everything above the picture's top. Within API 0.1, additively, the material
stage gains reflection and reflection_sampler: the world as a camera mirrored
about the horizontal water plane around the camera sees it, sky and solid
terrain at half resolution, cut away below the plane. Frame gains
water_plane, has_reflection and reflection_resolution.

The mirror is drawn only for a pack whose material function reads
reflection (CheckedStage::reads), so other packs and vanilla pay nothing and
the game scans for the plane only then: the median standing-water surface of
a grid of columns around the camera, rescanned when the camera changes block
or each second. Sections are culled against the union of the camera's and
the mirror's frusta; while mirroring, solid quads are not left out by facing
and occlusion culling is off, since both follow the camera's eye.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 13:24:14 +01:00
Ashura006andCursor cf4fc19a8a clippy: derive Default for ShadowCache instead of a manual impl.
Co-authored-by: Cursor <[email protected]>
2026-10-30 15:16:02 +03:00
NazaandClaude Opus 5.5 eaf150712d Secure chat on a server's client: chat session, signed chat and commands
Online servers with enforce-secure-profile=true (26.3's default) refused every chat
message and /msg from Horizon ("Chat disabled due to missing profile public key").

game/remote_chat.rs (ClientPacketListener):
- setKeyPair: after each login packet, and when the key pair is renewed, a chat session
  (random id, chain from link 0) goes to the server as ServerboundChatSessionUpdatePacket,
  only when the server plays the account under its own profile id: an offline-mode
  server's offline id gets none, so chat stays unsigned there as before.
- sendChat / sendCommand: chat and each message argument of a command (SignableCommand.of
  over the server's tree) are signed with the same time, salt and last seen, one chain
  link each; every packet carries the last-seen update.
- handlePlayerChat / markMessageAsProcessed: other players' packed last seen through the
  signature cache, and a ChatAck once more than 64 messages wait.
- sendUnattendedCommand: a clicked chat line, a dialog's button and a mod's command stay
  unsigned (vanilla asks before signing one; there is no such screen here).

tools/secure_chat_client_e2e.py: vanilla 26.3's server and a mock of the services that
issues certificates; online with enforce-secure-profile (chat logged as secure, a /msg
whisper delivered, nothing refused) and offline (unsigned, as before). The net harness
takes its chat lines from HORIZON_NET_CHAT and reports whether it signed.

Fixes the LocalChatSession.createMessageEncoder, SignedMessageChain.encoder,
ClientPacketListener.setKeyPair and ClientPacketListener.sendCommand rows of
horizon-rust/horizon#3520 and horizon-rust/horizon#3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:54:13 -03:00
NazaandClaude Opus 5.5 8f843abb92 Secure chat: the account's chat key pair, and the command arguments a client signs
- auth.rs: the services' certificates endpoint (player/getCertificates, discovery), POSTed
  with the account's access token: the PKCS#8 private key, the X.509 public key,
  publicKeySignatureV2, expiresAt and refreshedAfter (AccountProfileKeyPairManager.
  fetchProfileKeyPair). Fetched on a background thread at start and on each join, kept in
  memory like the token (no profilekeys file), renewed past refreshedAfter (checked at
  most hourly), and kept as it was when a fetch fails.
- horizon-command: ParseResults::signable_arguments (SignableCommand.of), moved out of
  the LAN host's handleSignedChatCommand so a client can run it over a server's tree.

Nothing sends the key yet: the client's wiring follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:54:13 -03:00
NazaandClaude Opus 5.5 06f55b418d Secure chat: the client's signing (RSA private keys, the message chain encoder, the last-seen tracker)
The protocol pieces a client needs to sign its chat as vanilla 26.3's does, beside the
server's side that is already here:

- rsa.rs: PKCS#8 (and bare PKCS#1) RSA private keys, as the services hand out a player's
  chat key, and RSASSA-PKCS1-v1_5 signatures (Signer.from), checked with the public key
  before they leave. Signing is deterministic, so a test compares OpenSSL's bytes.
- secure_chat.rs: SignedMessageChain.Encoder (LocalChatSession.createMessageEncoder), the
  client's LastSeenMessagesTracker, and MessageSignature.Packed.unpack against the cache.
  Tests run what the encoder signs through the server's Chain, and the tracker's updates
  through LastSeenMessagesValidator.

Nothing calls them yet: the account's key pair and the client's wiring follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:54:13 -03:00
TeiwazikandClaude Opus 5.5 f9ef4e0ea0 Shader API: the material stage's face, fluid, emission, normal and tangent
A pack's material stage cannot tell water from glass, a fluid's top from its
side, or a lamp from stone, and has no surface frame for normal maps. Within
API 0.1, additively, MaterialInput gains face and fluid (0 none, 1 water,
2 lava), emission (the block's HDR tier as 0..1), and the geometric normal and
tangent from the derivatives of the position and uv, snapped to an axis on
block faces so distant faces do not shimmer.

The meshers mark fluid quads in the high nibble of the colour's alpha byte,
which terrain.wgsl does not read (it reads only the emission in the low
nibble); the distant LOD tiles get the same flags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 12:28:19 +01:00
TeiwazikandClaude Opus 5.5 07d302639e Shader API: the post stage's history, last frame's input to the last pass
A temporal pass (TAA, denoising) needs its own output from the frame before.
Within API 0.1, additively, the post prelude gains history (group 3, binding 8):
after the passes have run, what the last pass read is copied into it, so a
temporal pass put second to last reads its previous result next frame. It is
black on the first frame and with a single pass. Binding 8 leaves 6 and 7 for
the shadow map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 12:06:41 +01:00
TeiwazikandClaude Opus 5.5 951c7d5165 Shader API: the frame's motion since the last one, and the eye's light eased
Temporal passes (reprojection, motion blur, accumulation) need where a point was
last frame; packs that adapt exposure or fade underwater fog need the eye's light
and medium without their steps. Within API 0.1, additively, Frame gains
previous_view_projection, camera_motion and delta_time, filled by NativeFrame from
the frame before, and eye_light_smooth, in_water and in_lava, eased with
half-lives of 2 s and a quarter second. The frame stays a multiple of 16 bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 11:55:38 +01:00
TeiwazikandClaude Opus 5.5 fa845cd580 Shader API: the material stage sees what lies behind translucent terrain
The material stage could shade water and glass only from their own texture: it
had no way to know what was behind them, so depth-tinted water and refraction
were impossible. Within API 0.1, additively:

- MaterialInput.screen: the point's place on the screen, 0..1.
- opaque_depth (binding 6) and opaque_color (binding 11): the world's depth and
  colour as the solid terrain and entities left them. The world pass ends before
  translucent terrain, the targets are copied, and a pass that loads them goes on;
  the depth is stored for that while a material stage runs. The colour can be
  copied from the scaled world target only: drawing straight to the window, it
  is a black 1x1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 11:45:06 +01:00
Ashura006andCursor f4bdd6e454 gfx: make shader-pack shadow cache opt-in with explicit invalidation.
Default off in horizon-graphics.txt. When on, reuse the last shadow map only if
sun/view, world revision, day time, ticks, pack uniforms and settings match,
and skip the cache while entity shadows are drawn.

Co-authored-by: Cursor <[email protected]>
2026-10-30 13:32:20 +03:00
Ashura006andCursor 81d1052b4a Restore benchmarks deleted from an outdated branch base.
The shadow-cache branch was cut from an older tree and looked like it removed
the published benchmarks folder; put those files back from current main.

Co-authored-by: Cursor <[email protected]>
2026-10-30 13:32:15 +03:00
Ashura006 803022a9e2 Merge origin/main into shadow-cache 2026-10-30 13:27:26 +03:00
DUzzL 193c85660a Swap worn armour with the held piece in creative mode 2026-10-30 11:16:03 +01:00
v3nn7 35216ef97f Detect incomplete title panoramas 2026-10-30 11:09:21 +01:00
martipopsandClaude Opus 5.5 dc6d372db8 Let a running game renew its session through the launcher CLI
`account session WHO` renews an account and hands the running game a new
read-once session file, or the public status when it needs a browser
sign-in or a retry. `account sign-in --for WHO` runs the UUID-bound
re-sign-in from the command line. The launcher passes its own path to the
game as HORIZON_LAUNCHER_EXE. `account session` is kept out of MCP: its
file holds a token.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:01:40 -04:00
0dab1469ac Show profile heads, auth progress, and targeted re-sign-in in the launcher
Fetch public skin heads on workers with a bounded, URL-keyed cache and UUID default fallback, report each account operation's actual phase, and bind Sign in again to the account's UUID so a different Microsoft account is rejected before any credentials are stored.

Co-Authored-By: Codex <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:34:27 -04:00
martipopsandCodex 3fab32c08a Give launcher account states semantic colors and icons
Show ready, refreshable, and sign-in-required states using the theme's success, warning, and danger colors, retaining icons and full labels.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:58:31 -04:00
ElyTheCatandAntigravity 5a2bfc3d24 OpenGL 4.2: Enable indirect execution and simulated multi-draw batching
Basic indirect drawing (glDrawArraysIndirect, glDrawElementsIndirect) is
standard in Desktop OpenGL 4.0+ and GLES 3.1+.

1. In wgpu-hal GLES adapter: decouple DownlevelFlags::INDIRECT_EXECUTION
   from compute shaders, enabling indirect execution on Desktop GL 4.0+
   (and GLES 3.1+), which unlocks multi_draw in horizon-client-render on
   OpenGL 4.2 hardware (e.g. Intel HD Graphics 4000).
2. In wgpu-hal GLES command and queue: avoid pushing draw_count separate
   Command variants into the command buffer. Push a single DrawIndirect /
   DrawIndexedIndirect command with draw_count. In queue execution, bind
   DRAW_INDIRECT_BUFFER once and loop glDraw*Indirect across offsets on the
   CPU, eliminating command buffer allocation churn and repeated buffer
   re-bindings.

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 10:21:03 +03:30
martipopsandCodex 501c9abbfe Handle completed sign-in only once while polling the launcher
Compare sign-in states directly instead of comparing a state to an Option's debug output, which continually restarted every account's status checks.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:49:01 -04:00
martipopsandCodex f0a06742ea Show account status and refresh actions in the launcher
Inspect and renew due sessions on workers, expose Refresh and Sign in again actions, and share the operation through CLI and MCP for future launcher-to-game integration.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:32:45 -04:00
martipopsandCodex 6e1ac28831 Add launcher-owned account status and forced session renewal
Keep authentication state credential-free, remember rejected refresh credentials, preserve rotated credentials after downstream failures, and serialize refresh with sign-in, sign-out and launch.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:32:45 -04:00
Kacpherek 8688ed6d65 Move the camel, horse and llama riding changes out into their own pull request 2026-10-30 06:32:05 +01:00
Kacpherek 8ccd4a136c Merge remote-tracking branch 'origin/main' into fix/split-boats-riding 2026-10-30 06:31:55 +01:00
Kacpherek 88ac6ed577 Merge remote-tracking branch 'origin/main' into fix/split-boats-riding
# Conflicts:
#	crates/horizon-client/src/mobs/living.rs
#	crates/horizon-client/src/motion.rs
#	crates/horizon-client/src/motion/fluids.rs
2026-10-30 06:31:04 +01:00
Alexander KrylovandHermes Agent c3f26fd45f Launcher: no borrow clippy says is needless
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 07:28:04 +03:00
Alexander KrylovandHermes Agent 306f4c1444 Launcher: the Settings and Home strings that never reached tr()
Twenty-nine literals were drawn straight into the window, so the tables never
had them: the game arguments and proxy rows with their explanations, every
text field's placeholder (one place now), Manage versions, the launcher's own
update lines, the release and patch-note empty states and the sponsors line.
Russian in ru.json; the other six tables carry the English source.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 07:28:04 +03:00
Alexander KrylovandHermes Agent 5b2d5c7580 Launcher: an accent is any hue, not one of seven
Accent carried one of seven names; it carries a hue now, and none for the
violet, whose tokens stay the palette's. Settings shows a strip of hues
beside the swatches: a click or a drag picks any colour, the swatches stay
as shortcuts, settings.json keeps "violet"/"blue" for those and writes a
picked hue in degrees. The contrast test runs over hues nobody preset too.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 07:17:26 +03:00
martipopsandClaude Opus 5.5 f96acabfdc Splitscreen: the alone check in device_event is no surfaces, as in window_event
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:46:46 -04:00
martipops 1a7e3da3c4 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 23:43:33 -04:00
martipops 5596fd89f4 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 23:43:33 -04:00
martipops f6bd4918d7 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 23:43:33 -04:00
martipops 38bda0fc71 Merge branch 'split/02-audio' into split/06-two-players 2026-10-29 23:42:35 -04:00
martipopsandClaude Opus 5.5 126d270448 Splitscreen: alone, device events go straight to P1 as window events do
Review: device_event turned P1's GUI scale on for every mouse motion even with no other
player; it now hands the event to P1 directly while there is no compositor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:42:35 -04:00
martipops ad604ad630 Merge remote-tracking branch 'upstream/main' into split/10-profile-page 2026-10-29 23:07:35 -04:00
martipops 92ba57a02e Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 23:07:35 -04:00
martipops c350caa74f Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 23:07:35 -04:00
martipops 91290d7d0a Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 23:07:35 -04:00
martipops 0286833cb7 Merge remote-tracking branch 'upstream/main' into split/06-two-players
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-29 23:07:34 -04:00
martipopsandClaude Opus 5.5 c7b5e41cbf Merge upstream main into the profile page
Upstream moved page titles and tooltips to translation keys: the profile page gets its own
(horizon.pages.split_profiles).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:44:48 -04:00
martipops 8a8ee31ea2 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 21:39:41 -04:00
martipops c9e0a240a4 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 21:39:41 -04:00
martipops 69bc9623a1 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 21:39:40 -04:00
martipopsandClaude Opus 5.5 dc772c4601 Merge upstream main into part 6
Upstream moved the title actions into title_action (the split guest's Quit guard goes there),
added the server's online mode beside our memory-seat limit bypass, the audio output's device
name (a shared output carries it), and dropped code mods' key hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:38:16 -04:00
SenjinandClaude Opus 5.5 c5ffee73bf HDR: lightning, beacons, end crystals, blazes, fireworks, orbs and glow frames shine
Light that isn't a block gets HDR output's tier too (entity.wgsl fs_hdr_mark):

- The additive pipelines marked nothing: lightning and the dragon's eyes and death
  rays get mark passes of their own (vs_lightning passes the tier on as vs_entity
  does). Lightning and the death rays very bright, the dragon's eyes bright.
- End crystals, their healing beam and beacon beams bright. The mark pass now wraps
  a beam's v in its sprite as fs_entity does, or it sampled transparent texels.
- Blazes bright, magma cubes and glow squid low, entity flames incandescent.
- Fireworks (sparks and the burst's flash) bright: translucent-layer particles
  carry a tier and get a mark pass after their draw.
- Experience orbs very bright, glow item frames incandescent (the item in one stays
  only lit).
- The overlay is the layer's low 16 bits everywhere (the entity batch's materials,
  the shader packs' plain-run and hurt-colour checks), so a tier never changes how
  anything draws, with HDR on or off.

Tuned in game on an AW3423DW (Senjin). HDR off: nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:43:59 +01:00
DUzzL 55b70b104f bots: add a fight behaviour (survival bots that attack the nearest mob and each other) 2026-10-29 22:48:06 +01:00
DUzzL c8144e531e Renderer: take a software adapter only after every backend failed (#3978) 2026-10-29 22:47:40 +01:00
Kacpherek 3dadd6ec59 Make the boats-riding fixes build on current main 2026-10-29 22:18:22 +01:00
Kacpherek a4dfcede95 Walk a camel's legs only while it stands and does not dash
A camel's legs used the default walk: they kept swinging while it sat or dashed and eased in at 0.4. Camel.updateWalkAnimation aims at 6 times the distance moved (at most 1) only in the standing pose with no dash animation running, otherwise at zero, and eases at 0.2. The remote client's own walk update uses it for camels too.
2026-10-29 22:07:07 +01:00
Kacpherek c88ba83675 Stand a sitting camel up when its lead pulls taut
A leashed sitting camel stayed down however hard the lead pulled. Camel.onElasticLeashPull stands it up when it is sitting, not changing pose, and the standing box fits where it is (canCamelChangePose, wouldNotSuffocateAtTargetPose). AbstractHorse.onElasticLeashPull, which it calls first, also ends a horse's grazing.
2026-10-29 22:07:07 +01:00
Kacpherek c30bcd2881 Let a horse slide on the rider's keys after a jump while it rears
A horse that landed from a small hop while still rearing ignored the keys until the rear ended, and a donkey or mule never reared on a jump. AbstractHorse.onPlayerJump sets allowStandSliding and calls standIfPossible (every horse that can rear), getRiddenInput only zeroes the input while the horse is standing on the ground without a jump pending and without allowStandSliding, and tick clears it once the horse stands no more.
2026-10-29 22:07:07 +01:00
Kacpherek 4053f61814 Slide a rider back and up while its horse rears
A horse, donkey, mule or undead horse that reared kept its rider at the fixed seat. AbstractHorse.getPassengerAttachmentPoint adds (0, 0.15, -0.7) times the previous tick's stand animation (standAnimO) to the seat, turned with the horse. The player and a mob rider both slide.
2026-10-29 22:07:07 +01:00
Kacpherek ce62d44445 Seat camel riders by the camel's sit and stand animation
The rider's seat on a camel was a fixed height for sitting and standing, built from the old full-height box, and a mob rider sat at the top of the box when the camel sat. Camel.getPassengerAttachmentPoint takes the body anchor from the current pose box and lerps it through the sit and stand transitions (getBodyAnchorAnimationYOffset), at the camel's age scale. The player and a mob rider both use it now.
2026-10-29 22:07:07 +01:00
Kacpherek 4b7a843380 Shrink a sitting camel's hitbox to its sitting size
A camel that sat down kept its standing box, so it blocked and was hit as if it still stood. Camel.getDefaultDimensions gives Pose.SITTING an adult box 1.43 lower with eyes at 0.845, and a baby box 0.425 tall with eyes at 0.41. Mob::awake_dims now follows the camel's synced pose, on the server and on a client.
2026-10-29 22:07:06 +01:00
Kacpherek 3d0edcfb5c Hurt a llama and its riders only for falls of six blocks
A llama landed with the generic fall sound and hurt on any damaging fall. Llama.causeFallDamage makes no fall sound, and hurts the llama and hands the fall to its riders only when the fall is six blocks or more.
2026-10-29 22:07:06 +01:00
Kacpherek 4d2a53d739 Pass a mount's fall damage on to its riders
A rider took no damage when the mob under it landed hard, and a mob on a mount's back never felt it either. Entity.propagateFallToPassengers hands the fall to every passenger (LivingEntity.causeFallDamage first, AbstractHorse.causeFallDamage after its own hurt and only when hurt), and each passenger runs its own causeFallDamage with its own safe distance. A mount now raises a fall event the game turns into Player.causeFallDamage for the local player and server players, and the mob causeFallDamage for a mob rider.
2026-10-29 22:06:54 +01:00
Kacpherek 1cd1607d2a Tilt a boat in a bubble column
AbstractBoatRenderer.submit turns a boat that is not under water about the axis (1, 0, 1) by the bubble angle when it is not 0 (Mth.equal, 1e-5), after the hurt tilt and before the model's flip, so a boat wobbles while a column works on it.
2026-10-29 22:06:35 +01:00
Kacpherek d4e1c8fef0 Read a boat's bubble angle when drawing it
AbstractBoatRenderer.extractRenderState copies AbstractBoat.getBubbleAngle(partialTicks), the wobble lerped between the last two ticks, into the render state. boat_geometry now reads it the same way (the drawing follows in the next commit).
2026-10-29 22:06:35 +01:00
Kacpherek 597220519e Apply a bubble column's throw or pull on the boat a client drives
The server decides when a column throws or pulls a boat, but the boat its player drives moves on that player's client, so the server's own change does nothing there. AbstractBoat.handleEntityEvent now takes entity events 71 and 72 and runs handleBubbleColumnEffect, which the client applies to the boat it drives.
2026-10-29 22:06:35 +01:00
Kacpherek a65f05b566 Throw or pull a boat after 60 ticks over a bubble column
Boats never rose or sank in a column and a down column let every rider stay. AbstractBoat.tickBubbleColumn now counts the bubble time down on the server while the boat stays above the column, then ejects the riders (down only), broadcasts entity event 71 (down) or 72 (up) and applies the throw or pull. A server's player is let out through guest_vehicle_tick. Clients run the other half: the wobble multiplier grows by 0.05 a tick while the bubble time runs and fades by 0.1 otherwise, and the angle is 10 * sin(0.5 * tickCount) * multiplier.
2026-10-29 22:06:25 +01:00
Kacpherek 159e53b81b Add the bubble column's throw and pull for boats
A boat in an up or down column never changed its vertical speed. AbstractBoat.handleBubbleColumnEffect sets it to 0.6 for an up column and subtracts 0.7 for a down one, on the side that moves the boat (canSimulateMovement).
2026-10-29 22:06:25 +01:00
Kacpherek 7c188c9dbd Start the bubble column countdown when a boat is above one
A boat above a bubble column, with nothing over it, had no bubble state and never splashed. AbstractBoat.onAboveBubbleColumn now marks the boat as above the column with its direction, starts the 60 tick bubble time when it is 0 and, one time in a hundred while the boat is not under water, posts the splash game event on the server and plays the splash sound and particle on the client (it does not push the boat, unlike Entity.onAboveBubbleColumn).
2026-10-29 22:06:25 +01:00
Kacpherek 883a379bbb Lift and drag boats inside a bubble column
A boat inside a bubble column, with water above it, kept its speed. Entity.handleOnInsideBubbleColumn now moves it up to 0.7 or down to -0.3 (AbstractBoat does not override it), twice a tick as AbstractBoat.tick calls applyEffectsFromBlocks twice. Only the side that moves the boat applies it (canSimulateMovement): the server for a boat nobody drives, the client for the boat its player drives. Boats the window does not tick also get their status refreshed, as getStatus runs on every boat.
2026-10-29 22:06:24 +01:00
Kacpherek aafa055c21 Send a boat's bubble time to the clients that track it
Boats had no AbstractBoat.DATA_ID_BUBBLE_TIME (entity data entry 13). The server now sends it with the other synched boat data, and sends 0 again once it drops back, and a client keeps it on the boat.
2026-10-29 22:06:24 +01:00
Kacpherek b3167c0351 Split the bubble column lookup out of bubble_column_push
bubble_column_push found the columns a box touches and pushed in one go, so nothing else could ask which column an entity is above or inside. bubble_columns_touching now returns each touched column with its drag and whether nothing sits above it (BubbleColumnBlock.entityInside); bubble_column_push uses it and behaves as before.
2026-10-29 22:06:24 +01:00
Ashura006 f0b02fd368 Remove benchmark logs and scripts from PR 2026-10-29 23:10:39 +03:00
Ashura006 a1bac98626 Format code 2026-10-29 23:08:52 +03:00
martipops 950e0aeac2 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 13:39:25 -04:00
martipops ec514fd94b Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 13:39:25 -04:00
martipops 664c82127f Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 13:39:25 -04:00
martipops 90f2db9114 Merge remote-tracking branch 'upstream/main' into split/06-two-players
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-10-29 13:39:15 -04:00
martipops 77682df7e9 Merge remote-tracking branch 'upstream/main' into split/10-profile-page
# Conflicts:
#	crates/horizon-client-render/src/lib.rs
#	crates/horizon-client/src/lib.rs
2026-10-29 13:35:38 -04:00
martipopsandClaude Opus 5.5 482adff394 Splitscreen: the profile page ("Who's playing?"), as a model
Part of the splitscreen series (#3541). The page a joining splitscreen player picks its
profile on, in horizon-client-render with its own tests, and the profile store it reads:
names kept in the game folder (splitscreen/profiles/<name>/), pages of them, New Profile...
(a player name: 3 to 16 letters, digits or underscores), forgetting one (hidden, its settings
kept), Cancel. Options pages it opens from grey Language and Resource Packs for another
player (the whole process's). Nothing opens it yet: the game side joins next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:11:16 -04:00
martipopsandClaude Opus 5.5 685548932b Splitscreen: players in windows of their own, or filling a monitor
Part 9 of the splitscreen series (#3541), on #3801. Each player plays in a window: Window 1
(the game's), Window 2, 3... opened for the players put there, or a borderless window filling
a monitor (over the menu bar on macOS, the system's borderless fullscreen elsewhere). Players
in one window share it. Closing a player's own window drops its players; the game's window
hides while nobody plays in it; titles say who plays where; a window reopens where it was.
Another player's F11 toggles its own window. HORIZON_SPLIT_WINDOWS (testing, until the Setup
screen) says each seat's window: `1,1,2`, or `m2` for monitor 2.

Live: tools/splitscreen_e2e.py adds `windows`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:01:59 -04:00
martipopsandClaude Opus 5.5 2edc3b57a4 Splitscreen: join with a controller; a lost controller's player waits
Part 8 of the splitscreen series (#3541), on #3794. While splitscreen watches the controllers
(HORIZON_SPLIT for now; the switch comes later), one reader serves every player, alone too:
the first controller used is P1's (its rumble and DualSense output lent to P1's turn). In P1's
world another controller pressing a button is offered to join on every view: A joins (as
P<seat>), Y drives P1 when P1 has none, B says no, 10 s and it goes. A controller that
disconnects leaves its player dimmed and asked: the same controller back takes it again, A on
another takes over, B lets it leave. Each view gets a short badge (who, and why: "Keyboard
and mouse" when it takes them).

Live: tools/splitscreen_e2e.py adds `alone` (watching, nobody joins).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:32:57 -04:00
martipops bd6ead73c3 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 12:24:01 -04:00
martipopsandClaude Opus 5.5 557a675d5d splitscreen_e2e: replies through a file, so Windows doesn't hang on launch
On Windows the game horizon-ctl launch starts inherits horizon-ctl's stdout pipe, which then
never closes while the game runs: subprocess.run waited forever. No pkill there either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:22:32 -04:00
martipops 408ca59a65 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 11:56:09 -04:00
martipopsandClaude Opus 5.5 c911e9060d splitscreen_e2e: find horizon-ctl.exe on Windows
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:56:01 -04:00
martipopsandClaude Opus 5.5 9b19ab5b04 Splitscreen: each controller drives its own player
Part 7 of the splitscreen series (#3541), on #3777. While several play, one controller reader
(PadRouter) serves every player's game: a controller nobody has goes, when pressed, to the
first player without one (P1 first); its first press is not passed on; a controller that
disconnects leaves its player without one. Each player's glyphs follow its controller.
Alone, P1 reads the controllers itself, exactly as without splitscreen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:50:52 -04:00
martipopsandClaude Opus 5.5 70a9fd6f6a Splitscreen: a second player in the same window (HORIZON_SPLIT=N)
Part 6 of the splitscreen series (#3541), on parts 1 (compositor), 2 (audio), 4 (pointer
ownership) and 5 (layout). With HORIZON_SPLIT=N (testing only, no switch yet), N-1 more
players join P1's world through memory as P2, P3..., each its own Game in this process,
rendered offscreen and composited into P1's window. Keyboard and mouse go to the view
clicked last. A player who quits or disconnects drops out; alone again, P1 draws on its
window as before. Guests never open the controllers (pad.feed); routing comes next.

Live: tools/splitscreen_e2e.py (join, four).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:34:36 -04:00
spiderandClaude Opus 5.5 572c5c4664 FSR 3.1 frame interpolation, ported to WGSL (frame generation 2/3)
AMD FidelityFX SDK 1.1.4's frame interpolation (MIT): dilated depth and camera
motion vectors, the estimated depths, the game and optical flow vector fields,
the disocclusion mask, the interpolation and the inpainting, as WGSL compute
passes. Not used by the game yet; the setting and the present thread follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:32:10 +01:00
martipops 563d85f957 Merge remote-tracking branch 'origin/split/05-viewports' into split/06-two-players 2026-10-29 11:25:55 -04:00
martipops ce0983fecc Merge remote-tracking branch 'origin/split/04-pointer-ownership' into split/06-two-players 2026-10-29 11:25:55 -04:00
martipops b3e5cb2fa6 Merge remote-tracking branch 'origin/split/02-audio' into split/06-two-players 2026-10-29 11:25:55 -04:00
Ashura006 e91569dbf1 gfx: implement temporal shadow cache with reprojection
- Add ShadowCache struct to store rendered shadow maps between frames
- Cache keyed by sun/moon angle, camera position, and shadow settings
- On cache hit: copy cached textures instead of re-rendering shadow pass
- On cache miss: render normally and store result in cache
- Add pass-timing control command for shader pack GPU profiling
- Expose ShadowTargets fresh/cleared fields for cache access

Benchmarks (seed 1234, render distance 16, time 6000):
- Avg FPS: +23-45% across biomes
- 1% low FPS: +85-167% improvement
- Shadow pass time: ~90% reduction on cache hit
2026-10-29 15:55:51 +03:00
dacrabandDeepSeek V4.1 Flash 1017 eea5bd0808 Launcher: a Linux AppImage that updates in place
Linux releases ship horizon-launcher-linux-x86_64.AppImage beside the
.tar.gz: one file to download, make executable and run, with the
libraries GPUI needs beside it. glibc, the graphics drivers and the
session's D-Bus stay the machine's (a driver cannot be packaged), so the
image needs glibc 2.35 or newer, like the .tar.gz. appimagetool 1.9.1
is pinned by SHA-256 and run extracted, so building needs no FUSE; the
image itself needs none either, as its runtime mounts it where FUSE
works and unpacks it where it does not.

Inside an AppImage, current_exe names a copy in the read-only mount, so
a launcher in one updates by replacing the file $APPIMAGE names, and
the menu entry it writes starts that file rather than the mount's copy.
The .tar.gz stays what every other launcher updates itself from.

Closes #2110.

Co-Authored-By: DeepSeek V4.1 Flash 1017 (River) <[email protected]>
2026-10-29 13:54:23 +02:00
socksproxandClaude Sonnet 5.5 40d30b411e launcher: let the player hide the partner banner on the Servers page too
One Hide, shown after the same three minutes on screen, now sits on the Servers
banner as well as the sidebar card, and hiding either hides both for good.
The on-screen timer counts on every page, so a player who lives in the Servers tab
reaches the Hide too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 04:00:55 +01:00
AHHhHHHHH-HandClaude Sonnet 5.5 ff74eaf087 Renderer: empty the sparsest vertex arena page so video memory comes back
The terrain vertex arena only freed a page once every range in it was free, so after a render distance change or far tile churn it kept pages that were mostly empty. On a 2 GiB card the next allocations then spilled to system memory and frames slowed to a crawl. When at least 128 MiB and half of the arena is free ranges, copy the meshes off the sparsest page into the other pages (32 MiB of GPU copies a frame) and let trim free it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-28 21:50:14 -03:00
Kacpherek 8d680ba46a Launcher on Windows: delete the binary an update moved aside
The next start removes horizon-launcher.exe.old-* beside it. The launcher
that just restarted into the new binary is still exiting, so it keeps
trying for a few seconds in the background; a file still in use (another
launcher window) is left for a later start.
2026-10-28 13:57:08 +01:00
Kacpherek 9130faeae9 Launcher on Windows: update by renaming the running file aside, not self_replace
self_replace copied the launcher under a random hidden name
(.horizon-launcher.<random>.__relocated__.exe), started that copy to wait
for the launcher to exit and delete it, and deleted the copy through a
hidden cmd.exe. Microsoft Defender quarantines that self-deleting pattern
as Trojan:Script/Wacatac.H!ml (#3175).

Windows lets a running binary be renamed, so the running launcher now
steps aside to horizon-launcher.exe.old-<pid>-<n> in its own folder and
the new binary takes its path. No copy is started, and the crate (with
its start-up hook that ran in every launch) is gone from the build.
2026-10-28 13:57:08 +01:00
1162 changed files with 237621 additions and 13762 deletions

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
+3 -1
View File
@@ -9,7 +9,9 @@ permissions:
actions: write
jobs:
cancel:
runs-on: ubuntu-latest
# One of playhorizon's small runners, out of GitHub's shared pool: no checkout, no code of the pull request
# (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
steps:
- name: Cancel the pull request's unfinished runs
env:
+41 -70
View File
@@ -1,7 +1,7 @@
name: checks
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
# check main requires, so it always runs: a skipped job counts as passed, and every run of this workflow must report
# a real verify. It gathers the python, clippy and tests jobs (a change to docs alone skips their Rust steps).
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
@@ -24,19 +24,32 @@ env:
HORIZON_TELEMETRY_URL: "off"
jobs:
# verify's checks run as three jobs at once (python, clippy, tests: ~4 min instead of ~8 one after another), and
# verify gathers them. A failed job makes its dependents skip, and a skipped required check counts as passed, so
# verify runs always() and fails unless all three passed.
python:
runs-on: ubuntu-latest
timeout-minutes: 30
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
#
# Where: GitHub's runners. With the repository variable SELF_HOSTED_CI=all (for when that pool is full: their 4 CPUs
# are about one GitHub runner, docs/ci-runners.md), main's pushes and this repository's own pull requests run on
# playhorizon's checks runners (label playhorizon-ci); a fork's pull request never does (and the runners refuse it
# themselves). There the build folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages,
# Python and the Rust cache steps are GitHub's only.
verify:
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
if: runner.environment == 'github-hosted'
with:
python-version: '3.12'
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
- name: Keep game artifacts out of Git
env:
@@ -52,30 +65,20 @@ jobs:
fi
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
clippy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
env:
HOSTED: ${{ runner.environment == 'github-hosted' }}
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
if [ "$HOSTED" = true ]; then
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Dependencies and the workspace's own check, saved from main's runs (pull requests read main's cache).
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
if: steps.rust.outputs.required != 'false' && runner.environment == 'github-hosted'
with:
shared-key: verify-clippy
shared-key: verify
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check formatting and lint
if: steps.rust.outputs.required != 'false'
@@ -84,32 +87,7 @@ jobs:
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
with:
shared-key: verify
save-if: ${{ github.ref == 'refs/heads/main' }}
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy already
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
# these 4-core runners it was no faster (#4978).
@@ -121,24 +99,12 @@ jobs:
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
verify:
if: always()
needs: [python, clippy, tests]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Did python, clippy and tests pass?
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
echo "python, clippy, tests: $RESULTS"
for result in $RESULTS; do [ "$result" = success ] || exit 1; done
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: ubuntu-latest
timeout-minutes: 30
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
@@ -152,12 +118,16 @@ jobs:
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust and the headers GPUI links against
if: steps.launcher.outputs.required != 'false'
env:
HOSTED: ${{ runner.environment == 'github-hosted' }}
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
if [ "$HOSTED" = true ]; then
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
if: steps.launcher.outputs.required != 'false'
if: steps.launcher.outputs.required != 'false' && runner.environment == 'github-hosted'
with:
workspaces: launcher
shared-key: launcher
@@ -174,7 +144,8 @@ jobs:
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
parity-scope:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
# A git diff of this repository's own branch: one of playhorizon's small runners (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
+4 -2
View File
@@ -29,7 +29,8 @@ jobs:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
group: claim-${{ github.event.issue.number }}
runs-on: ubuntu-latest
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
@@ -55,7 +56,8 @@ jobs:
run: python3 tools/ci/claim_command.py command
release:
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
runs-on: ubuntu-latest
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
+14 -2
View File
@@ -8,6 +8,9 @@ name: launcher
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
# commits.
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
# Linux ships two archives of one binary: the .tar.gz the launcher updates itself from, and an
# AppImage (packaging/linux/appimage.sh) for players without a distribution package, which replaces
# itself instead (launcher/src/backend.rs).
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
@@ -19,7 +22,7 @@ on:
push:
branches: [main, launcher, 'launcher-*']
paths:
# Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS.
# Everything the launcher is built and packaged from, as tools/release_train.py's LAUNCHER_PATHS.
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
@@ -29,6 +32,8 @@ on:
- 'crates/horizon-release-sign/**'
- 'crates/horizon-telemetry/**'
- 'crates/horizon-theme/**'
- 'packaging/linux/**'
- 'packaging/icons/**'
- '.github/workflows/launcher.yml'
schedule:
- cron: '7-59/10 * * * *'
@@ -161,7 +166,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev patchelf
- name: Metal shaders compiled now, or at run time without the Metal toolchain
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
@@ -254,6 +259,13 @@ jobs:
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
rm -rf out/Horizon.app
else
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
if [[ "$RUNNER_OS" == "Linux" ]]; then
# The .tar.gz below is what the launcher updates itself from; the AppImage is what a
# player downloads and double-clicks, and it replaces itself instead
# (packaging/linux/appimage.sh, launcher/src/backend.rs).
../packaging/linux/appimage.sh "$BIN/${{ matrix.binary }}" "$version" out
fi
mkdir -p dist
cp "$BIN/${{ matrix.binary }}" dist/
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
+31
View File
@@ -0,0 +1,31 @@
name: mojang republish
# Hourly: does Mojang's version manifest list a pinned-version JSON that research-lock.json does not know?
# If so, one issue "Mojang republished <version>: <sha8>" with the hashes and a lock snippet
# (tools/ci/mojang_republish.py). Reads only Mojang's public manifest.
# gh workflow run "mojang republish" -f dry_run=true
on:
schedule:
- cron: '23 * * * *' # hourly: a republished JSON breaks horizon-prepare for every new player until accepted
workflow_dispatch:
inputs:
dry_run:
description: Only print what it would do with issues
type: boolean
default: false
permissions:
contents: read
issues: write
concurrency:
group: mojang-republish
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Check the manifest
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/mojang_republish.py ${{ inputs.dry_run && '--dry-run' || '' }}
+55
View File
@@ -0,0 +1,55 @@
name: native hook stamp
# Opens a PR that numbers pending fragments after they merge. Only pushes to
# main run main's own tool; fragments are read as data, not PR code.
on:
push:
branches: [main]
paths:
- 'tools/native-hook-fragments/*.json'
permissions:
contents: read
concurrency:
group: native-hook-stamp
cancel-in-progress: false
jobs:
stamp:
if: github.repository == 'horizon-rust/horizon'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Open a stamp pull request
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
branch=automation/native-hook-stamp
if [ -n "$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')" ]; then
echo "A stamp pull request is already open."
exit 0
fi
python3 tools/stamp_native_hooks.py
if git diff --quiet; then
echo "No fragments to stamp."
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git checkout -b "$branch"
git add -u
git commit -m 'Stamp native hook API fragments'
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
fi
git push --force-with-lease origin "$branch"
gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'
+3 -1
View File
@@ -19,7 +19,9 @@ jobs:
concurrency:
group: overlap-note-${{ github.event.pull_request.number }}
cancel-in-progress: true
runs-on: ubuntu-latest
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
+3 -1
View File
@@ -16,7 +16,9 @@ jobs:
label:
# Title-only edits change nothing: skip them before a runner starts.
if: github.event.action != 'edited' || github.event.changes.body != null
runs-on: ubuntu-latest
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
+26 -8
View File
@@ -39,7 +39,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -56,6 +57,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
@@ -68,13 +70,23 @@ unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Write tests with signal
- Test observable behavior, not words in docs, comments or source. Keep checks for machine-consumed contracts.
- Use independent expected values or a vanilla oracle; do not derive the answer from the code under test.
- Test outcomes, not only calls to stubs. Use mocks to isolate behavior, not as the behavior itself.
- Skip tests of trivial constants, derives and getters, or behavior already covered by another test.
- Avoid pinning implementation details; a harmless refactor should not break a behavior test.
- Make slow tests faster without removing cases or weakening their coverage.
- Never delete parity harnesses or tests that pin vanilla behaviour: they are the spec.
## Build and test fast
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
@@ -91,8 +103,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- 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):
@@ -117,8 +132,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
state stays separate from the library tests. This compiles gameplay twice for executable tests.
| Subsystem | Files |
|---|---|
@@ -149,7 +167,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
+4
View File
@@ -113,6 +113,10 @@ it (edited on every push, never repeated): talk to its author in the thread befo
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
ourselves instead of sending it back to you; only design questions come back.
- **Once we've pushed to your branch, merge main into it; don't rebase or force-push.** A rebase or force-push
replaces our commits with yours and erases the fixes we added (it happened on #5188 and #5203). Tell your agent
too: `git merge origin/main`, never `git rebase` or `git push --force` on a branch a maintainer has touched. If
our fixes get erased anyway, we finish the work in our own pull request that keeps your commits and credits you.
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
Generated
+593 -14
View File
@@ -34,6 +34,17 @@ dependencies = [
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -163,6 +174,45 @@ dependencies = [
"libloading",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 2.0.21",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -181,6 +231,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -195,6 +251,9 @@ name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
@@ -217,6 +276,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -301,6 +369,15 @@ dependencies = [
"serde_json",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.6.0"
@@ -339,7 +416,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
@@ -349,7 +437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"chacha20 0.9.1",
"cipher",
"poly1305",
"zeroize",
@@ -386,6 +474,18 @@ dependencies = [
"error-code",
]
[[package]]
name = "cms"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
dependencies = [
"const-oid",
"der",
"spki",
"x509-cert",
]
[[package]]
name = "cobs"
version = "0.3.0"
@@ -425,6 +525,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -520,6 +626,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -548,7 +663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"rand_core 0.6.4",
"typenum",
]
@@ -571,10 +686,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"fiat-crypto",
"rand_core",
"rand_core 0.6.4",
"rustc_version",
"subtle",
]
@@ -596,6 +711,65 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"der_derive",
"flagset",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "der_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "deranged"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]]
name = "des"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
dependencies = [
"cipher",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -745,6 +919,12 @@ version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -897,6 +1077,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1035,6 +1216,12 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hidapi"
version = "2.6.7"
@@ -1101,6 +1288,15 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-chat-filter"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"toml",
]
[[package]]
name = "horizon-client"
version = "0.1.0"
@@ -1115,8 +1311,10 @@ dependencies = [
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-lib",
"horizon-client-render",
"horizon-command",
"horizon-core",
@@ -1125,6 +1323,7 @@ dependencies = [
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1136,6 +1335,9 @@ dependencies = [
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
@@ -1172,6 +1374,62 @@ dependencies = [
"winit",
]
[[package]]
name = "horizon-client-lib"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"flate2",
"fs4",
"gilrs",
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
"zip",
]
[[package]]
name = "horizon-client-render"
version = "0.1.0"
@@ -1183,6 +1441,7 @@ dependencies = [
"cpal",
"flate2",
"glam",
"half",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
@@ -1191,6 +1450,7 @@ dependencies = [
"horizon-core",
"horizon-hdr",
"horizon-items",
"horizon-narrator",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1208,6 +1468,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"smallvec",
"symphonia",
"unicode-bidi",
"ureq",
@@ -1274,6 +1535,7 @@ dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-world",
"log",
"serde",
"serde_json",
]
@@ -1293,6 +1555,26 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-narrator"
version = "0.1.0"
dependencies = [
"libloading",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"windows 0.62.2",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"regex",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1353,14 +1635,23 @@ dependencies = [
name = "horizon-server"
version = "0.1.0"
dependencies = [
"ab_glyph",
"base64",
"chrono",
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-release-sign",
"horizon-server-files",
"libc",
"p12-keystore",
"png",
"rcgen",
"rustls",
"serde",
"serde_json",
"sha1",
"sha2",
"toml",
"toml_edit",
@@ -1387,6 +1678,7 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
"png",
"serde",
"serde_json",
"sha2",
@@ -1398,8 +1690,10 @@ dependencies = [
name = "horizon-shaders"
version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"naga",
"png",
"zip",
]
@@ -1619,6 +1913,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -1910,6 +2205,12 @@ dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.3.0"
@@ -1945,8 +2246,6 @@ checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "naga"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set",
@@ -2023,6 +2322,16 @@ dependencies = [
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
@@ -2032,6 +2341,22 @@ dependencies = [
"memchr",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -2043,6 +2368,15 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2419,6 +2753,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2480,6 +2823,29 @@ dependencies = [
"ttf-parser",
]
[[package]]
name = "p12-keystore"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2"
dependencies = [
"base64",
"cbc",
"cms",
"der",
"des",
"hex",
"hmac",
"pkcs12",
"pkcs5",
"rand",
"rc2",
"sha1",
"sha2",
"thiserror 2.0.21",
"x509-parser",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2503,6 +2869,25 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2546,6 +2931,36 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs12"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2"
dependencies = [
"cms",
"const-oid",
"der",
"digest",
"spki",
"x509-cert",
"zeroize",
]
[[package]]
name = "pkcs5"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6"
dependencies = [
"aes",
"cbc",
"der",
"pbkdf2",
"scrypt",
"sha2",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -2597,7 +3012,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
@@ -2638,6 +3053,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "pp-rs"
version = "0.2.1"
@@ -2707,6 +3128,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20 0.10.2",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -2716,6 +3148,12 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -2740,6 +3178,28 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rc2"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd"
dependencies = [
"cipher",
]
[[package]]
name = "rcgen"
version = "0.14.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
dependencies = [
"ring",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -2831,6 +3291,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -2907,6 +3376,15 @@ dependencies = [
"twox-hash",
]
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -2928,6 +3406,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"pbkdf2",
"salsa20",
"sha2",
]
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
@@ -3006,7 +3495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3017,7 +3506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3132,7 +3621,7 @@ checksum = "c5482afe85a0b6ce956c945401598dbc527593c77ba51d0a87a586938b1b893a"
dependencies = [
"curve25519-dalek",
"hkdf",
"rand_core",
"rand_core 0.6.4",
"sha2",
]
@@ -3145,6 +3634,16 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3262,6 +3761,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -3322,6 +3832,36 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -3441,7 +3981,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"nom 8.0.0",
"petgraph",
]
@@ -4511,6 +5051,35 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x509-cert"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
dependencies = [
"const-oid",
"der",
"spki",
]
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom 7.1.3",
"oid-registry",
"ring",
"rusticata-macros",
"thiserror 2.0.21",
"time",
]
[[package]]
name = "xcursor"
version = "0.3.11"
@@ -4555,6 +5124,16 @@ version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yasna"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec",
"time",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -4575,7 +5154,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -4616,7 +5195,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+7 -1
View File
@@ -1,12 +1,14 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
"crates/horizon-narrator",
"crates/horizon-threads",
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-lib",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
@@ -14,6 +16,7 @@ members = [
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-chat-filter",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
@@ -28,6 +31,7 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-optifine",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"xtask",
@@ -59,6 +63,8 @@ large_enum_variant = "allow"
# Drop this and the folder once a wgpu release has these fixes.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
# naga 30.0.1, with the fixes to its GLSL frontend that shader packs need (THIRD_PARTY_NOTICES.md).
naga = { path = "third_party/naga" }
# Line tables keep file:line in backtraces; full debug info made every
# example and test binary hundreds of megabytes and slow to link.
+3 -3
View File
@@ -742,13 +742,13 @@ Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayla
</details>
#### AppImage and a Nix flake
#### A Nix flake
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
Install Horizon on NixOS through a Nix flake.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
A player's idea (#horizon-ideas). The launcher's AppImage ships with each launcher release ([packaging/linux/appimage.sh](packaging/linux/appimage.sh)): one file with the libraries GPUI needs, which replaces itself when it updates. A flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA) is still missing, the way horizon-parity's NixOS setup runs them today.
</details>
+11 -4
View File
@@ -8,6 +8,12 @@ developers, with upstream fixes applied
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## naga
`third_party/naga` is [naga](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs developers, with
fixes to its GLSL frontend that shader packs need, each applied in the commit that needed it. It is licensed under MIT or Apache-2.0; both licenses
are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## Pixelify Sans
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
@@ -23,13 +29,14 @@ the same grid.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` is a port to WGSL of the
optical flow shaders of the [AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK)
1.1.4 (`sdk/include/FidelityFX/gpu/opticalflow`, at commit
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` and `interpolation.wgsl` are
ports to WGSL of the optical flow and frame interpolation shaders of the
[AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK) 1.1.4
(`sdk/include/FidelityFX/gpu/{opticalflow,frameinterpolation}`, at commit
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
Inc., under the MIT license, whose text heads both files. AMD and FidelityFX are AMD's
Inc., under the MIT license, whose text heads each file. AMD and FidelityFX are AMD's
trademarks; Horizon is not affiliated with AMD.
## Mod loader logos
File diff suppressed because it is too large. Load diff
@@ -6,5 +6,5 @@ authors = []
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks, items and mob effects to your worlds".
# registries: "May add blocks, items, mob effects and entity types to your worlds".
capabilities = ["registries"]
+86 -13
View File
@@ -8,12 +8,16 @@
//! check, off. A check that fails *flags* the player: its violation level (VL) rises by the flag's
//! weight and falls back by `decay` a second. From `setback` on, the move or action is cancelled (the
//! player is sent back where the server last had them); from `alert` on, online operators and the
//! console are told; from `kick` on, the player is disconnected. Nothing bans. Any threshold at 0 turns
//! that action off.
//! console are told; from `kick` on, the player is disconnected. A check's kicks are counted per player
//! across sessions ([`Strikes`]), and the kick that reaches its `ban_after` bans the player instead
//! (`banned-players.json`, reason "Horizon anticheat: <check>"). Any threshold at 0 turns that action
//! off.
//!
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
//! do, never from another anticheat's code.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub mod combat;
@@ -28,12 +32,14 @@ pub struct Actions {
pub kick: f32,
/// Violation level lost each second.
pub decay: f32,
/// The kick, counting this check's earlier ones for the player, that bans instead; 0: never.
pub ban_after: u32,
}
macro_rules! checks {
($(
$(#[$doc:meta])*
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr] {
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr, $ban_after:expr] {
$( $(#[$xdoc:meta])* $extra:ident: $ty:ty = $default:expr, )*
}
)*) => {
@@ -59,6 +65,7 @@ macro_rules! checks {
pub alert: f32,
pub kick: f32,
pub decay: f32,
pub ban_after: u32,
$( $(#[$xdoc])* pub $extra: $ty, )*
}
@@ -70,6 +77,7 @@ macro_rules! checks {
alert: $alert,
kick: $kick,
decay: $decay,
ban_after: $ban_after,
$($extra: $default,)*
}
}
@@ -87,6 +95,9 @@ macro_rules! checks {
pub alert_level: u8,
/// Every flag, one line each, relative to the server's folder; empty: no file.
pub log: String,
/// The players' kicks by check ([`Strikes`]), relative to the server's folder; empty: kept
/// only while the server runs.
pub strikes: String,
$(pub $field: $Config,)*
}
@@ -97,6 +108,7 @@ macro_rules! checks {
alerts: true,
alert_level: 2,
log: "logs/anticheat.log".into(),
strikes: "anticheat-strikes.json".into(),
$($field: $Config::default(),)*
}
}
@@ -112,7 +124,13 @@ macro_rules! checks {
match check {
$(Check::$Check => {
let c = &self.$field;
Actions { setback: c.setback, alert: c.alert, kick: c.kick, decay: c.decay }
Actions {
setback: c.setback,
alert: c.alert,
kick: c.kick,
decay: c.decay,
ban_after: c.ban_after,
}
})*
}
}
@@ -125,7 +143,7 @@ checks! {
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
/// It logs only by default; once trusted: `setback = 2`, `alert = 5`, `kick = 20`.
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02] {
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02, 0] {
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
tolerance_ms: f64 = 300.0,
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
@@ -135,9 +153,12 @@ checks! {
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
/// the ground to skip fall damage) and walking on water. The claim is dropped, so vanilla's
/// fall damage follows; from `setback` on the player is also sent back. Logging only (`setback = 0`,
/// the default), the claim stands. Once trusted: `setback = 5`, `alert = 15`, `kick = 60`.
ground: Ground => GroundConfig [0.0, 0.0, 0.0, 2.0] {}
/// fall damage follows (the player is never sent back: `setback = 0` makes the check log only), and
/// a player that keeps claiming is kicked, then banned at the second kick (`ban_after = 2`). A claim standing still isn't
/// judged (no fall to skip). On the official server's first two hours (67 players), every player who
/// wasn't flying or walking on water peaked at level 7 (4 claims standing still in one second); the
/// ones who were, at 113 to 3749.
ground: Ground => GroundConfig [10.0, 15.0, 30.0, 2.0, 2] {}
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
@@ -149,7 +170,7 @@ checks! {
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
/// until the moves agree again. It logs only by default; once trusted: `setback = 5`, `alert = 15`,
/// `kick = 60`.
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0] {
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0, 0] {
/// How far, in blocks, a move may land from where the replay puts it.
tolerance: f64 = 0.03,
}
@@ -157,8 +178,9 @@ checks! {
/// Combat: attacks vanilla's client can't send (`combat`): one with no swing after it (a kill aura
/// that hides its swing), or a second entity attacked in the same client tick (vanilla attacks the
/// one entity under the crosshair, picked once a tick). Each flags once; from `setback` on the
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default.
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5] {}
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default: the first
/// kick warns, the second bans (`ban_after = 2`).
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5, 2] {}
}
/// How many checks there are.
@@ -207,6 +229,33 @@ impl Config {
}
}
/// How many times each check kicked each player, kept across sessions (`strikes`, a JSON object of
/// players' UUIDs to check names to counts): the kick that reaches a check's `ban_after` is a ban.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Strikes(pub BTreeMap<String, BTreeMap<String, u32>>);
impl Strikes {
/// Player `uuid` was kicked by `check`: its kicks by it so far, this one included, and whether this
/// one bans. A ban starts the count again, so a player an operator pardons is kicked before the
/// next ban.
pub fn kick(&mut self, uuid: &str, check: Check, ban_after: u32) -> (u32, bool) {
let count = self
.0
.entry(uuid.to_owned())
.or_default()
.entry(check.name().to_owned())
.or_default();
*count = count.saturating_add(1);
let kicks = *count;
let ban = ban_after > 0 && kicks >= ban_after;
if ban {
*count = 0;
}
(kicks, ban)
}
}
/// What a flag leads to.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Verdict {
@@ -277,14 +326,19 @@ mod tests {
}
#[test]
fn on_by_default_but_only_combat_acts() {
fn on_by_default_but_only_combat_and_ground_act() {
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
assert_eq!(c, Config::default());
for &check in Check::ALL {
assert!(c.runs(check), "{}", check.name());
let a = c.actions(check);
let acts = [a.setback, a.alert, a.kick] != [0.0; 3];
assert_eq!(acts, check == Check::Combat, "{}", check.name());
assert_eq!(
acts,
matches!(check, Check::Combat | Check::Ground),
"{}",
check.name()
);
}
let off = Config::from_server_toml("[anticheat]\nenabled = false\n").unwrap();
assert!(Check::ALL.iter().all(|&check| !off.runs(check)));
@@ -336,6 +390,25 @@ mod tests {
assert!(v.kick && v.alert);
}
#[test]
fn the_kick_that_reaches_ban_after_bans() {
let c = Config::default();
assert_eq!(c.actions(Check::Combat).ban_after, 2);
assert_eq!(c.actions(Check::Prediction).ban_after, 0);
let mut s = Strikes::default();
assert_eq!(s.kick("a", Check::Combat, 2), (1, false));
// Another player, another check: their own counts.
assert_eq!(s.kick("b", Check::Combat, 2), (1, false));
assert_eq!(s.kick("a", Check::Timer, 2), (1, false));
assert_eq!(s.kick("a", Check::Combat, 2), (2, true));
// Pardoned, the player is kicked once more before the next ban.
assert_eq!(s.kick("a", Check::Combat, 2), (1, false));
// 0 never bans; 1 bans at the first kick.
assert_eq!(s.kick("c", Check::Timer, 0), (1, false));
assert_eq!(s.kick("c", Check::Timer, 0), (2, false));
assert_eq!(s.kick("d", Check::Ground, 1), (1, true));
}
#[test]
fn zero_turns_an_action_off() {
let mut c = on();
+18 -6
View File
@@ -33,9 +33,21 @@ pub enum Change {
Velocity([f64; 3]),
/// ClientboundExplodePacket's player knockback, added to the velocity.
Knockback([f64; 3]),
/// ClientboundUpdateAttributesPacket or a mob effect for the player itself: the server's
/// attributes and effects, as they are when the replay reaches it.
State,
/// ClientboundUpdateAttributesPacket for the player itself: the server's attributes as they
/// are when the replay reaches it, but for the movement speed's sprint modifier, which the
/// client's sprinting decides: `sprint` is whether the packet had it when it carried the
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
Attributes { sprint: Option<bool> },
/// ClientboundSetEntityDataPacket for the player itself with its shared flags: their sprinting
/// bit, which the client takes as sent (SynchedEntityData.assignValues sets the flag alone, its
/// speed modifier and wasSprinting stay). A client whose sprint the server cleared this way
/// starts it again in its next aiStep, which adds the modifier back, and says nothing.
Sprinting(bool),
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
/// are the server's: they reach the client as attributes.)
Effects,
/// ClientboundPlayerAbilitiesPacket (a game mode, flight given or taken), or the player's chest
/// slot (a glider put on or taken off): the replay takes these from the server every tick, so a
/// tick played before the client had them may be off.
@@ -386,16 +398,16 @@ mod tests {
fn changes_keep_the_server_tick_that_sent_them() {
let mut r = Record::default();
r.changed(3, Change::Velocity([0.0, 0.4, 0.0]));
r.changed(5, Change::State);
r.changed(5, Change::Effects);
let all: Vec<_> = r.changes_since(0).unwrap().copied().collect();
assert_eq!(
all,
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::State)]
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::Effects)]
);
assert_eq!(r.changes_since(1).unwrap().count(), 1);
assert!(r.changes_since(3).is_none());
for i in 0..CHANGES_KEPT as u64 {
r.changed(6 + i, Change::State);
r.changed(6 + i, Change::Effects);
}
// The first two are gone.
assert!(r.changes_since(1).is_none());
+334 -26
View File
@@ -6,6 +6,12 @@
//! and `force-gamemode=true`): they never touch blocks, so they need no world of their own, and no server
//! can refuse their moves for walking into terrain. Every behaviour is deterministic from `--seed`.
//!
//! `--behaviour fight` is the exception: the bots play in survival (`gamemode=survival`, `force-gamemode=true`,
//! and `allow-flight=true` because they hold their spawn height instead of standing on terrain they cannot
//! see). They track the entities the server sends, walk to the nearest living one and attack it at vanilla's
//! bare-hand cooldown (every 5 ticks). With `--pvp true` (the default) the other bots are targets too, which
//! hurts only when the server has `pvp=true`.
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
@@ -23,7 +29,7 @@ use horizon_protocol::{
buf::{Reader, Writer},
connection::{ConnWriter, Connection, Counters},
crypto::offline_uuid,
ids,
entity_data, ids,
item::Stack,
packets::{
Empty, configuration,
@@ -34,7 +40,7 @@ use horizon_protocol::{
};
use serde_json::json;
use std::{
collections::HashSet,
collections::{BTreeMap, HashSet},
sync::{
Arc, LazyLock, Mutex, OnceLock,
atomic::{AtomicBool, AtomicU64, Ordering},
@@ -48,7 +54,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--server HOST:PORT default 127.0.0.1:25565
--bots N default 10
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
--behaviour B idle, walk, explore, fight or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
fight needs gamemode=survival, force-gamemode=true and allow-flight=true.
stand: stays on the ground where the server puts it (/tp), for a survival test.
One abusive client, to test a server's limits (run it beside a normal crowd):
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
@@ -78,6 +85,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--view-distance N what the bots ask for; default 10
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--pvp B fight bots also attack each other (true or false); default true
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--brand B the client brand sent in configuration (minecraft:brand); default none
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
@@ -99,6 +107,7 @@ enum Behaviour {
Stand,
Walk,
Explore,
Fight,
FloodMove,
FloodAcks,
FloodPackets,
@@ -118,6 +127,7 @@ impl Behaviour {
Self::Stand => "stand",
Self::Walk => "walk",
Self::Explore => "explore",
Self::Fight => "fight",
Self::FloodMove => "flood-move",
Self::FloodAcks => "flood-acks",
Self::FloodPackets => "flood-packets",
@@ -142,6 +152,7 @@ struct Options {
view_distance: i8,
seed: u64,
explore_speed: f64,
pvp: bool,
/// The player to attack, and how long after joining to start.
attack: Option<String>,
attack_after: f64,
@@ -176,6 +187,7 @@ fn options() -> Result<Options, String> {
view_distance: 10,
seed: 12345,
explore_speed: 22.0,
pvp: true,
attack: None,
attack_after: 5.0,
no_swing: false,
@@ -231,6 +243,7 @@ fn options() -> Result<Options, String> {
"--explore-speed" => {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
@@ -294,10 +307,11 @@ fn options() -> Result<Options, String> {
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
const BEHAVIOURS: [&str; 15] = [
const BEHAVIOURS: [&str; 16] = [
"idle",
"walk",
"explore",
"fight",
"mixed",
"stand",
"flood-move",
@@ -313,7 +327,7 @@ fn options() -> Result<Options, String> {
];
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
return Err(format!(
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
"--behaviour {}: use idle, walk, explore, fight, mixed or a flood behaviour (--help lists them)",
o.behaviour
));
}
@@ -334,6 +348,10 @@ struct Bot {
pulled_back: AtomicU64,
/// Ticks the bot couldn't move because it didn't hold its chunk yet.
waited: AtomicU64,
/// Attack packets sent (fight bots and --attack).
attacks: AtomicU64,
/// The entities the server tells the bot about (fight bots only).
entities: Mutex<Entities>,
/// The chunks the bot holds (ClientChunkCache).
held: Mutex<HashSet<(i32, i32)>>,
/// Where the server last put the bot (a teleport), to start from.
@@ -346,7 +364,6 @@ struct Bot {
deaf: AtomicBool,
/// `--attack`: each target's UUID and, once AddEntity showed it, its entity id.
target: Mutex<Vec<(u128, Option<i32>)>>,
attacks: AtomicU64,
/// The last SetHealth.
health: Mutex<Option<f32>>,
}
@@ -399,6 +416,142 @@ impl Random {
}
}
/// Vanilla's bare-hand attack speed is 4 a second: a full-strength swing every 20 / 4 ticks.
const ATTACK_COOLDOWN_TICKS: u32 = 5;
/// How far, in blocks, a fight bot strikes (the survival entity interaction range is 3).
const ATTACK_REACH: f64 = 2.5;
/// A fight bot walks up to this horizontal distance, so it does not stand inside its target.
const CLOSE_IN: f64 = 1.5;
/// Walking speed in blocks a tick.
const FIGHT_STEP: f64 = 0.215;
/// `LivingEntity.DATA_HEALTH_ID`: only living entities carry it, and it is sent once it differs from 1.
const HEALTH_DATA: u8 = 9;
/// What a fight bot knows of one entity.
#[derive(Clone, Debug, PartialEq)]
struct Tracked {
uuid: u128,
pos: [f64; 3],
/// Set once the server sends the health of the entity, which only living entities have.
health: Option<f32>,
}
/// The entities around a bot, from AddEntity, the move packets, SetEntityData and RemoveEntities.
/// Ordered by id, so ties between equally near targets always go to the same entity.
#[derive(Default)]
struct Entities(BTreeMap<i32, Tracked>);
impl Entities {
fn update(&mut self, id: i32, body: &[u8]) {
let Ok(packet) = play::Clientbound::decode(id, body) else {
return;
};
match packet {
play::Clientbound::AddEntity(p) => {
self.0.insert(
p.id,
Tracked {
uuid: p.uuid,
pos: p.position,
health: None,
},
);
}
play::Clientbound::MoveEntityPos(play::MoveEntityPos(m))
| play::Clientbound::MoveEntityPosRot(play::MoveEntityPosRot(m)) => {
if let (Some(e), Some(delta)) = (self.0.get_mut(&m.id), &m.delta) {
e.pos = delta.end(e.pos);
}
}
play::Clientbound::EntityPositionSync(p) => {
if let Some(e) = self.0.get_mut(&p.id) {
e.pos = p.path.end();
}
}
play::Clientbound::SetEntityData(p) => {
if let Some(e) = self.0.get_mut(&p.id) {
for (index, _, value) in &p.values {
if *index == HEALTH_DATA
&& let entity_data::Value::Float(health) = value
{
e.health = Some(*health);
}
}
}
}
play::Clientbound::RemoveEntities(p) => {
for id in p.ids {
self.0.remove(&id);
}
}
_ => {}
}
}
/// The nearest living entity that is not dead; other bots only when `pvp`.
fn nearest(&self, from: [f64; 3], pvp: bool, bots: &HashSet<u128>) -> Option<(i32, [f64; 3])> {
let mut best: Option<(f64, i32, [f64; 3])> = None;
for (id, e) in &self.0 {
if !e.health.is_some_and(|h| h > 0.0) || (!pvp && bots.contains(&e.uuid)) {
continue;
}
let d = distance(from, e.pos);
if best.is_none_or(|(b, ..)| d < b) {
best = Some((d, *id, e.pos));
}
}
best.map(|(_, id, pos)| (id, pos))
}
}
fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over.
/// Returns the entity to attack. `since_attack` counts ticks since the last swing.
fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option<i32> {
*since_attack = since_attack.saturating_add(1);
let (id, at) = target;
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
let horizontal = dx.hypot(dz);
if horizontal > CLOSE_IN {
let step = FIGHT_STEP.min(horizontal - CLOSE_IN);
pos[0] += dx / horizontal * step;
pos[2] += dz / horizontal * step;
}
if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second).
fn walk_step(
pos: &mut [f64; 3],
target: &mut [f64; 3],
spawn: [f64; 3],
cruise: f64,
random: &mut Random,
) {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
if distance < 1.0 {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 8.0 + random.unit() * 40.0;
*target = [
spawn[0] + angle.cos() * reach,
cruise,
spawn[2] + angle.sin() * reach,
];
} else {
let step = 0.28_f64.min(distance);
pos[0] += dx / distance * step;
pos[2] += dz / distance * step;
}
}
/// One bot thread that panics poisons the locks it held; the others and the report go on.
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
@@ -654,6 +807,7 @@ fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -716,6 +870,9 @@ fn read(
}
}
}
if track {
lock(&bot.entities).update(id, &body);
}
Ok(())
}
cb::SET_HEALTH => {
@@ -768,6 +925,16 @@ fn read(
}
_ => Ok(()),
},
cb::MOVE_ENTITY_POS
| cb::MOVE_ENTITY_POS_ROT
| cb::ENTITY_POSITION_SYNC
| cb::SET_ENTITY_DATA
| cb::REMOVE_ENTITIES
if track =>
{
lock(&bot.entities).update(id, &body);
Ok(())
}
cb::DISCONNECT | cb::START_CONFIGURATION => {
bot.online.store(false, Ordering::SeqCst);
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
@@ -797,6 +964,7 @@ fn run_bot(
o: Arc<Options>,
index: usize,
behaviour: Behaviour,
bots: Arc<HashSet<u128>>,
bot: Arc<Bot>,
until: Arc<Mutex<Option<Instant>>>,
) {
@@ -821,7 +989,8 @@ fn run_bot(
bot.online.store(true, Ordering::SeqCst);
{
let (writer, bot) = (writer.clone(), bot.clone());
thread::spawn(move || read(reader, writer, bot));
let track = behaviour == Behaviour::Fight;
thread::spawn(move || read(reader, writer, bot, track));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -872,7 +1041,13 @@ fn run_bot(
let mut sequence = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
let cruise = spawn[1] + o.height;
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
let cruise = if behaviour == Behaviour::Fight {
spawn[1]
} else {
spawn[1] + o.height
};
let mut since_attack = ATTACK_COOLDOWN_TICKS;
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
@@ -926,22 +1101,22 @@ fn run_bot(
} else {
match behaviour {
Behaviour::Idle | Behaviour::Stand => {}
Behaviour::Walk => {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
if distance < 1.0 {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 8.0 + random.unit() * 40.0;
target = [
spawn[0] + angle.cos() * reach,
cruise,
spawn[2] + angle.sin() * reach,
];
Behaviour::Walk => walk_step(&mut pos, &mut target, spawn, cruise, &mut random),
Behaviour::Fight => {
let near = lock(&bot.entities).nearest(pos, o.pvp, &bots);
if let Some(near) = near {
if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) {
// The client sends the attack, then swings the arm.
let sent = send(
play::Serverbound::Attack(play::Attack { id: victim }).encode(),
) && send(raw(sb::PUNCH, |w| w.varint(0)));
if sent {
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
}
} else {
// Sprinting: 5.6 blocks a second.
let step = 0.28_f64.min(distance);
pos[0] += dx / distance * step;
pos[2] += dz / distance * step;
since_attack = since_attack.saturating_add(1);
walk_step(&mut pos, &mut target, spawn, cruise, &mut random);
}
}
Behaviour::FloodMove
@@ -1044,7 +1219,10 @@ fn run_bot(
x: pos[0],
y: pos[1],
z: pos[2],
flags: u8::from(behaviour == Behaviour::Stand || o.ground),
// Fight bots stand on the ground; the others fly.
flags: u8::from(
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
),
})
.encode(),
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
@@ -1071,6 +1249,7 @@ fn behaviour_of(mode: &str, index: usize) -> Behaviour {
"stand" => Behaviour::Stand,
"walk" => Behaviour::Walk,
"explore" => Behaviour::Explore,
"fight" => Behaviour::Fight,
"flood-move" => Behaviour::FloodMove,
"flood-acks" => Behaviour::FloodAcks,
"flood-packets" => Behaviour::FloodPackets,
@@ -1101,14 +1280,23 @@ fn main() {
let start = Instant::now();
let until: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
let bots: Vec<Arc<Bot>> = (0..o.bots).map(|_| Arc::new(Bot::default())).collect();
// The uuids of all bots, so that `--pvp false` leaves them alone.
let bot_uuids: Arc<HashSet<u128>> = Arc::new(
(0..o.bots)
.map(|i| offline_uuid(&format!("{}{i}", o.name)))
.collect(),
);
let launcher = {
let (o, bots, until) = (o.clone(), bots.clone(), until.clone());
let bot_uuids = bot_uuids.clone();
thread::spawn(move || {
let mut handles = Vec::new();
for (i, bot) in bots.iter().enumerate() {
let (o2, bot, until) = (o.clone(), bot.clone(), until.clone());
let behaviour = behaviour_of(&o.behaviour, i);
handles.push(thread::spawn(move || run_bot(o2, i, behaviour, bot, until)));
let (behaviour, uuids) = (behaviour_of(&o.behaviour, i), bot_uuids.clone());
handles.push(thread::spawn(move || {
run_bot(o2, i, behaviour, uuids, bot, until);
}));
thread::sleep(Duration::from_secs_f64(o.join_every));
}
handles
@@ -1145,6 +1333,7 @@ fn main() {
"resent": total(|b| &b.resent),
"pulled_back": total(|b| &b.pulled_back),
"waited_ticks": total(|b| &b.waited),
"attacks": total(|b| &b.attacks),
})
);
previous_chunks = chunks;
@@ -1164,6 +1353,7 @@ fn main() {
"behaviour": o.behaviour,
"seed": o.seed,
"explore_speed": o.explore_speed,
"pvp": o.pvp,
"view_distance": o.view_distance,
"seconds": o.seconds,
"joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(),
@@ -1171,6 +1361,7 @@ fn main() {
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
"attacks": bots.iter().map(|b| b.attacks.load(Ordering::Relaxed)).sum::<u64>(),
"bytes_in": bots.iter().map(|b| b.bytes_in()).sum::<u64>(),
"bytes_out": bots.iter().map(|b| b.bytes_out()).sum::<u64>(),
"join_time": join_summary(
@@ -1223,6 +1414,123 @@ fn main() {
#[cfg(test)]
mod tests {
use super::*;
use play::{
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
};
fn feed(entities: &mut Entities, packet: &Clientbound) {
let bytes = packet.encode();
let mut r = Reader::new(&bytes);
let id = r.varint().unwrap();
entities.update(id, &bytes[bytes.len() - r.remaining()..]);
}
fn add(id: i32, uuid: u128, pos: [f64; 3]) -> Clientbound {
Clientbound::AddEntity(AddEntity {
id,
uuid,
kind: 1,
position: pos,
velocity: [0.0; 3],
pitch: 0,
yaw: 0,
head_yaw: 0,
data: 0,
})
}
fn health(id: i32, health: f32) -> Clientbound {
Clientbound::SetEntityData(SetEntityData {
id,
values: vec![(HEALTH_DATA, 3, entity_data::Value::Float(health))],
})
}
#[test]
fn tracks_living_entities_only() {
let mut e = Entities::default();
feed(&mut e, &add(1, 10, [3.0, 64.0, 0.0]));
feed(&mut e, &add(2, 11, [5.0, 64.0, 0.0]));
feed(&mut e, &health(2, 20.0));
// Entity 1 never sent a health (an item, an arrow), so it is no target.
assert_eq!(
e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()),
Some((2, [5.0, 64.0, 0.0]))
);
feed(&mut e, &health(2, 0.0));
assert_eq!(e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()), None);
feed(&mut e, &health(2, 4.0));
feed(
&mut e,
&Clientbound::RemoveEntities(RemoveEntities { ids: vec![2] }),
);
assert_eq!(e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()), None);
}
#[test]
fn moves_follow_the_deltas() {
let mut e = Entities::default();
feed(&mut e, &add(7, 1, [0.0, 64.0, 0.0]));
feed(&mut e, &health(7, 20.0));
feed(
&mut e,
&Clientbound::MoveEntityPos(MoveEntityPos(MoveEntity {
id: 7,
delta: Some(VecDelta::Linear([4096, 0, -8192])),
rotation: None,
on_ground: true,
})),
);
assert_eq!(e.0[&7].pos, [1.0, 64.0, -2.0]);
}
#[test]
fn pvp_false_spares_the_bots_and_ties_go_to_the_lowest_id() {
let mut e = Entities::default();
feed(&mut e, &add(9, 100, [2.0, 64.0, 0.0]));
feed(&mut e, &add(4, 101, [-2.0, 64.0, 0.0]));
feed(&mut e, &add(5, 102, [0.0, 64.0, 6.0]));
for id in [9, 4, 5] {
feed(&mut e, &health(id, 20.0));
}
let bots = HashSet::from([100, 101]);
let from = [0.0, 64.0, 0.0];
assert_eq!(e.nearest(from, true, &bots).map(|t| t.0), Some(4));
assert_eq!(e.nearest(from, false, &bots).map(|t| t.0), Some(5));
}
#[test]
fn attacks_at_the_vanilla_cooldown() {
let mut pos = [0.0, 64.0, 0.0];
let target = (3, [1.0, 64.0, 0.0]);
let mut since = ATTACK_COOLDOWN_TICKS;
let hits: Vec<u32> = (0..40)
.filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick))
.collect();
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
assert!(
hits.windows(2)
.all(|w| w[1] - w[0] == ATTACK_COOLDOWN_TICKS)
);
assert_eq!(hits.len(), 8);
}
#[test]
fn walks_into_reach_before_striking() {
let mut pos = [0.0, 64.0, 0.0];
let target = (3, [10.0, 64.0, 0.0]);
let mut since = ATTACK_COOLDOWN_TICKS;
let mut first = None;
for tick in 0..100 {
if fight_tick(&mut pos, target, &mut since).is_some() {
first = Some(tick);
break;
}
}
assert!(first.is_some_and(|t| t > 30));
assert!((target.1[0] - pos[0]).abs() <= ATTACK_REACH);
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
}
#[test]
fn percentiles_use_nearest_rank() {
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "horizon-chat-filter"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.88"
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
[lints]
workspace = true
+63
View File
@@ -0,0 +1,63 @@
# Horizon's chat filter: the hate slurs a server refuses in chat, private messages, /say, /me, team
# messages, signs, anvil names and book titles (docs/chat-filter.md). This is a moderation list: the
# words are written here only so the filter can recognise them, because players spammed them on the
# official server (2026-10-31). Add a word when players find a way around the list; keep insults that
# aren't slurs out of it (moderators handle those).
#
# One entry a line, matched after normalising the text: lower case, accents and look-alike letters
# (Cyrillic, Greek, full-width, small capitals...), leetspeak (1 i, 3 e, 4 a, 0 o, @ a, $ s, 9 g),
# separators between letters (n.i.g, n_i_g) and repeated letters. A doubled letter in an entry must be
# doubled in the text: "niger", the river and the country, isn't "nigger".
# word matches inside a longer word too
# =word only as a whole word ("spic", not "suspicious")
# ~word also when it's spread over several words ("nig ger", "n i g g e r")
# !word never a match: a word that contains an entry ("snigger"); !word* any word starting so
~nigger
~nigga
nigguh
niggah
niqqa
niqqer
nlgger
nlgga
kneegrow
=nig
=nigs
=niglet
=niglets
=nibba
=nibbas
=nibber
=nibbers
jigaboo
porchmonkey
=coon
=coons
~faggot
=fag
=fags
=faggy
=fagot
=fagots
=tranny
=trannies
=kike
=kikes
=chink
=chinks
=gook
=gooks
=spic
=spics
wetback
=beaner
=beaners
raghead
towelhead
zipperhead
=paki
=pakis
!snigger*
!niggard*
+472
View File
@@ -0,0 +1,472 @@
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
//! player's text and carries out what it decides.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
mod normalise;
pub use normalise::{Run, Text};
/// The list built into the game, used when `words_file` is empty.
pub const DEFAULT_WORDS: &str = include_str!("../data/slurs.txt");
/// The mutes, beside server.properties.
pub const MUTES_FILE: &str = "chat-filter-mutes.json";
/// `server.toml`'s `[chat_filter]`: on by default.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub enabled: bool,
/// The hit, counted within `window_minutes`, that mutes the player; 0: never.
pub mute_after: u32,
/// The hit that bans the player for `tempban_hours`; 0: never.
pub tempban_after: u32,
/// How long a hit counts towards the next ones (the time spent muted doesn't count).
pub window_minutes: u32,
pub mute_minutes: u32,
pub tempban_hours: u32,
/// The slur list, relative to the server's folder; empty: the list built into the game.
pub words_file: String,
/// Every refused text with the player and what followed, relative to the server's folder.
pub log: String,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
mute_after: 2,
tempban_after: 3,
window_minutes: 10,
mute_minutes: 10,
tempban_hours: 24,
words_file: String::new(),
log: "logs/chat-filter.log".into(),
}
}
}
impl Config {
/// The `[chat_filter]` section of a whole `server.toml`; the default without it.
pub fn from_server_toml(text: &str) -> Result<Self, String> {
#[derive(Deserialize)]
struct File {
#[serde(default)]
chat_filter: Config,
}
// Other sections are the supervisor's to check: only this one is read here.
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let mut only = toml::Table::new();
if let Some(section) = table.remove("chat_filter") {
only.insert("chat_filter".into(), section);
}
let file: File = only
.try_into()
.map_err(|e: toml::de::Error| format!("[chat_filter]: {e}"))?;
file.chat_filter.check()?;
Ok(file.chat_filter)
}
pub fn check(&self) -> Result<(), String> {
if self.window_minutes == 0 {
return Err("chat_filter.window_minutes must be 1 or more".into());
}
Ok(())
}
}
/// How an entry of the list matches.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
/// Inside a longer word too.
Embedded,
/// Only as a whole word.
Whole,
/// Inside a word, or spread over several words from the start of one.
Spread,
}
#[derive(Debug)]
struct Entry {
runs: Vec<Run>,
kind: Kind,
}
/// The slur list, compiled: each entry's letters as runs, indexed by first letter.
#[derive(Debug)]
pub struct Filter {
entries: Vec<Entry>,
by_first: [Vec<u16>; 27],
/// Words never matched: exact (`false`) or as a prefix (`true`).
allowed: Vec<(Vec<Run>, bool)>,
}
impl Filter {
/// Compiles a list in `data/slurs.txt`'s format.
pub fn new(list: &str) -> Self {
let mut f = Filter {
entries: Vec::new(),
by_first: Default::default(),
allowed: Vec::new(),
};
for line in list.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(word) = line.strip_prefix('!') {
let (word, prefix) = match word.strip_suffix('*') {
Some(w) => (w, true),
None => (word, false),
};
f.allowed.push((normalise::word(word), prefix));
continue;
}
let (kind, word) = match line.as_bytes()[0] {
b'=' => (Kind::Whole, &line[1..]),
b'~' => (Kind::Spread, &line[1..]),
_ => (Kind::Embedded, line),
};
let runs = normalise::word(word);
let Some(first) = runs.first() else {
continue;
};
f.by_first[usize::from(first.letter)].push(f.entries.len() as u16);
f.entries.push(Entry { runs, kind });
}
f
}
/// The built-in list.
pub fn default_list() -> Self {
Self::new(DEFAULT_WORDS)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Whether `text` holds a slur of the list.
pub fn matches(&self, text: &str) -> bool {
let t = Text::new(text);
let runs = &t.runs;
for &(start, end) in t.words.iter().chain(&t.spaced) {
let word = &runs[start..end];
if self.is_allowed(word) {
continue;
}
for at in 0..word.len() {
for &e in &self.by_first[usize::from(word[at].letter)] {
let e = &self.entries[usize::from(e)];
let whole = e.kind == Kind::Whole;
if (!whole || at == 0) && fits(&e.runs, &word[at..], whole) {
return true;
}
}
}
}
// Spread entries over the whole text, from the start of a word, across at least two.
for (w, &at) in t.joined_at.iter().enumerate() {
for &e in &self.by_first[usize::from(t.joined[at].letter)] {
let e = &self.entries[usize::from(e)];
if e.kind == Kind::Spread
&& fits(&e.runs, &t.joined[at..], false)
&& t.spans_words(w, e.runs.len())
{
return true;
}
}
}
false
}
fn is_allowed(&self, word: &[Run]) -> bool {
self.allowed.iter().any(|(a, prefix)| {
if *prefix {
// A prefix: its last run may be part of a longer one.
word.len() >= a.len() && fits(a, word, false)
} else {
fits(a, word, true)
}
})
}
}
/// Whether `entry`'s runs start `runs` (are all of it with `whole`): the same letters, each at least
/// as many times as the entry has it.
fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
if runs.len() < entry.len() || (whole && runs.len() != entry.len()) {
return false;
}
entry
.iter()
.zip(runs)
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
}
/// What a hit leads to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// The text is refused, nothing more.
Refused,
/// Refused, and the player can't chat until then (epoch milliseconds).
Muted { until: i64 },
/// Refused, and the player is banned until then.
Banned { until: i64 },
}
impl Action {
pub fn name(self) -> &'static str {
match self {
Action::Refused => "refused",
Action::Muted { .. } => "muted",
Action::Banned { .. } => "banned",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds.
pub until: i64,
}
#[derive(Default, Serialize, Deserialize)]
struct MutesFile {
mutes: Vec<Mute>,
}
/// One player's hits: how many, and when the last one stops counting.
#[derive(Clone, Copy, Debug)]
struct Strikes {
count: u32,
counts_until: i64,
}
/// The filter, the players' hits and the mutes.
pub struct Guard {
pub config: Config,
pub filter: Filter,
strikes: HashMap<u128, Strikes>,
mutes: HashMap<u128, Mute>,
/// Where the mutes are kept; `None` keeps them in memory only (tests).
file: Option<PathBuf>,
}
const MINUTE: i64 = 60_000;
impl Guard {
pub fn new(config: Config, filter: Filter, file: Option<PathBuf>) -> Self {
let mut mutes = HashMap::new();
if let Some(text) = file.as_ref().and_then(|f| std::fs::read_to_string(f).ok())
&& let Ok(saved) = serde_json::from_str::<MutesFile>(&text)
{
for m in saved.mutes {
if let Some(uuid) = parse_uuid(&m.uuid) {
mutes.insert(uuid, m);
}
}
}
Self {
config,
filter,
strikes: HashMap::new(),
mutes,
file,
}
}
/// The server's guard: `words_file` or the built-in list, the mutes in `dir`. An unreadable
/// `words_file` falls back to the built-in list, with why.
pub fn load(config: Config, dir: &Path) -> (Self, Option<String>) {
let mut problem = None;
let filter = if config.words_file.is_empty() {
Filter::default_list()
} else {
match std::fs::read_to_string(dir.join(&config.words_file)) {
Ok(text) => Filter::new(&text),
Err(e) => {
problem = Some(format!(
"chat_filter.words_file {}: {e}; using the built-in list",
config.words_file
));
Filter::default_list()
}
}
};
(
Self::new(config, filter, Some(dir.join(MUTES_FILE))),
problem,
)
}
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
}
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
pub fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
let c = &self.config;
let window = i64::from(c.window_minutes) * MINUTE;
let s = self.strikes.entry(uuid).or_insert(Strikes {
count: 0,
counts_until: 0,
});
if now > s.counts_until {
s.count = 0;
}
s.count += 1;
s.counts_until = now + window;
let count = s.count;
let action = if c.tempban_after > 0 && count >= c.tempban_after {
self.strikes.remove(&uuid);
Action::Banned {
until: now + i64::from(c.tempban_hours) * 60 * MINUTE,
}
} else if c.mute_after > 0 && count >= c.mute_after {
let until = now + i64::from(c.mute_minutes) * MINUTE;
// The window starts again when the mute ends.
s.counts_until = until + window;
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
self.save();
Action::Muted { until }
} else {
Action::Refused
};
(count, action)
}
/// Lifts a player's mute (and forgets their hits), by name; the name as muted, if they were.
pub fn unmute(&mut self, name: &str) -> Option<String> {
let uuid = *self
.mutes
.iter()
.find(|(_, m)| m.name.eq_ignore_ascii_case(name))?
.0;
let mute = self.mutes.remove(&uuid)?;
self.strikes.remove(&uuid);
self.save();
Some(mute.name)
}
fn save(&mut self) {
let Some(file) = &self.file else {
return;
};
let now = now_millis();
self.mutes.retain(|_, m| m.until > now);
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
let tmp = file.with_extension("json.tmp");
if std::fs::write(&tmp, text).is_ok() {
let _ = std::fs::rename(&tmp, file);
}
}
}
}
/// One line of `logs/chat-filter.log`, without its newline.
pub fn log_line(
now: i64,
name: &str,
uuid: u128,
place: &str,
count: u32,
action: Action,
text: &str,
) -> String {
let text: String = text
.chars()
.map(|c| if c == '\n' { ' ' } else { c })
.collect();
format!(
"{} {name} {} {place} hit {count} {}: {text}",
utc(now),
uuid_string(uuid),
action.name()
)
}
/// Appends a line to the log at `path` (its folder made if needed).
pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
use std::io::Write;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(f, "{line}")
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
pub fn utc(millis: i64) -> String {
let secs = millis.div_euclid(1000);
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
rest / 3600,
rest / 60 % 60,
rest % 60
)
}
fn uuid_string(u: u128) -> String {
let h = format!("{u:032x}");
format!(
"{}-{}-{}-{}-{}",
&h[..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
fn parse_uuid(s: &str) -> Option<u128> {
u128::from_str_radix(&s.replace('-', ""), 16).ok()
}
#[cfg(test)]
mod tests;
+255
View File
@@ -0,0 +1,255 @@
//! A text as the filter sees it: words of letters `a`..`z`, each letter with how many times it repeats.
//! Case, accents, look-alike letters (Cyrillic, Greek, full-width, small capitals, styled mathematical
//! letters, circled and squared letters), leetspeak and invisible characters are folded away;
//! separators inside a word are dropped (`n.i.g` is `nig`), and whitespace ends a word.
/// The letter code of anything that is a letter but none of `a`..`z`: it never matches.
const OTHER: u8 = 26;
/// One letter and how many times it repeats (`gg` is `g` twice).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Run {
/// 0..=25 for `a`..`z`, 26 for any other letter.
pub letter: u8,
pub count: u8,
/// Every one of its characters was punctuation read as a letter (`!` as `i`, `@` as `a`).
pub weak: bool,
}
/// A whole message, normalised.
#[derive(Debug, Default)]
pub struct Text {
pub runs: Vec<Run>,
/// Each word's runs, `runs[start..end]`.
pub words: Vec<(usize, usize)>,
/// More ranges of `runs` to match as words: letters spaced out (`n i g g a`), joined, and words
/// without the punctuation they end with (`fag!`).
pub spaced: Vec<(usize, usize)>,
/// Every word one after the other, a letter that ends one and starts the next merged.
pub joined: Vec<Run>,
/// Where each word starts in `joined`.
pub joined_at: Vec<usize>,
/// The last word each run of `joined` belongs to.
joined_last: Vec<usize>,
}
enum Fold {
Letter(u8, bool),
Separator,
Space,
Skip,
}
impl Text {
pub fn new(text: &str) -> Self {
let mut t = Text::default();
let mut start = 0;
for c in text.chars() {
match fold(c) {
Fold::Letter(letter, weak) => {
let in_word = t.runs.len() > start;
match t.runs.last_mut() {
Some(last) if in_word && last.letter == letter => {
last.count = last.count.saturating_add(1);
last.weak &= weak;
}
_ => t.runs.push(Run {
letter,
count: 1,
weak,
}),
}
}
Fold::Space => {
t.end_word(start);
start = t.runs.len();
}
Fold::Separator | Fold::Skip => {}
}
}
t.end_word(start);
t.add_spaced();
t.join();
t
}
fn end_word(&mut self, start: usize) {
if self.runs.len() > start {
self.words.push((start, self.runs.len()));
}
}
/// Spaced-out letters and words without their trailing punctuation, as extra words (their runs
/// copied after the real words').
fn add_spaced(&mut self) {
let words = self.words.len();
let mut w = 0;
while w < words {
let mut end = w;
while end < words && self.words[end].1 - self.words[end].0 == 1 {
end += 1;
}
if end - w >= 2 {
let first = self.runs.len();
for k in w..end {
let run = self.runs[self.words[k].0];
let in_word = self.runs.len() > first;
match self.runs.last_mut() {
Some(last) if in_word && last.letter == run.letter => {
last.count = last.count.saturating_add(run.count);
}
_ => self.runs.push(run),
}
}
self.spaced.push((first, self.runs.len()));
w = end;
} else {
w += 1;
}
}
for k in 0..words {
let (start, mut end) = self.words[k];
while end > start + 1 && self.runs[end - 1].weak {
end -= 1;
}
if end < self.words[k].1 {
self.spaced.push((start, end));
}
}
}
fn join(&mut self) {
for (w, &(start, end)) in self.words.iter().enumerate() {
let mut first = true;
for run in &self.runs[start..end] {
match self.joined.last_mut() {
Some(last) if first && last.letter == run.letter => {
last.count = last.count.saturating_add(run.count);
self.joined_at.push(self.joined.len() - 1);
*self.joined_last.last_mut().expect("beside joined") = w;
}
_ => {
if first {
self.joined_at.push(self.joined.len());
}
self.joined.push(*run);
self.joined_last.push(w);
}
}
first = false;
}
}
}
/// Whether `len` runs of `joined` from word `w`'s start reach into a later word.
pub fn spans_words(&self, w: usize, len: usize) -> bool {
self.joined_last
.get(self.joined_at[w] + len - 1)
.is_some_and(|&last| last > w)
}
}
/// An entry of the list, normalised the same way (one word, whitespace ignored).
pub fn word(text: &str) -> Vec<Run> {
let t = Text::new(&text.replace(char::is_whitespace, ""));
t.words
.first()
.map_or_else(Vec::new, |&(s, e)| t.runs[s..e].to_vec())
}
fn letter(c: char) -> Fold {
Fold::Letter(c as u8 - b'a', false)
}
fn fold(c: char) -> Fold {
if c.is_ascii_alphabetic() {
return letter(c.to_ascii_lowercase());
}
if c.is_ascii() {
return match c {
'0' => letter('o'),
'1' => letter('i'),
'3' => letter('e'),
'4' => letter('a'),
'5' => letter('s'),
'6' | '9' => letter('g'),
'7' => letter('t'),
'8' => letter('b'),
'!' | '|' => Fold::Letter(b'i' - b'a', true),
'@' => Fold::Letter(0, true),
'$' => Fold::Letter(b's' - b'a', true),
c if c.is_ascii_whitespace() => Fold::Space,
_ => Fold::Separator,
};
}
let u = c as u32;
let from = |base: u32, span: u32| char::from(b'a' + ((u - base) % span) as u8);
let mapped = match u {
// Zero-width characters, soft hyphen, combining marks, variation selectors.
0xAD | 0x34F | 0x200B..=0x200F | 0x2060..=0x2064 | 0xFEFF | 0x180E => return Fold::Skip,
0x300..=0x36F
| 0x1AB0..=0x1AFF
| 0x1DC0..=0x1DFF
| 0x20D0..=0x20FF
| 0xFE00..=0xFE0F
| 0xFE20..=0xFE2F
| 0xE0100..=0xE01EF => return Fold::Skip,
// Full-width Latin and digits.
0xFF21..=0xFF3A => from(0xFF21, 26),
0xFF41..=0xFF5A => from(0xFF41, 26),
0xFF10..=0xFF19 => return fold(char::from(b'0' + (u - 0xFF10) as u8)),
// Mathematical alphanumerics: 52 letters a style, then digits.
0x1D400..=0x1D6A3 => from(0x1D400, 26),
0x1D7CE..=0x1D7FF => return fold(char::from(b'0' + ((u - 0x1D7CE) % 10) as u8)),
// Circled, parenthesised, squared and negative letters, regional indicators.
0x24B6..=0x24CF => from(0x24B6, 26),
0x24D0..=0x24E9 => from(0x24D0, 26),
0x249C..=0x24B5 => from(0x249C, 26),
0x1F130..=0x1F149 => from(0x1F130, 26),
0x1F150..=0x1F169 => from(0x1F150, 26),
0x1F170..=0x1F189 => from(0x1F170, 26),
0x1F1E6..=0x1F1FF => from(0x1F1E6, 26),
_ => {
if c.is_whitespace() {
return Fold::Space;
}
let lower = c.to_lowercase().next().unwrap_or(c);
match LOOK_ALIKES.iter().find(|(_, chars)| chars.contains(lower)) {
Some((base, _)) => *base,
None if c.is_alphabetic() => return Fold::Letter(OTHER, false),
None => return Fold::Separator,
}
}
};
letter(mapped)
}
/// Letters with accents and letters that look like a Latin one, by the letter they read as (lower case).
const LOOK_ALIKES: &[(char, &str)] = &[
('a', "àáâãäåāăąǎǟǡǻȁȃȧạảấầẩẫậắằẳẵặⱥɐаαᴀᵃª"),
('b', "ƀɓḃḅḇвьъβʙᵇ"),
('c', "çćĉċčƈḉȼсϲᴄᶜ"),
('d', "ďđɗḋḍḏḑḓԁδᴅᵈ"),
('e', "èéêëēĕėęěȅȇȩẹẻẽếềểễệḕḗḙḛḝɇеёєεᴇᵉ€"),
('f', "ƒḟꜰᶠ"),
('g', "ĝğġģǥǧǵɠḡɡɢᵍ"),
('h', "ĥħȟḣḥḧḩḫẖнһʜʰ"),
('i', "ìíîïĩīĭįıǐȉȋḭḯỉịіїιɪⁱ¡ӏ"),
('j', "ĵǰјᴊʲ"),
('k', "ķĸǩḱḳḵкκᴋᵏ"),
('l', "ĺļľŀłḷḹḻḽʟˡ"),
('m', "мᴍᵐ"),
('n', "ñńņňʼnŋǹṅṇṉṋиипηπɴⁿ"),
('o', "òóôõöøōŏőơǒǫǭǿȍȏȫȭȯȱọỏốồổỗộớờởỡợоοσᴏᵒº"),
('p', "ṕṗрρᴘᵖ"),
('q', "ԛ"),
('r', "ŕŗřȑȓṙṛṝṟгʀʳ"),
('s', "śŝşšșṡṣṥṧṩѕꜱˢß"),
('t', "ţťŧțṫṭṯṱẗтτᴛᵗ"),
('u', "ùúûüũūŭůűųưǔǖǘǚǜȕȗụủứừửữựυᴜᵘ"),
('v', "νᴠᵛ"),
('w', "ŵẁẃẅẇẉẘԝωᴡʷ"),
('x', "хχˣ"),
('y', "ýÿŷȳẏẙỳỵỷỹуүγʏʸ"),
('z', "źżžƶẑẓẕᴢᶻ"),
];
+253
View File
@@ -0,0 +1,253 @@
// The test texts spell the slurs the filter must refuse: that is what they test.
use super::*;
fn filter() -> Filter {
Filter::default_list()
}
#[test]
fn the_list_compiles() {
let f = filter();
assert!(f.len() > 30, "{} entries", f.len());
assert_eq!(f.allowed.len(), 2);
}
#[test]
fn normalising_catches_the_usual_disguises() {
let f = filter();
for text in [
"nigger",
"NIGGER",
"shut up nigga",
"Niggers",
"n1gg3r",
"ni99a",
"N!GGA",
"nigg@",
"n.i.g.g.e.r",
"n_i_g_g_a",
"n i g g e r",
"n i gg a",
"nig ger",
"nig gaaa",
"niiiiggggggerrrrr",
"ñíggér",
"nigger",
"Niggeг", // Cyrillic ghe for r
"nіgger", // Cyrillic i
"nigg\u{0435}r", // Cyrillic e
"n\u{200B}igger", // a zero-width space
"n\u{0301}igger", // a combining accent
"𝐧𝐢𝐠𝐠𝐞𝐫",
"ⓝⓘⓖⓖⓐ",
"ɴɪɢɢᴀ",
"hi nig",
"f a g",
"fag!",
"you $pic",
"FAGGOTINO-CHAN",
"sandnigger",
"lol kike",
] {
assert!(f.matches(text), "{text:?} should be refused");
}
}
#[test]
fn ordinary_words_pass() {
let f = filter();
for text in [
"hello",
"Niger",
"I live in Niger",
"Nigeria and the Niger river",
"nigerian food",
"proud2benigerian",
"don't snigger",
"sniggering at that",
"a niggardly offer",
"knight",
"qKnights",
"its still night",
"anyone able to boot the latest nightly version?",
"i hate nighbours",
"can someune give mi night vision",
"suspicious stew",
"spicy food",
"a raccoon in a cocoon",
"Scunthorpe",
"bigger trigger digger",
"gg",
"gg ez",
"mini gg all",
"kniga",
"shenanigans",
"dignity and signing",
"Pakistan",
"chinking glasses",
"the trannys? no: transmission",
"1nicerOli",
"ask me evernig",
"冰淇淋",
"привет",
"",
"!!!",
] {
assert!(!f.matches(text), "{text:?} should pass");
}
}
#[test]
fn a_custom_list_has_the_same_syntax() {
let f = Filter::new("# comment\n=bad\n~worse\nmeh\n!mehish*\n");
assert!(f.matches("so bad"));
assert!(!f.matches("badly"));
assert!(f.matches("wor se"));
assert!(f.matches("xxmehxx"));
assert!(!f.matches("mehishness"));
assert!(!f.matches("me h"));
}
fn guard(config: Config) -> Guard {
Guard::new(config, filter(), None)
}
const NOW: i64 = 1_793_469_939_000;
const MIN: i64 = 60_000;
#[test]
fn the_second_hit_mutes_and_the_third_bans() {
let mut g = guard(Config::default());
let id = 7;
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
assert_eq!(g.muted_until(id, NOW), None);
let until = NOW + 3 * MIN + 10 * MIN;
assert_eq!(
g.strike(id, "Ann", NOW + 3 * MIN),
(2, Action::Muted { until })
);
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
assert_eq!(g.muted_until(id, until), None);
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
assert_eq!(
g.strike(id, "Ann", until + 8 * MIN),
(
3,
Action::Banned {
until: until + 8 * MIN + 24 * 60 * MIN
}
)
);
// Then it starts again.
assert_eq!(g.strike(id, "Ann", until + 9 * MIN), (1, Action::Refused));
}
#[test]
fn hits_far_apart_dont_add_up() {
let mut g = guard(Config::default());
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
assert_eq!(g.strike(1, "Bo", NOW + 11 * MIN), (1, Action::Refused));
// Players are counted apart.
assert_eq!(g.strike(2, "Cy", NOW + 12 * MIN), (1, Action::Refused));
}
#[test]
fn thresholds_are_configurable() {
let mut g = guard(Config {
mute_after: 0,
tempban_after: 2,
tempban_hours: 1,
..Config::default()
});
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
assert_eq!(
g.strike(1, "Bo", NOW + MIN),
(
2,
Action::Banned {
until: NOW + MIN + 60 * MIN
}
)
);
let mut off = guard(Config {
mute_after: 0,
tempban_after: 0,
..Config::default()
});
for k in 1..5 {
assert_eq!(off.strike(1, "Bo", NOW + k), (k as u32, Action::Refused));
}
}
#[test]
fn mutes_survive_a_restart_and_unmute_lifts_them() {
let dir = std::env::temp_dir().join(format!("chat-filter-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let now = now_millis();
let (mut g, problem) = Guard::load(Config::default(), &dir);
assert_eq!(problem, None);
g.strike(5, "Dee", now);
let (_, action) = g.strike(5, "Dee", now);
assert!(matches!(action, Action::Muted { .. }));
let (mut again, _) = Guard::load(Config::default(), &dir);
assert!(again.muted_until(5, now).is_some());
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
assert_eq!(again.unmute("dee"), None);
assert_eq!(again.muted_until(5, now), None);
let (reloaded, _) = Guard::load(Config::default(), &dir);
assert_eq!(reloaded.muted_until(5, now), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_missing_words_file_falls_back_to_the_built_in_list() {
let config = Config {
words_file: "nope.txt".into(),
..Config::default()
};
let (g, problem) = Guard::load(config, Path::new("/nonexistent-horizon"));
assert!(problem.unwrap().contains("nope.txt"));
assert!(g.filter.matches("nigger"));
}
#[test]
fn the_section_reads_from_server_toml() {
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
assert_eq!(c, Config::default());
assert!(c.enabled);
let c = Config::from_server_toml(
"[chat_filter]\nenabled = true\nmute_after = 3\ntempban_after = 5\nwords_file = \"slurs.txt\"\n",
)
.unwrap();
assert_eq!((c.mute_after, c.tempban_after), (3, 5));
assert_eq!(c.words_file, "slurs.txt");
assert!(
!Config::from_server_toml("[chat_filter]\nenabled = false\n")
.unwrap()
.enabled
);
assert!(Config::from_server_toml("[chat_filter]\nmute = 2\n").is_err());
assert!(Config::from_server_toml("[chat_filter]\nwindow_minutes = 0\n").is_err());
}
#[test]
fn log_lines_have_the_time_the_player_and_the_text() {
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
assert_eq!(utc(951_782_400_000), "2000-02-29T00:00:00Z");
assert_eq!(
log_line(NOW, "Ann", 1, "chat", 2, Action::Muted { until: 0 }, "a\nb"),
"2026-10-31T18:05:39Z Ann 00000000-0000-0000-0000-000000000001 chat hit 2 muted: a b"
);
}
#[test]
fn a_message_costs_little() {
// A long message through the whole list stays far below a tick (debug builds included).
let f = filter();
let text = "the quick brown fox jumps over the lazy dog ".repeat(6);
let start = std::time::Instant::now();
for _ in 0..1000 {
assert!(!f.matches(&text));
}
assert!(start.elapsed().as_millis() < 2000, "{:?}", start.elapsed());
}
+5
View File
@@ -69,6 +69,9 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
@@ -309,6 +312,8 @@ mod tests {
uk.get("horizon.options.on_screen_keyboard"),
Some("Екранна клавіатура")
);
let tr = Language::load(&[&empty], "tr_tr");
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
let es = Language::load(&[&empty], "es_es");
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
let es_ve = Language::load(&[&empty], "es_ve");
+34 -1
View File
@@ -118,6 +118,12 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
None => mod_effect(id),
}
}
/// A content mod's effect id (`<ns>:<path>`), not vanilla's with or
/// without `minecraft:`.
#[inline]
pub fn is_mod_effect(id: &str) -> bool {
!id.starts_with("minecraft:") && id.bytes().any(|b| b == b':')
}
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
#[cold]
#[inline(never)]
@@ -130,6 +136,28 @@ pub fn description_id(id: &str) -> String {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
format!("effect.{namespace}.{}", path.replace('/', "."))
}
/// The effect's sprite in the GUI atlas (`Hud.getMobEffectSprite`: the key
/// with `mob_effect/` before its path): `mob_effect/<path>` for vanilla's,
/// `<ns>:mob_effect/<path>` for a mod's.
pub fn sprite(id: &str) -> String {
let mut out = String::new();
push_sprite(&mut out, id);
out
}
/// [`sprite`], appended to `out`.
pub fn push_sprite(out: &mut String, id: &str) {
let path = match id.split_once(':') {
None => id,
Some(("minecraft", path)) => path,
Some((namespace, path)) => {
out.push_str(namespace);
out.push(':');
path
}
};
out.push_str("mob_effect/");
out.push_str(path);
}
/// Namespaced effect ids for mob effect storage; a mod's id already is.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
@@ -300,10 +328,13 @@ mod tests {
use super::*;
#[test]
fn description_ids_keep_the_namespace() {
fn description_ids_and_sprites_keep_the_namespace() {
assert_eq!(description_id("speed"), "effect.minecraft.speed");
assert_eq!(description_id("minecraft:speed"), "effect.minecraft.speed");
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
assert_eq!(sprite("speed"), "mob_effect/speed");
assert_eq!(sprite("minecraft:speed"), "mob_effect/speed");
assert_eq!(sprite("ruby:curse/dark"), "ruby:mob_effect/curse/dark");
}
/// `PotionContents.getColorOptional`: each visible effect's colour weighted by its
@@ -340,6 +371,8 @@ mod tests {
(0xFF0000, Category::Neutral, false)
);
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
assert!(is_mod_effect(glow.id) && !is_mod_effect("speed"));
assert!(!is_mod_effect(namespaced("speed")));
assert_eq!(namespaced(glow.id), "ruby:glow");
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
@@ -28,6 +28,12 @@ pub struct Registries {
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// A dedicated server's lists, for the completions of `/op`, `/deop`, `/pardon`, `/pardon-ip` and
/// `/whitelist`: the operators' names, the banned names and addresses, the whitelisted names.
pub ops: Vec<String>,
pub banned_players: Vec<String>,
pub banned_ips: Vec<String>,
pub whitelisted: Vec<String>,
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
/// each once.
@@ -81,6 +81,19 @@ impl InteractionInput {
}
}
/// The sentence of `Minecraft.handleKeybinds` that follows a key which opened
/// a screen: `Gui.setScreen` has already run `KeyMapping.releaseAll()`, so
/// `key.use` reads up and the same call releases an active use
/// (`player.isUsingItem() && !keyUse.isDown()` → `MultiPlayerGameMode.releaseUsingItem`).
/// Only the screens opened from the keybinds do this (the inventory, chat and
/// advancements); the pause menu, server containers, mount inventories and
/// dialogs release on the first tick after closing instead, as vanilla does.
pub fn release_on_screen_open(world: &mut impl InteractionWorld) {
if world.using_item() {
world.release_using();
}
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -10,5 +10,6 @@ pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod window;
@@ -0,0 +1,232 @@
//! OS modifier reconciliation and modifiers synthesized by agent control.
use crate::{
keybinds::{KeyBinds, key_code},
keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys},
};
use std::collections::HashSet;
use winit::keyboard::{KeyCode, ModifiersState};
const MODIFIERS: [(ModifiersState, [KeyCode; 2]); 4] = [
(
ModifiersState::SHIFT,
[KeyCode::ShiftLeft, KeyCode::ShiftRight],
),
(
ModifiersState::CONTROL,
[KeyCode::ControlLeft, KeyCode::ControlRight],
),
(ModifiersState::ALT, [KeyCode::AltLeft, KeyCode::AltRight]),
(
ModifiersState::SUPER,
[KeyCode::SuperLeft, KeyCode::SuperRight],
),
];
/// A synthesized key updates the same modifier word as window events.
/// Releasing one side must preserve the other side's hold.
pub fn set_key(
modifiers: &mut ModifiersState,
physical: &mut HashSet<KeyCode>,
key: KeyCode,
down: bool,
) {
let Some((bit, pair)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&key)) else {
return;
};
if down {
physical.insert(key);
} else {
physical.remove(&key);
}
modifiers.set(*bit, pair.iter().any(|key| physical.contains(key)));
}
/// A modifier held for one synthesized click (the pad's Y, an agent's
/// shift-click): what [`restore`] puts back once the click is done.
#[derive(Clone, Copy, Debug)]
pub struct Held {
key: KeyCode,
was_down: bool,
bit: bool,
}
/// Holds `key` in both the physical keys and the modifier word, which is
/// what screens read (`InputWithModifiers.hasShiftDown`).
pub fn hold(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, key: KeyCode) -> Held {
let bit = MODIFIERS
.iter()
.find(|(_, pair)| pair.contains(&key))
.is_some_and(|(bit, _)| modifiers.contains(*bit));
let held = Held {
key,
was_down: physical.contains(&key),
bit,
};
set_key(modifiers, physical, key, true);
held
}
/// Undoes [`hold`]: a key or bit that was already down stays down.
pub fn restore(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, held: Held) {
if !held.was_down {
physical.remove(&held.key);
}
if let Some((bit, _)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&held.key)) {
modifiers.set(*bit, held.bit);
}
}
/// Recover missing modifier releases without releasing a rebound key or a pad.
/// ToggleKeyMapping itself preserves keyboard and controller toggles on release.
pub fn reconcile(
modifiers: ModifiersState,
physical: &mut HashSet<KeyCode>,
bindings: &KeyBinds,
toggles: &mut ToggleKeys,
options: [bool; 2],
pad_held: [bool; 2],
) {
for (bit, pair) in MODIFIERS {
if modifiers.contains(bit) {
continue;
}
for key in pair {
if !physical.remove(&key) {
continue;
}
for (slot, name) in [(SNEAK, "key.sneak"), (SPRINT, "key.sprint")] {
if key_code(bindings.get(name)) == Some(key) && !pad_held[slot] {
toggles.key(slot, KeyAction::Release, false, options[slot]);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modifier_taps_release_both_bits() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
for (bit, pair) in MODIFIERS {
for key in pair {
set_key(&mut modifiers, &mut physical, key, true);
assert!(modifiers.contains(bit));
set_key(&mut modifiers, &mut physical, key, false);
assert!(modifiers.is_empty());
assert!(physical.is_empty());
}
}
}
#[test]
fn releasing_one_side_keeps_the_other_modifier_held() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
for (bit, [left, right]) in MODIFIERS {
set_key(&mut modifiers, &mut physical, left, true);
set_key(&mut modifiers, &mut physical, right, true);
set_key(&mut modifiers, &mut physical, left, false);
assert!(modifiers.contains(bit));
set_key(&mut modifiers, &mut physical, right, false);
assert!(modifiers.is_empty());
}
}
#[test]
fn a_held_click_restores_the_previous_state() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
assert!(modifiers.shift_key());
restore(&mut modifiers, &mut physical, held);
assert!(modifiers.is_empty());
assert!(physical.is_empty());
// Right Shift already held, or the OS word set with no physical key.
for start in [HashSet::from([KeyCode::ShiftRight]), HashSet::new()] {
let mut modifiers = ModifiersState::SHIFT;
let mut physical = start.clone();
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
restore(&mut modifiers, &mut physical, held);
assert_eq!(modifiers, ModifiersState::SHIFT);
assert_eq!(physical, start);
}
}
#[test]
fn lost_releases_respect_rebindings_and_pad_holds() {
for slot in [SNEAK, SPRINT] {
for rebound in [false, true] {
for pad in [false, true] {
for toggle in [false, true] {
let mut bindings = KeyBinds::default();
let (name, modifier) = if slot == SNEAK {
("key.sneak", KeyCode::ShiftLeft)
} else {
("key.sprint", KeyCode::ControlLeft)
};
if rebound {
bindings.set(name, "key.keyboard.c");
}
let mut physical = HashSet::from([modifier]);
if rebound {
physical.insert(KeyCode::KeyC);
}
let mut toggles = ToggleKeys::default();
toggles.key(slot, KeyAction::Press, false, toggle);
let mut options = [false; 2];
options[slot] = toggle;
let mut pad_held = [false; 2];
pad_held[slot] = pad;
reconcile(
ModifiersState::ALT,
&mut physical,
&bindings,
&mut toggles,
options,
pad_held,
);
assert_eq!(toggles.is_down(slot), rebound || pad || toggle);
assert!(!physical.contains(&modifier));
assert_eq!(physical.contains(&KeyCode::KeyC), rebound);
}
}
}
}
}
#[test]
fn unrelated_modifier_change_does_not_release_an_action() {
let mut toggles = ToggleKeys::default();
toggles.key(SNEAK, KeyAction::Press, false, false);
reconcile(
ModifiersState::ALT,
&mut HashSet::new(),
&KeyBinds::default(),
&mut toggles,
[false; 2],
[false; 2],
);
assert!(toggles.is_down(SNEAK));
}
#[test]
fn lost_modifier_release_preserves_a_controller_toggle() {
let mut toggles = ToggleKeys::default();
toggles.pad_toggle(SNEAK, false);
reconcile(
ModifiersState::empty(),
&mut HashSet::from([KeyCode::ShiftLeft]),
&KeyBinds::default(),
&mut toggles,
[false; 2],
[false; 2],
);
assert!(toggles.is_down(SNEAK));
}
}
+55
View File
@@ -473,6 +473,37 @@ pub fn monitor_video_modes(monitor: &winit::monitor::MonitorHandle) -> Vec<Video
.collect()
}
/// The video modes of the window's monitor, listed once per monitor. Listing them takes tens of
/// milliseconds on Windows, and settings apply at every step of a dragged slider: listing them
/// each time made the slider stutter.
#[derive(Debug)]
pub struct VideoModeList<M = winit::monitor::MonitorHandle> {
listed: Option<M>,
}
impl<M> Default for VideoModeList<M> {
fn default() -> Self {
Self { listed: None }
}
}
impl<M: PartialEq> VideoModeList<M> {
/// The modes of `monitor` when it is not the monitor listed last, else `None` (the list
/// stands). No monitor lists none.
pub fn refresh(
&mut self,
monitor: Option<M>,
list: impl FnOnce(&M) -> Vec<VideoMode>,
) -> Option<Vec<VideoMode>> {
if monitor.is_some() && monitor == self.listed {
return None;
}
let modes = monitor.as_ref().map(list).unwrap_or_default();
self.listed = monitor;
Some(modes)
}
}
/// `Monitor.currentMode`: the monitor's desktop mode; winit has no such query, so the mode of
/// the monitor's own size with the highest refresh rate and depth.
fn monitor_current_mode(
@@ -698,4 +729,28 @@ mod tests {
Some(Immediate)
);
}
#[test]
fn video_modes_are_listed_again_only_for_another_monitor() {
let mode = |width| VideoMode {
width,
height: 1080,
red_bits: 8,
green_bits: 8,
blue_bits: 8,
refresh_rate: 60.0,
};
let mut list = VideoModeList::default();
let mut listings = 0;
let mut modes = |monitor: &i32| {
listings += 1;
vec![mode(*monitor)]
};
assert_eq!(list.refresh(Some(1), &mut modes), Some(vec![mode(1)]));
assert_eq!(list.refresh(Some(1), &mut modes), None);
assert_eq!(list.refresh(Some(2), &mut modes), Some(vec![mode(2)]));
assert_eq!(list.refresh(None, &mut modes), Some(vec![]));
assert_eq!(list.refresh(Some(2), &mut modes), Some(vec![mode(2)]));
assert_eq!(listings, 3);
}
}
+221
View File
@@ -0,0 +1,221 @@
[package]
name = "horizon-client-lib"
autobins = false
autoexamples = false
autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Reusable client systems, control tool and parity probes"
[lib]
path = "../horizon-client/src/client_lib.rs"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[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"] }
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
[lints]
workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
[[example]]
name = "json_load_bench"
path = "../horizon-client/examples/json_load_bench.rs"
[[example]]
name = "mod_port_probe"
path = "../horizon-client/examples/mod_port_probe.rs"
[[example]]
name = "model_library_bench"
path = "../horizon-client/examples/model_library_bench.rs"
[[example]]
name = "model_states_bench"
path = "../horizon-client/examples/model_states_bench.rs"
[[example]]
name = "old_world_fixture"
path = "../horizon-client/examples/old_world_fixture.rs"
[[example]]
name = "portal_scan_bench"
path = "../horizon-client/examples/portal_scan_bench.rs"
[[example]]
name = "random_tick_eligibility_bench"
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
[[example]]
name = "resource_memory"
path = "../horizon-client/examples/resource_memory.rs"
[[example]]
name = "save_batch_bench"
path = "../horizon-client/examples/save_batch_bench.rs"
[[example]]
name = "shared_json_startup_profile"
path = "../horizon-client/examples/shared_json_startup_profile.rs"
[[example]]
name = "sprite_cache_parity"
path = "../horizon-client/examples/sprite_cache_parity.rs"
[[example]]
name = "client_probe"
path = "../horizon-client/examples/probe/main.rs"
[[test]]
name = "custom_component_api"
path = "../horizon-client/tests/custom_component_api.rs"
[[test]]
name = "custom_component_unregistered"
path = "../horizon-client/tests/custom_component_unregistered.rs"
[[test]]
name = "custom_component_wire"
path = "../horizon-client/tests/custom_component_wire.rs"
[[test]]
name = "forced_chunks"
path = "../horizon-client/tests/forced_chunks.rs"
[[test]]
name = "generation_saves"
path = "../horizon-client/tests/generation_saves.rs"
[[test]]
name = "lan_bot"
path = "../horizon-client/tests/lan_bot.rs"
[[test]]
name = "streaming_meshes"
path = "../horizon-client/tests/streaming_meshes.rs"
[[test]]
name = "tall_column"
path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
+4
View File
@@ -15,6 +15,8 @@ horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
# The narrator option's status and whether the narrator is there (Options.narrator).
horizon-narrator = { path = "../horizon-narrator" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
@@ -33,12 +35,14 @@ glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
half = "2"
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
sha1 = "0.10"
sha2 = "0.10.9"
smallvec = { version = "1", features = ["const_new"] }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
unicode-bidi = "0.3.18"
ureq = { version = "2", default-features = false, features = ["tls"] }
@@ -96,6 +96,11 @@ pub struct RenderAssets {
pub translucent_layers: Vec<bool>,
/// Each layer's `MipmapGenerator` settings.
pub sprite_mips: Vec<crate::mipmap::Settings>,
/// The resource packs' LabPBR maps (normals, specular), layer by layer at `pbr_size`:
/// empty when they ship none.
pub pbr_normals: Vec<u8>,
pub pbr_specular: Vec<u8>,
pub pbr_size: u32,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -235,6 +240,9 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
if draws {
crate::native_emissive::load(root, &mut sprites);
}
sprites.save_disk_cache();
if draws {
sprites.finish();
@@ -256,6 +264,9 @@ impl RenderAssets {
sprite_layers: sprites.layers,
translucent_layers: sprites.translucent,
sprite_mips: sprites.mips,
pbr_normals: sprites.normals,
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
})
}
}
+28 -2
View File
@@ -1179,6 +1179,15 @@ impl Audio {
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
}
/// The `uiSoundReserve` extra: positional sounds leave
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
pub fn set_listener_reserve(&mut self, on: bool) {
self.engine.listener_reserve = if on {
crate::sound_engine::HEARD_RESERVE
} else {
0
};
}
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
pub fn pause(&mut self) {
self.engine.pause_all_except(
@@ -1226,6 +1235,17 @@ impl Audio {
self.play(event, Some(at), volume, pitch);
return;
}
self.play_after(
event,
at,
volume,
pitch,
(distance_sqr.sqrt() / 40.0 * 20.0) as i32,
);
}
/// `play` at a world position, starting `ticks` sound-engine ticks (20 a
/// second) from now: `SoundEngine.playDelayed`.
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
let seed = self.random.next_long();
if let Some(capture) = self.capture.as_mut() {
capture.push((event.to_owned(), at, volume, pitch));
@@ -1239,8 +1259,7 @@ impl Audio {
let instance =
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
let id = self.engine.add(instance, false);
self.engine
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
self.engine.play_delayed(id, ticks);
}
/// `play` for this client only: a sound a packet's handler plays
/// (`playLocalSound`), which the guests play from that packet themselves.
@@ -1336,6 +1355,13 @@ impl Audio {
self.stop_instance(id);
self.release(id);
}
/// `SoundBufferLibrary.enumerate`: the decoded buffers kept and their bytes.
pub fn cache_stats(&self) -> (usize, usize) {
self.backend
.cache
.lock()
.map_or((0, 0), |c| (c.entries.len(), c.bytes))
}
/// Every sound event id (for command completion).
/// The engine's table sizes and the mixer's channels (`status`).
pub fn census(&self) -> serde_json::Value {
+284
View File
@@ -272,6 +272,192 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
}
}
/// The account's chat key pair (ProfileKeyPair): the private key that signs
/// its chat, the public key with the services' signature over it, and when
/// to fetch a new one (refreshedAfter, epoch milliseconds). Like the access
/// token it stays in memory: vanilla's profilekeys file is never written.
pub struct ChatKeyPair {
pub private: horizon_protocol::rsa::RsaPrivateKey,
pub public: horizon_protocol::secure_chat::ProfileKey,
pub refreshed_after: i64,
}
impl std::fmt::Debug for ChatKeyPair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatKeyPair")
.field("expires_at", &self.public.expires_at)
.field("refreshed_after", &self.refreshed_after)
.finish_non_exhaustive()
}
}
impl ChatKeyPair {
/// ProfileKeyPair.dueRefresh.
pub fn due_refresh(&self, now: i64) -> bool {
self.refreshed_after < now
}
}
/// Epoch milliseconds now.
pub fn now_millis() -> i64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// Instant.parse for the services' UTC times
/// (`2022-07-30T23:05:24.548227Z`): epoch milliseconds.
pub fn parse_instant(text: &str) -> Option<i64> {
let text = text.strip_suffix('Z')?;
let (date, time) = text.split_once('T')?;
let mut date = date.splitn(3, '-').map(|p| p.parse::<i64>().ok());
let (y, m, d) = (date.next()??, date.next()??, date.next()??);
let (hms, fraction) = time.split_once('.').unwrap_or((time, ""));
let mut hms = hms.splitn(3, ':').map(|p| p.parse::<i64>().ok());
let (h, min, s) = (hms.next()??, hms.next()??, hms.next()??);
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || h > 23 || min > 59 || s > 60 {
return None;
}
if !fraction.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let millis = format!("{fraction:0<3}")[..3].parse::<i64>().ok()?;
// Days from 1970-01-01 (Howard Hinnant's days_from_civil).
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(((days * 24 + h) * 60 + min) * 60_000 + s * 1000 + millis)
}
/// Crypt.stringToPemRsaPrivateKey / stringToRsaPublicKey: the base64
/// between a PEM block's armour lines.
fn pem_der(pem: &str) -> Option<Vec<u8>> {
let body: String = pem
.lines()
.filter(|l| !l.starts_with("-----"))
.flat_map(|l| l.chars().filter(|c| !c.is_whitespace()))
.collect();
crate::skins::base64_decode(&body)
}
/// KeyPairResponse from the certificates endpoint, as
/// AccountProfileKeyPairManager.fetchProfileKeyPair reads it: the PEM key
/// pair, publicKeySignatureV2 (the signature over the profile id too),
/// expiresAt and refreshedAfter. None for anything malformed, or a private
/// key that isn't the public key's other half.
pub fn parse_certificates(body: &str) -> Option<ChatKeyPair> {
use horizon_protocol::rsa::{RsaPrivateKey, RsaPublicKey};
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let field = |path: &str| v.pointer(path).and_then(|s| s.as_str());
let private = RsaPrivateKey::from_der(&pem_der(field("/keyPair/privateKey")?)?).ok()?;
let key = pem_der(field("/keyPair/publicKey")?)?;
if RsaPublicKey::from_der(&key).ok()? != private.public {
return None;
}
let signature = crate::skins::base64_decode(field("/publicKeySignatureV2")?)?;
Some(ChatKeyPair {
private,
public: horizon_protocol::secure_chat::ProfileKey {
expires_at: parse_instant(field("/expiresAt")?)?,
key,
signature,
},
refreshed_after: parse_instant(field("/refreshedAfter")?)?,
})
}
/// UserApiService.getKeyPair: the certificates endpoint, POSTed with the
/// access token.
pub fn fetch_chat_keys(session: &AuthSession) -> Result<ChatKeyPair, JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "player", "getCertificates").ok_or(JoinError::Unavailable)?;
let body = match agent()
.post(&url)
.set("Authorization", &format!("Bearer {}", session.access_token))
.set("Content-Type", "application/json; charset=utf-8")
.send_string("")
{
Ok(response) => response.into_string().unwrap_or_default(),
Err(ureq::Error::Status(status, response)) => {
return Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
));
}
Err(ureq::Error::Transport(_)) => return Err(JoinError::Unavailable),
};
parse_certificates(&body)
.ok_or_else(|| JoinError::Other("Malformed profile key pair response".to_owned()))
}
/// AccountProfileKeyPairManager.MINIMUM_PROFILE_KEY_REFRESH_INTERVAL.
const KEY_RETRY: Duration = Duration::from_secs(60 * 60);
/// The chat key pair as AccountProfileKeyPairManager keeps it: fetched on a
/// background thread, kept until it is due a refresh, and on a failure kept
/// as it was (the next try waits an hour, or the next join).
#[derive(Default)]
struct ChatKeys {
current: Option<Arc<ChatKeyPair>>,
fetching: bool,
next_try: Option<std::time::Instant>,
}
static CHAT_KEYS: Mutex<ChatKeys> = Mutex::new(ChatKeys {
current: None,
fetching: false,
next_try: None,
});
/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
/// then prepareKeyPair: start fetching when there is no key pair or it is
/// due a refresh. Nothing without a signed-in account.
pub fn prepare_chat_keys(now: bool) {
let Some(session) = current() else {
return;
};
let mut keys = CHAT_KEYS.lock().unwrap();
let instant = std::time::Instant::now();
if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
return;
}
keys.next_try = Some(instant + KEY_RETRY);
if keys
.current
.as_ref()
.is_some_and(|k| !k.due_refresh(now_millis()))
{
return;
}
keys.fetching = true;
drop(keys);
let spawned = std::thread::Builder::new()
.name("Profile key fetch".into())
.spawn(move || {
let result = fetch_chat_keys(&session);
let mut keys = CHAT_KEYS.lock().unwrap();
keys.fetching = false;
match result {
Ok(pair) => keys.current = Some(Arc::new(pair)),
Err(e) => log::error!("Failed to retrieve profile key pair: {e:?}"),
}
});
if spawned.is_err() {
CHAT_KEYS.lock().unwrap().fetching = false;
}
}
/// The chat key pair fetched so far, unless it has expired.
pub fn chat_keys() -> Option<Arc<ChatKeyPair>> {
let now = now_millis();
CHAT_KEYS
.lock()
.unwrap()
.current
.clone()
.filter(|k| !k.public.has_expired(now))
}
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
/// The signed-in account's active skin and cape from `GET /minecraft/profile`.
@@ -485,6 +671,104 @@ mod tests {
);
}
#[test]
fn instants_like_java() {
assert_eq!(parse_instant("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(parse_instant("1970-01-01T00:00:01.5Z"), Some(1500));
// Instant.parse("2022-07-30T23:05:24.548227Z").toEpochMilli().
assert_eq!(
parse_instant("2022-07-30T23:05:24.548227Z"),
Some(1_659_222_324_548)
);
assert_eq!(
parse_instant("2024-02-29T12:00:00Z"),
Some(1_709_208_000_000)
);
assert_eq!(parse_instant("1969-12-31T23:59:59Z"), Some(-1000));
for bad in [
"2022-07-30T23:05:24",
"2022-13-01T00:00:00Z",
"2022-07-30 23:05:24Z",
"2022-07-30T23:05:24.5x1Z",
"",
] {
assert_eq!(parse_instant(bad), None, "{bad}");
}
}
/// A throwaway 1024-bit key (`openssl genrsa`, PKCS#8 as the services
/// send it under "RSA PRIVATE KEY") and its public half.
const PRIVATE_B64: &str = "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAKSDqaLOF2Ltt4tzWblkFeNYhdss67BhXTbOMUYjpcPdtf4dzNtgZsTf4WEluvc8pItGWChdpq3xzPC9GL4PZ+Oh1o6VR/wSm20/50QT7V+YcSP1VrVKjDpDSg/UoC1Fmdc/vb9AO8Ip7TxuRYd/uyog/ThQ8aGcWtvvtwPqx4XrAgMBAAECgYA+ZGy2gQDflRZSPeoeZHXkphMF/7BXUWW/vk1zs1tPwEsgV/70iEgRjHL2nA+fapFg/Mm8bWAkghrehmjW1BLq5w8sGdGJCHfE/kauu78jJK0/ME38e9bsSEvv5HMMxyx0mlV8DQu2pzCT71ceT8VD/1pewUbpeZypj5pxtsCIMQJBANXgca/mnAQXTuowI8FqMiald6OuAcAWbOaNrCB5HHWuxH7hFatSqGrgp4YLqHv7bzeodU1y/uxwc+/01khd0rMCQQDE6mPfIdZxHhWkMUAKhMEnrLanfCL0dfyAe+lBpqgPwO3UurCfCaFX5RdYB3fCyvgy5foHN1/I8/Eobp+enrvpAkBgkZnjeXWiZz/Lf/b1AtUoG/g8IHpL2BRNuVQpyWVvQX0ZaNoW5BiFQXCClsr5SBr8f4SPdsfhzBidFOiX/jz/AkBDuk6Bd3T3UR6ma54YQBLjX38vWf330QD4/CV9RCH30Sa8DT4kZT6OKah+yYrCf+cgMoNtqfkS9/xRKMNT3krJAkAaFrAT6HF5Y9b5OLASbbFe7MZ4zNCzuEl6plKNUi+JCHESaU/VCCuYOLoWLeSDFYkZUoSmqiRxH8fVTsBz393x";
const PUBLIC_B64: &str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCkg6mizhdi7beLc1m5ZBXjWIXbLOuwYV02zjFGI6XD3bX+HczbYGbE3+FhJbr3PKSLRlgoXaat8czwvRi+D2fjodaOlUf8EpttP+dEE+1fmHEj9Va1Sow6Q0oP1KAtRZnXP72/QDvCKe08bkWHf7sqIP04UPGhnFrb77cD6seF6wIDAQAB";
fn pem(label: &str, b64: &str) -> String {
let lines: Vec<&str> = b64
.as_bytes()
.chunks(76)
.map(|c| std::str::from_utf8(c).unwrap())
.collect();
format!(
"-----BEGIN {label}-----\n{}\n-----END {label}-----\n",
lines.join("\n")
)
}
fn certificates(private: &str, public: &str) -> String {
serde_json::json!({
"keyPair": {
"privateKey": pem("RSA PRIVATE KEY", private),
"publicKey": pem("RSA PUBLIC KEY", public),
},
"publicKeySignature": "djE=",
"publicKeySignatureV2": "c2ln",
"expiresAt": "2022-07-30T23:05:24.548227Z",
"refreshedAfter": "2022-07-30T15:05:24.548227Z",
})
.to_string()
}
#[test]
fn certificates_response() {
let pair = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
assert_eq!(
pair.public.key,
crate::skins::base64_decode(PUBLIC_B64).unwrap()
);
assert_eq!(pair.public.signature, b"sig", "publicKeySignatureV2");
assert_eq!(pair.public.expires_at, 1_659_222_324_548);
assert_eq!(pair.refreshed_after, 1_659_193_524_548);
assert_eq!(pair.private.public_der(), pair.public.key);
assert!(pair.due_refresh(1_659_193_524_549));
assert!(!pair.due_refresh(1_659_193_524_548));
assert!(!format!("{pair:?}").contains("MII"));
// A public key that isn't the private key's, and broken fields.
let other = horizon_protocol::rsa::RsaPrivateKey::generate(1024).unwrap();
let other = base64(&other.public_der());
assert!(parse_certificates(&certificates(PRIVATE_B64, &other)).is_none());
assert!(parse_certificates(&certificates("bm9wZQ==", PUBLIC_B64)).is_none());
assert!(parse_certificates("{}").is_none());
assert!(parse_certificates("").is_none());
}
fn base64(bytes: &[u8]) -> String {
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for c in bytes.chunks(3) {
let n = c
.iter()
.enumerate()
.fold(0u32, |n, (i, &b)| n | u32::from(b) << (16 - 8 * i));
for i in 0..4 {
out.push(if i <= c.len() {
char::from(A[(n >> (18 - 6 * i) & 63) as usize])
} else {
'='
});
}
}
out
}
#[test]
fn discovery_endpoints() {
let doc = serde_json::json!({"discovery": {"session": {"endpoints": {"join": {"uri": "https://s/join"}}}}});
+121
View File
@@ -387,6 +387,52 @@ impl Biomes {
}
}
/// A biome's colours as a mod may change them (`native_biome_colors`), each
/// 0xRRGGBB: what tints grass (before the dark forest's modifier; the swamp's
/// noise colours replace it), leaves and vines, dry foliage (leaf litter) and
/// water.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BiomeColors {
pub grass: u32,
pub foliage: u32,
pub dry_foliage: u32,
pub water: u32,
}
impl Biomes {
/// This table with each biome's colours passed through `f`, given the
/// biome's name (`minecraft:plains`) and its colours; None when `f`
/// changed none. Bits past 0xFFFFFF are dropped.
pub fn with_colors(&self, mut f: impl FnMut(&str, &mut BiomeColors)) -> Option<Self> {
let mut out = self.clone();
let mut changed = false;
for b in &mut out.list {
let before = BiomeColors {
grass: b.grass,
foliage: b.foliage,
dry_foliage: b.dry_foliage,
water: b.water,
};
let mut colors = before;
f(&b.name, &mut colors);
let after = BiomeColors {
grass: colors.grass & 0xFF_FFFF,
foliage: colors.foliage & 0xFF_FFFF,
dry_foliage: colors.dry_foliage & 0xFF_FFFF,
water: colors.water & 0xFF_FFFF,
};
if after != before {
changed = true;
b.grass = after.grass;
b.foliage = after.foliage;
b.dry_foliage = after.dry_foliage;
b.water = after.water;
}
}
changed.then_some(out)
}
}
/// A 24-bit colour as sRGB components in 0..1.
pub fn rgb(c: u32) -> [f32; 3] {
[16, 8, 0].map(|shift| (c >> shift & 255) as f32 / 255.0)
@@ -534,6 +580,81 @@ mod pack_tests {
}
}
#[cfg(test)]
mod color_tests {
use super::*;
use serde_json::json;
fn biomes() -> Biomes {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let effects = |grass: u32, modifier: &str| {
json!({"effects": {"grass_color": grass, "foliage_color": 0x112233,
"dry_foliage_color": 0x445566, "water_color": 0x3F76E4,
"grass_color_modifier": modifier}})
};
let list = vec![
BiomeInfo::from_json("minecraft:plains".into(), &effects(0x91BD59, "none"), &maps),
BiomeInfo::from_json(
"minecraft:dark_forest".into(),
&effects(0x91BD59, "dark_forest"),
&maps,
),
];
Biomes {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
#[test]
fn a_change_reaches_the_tints_the_mesher_reads() {
let autumn = biomes()
.with_colors(|name, c| {
c.foliage = 0xD2691E;
if name == "minecraft:plains" {
c.grass = 0xBFA055;
}
})
.expect("changed");
let plains = autumn.id("minecraft:plains");
assert_eq!(autumn.grass(plains, 0.0, 0.0), 0xBFA055);
assert_eq!(autumn.get(plains).foliage, 0xD2691E);
assert_eq!(autumn.get(plains).dry_foliage, 0x445566);
assert_eq!(autumn.get(plains).water, 0x3F76E4);
// The dark forest's modifier still darkens its (unchanged) grass:
// ((0x91BD59 & 0xFEFEFE) + 0x28340A) >> 1.
let dark = autumn.id("minecraft:dark_forest");
assert_eq!(autumn.grass(dark, 0.0, 0.0), 0x5C7831);
assert_eq!(autumn.get(dark).foliage, 0xD2691E);
}
#[test]
fn no_change_gives_none_and_alpha_bits_are_dropped() {
assert!(biomes().with_colors(|_, _| {}).is_none());
assert!(
biomes()
.with_colors(|_, c| c.water |= 0xFF00_0000)
.is_none()
);
let blue = biomes()
.with_colors(|_, c| c.water = 0xFF00_00FF)
.expect("changed");
assert_eq!(blue.get(0).water, 0x0000FF);
}
}
#[cfg(test)]
mod blend_tests {
use super::*;
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::{BlockEntity, ModContainer};
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -73,6 +73,9 @@ pub struct BlockEntities {
/// The containers of mod blocks' block entities, by block state id;
/// built with `states`.
mod_containers: FxHashMap<u32, Arc<ModContainer>>,
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
}
impl std::ops::Deref for BlockEntities {
@@ -162,6 +165,13 @@ impl BlockEntities {
pub fn mod_container(&self, state: u32) -> Option<&Arc<ModContainer>> {
self.mod_containers.get(&state)
}
pub fn set_mod_fluids(&mut self, fluids: FxHashMap<u32, Arc<ModFluidTanks>>) {
self.mod_fluids = fluids;
}
/// The fluid tanks of a mod block's block entity in block state `state`.
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
@@ -885,25 +885,26 @@ impl BlockEntityRenderer {
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(visibility));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
let items: Value = serde_json::from_slice(&bytes)?;
for (item, components) in items.as_object().into_iter().flatten() {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
};
for (item, components) in &items {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
}
Ok(renderer)
+19 -1
View File
@@ -13,7 +13,8 @@ use std::{collections::HashSet, fs, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
description_id, effect, mod_effects, namespaced, potion, potion_color, register_mod_effects,
description_id, effect, is_mod_effect, mod_effects, namespaced, potion, potion_color,
register_mod_effects,
};
/// `MobEffectUtil.formatDuration` at 20 TPS.
pub fn format_duration(ticks: i32) -> String {
@@ -159,6 +160,18 @@ pub struct Brewing {
}
impl Brewing {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
// NeoForge's load conditions: a file whose don't hold is silently
// absent. Vanilla's brewing recipes carry none; a pack's can.
let mods: Vec<String> = {
let mut mods = horizon_items::content_namespaces::mod_ids(root);
if !mods.iter().any(|m| m == "minecraft") {
mods.push("minecraft".to_owned());
}
mods
};
let conditions_hold = |v: &Value| {
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
};
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
@@ -190,6 +203,11 @@ impl Brewing {
None => sources.push((id.to_owned(), v)),
}
}
// The gate answers the merged file, as the crafting loader's does:
// a false-conditioned pack file leaves the lower one gone too.
// The data's recipe filters, over the final set.
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
sources.retain_mut(|(id, v)| conditions_hold(v) && filters.apply(id, v));
sources.sort_by(|a, b| a.0.cmp(&b.0));
let mut recipes = Vec::new();
for (id, v) in sources {
+123 -4
View File
@@ -434,6 +434,10 @@ pub struct Camera {
pub up: [f32; 3],
pub left: [f32; 3],
pub detached: bool,
/// The entity's eye and view rotation (yaw, pitch), where a detached camera backs off
/// from.
pub eye: [f64; 3],
pub eye_rotation: [f32; 2],
}
impl Default for Camera {
fn default() -> Self {
@@ -450,6 +454,8 @@ impl Default for Camera {
up: [0.0, 1.0, 0.0],
left: [-1.0, 0.0, 0.0],
detached: false,
eye: [0.0; 3],
eye_rotation: [0.0; 2],
}
}
}
@@ -495,6 +501,19 @@ impl Camera {
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.align_with_entity_distance(entity, partial, camera_type, |distance| distance, clip);
}
/// Adjust the detached distance after entity/mount scaling, before collision clipping.
/// Only finite overrides in 0..=1024 blocks are accepted (within the game's ray budget).
/// First-person views do not call `adjust_distance`.
pub fn align_with_entity_distance(
&mut self,
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
adjust_distance: impl FnOnce(f32) -> f32,
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
@@ -505,6 +524,8 @@ impl Camera {
+ f64::from(lerp(partial, self.eye_height_old, self.eye_height)),
lerp_d(p, entity.previous[2], entity.position[2]),
];
self.eye = self.position;
self.eye_rotation = [self.y_rot, self.x_rot];
self.detached = !camera_type.is_first_person();
if self.detached {
if camera_type.is_mirrored() {
@@ -520,10 +541,14 @@ impl Camera {
mount_scale = s;
mount_distance = d as f32;
}
let zoom = self.max_zoom(
java_max(scale * distance, mount_scale * mount_distance),
&mut clip,
);
let vanilla = java_max(scale * distance, mount_scale * mount_distance);
let adjusted = adjust_distance(vanilla);
let distance = if adjusted.is_finite() && (0.0..=1024.0).contains(&adjusted) {
adjusted
} else {
vanilla
};
let zoom = self.max_zoom(distance, &mut clip);
self.move_by(-zoom, 0.0, 0.0);
} else if let Some(LivingCamera {
sleeping: Some(bed),
@@ -961,6 +986,100 @@ mod tests {
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
}
#[test]
fn detached_distance_override_keeps_collision_clipping() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: Some(LivingCamera {
scale: 2.0,
camera_distance: 3.0,
sleeping: None,
dead: None,
}),
mount: Some((3.0, 4.0)),
};
for kind in [CameraType::ThirdPersonBack, CameraType::ThirdPersonFront] {
let mut camera = Camera::default();
let mut calls = 0;
let mut rays = 0;
let z = if kind.is_mirrored() { 1.0 } else { -1.0 };
camera.align_with_entity_distance(
&entity,
1.0,
kind,
|distance| {
assert_eq!(distance, 12.0);
8.0
},
|_, _| None,
);
assert!((camera.position[2] - z * 8.0).abs() < 1e-5);
camera.align_with_entity_distance(
&entity,
1.0,
kind,
|distance| {
calls += 1;
assert_eq!(distance, 12.0);
8.0
},
|_, _| {
rays += 1;
Some([0.0, 0.0, z])
},
);
assert_eq!((calls, rays), (1, 8));
assert!((camera.position[2] - z).abs() < 0.2);
}
}
#[test]
fn detached_distance_rejects_invalid_values_and_accepts_zero() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: None,
mount: None,
};
for distance in [f32::NAN, f32::INFINITY, -1.0, 1025.0] {
let mut camera = Camera::default();
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::ThirdPersonBack,
|_| distance,
|_, _| None,
);
assert!((camera.position[2] + 4.0).abs() < 1e-6);
}
let mut camera = Camera::default();
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::ThirdPersonBack,
|_| 0.0,
|_, _| None,
);
assert_eq!(camera.position, camera.eye);
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::FirstPerson,
|_| panic!("first person has no detached distance"),
|_, _| None,
);
assert_eq!(camera.position, camera.eye);
}
#[test]
fn fov_modifier_combines_speed_flight_and_bow() {
let mut p = FovPlayer {
flying: false,
+97 -4
View File
@@ -878,6 +878,22 @@ impl Chat {
}
}
/// `SuggestionsList.getNarrationMessage` for entry `index` of `count`.
fn suggestion_narration(index: usize, count: usize, text: &str, tooltip: Option<&str>) -> Text {
let mut args = vec![
Text::literal((index + 1).to_string()),
Text::literal(count.to_string()),
Text::literal(text),
];
match tooltip {
Some(tooltip) => {
args.push(Text::literal(tooltip));
Text::translate("narration.suggestion.tooltip", &args)
}
None => Text::translate("narration.suggestion", &args),
}
}
/// `CommandSuggestions.SuggestionsList`.
#[derive(Clone, Debug)]
struct SuggestionList {
@@ -889,8 +905,17 @@ struct SuggestionList {
current: usize,
last_mouse: (i32, i32),
tab_cycles: bool,
/// `lastNarratedEntry`: the entry the narrator last said (-1: none yet).
last_narrated: isize,
}
impl SuggestionList {
/// `getNarrationMessage`: "Selected suggestion 2 out of 5: …", with its
/// tooltip when it has one. It marks the entry said.
fn narration_message(&mut self) -> Text {
self.last_narrated = self.current as isize;
let s = &self.list[self.current];
suggestion_narration(self.current, self.list.len(), &s.text, s.tooltip.as_deref())
}
fn contains(&self, x: i32, y: i32) -> bool {
let [rx, ry, w, h] = self.rect;
x >= rx && x <= rx + w && y >= ry && y <= ry + h
@@ -1013,6 +1038,53 @@ fn last_word_index(s: &str) -> usize {
}
if prev_space { s.len() } else { out }
}
/// `ChatScreen.normalizeChatMessage`: `String.trim`, then
/// `StringUtils.normalizeSpace` (one space for every run of whitespace), then
/// `StringUtil.trimChatMessage` (the first 256 UTF-16 code units, the length
/// `ServerboundChatPacket`'s `stringUtf8(256)` decodes). The chat screen sends
/// this, not what was typed: a line of spaces sends nothing, and a message the
/// packet cannot hold is cut on the client, as vanilla's is, instead of by the
/// server.
pub fn normalize_chat_message(message: &str) -> String {
// String.trim: the code points up to U+0020, which is not `str::trim`'s
// Unicode whitespace (U+00A0 is not Java whitespace and stays).
let trimmed = message.trim_matches(|c: char| c <= ' ');
truncate_utf16(&normalize_space(trimmed), 256)
}
/// `StringUtils.normalizeSpace`: one space for every run of
/// `Character.isWhitespace`, and none at either end.
fn normalize_space(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = false;
for c in s.chars() {
if crate::ui::java_whitespace(c) {
space = !out.is_empty();
} else {
if space {
out.push(' ');
space = false;
}
out.push(c);
}
}
out
}
/// `StringUtil.truncateStringIfNecessary(s, 256, false)`: Java counts UTF-16
/// code units (`String.length`), so 129 emoji are cut to 128 of them, where
/// `chars().take(256)` would keep all 129 (258 units) for the server to
/// reject. A cut between the halves of a surrogate pair keeps the character
/// below it: vanilla's `substring` leaves half a pair, which a Java `String`
/// may hold and a Rust `String` may not.
fn truncate_utf16(s: &str, max_units: usize) -> String {
let mut units = 0;
for (byte, c) in s.char_indices() {
units += c.len_utf16();
if units > max_units {
return s[..byte].to_owned();
}
}
s.to_owned()
}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
pub fn new(
@@ -1102,12 +1174,14 @@ impl ChatScreen {
let value = self.input.value.clone();
let auto = self.auto_suggestions;
let abilities = self.abilities;
let message = std::mem::take(&mut self.input.message);
*self = if self.layout == SuggestLayout::CHAT {
Self::new(m, c, &value, self.history_pos, width, height, abilities)
} else {
Self::command_block(m, c, &value, width, height)
};
self.auto_suggestions = auto;
self.input.message = message;
}
}
/// `ChatScreen.onEdited`.
@@ -1331,11 +1405,12 @@ impl ChatScreen {
};
self.suggestions = None;
if self.allow_suggestions && self.auto_suggestions {
self.show_suggestions(m);
self.show_suggestions(m, false);
}
}
/// `CommandSuggestions.showSuggestions`.
fn show_suggestions(&mut self, m: &Measure) {
/// `CommandSuggestions.showSuggestions`: `immediate` (Tab asked for
/// them) has the narrator say the first one at once.
fn show_suggestions(&mut self, m: &Measure, immediate: bool) {
let Some((start, list)) = self.pending.clone() else {
return;
};
@@ -1378,6 +1453,7 @@ impl ChatScreen {
current: 0,
last_mouse: (0, 0),
tab_cycles: false,
last_narrated: if immediate { -1 } else { 0 },
};
self.select(&mut l, 0);
self.suggestions = Some(l);
@@ -1397,6 +1473,9 @@ impl ChatScreen {
self.input.suggestion = applied
.strip_prefix(self.input.value.as_str())
.map(str::to_owned);
if l.last_narrated != l.current as isize {
horizon_narrator::say_system_now(&l.narration_message().plain());
}
}
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
self.select(l, l.current as isize + dir);
@@ -1507,7 +1586,7 @@ impl ChatScreen {
self.suggestions = Some(l);
handled
} else if key == K::Tab {
self.show_suggestions(m);
self.show_suggestions(m, true);
true
} else {
false
@@ -1740,4 +1819,18 @@ impl ChatScreen {
pub fn suggestions_visible(&self) -> bool {
self.suggestions.is_some()
}
/// `CommandSuggestions.getUsageNarration` while the list shows
/// (`isVisible`): whether Tab cycles it or fills the input in, and
/// whether Escape hides it (`allowHiding`, which only the chat's turns
/// off).
pub fn suggestions_usage(&self) -> Option<&'static str> {
let list = self.suggestions.as_ref()?;
let hidable = self.layout != SuggestLayout::CHAT;
Some(match (list.tab_cycles, hidable) {
(true, true) => "narration.suggestion.usage.cycle.hidable",
(true, false) => "narration.suggestion.usage.cycle.fixed",
(false, true) => "narration.suggestion.usage.fill.hidable",
(false, false) => "narration.suggestion.usage.fill.fixed",
})
}
}
@@ -578,3 +578,71 @@ fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
assert_eq!(lines(&chat), [marker.clone(), marker]);
assert!(chat.deletions.is_empty());
}
/// `SuggestionsList.select` says an entry once (`lastNarratedEntry`), as
/// "suggestion N out of M", with its tooltip when it has one.
#[test]
fn a_suggestion_is_narrated_by_its_place_once() {
let suggestion = |text: &str, tooltip: Option<&str>| Suggestion {
range: (1, 1),
text: text.into(),
tooltip: tooltip.map(str::to_owned),
int_value: None,
};
let mut l = SuggestionList {
rect: [0; 4],
original: "/".into(),
list: vec![
suggestion("give", None),
suggestion("gamemode", Some("Mode")),
],
offset: 0,
current: 1,
last_mouse: (0, 0),
tab_cycles: false,
last_narrated: 0,
};
let said = l.narration_message().component();
assert_eq!(l.last_narrated, 1);
assert_eq!(said["translate"], "narration.suggestion.tooltip");
assert_eq!(said["with"].as_array().map(Vec::len), Some(4));
l.current = 0;
let said = l.narration_message().component();
assert_eq!(said["translate"], "narration.suggestion");
assert_eq!(said["with"].as_array().map(Vec::len), Some(3));
}
#[test]
fn a_chat_message_is_trimmed_and_its_whitespace_runs_collapsed() {
// ChatScreen.normalizeChatMessage: String.trim, then StringUtils
// .normalizeSpace, so a command gets the arguments the player meant.
assert_eq!(normalize_chat_message(" hello there "), "hello there");
assert_eq!(normalize_chat_message("/msg Alex\thi"), "/msg Alex hi");
assert_eq!(normalize_chat_message("a\n\nb\r\nc"), "a b c");
assert_eq!(normalize_chat_message("\t \n"), "");
assert_eq!(normalize_chat_message(""), "");
// Character.isWhitespace is not str::trim's Unicode whitespace: U+00A0 is
// no Java whitespace, so it is neither trimmed nor collapsed, while the
// ideographic space U+3000 and Java's U+001C separators are.
assert_eq!(normalize_chat_message(" \u{a0} hi"), "\u{a0} hi");
assert_eq!(normalize_chat_message("a\u{a0}\u{a0}b"), "a\u{a0}\u{a0}b");
assert_eq!(normalize_chat_message("a\u{3000}\u{3000}b"), "a b");
assert_eq!(normalize_chat_message("a\u{1c}b"), "a b");
}
#[test]
fn a_chat_message_is_cut_to_the_256_utf16_units_the_packet_holds() {
// StringUtil.trimChatMessage counts String.length(), UTF-16 code units:
// 256 ASCII characters are all kept, 257 are cut to 256.
assert_eq!(normalize_chat_message(&"a".repeat(256)).len(), 256);
assert_eq!(normalize_chat_message(&"a".repeat(300)).len(), 256);
// An emoji is two units, so 128 of them fill the packet and the 129th is
// dropped: chars().take(256) would keep all 129 and the server would
// reject the message.
let emoji = normalize_chat_message(&"\u{1f600}".repeat(129));
assert_eq!(emoji, "\u{1f600}".repeat(128));
assert_eq!(emoji.encode_utf16().count(), 256);
// A cut between the halves of a pair keeps the character below it.
let mixed = format!("{}\u{1f600}", "b".repeat(255));
assert_eq!(normalize_chat_message(&mixed), "b".repeat(255));
}
+5 -1
View File
@@ -212,7 +212,7 @@ impl Default for ColumnLayout {
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
/// global palette).
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(8);
/// Bits of the block states' global palette: 16 for vanilla's 35,723 states
/// (what vanilla servers and clients send), more when content mods add states
@@ -391,6 +391,9 @@ pub struct Chunk {
pub light_loaded: bool,
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
pub fresh: bool,
/// Finished from a proto chunk just now, in any kind of world, not read
/// from a save (`ChunkEvent.Load`'s `isNewChunk`).
pub generated: bool,
/// Block entities read from a save, moved into the world on arrival.
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
/// `CustomName` of the container block entities (shown as the screen title).
@@ -428,6 +431,7 @@ impl Chunk {
lit: false,
light_loaded: false,
fresh: false,
generated: false,
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
@@ -120,6 +120,21 @@ pub struct ModContainer {
pub outputs: Vec<usize>,
pub menu: Option<String>,
}
/// The fluid tanks of a mod block's block entity, in the container's shape:
/// each tank's capacity in millibuckets, the tanks each face reaches, and
/// the tanks a fill may reach and a drain may take. The tanks' contents are
/// not here: they live in the entity's saved compound, under `fluids`, so
/// they survive restarts and the block's own tick reads them.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModFluidTanks {
/// Each tank's capacity in millibuckets, in tank order.
pub tanks: Vec<i32>,
pub faces: [Vec<usize>; 6],
/// The tanks a fill may reach (`canPlaceItemThroughFace`).
pub inputs: Vec<usize>,
/// The tanks a drain may take from (`canTakeItemThroughFace`).
pub outputs: Vec<usize>,
}
/// The vanilla menus a mod container can open, with the slot count each
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
@@ -218,6 +233,9 @@ pub struct SkullSkin {
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<NameOrId>,
/// `PlayerSkin.Patch.body`: the profile's `texture`, a resource pack
/// texture drawn instead of any skin.
pub texture: Option<String>,
pub fallback: &'static str,
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
@@ -276,11 +294,19 @@ pub struct Beacon {
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `JukeboxBlockEntity`'s one slot (`ContainerSingleItem`), so a hopper or
/// a dropper reaches it as it reaches any container.
pub items: [Option<ItemStack>; 1],
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
impl Jukebox {
/// `JukeboxBlockEntity.getTheItem`: the disc it holds.
pub fn record(&self) -> Option<&ItemStack> {
self.items[0].as_ref()
}
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
+51 -13
View File
@@ -1,7 +1,15 @@
//! Cloud visibility and flat/fancy cell geometry (`CloudRenderer`).
/// `EnvironmentAttributes.CLOUD_HEIGHT`'s default, and the overworld's `visual/cloud_height`.
pub const CLOUD_HEIGHT: f64 = 192.33;
/// Whether the cloud color has any packed alpha (vanilla gates the clouds on it).
pub fn has_color(alpha: f32) -> bool {
(alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
}
/// `LevelRenderer`: skip OFF clouds and colors with no packed alpha.
pub fn visible(mode: &str, alpha: f32) -> bool {
mode != "false" && (alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
mode != "false" && has_color(alpha)
}
/// `buildFlatCell`: bottom plane, in both windings for the culled cloud pipeline.
@@ -110,7 +118,7 @@ impl Input<'_> {
z -= (z / th).floor() * th;
let cell_x = (x / 12.0).floor() as i64;
let cell_z = (z / 12.0).floor() as i64;
let bottom = (192.33 - self.camera.y) as f32;
let bottom = (CLOUD_HEIGHT - self.camera.y) as f32;
let key = MeshKey {
cell_x,
cell_z,
@@ -169,11 +177,13 @@ impl Mesh {
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
let mut quad = |p: [[f32; 3]; 4], shade: u32| {
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
// `shade`.
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal: [0; 4],
normal,
pos: v,
uv,
layer: 4,
@@ -186,17 +196,41 @@ impl Mesh {
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0);
quad(p, 0, [0; 4]);
}
continue;
}
let quads = [
([[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]], 1),
([[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]], 0),
([[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]], 2),
([[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]], 2),
([[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]], 3),
([[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]], 3),
(
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
1,
[0, -127, 0, 0],
),
(
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
0,
[0, 127, 0, 0],
),
(
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
2,
[0, 0, -127, 0],
),
(
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
2,
[0, 0, 127, 0],
),
(
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
3,
[-127, 0, 0, 0],
),
(
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
3,
[127, 0, 0, 0],
),
];
let mask = face_mask(
dx as i32,
@@ -210,8 +244,12 @@ impl Mesh {
],
);
for (direction, inside) in faces(mask) {
let (p, shade) = quads[usize::from(direction)];
quad(if inside { [p[3], p[2], p[1], p[0]] } else { p }, shade);
let (p, shade, n) = quads[usize::from(direction)];
if inside {
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
} else {
quad(p, shade, n);
}
}
}
self.key = Some(key);
+158 -4
View File
@@ -343,8 +343,15 @@ impl EntityMovementWorld for SceneCollisions<'_, '_> {
}
impl DynamicSupportWorld for SceneCollisions<'_, '_> {
type Error = io::Error;
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
/// `ShulkerBoxBlockEntity.isClosed`: whether the lid is still shut. A
/// closed box collides as its plain block (`ShulkerBoxBlock.getShape`
/// asks the block entity, which has grown once the lid starts to open).
fn shulker_is_closed(&mut self, p: [i32; 3]) -> io::Result<Option<bool>> {
Ok(self
.world
.shulkers
.get(&p)
.map(|s| s.status == crate::block_entity_anim::ShulkerStatus::Closed))
}
/// `MovingPistonBlock.getCollisionShape`: the block entity's moving shape
/// (`PistonMovingBlockEntity.getCollisionShape`), outside any push.
@@ -373,11 +380,17 @@ impl PistonCollisionWorld for SceneCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for SceneCollisions<'_, '_> {
/// `ShulkerBoxBlockEntity.getBoundingBox`: the box's collision shape
/// (`getShape`) is the free shape grown by the lid's progress, which is
/// what lets an entity sink into an opening box. None: no such box here.
fn shulker_collision_state(
&mut self,
_: [i32; 3],
p: [i32; 3],
) -> io::Result<Option<ShulkerCollisionState>> {
Ok(None)
Ok(self.world.shulkers.get(&p).map(|s| ShulkerCollisionState {
previous_progress: s.progress_old,
progress: s.progress,
}))
}
}
impl LiquidCollisionWorld for SceneCollisions<'_, '_> {
@@ -497,6 +510,8 @@ impl EntityMovementWorld for LookupCollisions<'_, '_> {
}
impl DynamicSupportWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
/// No block-entity data: a lookup world has no shulker box to ask, so the
/// box collides as a shut one (`VoxelShape::block()`).
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
}
@@ -505,6 +520,8 @@ impl DynamicSupportWorld for LookupCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for LookupCollisions<'_, '_> {
/// No block-entity data: the block table's own shape stands in for the lid
/// (`ShulkerBoxBlockEntity.getBoundingBox` needs a block entity).
fn shulker_collision_state(
&mut self,
_: [i32; 3],
@@ -611,3 +628,140 @@ mod border_movement_tests {
);
}
}
#[cfg(test)]
mod shulker_collision_tests {
use super::*;
use crate::block_entity_anim::{Shulker, ShulkerStatus, progress_delta_aabb};
use horizon_core::geometry::{Aabb, Axis, Direction, Vec3, VoxelShape};
/// A shulker box facing up at progress 0.0 (closed): collision is a 1x1x1 block.
#[test]
fn shulker_closed_collision_is_full_block() {
let shulker = Shulker {
status: ShulkerStatus::Closed,
progress: 0.0,
progress_old: 0.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Closed box: height 1.0
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.0).abs() < 1.0e-9);
// Full horizontal extent
assert!((aabb.min(Axis::X) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::X) - 1.0).abs() < 1.0e-9);
}
/// A shulker box facing up at progress 1.0 (fully open): collision is 1.5 tall.
#[test]
fn shulker_open_collision_grows_upward() {
let shulker = Shulker {
status: ShulkerStatus::Opened,
progress: 1.0,
progress_old: 1.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Fully open: height 1.5 (lid extends 0.5 above the block)
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.5).abs() < 1.0e-9);
}
/// A shulker box halfway open: collision height is 1.25.
#[test]
fn shulker_half_open_collision_height() {
let shulker = Shulker {
status: ShulkerStatus::Opening,
progress: 0.5,
progress_old: 0.5,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Half open: height 1.25
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.25).abs() < 1.0e-9);
}
/// Vanilla clip quirk: an entity whose feet are *inside* the grown lid box
/// moving DOWN is not stopped by the box (collision only triggers when
/// approaching from outside). An entity ABOVE the box moving DOWN is stopped.
#[test]
fn shulker_grown_box_allows_clip_when_inside() {
// Box at progress 1.0: spans y=0..1.5
let shulker = Shulker {
status: ShulkerStatus::Opened,
progress: 1.0,
progress_old: 1.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
let box_shape = VoxelShape::from_box(aabb).expect("shulker box is a valid shape");
// Entity box whose feet are at y=0.5 (inside the grown box), height 1.8
let entity = Aabb::new(Vec3::new(0.2, 0.5, 0.2), Vec3::new(0.8, 2.3, 0.8));
// Moving DOWN from inside the box: collide() should NOT stop it
// (the entity is already intersecting the box, so the downward movement
// sees no face to hit from above).
let down = -0.1;
let moved_down = box_shape.collide(Axis::Y, entity, down);
assert!(
(moved_down - down).abs() < 1.0e-9,
"entity inside grown box should fall through (clip): moved_down={moved_down}, expected={down}"
);
// Entity box whose feet are at y=1.6 (just ABOVE the grown box)
let entity_above = Aabb::new(Vec3::new(0.2, 1.6, 0.2), Vec3::new(0.8, 3.4, 0.8));
// Moving DOWN from above: collide() SHOULD stop it at y=1.5
let moved_down_above = box_shape.collide(Axis::Y, entity_above, -0.2);
// The box's top is at y=1.5, entity bottom at 1.6, so max down is -0.1
assert!(
(moved_down_above - (-0.1)).abs() < 1.0e-9,
"entity above grown box should be stopped: moved_down_above={moved_down_above}, expected=-0.1"
);
}
/// An entity standing on the lid (y=1.0 when closed, y=1.5 when open)
/// and pushed up by the lid: the lid's swept AABB pushes it along the facing.
/// This is tested at the integration level via `push_shulker_entities`;
/// here we only verify the swept box geometry matches vanilla's
/// `Shulker.getProgressDeltaAabb`.
#[test]
fn shulker_swept_box_matches_vanilla_delta() {
// From closed (0.0) to progress 0.5: the lid sweeps from y=0..1 (closed box)
// to y=0..1.5 (half open), so the swept volume in world coords at position
// [0.5, 0.0, 0.5] (bottom center of block at y=0) is y=1.0..1.5.
let delta = progress_delta_aabb(1.0, Direction::Up as u8, 0.0, 0.5, [0.5, 0.0, 0.5]);
assert!((delta[1] - 1.0).abs() < 1.0e-9); // minY
assert!((delta[4] - 1.5).abs() < 1.0e-9); // maxY
// From progress 0.5 to 1.0: swept y=1.5..2.0 in world coords
let delta2 = progress_delta_aabb(1.0, Direction::Up as u8, 0.5, 1.0, [0.5, 0.0, 0.5]);
assert!((delta2[1] - 1.5).abs() < 1.0e-9);
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
}
}
+98 -4
View File
@@ -4,11 +4,64 @@
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
PotentSulfur, Shelf, Skull, SkullSkin,
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
/// a content mod's menu type (`data/<id>/horizon/menu/`, vanilla's
/// `MenuType`): its container slots, its data slots (`ContainerData`) and the
/// slots players only take from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ModMenuType {
pub id: &'static str,
pub slots: usize,
pub data_slots: usize,
pub results: Vec<usize>,
}
static MOD_MENU_TYPES: std::sync::OnceLock<Vec<ModMenuType>> = std::sync::OnceLock::new();
/// registers the content mods' menu types, `(id, slots, data slots,
/// results)`, a list set once per process (false when it already was).
pub fn register_mod_menu_types(
menus: impl IntoIterator<Item = (String, usize, usize, Vec<usize>)>,
) -> bool {
if MOD_MENU_TYPES.get().is_some() {
return false;
}
let menus = menus
.into_iter()
.map(|(id, slots, data_slots, results)| ModMenuType {
id: Box::leak(id.into_boxed_str()),
slots,
data_slots,
results,
});
MOD_MENU_TYPES.set(menus.collect()).is_ok()
}
/// the content mods' menu types, in registry order.
pub fn mod_menu_types() -> &'static [ModMenuType] {
MOD_MENU_TYPES.get().map_or(&[], Vec::as_slice)
}
/// the mod menu type named `id`, kept out of line: a vanilla menu never
/// looks one up.
#[cold]
#[inline(never)]
pub fn mod_menu_type(id: &str) -> Option<&'static ModMenuType> {
mod_menu_types().iter().find(|m| m.id == id)
}
/// whether a client of `brand` can be sent menu type `menu`: a vanilla
/// client reads the type through `ByteBufCodecs.registry(Registries.MENU)`,
/// and an id it lacks (`IdMap.byIdOrThrow`) disconnects it, so it is sent
/// vanilla's only. A Horizon client has the server's (the content identity).
pub fn menu_reaches(brand: &str, menu: &str) -> bool {
brand == "horizon" || menu.starts_with("minecraft:")
}
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
match self {
@@ -82,6 +135,17 @@ pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_w
display_name(first.or(second), "container.chestDouble")
}
/// `BaseContainerBlockEntity.sendChestLockedNotifications`: the action bar
/// message of a container that refuses to open, `container.isLocked` naming it
/// by its display name.
pub fn locked_message(title: &horizon_world::nbt::Tag) -> horizon_world::nbt::Tag {
use horizon_world::nbt::Tag;
Tag::compound([
("translate", Tag::String("container.isLocked".into())),
("with", Tag::List(vec![title.clone()])),
])
}
/// The menu a container vehicle's `createMenu` builds (`ContainerEntity`):
/// `AbstractChestBoat.createMenu` and `MinecartChest.createMenu` are
/// `ChestMenu.threeRows` (a 9x3 menu), `MinecartHopper.createMenu` is a
@@ -185,6 +249,7 @@ impl SkullSkin {
Self {
download,
dynamic,
texture: crate::skins::profile_texture_nbt(profile),
fallback,
}
}
@@ -410,7 +475,7 @@ impl BlockEntity {
Self::Furnace(f) => f.items.iter().flatten().cloned().collect(),
Self::Brewing(b) => b.items.iter().flatten().cloned().collect(),
Self::Sign(_) => Vec::new(),
Self::Jukebox(j) => j.record.iter().cloned().collect(),
Self::Jukebox(j) => j.items.iter().flatten().cloned().collect(),
Self::Beacon(_)
| Self::CreakingHeart(_)
| Self::Spawner(_)
@@ -443,7 +508,6 @@ impl BlockEntity {
Self::Furnace(f) => &f.items,
Self::Brewing(b) => &b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
@@ -457,6 +521,7 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Jukebox(j) => &j.items,
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::Shelf(b) => &b.items,
@@ -477,7 +542,6 @@ impl BlockEntity {
Self::Furnace(f) => &mut f.items,
Self::Brewing(b) => &mut b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
@@ -491,6 +555,7 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Jukebox(j) => &mut j.items,
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::Shelf(b) => &mut b.items,
@@ -599,6 +664,15 @@ mod tests {
use super::*;
use horizon_core::random::{LegacyRandom, RandomSource};
/// a client that isn't Horizon's is sent vanilla's menu types only.
#[test]
fn a_mod_menu_type_reaches_horizon_clients_only() {
assert!(menu_reaches("horizon", "ruby:press"));
assert!(!menu_reaches("vanilla", "ruby:press"));
assert!(!menu_reaches("fabric", "ruby:press"));
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
}
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
@@ -634,6 +708,26 @@ mod tests {
assert_eq!(fifth.count, 1);
}
/// `JukeboxBlockEntity`'s single slot is a `Container` slot, so a hopper
/// (`HopperBlockEntity.getContainerAt`) can reach the disc in it.
#[test]
fn a_jukebox_offers_its_record_as_its_one_container_slot() {
let mut entity = BlockEntity::Jukebox(Jukebox::default());
assert_eq!(entity.slots().len(), 1);
assert!(entity.slots()[0].is_none(), "empty until a disc lands");
let disc = ItemStack::new("minecraft:cat", 1);
entity.slots_mut()[0] = Some(disc.clone());
let slots = entity.slots();
assert_eq!(slots[0].as_ref().map(|s| &*s.item), Some("minecraft:cat"));
match &entity {
BlockEntity::Jukebox(j) => {
assert_eq!(j.record().map(|s| &*s.item), Some("minecraft:cat"));
}
_ => panic!("a jukebox"),
}
assert_eq!(entity.contents().len(), 1);
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
+38 -1
View File
@@ -195,6 +195,14 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
fn windows_processor_name(query: &str) -> Option<String> {
query.lines().find_map(|line| {
let (_, value) = line.split_once("REG_SZ")?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
@@ -235,6 +243,18 @@ pub fn cpu() -> String {
})
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
command_output(
"reg",
&[
"query",
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
"/v",
"ProcessorNameString",
],
)
.and_then(|query| windows_processor_name(&query))
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
@@ -354,7 +374,8 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
);
crate::logging::flush();
crate::telemetry::keep_crash(home, &report);
match report.save(home) {
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
Ok(path) => {
eprintln!("{SAVED_LINE}{}", path.display());
Some(path)
@@ -636,6 +657,22 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
let query = r#"
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
"#;
assert_eq!(
windows_processor_name(query).as_deref(),
Some("13th Gen Intel(R) Core(TM) i7-13700F")
);
assert_eq!(
windows_processor_name("ProcessorNameString REG_SZ "),
None
);
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
+183 -16
View File
@@ -23,7 +23,7 @@ enum Source {
}
impl DataPack {
/// Every file path under `prefix` (relative to the pack root).
fn list(&self, prefix: &str) -> Vec<String> {
pub fn list(&self, prefix: &str) -> Vec<String> {
match &self.source {
Source::Zip(z) => z
.names()
@@ -58,12 +58,13 @@ impl DataPack {
}
out
}
fn read(&self, path: &str) -> Option<Vec<u8>> {
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
match &self.source {
Source::Zip(z) => z.read(path).ok(),
Source::Dir(root) => std::fs::read(root.join(path)).ok(),
}
}
/// The `description` of `pack.mcmeta`, as text.
pub fn description(&self) -> String {
let Some(bytes) = self.read("pack.mcmeta") else {
@@ -75,6 +76,24 @@ impl DataPack {
other => other["text"].as_str().unwrap_or_default().to_owned(),
}
}
/// The `features.enabled` flag ids of `pack.mcmeta`
/// (`FeatureFlagsMetadataSection`): the flags the pack asks to turn on.
pub fn features(&self) -> Vec<String> {
self.mcmeta()["features"]["enabled"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|f| f.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
/// The `pack.mcmeta` as JSON, empty when it is missing or unreadable.
fn mcmeta(&self) -> serde_json::Value {
self.read("pack.mcmeta")
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
}
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
@@ -127,14 +146,43 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// The resources root's built-in feature packs (`ServerPacksSource`
/// scanning the game's `data/minecraft/datapacks`): every folder with a
/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
/// selects them.
pub fn feature_packs(resources: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(resources.join("data").join("minecraft").join("datapacks"))
else {
return Vec::new();
};
let mut packs = Vec::new();
for e in entries.flatten() {
let path = e.path();
if path.join("pack.mcmeta").exists() {
packs.push(DataPack {
id: e.file_name().to_string_lossy().into_owned(),
source: Source::Dir(path),
});
}
}
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// The packs of `<world>/datapacks` plus the resources root's feature packs:
/// the world's whole repository, as `PackRepository.reload` sees it.
pub fn discover_with_features(world_root: &Path, resources: &Path) -> Vec<DataPack> {
let mut packs = discover(world_root);
packs.extend(feature_packs(resources));
packs
}
// ---- Create World ------------------------------------------------------------
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
/// built-in one (`resources` holds its `pack.png`), which can be unselected
/// and stays at the bottom. Horizon has no experiments, so their feature
/// packs are not offered.
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`), the
/// built-in one (`resources` holds its `pack.png`), and the feature packs
/// of `resources` (`PackSource.FEATURE`), available at the bottom and off
/// until picked — the Experiments.
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
use crate::{
pack_screen::{Compat, PackInfo, Source, load_icon},
@@ -172,6 +220,23 @@ pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::Pac
position: Position::Bottom,
fixed: false,
});
for p in feature_packs(resources) {
let meta = p.mcmeta();
let (description, description_style) =
crate::packs::component_root(&meta["pack"]["description"]);
infos.push(PackInfo {
icon: None,
id: p.id.clone(),
title: Text::translate(&format!("dataPack.{}.name", p.id), &[]),
description,
description_style,
source: Source::Feature,
compat: crate::packs::compatibility_for(&meta, format),
required: false,
position: Position::Bottom,
fixed: false,
});
}
infos
}
/// A new empty folder for Create World's packs
@@ -185,6 +250,59 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
/// does (a folder zipped by the system, which adds a wrapper). Copied into
/// `dir`; false when `path` is no pack.
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
fn copy_dir(from: &Path, at: &Path) {
let _ = std::fs::create_dir_all(at);
for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
let path = entry.path();
let to = at.join(entry.file_name());
if path.is_dir() {
copy_dir(&path, &to);
} else {
let _ = std::fs::copy(&path, &to);
}
}
}
let name = || {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
};
if path.is_file() {
return path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("zip"))
&& {
let _ = std::fs::create_dir_all(dir);
std::fs::copy(path, dir.join(name())).is_ok()
};
}
if !path.is_dir() {
return false;
}
if path.join("pack.mcmeta").is_file() {
copy_dir(path, &dir.join(name()));
return true;
}
let own: Vec<PathBuf> = std::fs::read_dir(path)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
.collect();
match own.as_slice() {
[one] => {
copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
true
}
_ => false,
}
}
/// What's in a folder of packs, to notice packs added or removed while the
/// screen shows it (`PackSelectionScreen.Watcher`).
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
@@ -230,7 +348,11 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
/// The built-in pack is selected unless the world turned it off.
pub fn for_recreate(world: &Path) -> (Option<PathBuf>, Vec<String>) {
let packs = discover(world);
let mut selected = enabled_ids(world, &packs);
// No flags here: the recreated world keeps the original's
// `enabled_features`, and the flag-aware load re-adds the feature packs
// it asks for (`for_recreate`'s selection only seeds the new world's
// `datapacks.json`).
let mut selected = enabled_ids(world, &packs, &[]);
let vanilla_off = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
@@ -453,7 +575,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -461,17 +583,21 @@ pub struct DataStore {
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` or `tags/<sub>/`, where the id
/// keeps the sub path so a nested tag kind stays addressable.
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" | "dialog" => kind,
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
"tags" => {
let (sub, name) = rest.rsplit_once('/')?;
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
return Some(format!("tags/{ns}:{}", rest.strip_suffix(".json")?));
}
_ => return None,
};
@@ -503,7 +629,10 @@ impl DataStore {
"loot_table",
"predicate",
"recipe",
"recipe_filter",
"tags",
"trade_set",
"villager_trade",
]) {
if !seen.insert(f.clone()) {
continue;
@@ -617,10 +746,25 @@ fn hash_normalize(s: &str) -> String {
}
}
/// Whether every flag `pack` asks for (`Pack.getRequestedFeatures`) is among
/// the world's `enabled_features`.
fn features_enabled(pack: &DataPack, enabled_features: &[String]) -> bool {
pack.features()
.iter()
.all(|f| enabled_features.iter().any(|e| e == f))
}
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
/// order, then every pack it lists neither way, in name order.
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
///
/// `enabled_features` are the world's feature flags
/// (`WorldDataConfiguration.enabledFeatures`): a pack a pack file selected
/// while asking for missing features comes off
/// (`MinecraftServer.configurePackRepository`), and a feature pack whose
/// features the world enables is forced on however the world's selection
/// feels about it (`MinecraftServer.enableForcedFeaturePacks`).
pub fn enabled_ids(world: &Path, packs: &[DataPack], enabled_features: &[String]) -> Vec<String> {
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
@@ -639,9 +783,32 @@ pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
let mut enabled: Vec<String> = list("enabled")
.into_iter()
.filter(|id| packs.iter().any(|p| &p.id == id))
.filter(|id| {
packs
.iter()
.find(|p| &p.id == id)
.is_none_or(|p| features_enabled(p, enabled_features))
})
.collect();
for p in packs {
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
// A pack nobody mentions comes on (`PackRepository.reload` selects
// every new pack), but a feature pack stays off until the world
// enabled its flag (`PackSource.FEATURE` starts unselected).
if !enabled.contains(&p.id)
&& !disabled.contains(&p.id)
&& p.id.starts_with("file/")
&& features_enabled(p, enabled_features)
{
enabled.push(p.id.clone());
}
}
for p in packs {
let feature_pack = !p.id.starts_with("file/") && p.id != "vanilla";
if feature_pack
&& !p.features().is_empty()
&& features_enabled(p, enabled_features)
&& !enabled.contains(&p.id)
{
enabled.push(p.id.clone());
}
}
@@ -752,7 +919,7 @@ mod tests {
)
.unwrap();
let packs = discover(&dir);
let enabled = enabled_ids(&dir, &packs);
let enabled = enabled_ids(&dir, &packs, &[]);
assert_eq!(enabled, ["file/b", "file/a"]);
let enabled: Vec<&DataPack> = enabled
.iter()
@@ -13,6 +13,37 @@ use std::collections::HashMap;
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
pub const MAX: usize = 16;
/// Camera-relative position and radius; RGB intensity in a second vec4.
pub type Colored = [[f32; 4]; 2];
/// Nearest validated native visual lights; never enters gameplay lighting.
pub fn select_colored(
sources: impl Iterator<Item = ([f64; 3], u32, f32, f32)>,
camera: DVec3,
) -> Vec<Colored> {
let mut nearest: Vec<_> = sources.collect();
nearest.sort_by(|a, b| {
(DVec3::from(a.0) - camera)
.length_squared()
.total_cmp(&(DVec3::from(b.0) - camera).length_squared())
});
nearest
.into_iter()
.take(MAX)
.map(|(at, rgb, radius, intensity)| {
let at = (DVec3::from(at) - camera).as_vec3();
[
[at.x, at.y, at.z, radius],
[
((rgb >> 16) & 255) as f32 / 255.0,
((rgb >> 8) & 255) as f32 / 255.0,
(rgb & 255) as f32 / 255.0,
intensity,
],
]
})
.collect()
}
/// What an entity on fire gives off, as a lit campfire or fire block does.
pub const BURNING: u8 = 15;
/// Sources farther than this from the camera light nothing it can see well.
@@ -87,6 +118,17 @@ pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
mod tests {
use super::*;
#[test]
fn colored_sources_are_camera_relative_and_bounded() {
let lights = select_colored(
(0..20).map(|x| ([x as f64, 64.0, 0.0], 0xff8040, 5.0, 0.5)),
DVec3::new(2.0, 64.0, 0.0),
);
assert_eq!(lights.len(), MAX);
assert_eq!(lights[0][0][0], 0.0);
assert_eq!(lights[0][1], [1.0, 128.0 / 255.0, 64.0 / 255.0, 0.5]);
}
#[test]
fn nearest_sources_win() {
let camera = DVec3::new(100.0, 64.0, 100.0);
+39 -9
View File
@@ -235,10 +235,12 @@ impl PlayerEffects {
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
/// answer: the effect ends once the absorption is used up. Returns the
/// actions to apply, in order, and whether Raid Omen was newly added.
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
/// (on its last tick) and a content mod's effect (every tick, with the
/// tick count `shouldApplyEffectTickThisTick` takes); `absorption_empty`
/// is `AbsorptionMobEffect`'s answer: the effect ends once the
/// absorption is used up. Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -247,7 +249,7 @@ impl PlayerEffects {
/// ticked; the next tick refreshes Raid Omen and drops Bad Omen.
pub fn tick_with(
&mut self,
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
omen: &mut dyn FnMut(&EffectInstance, i32) -> OmenTick,
absorption_empty: bool,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
@@ -269,8 +271,9 @@ impl PlayerEffects {
actions.push(a);
}
let answer = match e.id {
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
"bad_omen" => omen(e, t),
"raid_omen" if t == 1 => omen(e, t),
id if potions::is_mod_effect(id) => omen(e, t),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
@@ -508,7 +511,7 @@ mod tests {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("bad_omen", 100, 1));
fx.add(EffectInstance::new("speed", 50, 0));
let mut convert = |e: &EffectInstance| match e.id {
let mut convert = |e: &EffectInstance, _: i32| match e.id {
"bad_omen" => OmenTick::Add(EffectInstance::new("raid_omen", 600, e.amplifier)),
_ => OmenTick::Continue,
};
@@ -550,12 +553,39 @@ mod tests {
.is_empty()
);
}
/// A content mod's effect answers each tick before it counts down (an
/// infinite one with the tick count), and goes at once on Remove.
#[test]
fn a_mod_effect_answers_before_it_counts_down() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("ruby:glow", 3, 1));
fx.add(EffectInstance::new("ruby:bleed", -1, 0));
let mut seen = Vec::new();
let mut answer = |e: &EffectInstance, t: i32| {
seen.push((e.id, t));
match (e.id, t) {
("ruby:glow", 2) => OmenTick::Remove,
_ => OmenTick::Continue,
}
};
fx.tick_with(&mut answer, false);
fx.tick_with(&mut answer, false);
let ticks = [
("ruby:glow", 3),
("ruby:bleed", 1),
("ruby:glow", 2),
("ruby:bleed", 2),
];
assert_eq!(seen, ticks);
assert!(fx.get("ruby:glow").is_none());
assert_eq!(fx.get("ruby:bleed").unwrap().duration, -1);
}
#[test]
fn raid_omen_acts_on_its_last_tick() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("raid_omen", 2, 0));
let mut seen = Vec::new();
let mut watch = |e: &EffectInstance| {
let mut watch = |e: &EffectInstance, _: i32| {
seen.push(e.duration);
OmenTick::Remove
};
+30 -2
View File
@@ -22,6 +22,10 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
colored_lights: array<array<vec4<f32>, 2>, 16>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 32>,
emissive_count: vec4<u32>,
sunrise_fan: array<vec4<f32>, 32>,
filtering: vec4<f32>,
}
@@ -90,6 +94,15 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
}
return level * 16.0;
}
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
var rgb = vec3(0.0);
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
let at = globals.colored_lights[i][0];
let color = globals.colored_lights[i][1];
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
}
return min(rgb, vec3(1.0));
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
@@ -103,6 +116,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
if globals.colored_count.x > 0u && !unlit {
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
}
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range; 11 and 12
// (cracks): the destroy stage sprite's origin.
out.wrap = vec2(f32(light.x | (light.y << 8u)), f32(light.z | (light.w << 8u))) / 65535.0;
@@ -141,7 +157,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
@location(7) offset: vec3<f32>) -> Out {
return entity_vertex(offset + position, uv, overlay, color, light, normal);
var out = entity_vertex(offset + position, uv, overlay & 0xffffu, color, light, normal);
out.tier = (overlay >> 16u) & 0xffu;
return out;
}
struct CloudInfo {
offset: vec4<f32>,
@@ -294,11 +312,21 @@ fn shadow_color(in: Out) -> vec4<f32> {
// way terrain.wgsl marks a light source's, drawn again after the entities with
// only alpha written, so its colour stays as blended.
@fragment fn fs_hdr_mark(in: Out) -> @location(0) vec4<f32> {
if in.tier == 0u || textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
// Beams wrap their v in the sprite as fs_entity does: sample where they draw.
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.tier == 0u || textureSample(atlas, atlas_sampler, uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
}
fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
if in.overlay == 37u {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
@@ -141,6 +141,8 @@ pub struct Sky {
pub fog_end: f32,
/// `visual/cloud_fog_end_distance`.
pub cloud_fog_end: f32,
/// `visual/cloud_height`.
pub cloud_height: f32,
}
impl Environment {
pub fn load(root: &Path) -> Result<Self> {
@@ -309,6 +311,10 @@ impl Environment {
.base
.get("minecraft:visual/cloud_fog_end_distance")
.map_or(2048.0, |v| v[0]),
cloud_height: self
.base
.get("minecraft:visual/cloud_height")
.map_or(crate::clouds::CLOUD_HEIGHT as f32, |v| v[0]),
}
}
}
+64 -7
View File
@@ -96,8 +96,13 @@ pub struct Extras {
pub minimap_rotate: bool,
/// Retry lost multiplayer connections (`reconnect`).
pub auto_reconnect: bool,
/// Positional sounds leave a few static sound channels free for UI sounds
/// and others played to the listener (`sound_engine::HEARD_RESERVE`).
pub ui_sound_reserve: bool,
/// The player's own name tag over its model in third person.
pub own_name_tag: bool,
/// No particles from breaking or mining a block (other particles stay).
pub hide_block_particles: bool,
/// Mouse Tweaks-style drags in the inventory and storage containers.
pub mouse_tweaks: bool,
/// With them, the wheel over a slot moves its items one at a time.
@@ -113,6 +118,8 @@ pub struct Extras {
pub agent_control: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -138,7 +145,9 @@ impl Default for Extras {
minimap_zoom: 1.0,
minimap_rotate: false,
auto_reconnect: false,
ui_sound_reserve: false,
own_name_tag: false,
hide_block_particles: false,
mouse_tweaks: false,
mouse_tweaks_wheel: true,
sort: false,
@@ -146,6 +155,7 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
discord_presence: true,
splitscreen: false,
}
}
}
@@ -213,7 +223,9 @@ impl Extras {
}
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
"uiSoundReserve" => e.ui_sound_reserve = flag(e.ui_sound_reserve),
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
"hideBlockParticles" => e.hide_block_particles = flag(e.hide_block_particles),
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
@@ -221,6 +233,7 @@ impl Extras {
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
_ => {}
}
}
@@ -228,7 +241,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -254,7 +267,9 @@ impl Extras {
self.minimap_zoom,
bool_text(self.minimap_rotate),
bool_text(self.auto_reconnect),
bool_text(self.ui_sound_reserve),
bool_text(self.own_name_tag),
bool_text(self.hide_block_particles),
bool_text(self.mouse_tweaks),
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
@@ -263,6 +278,7 @@ impl Extras {
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -383,7 +399,9 @@ pub enum Item {
Fullbright,
DynamicLights,
OwnNameTag,
HideBlockParticles,
AutoReconnect,
UiSoundReserve,
MouseTweaks,
MouseTweaksWheel,
Sort,
@@ -393,6 +411,8 @@ pub enum Item {
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the Splitscreen settings page.
Splitscreen,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -403,6 +423,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(DynamicLights),
Err("Third person"),
Ok(OwnNameTag),
Err("Particles"),
Ok(HideBlockParticles),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
@@ -410,6 +432,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(SortKey),
Err("Multiplayer"),
Ok(AutoReconnect),
Err("Sound"),
Ok(UiSoundReserve),
]
}
impl Item {
@@ -418,7 +442,9 @@ impl Item {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::OwnNameTag => "Own name tag",
Item::HideBlockParticles => "Hide block-break particles",
Item::AutoReconnect => "Auto-reconnect",
Item::UiSoundReserve => "Keep channels for UI sounds",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
@@ -426,16 +452,18 @@ impl Item {
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
pub fn parent(self) -> Option<Item> {
use Item::*;
Some(match self {
UiSoundReserve => return None,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
return None;
}
})
@@ -450,9 +478,15 @@ impl Item {
Item::OwnNameTag => {
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
}
Item::HideBlockParticles => {
"Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay."
}
Item::AutoReconnect => {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
Item::UiSoundReserve => {
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
}
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
@@ -471,6 +505,9 @@ impl Item {
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
@@ -481,10 +518,14 @@ impl Item {
use crate::graphics_style::Cost;
match self {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
Some(Cost::None)
}
Item::OwnNameTag
| Item::HideBlockParticles
| Item::AutoReconnect
| Item::MouseTweaks
| Item::Sort
| Item::Splitscreen => Some(Cost::None),
_ => None,
}
}
@@ -494,13 +535,16 @@ impl Item {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag,
Item::HideBlockParticles => e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
_ => return None,
})
}
@@ -521,13 +565,16 @@ impl Item {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
Item::HideBlockParticles => e.hide_block_particles = !e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::SortKey => {}
}
}
@@ -549,7 +596,8 @@ mod tests {
let d = Extras::default();
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
@@ -575,11 +623,20 @@ mod tests {
e.auto_reconnect = true;
Item::OwnNameTag.cycle(&mut e, false);
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
assert_eq!(Item::HideBlockParticles.switch(&e), Some(false));
Item::HideBlockParticles.cycle(&mut e, false);
assert!(e.hide_block_particles);
assert_eq!(Item::HideBlockParticles.switch(&e), Some(true));
let only = Extras::parse("hideBlockParticles:true\n");
assert!(only.hide_block_particles && !only.own_name_tag);
e.mouse_tweaks = true;
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
}
+3 -85
View File
@@ -1,85 +1,3 @@
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
//! keyed by small integers, such as chunk coordinates. Iteration order of
//! such maps carries no meaning: the default hasher's is random anyway.
use std::hash::{BuildHasherDefault, Hasher};
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
#[derive(Clone, Copy, Default)]
pub struct FxHasher {
hash: u64,
}
impl FxHasher {
#[inline]
fn add(&mut self, word: u64) {
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
}
}
impl Hasher for FxHasher {
#[inline]
fn write(&mut self, bytes: &[u8]) {
let (words, rest) = bytes.as_chunks::<8>();
for word in words {
self.add(u64::from_le_bytes(*word));
}
if !rest.is_empty() {
let mut word = [0u8; 8];
word[..rest.len()].copy_from_slice(rest);
self.add(u64::from_le_bytes(word));
}
}
#[inline]
fn write_u8(&mut self, i: u8) {
self.add(u64::from(i));
}
#[inline]
fn write_u16(&mut self, i: u16) {
self.add(u64::from(i));
}
#[inline]
fn write_u32(&mut self, i: u32) {
self.add(u64::from(i));
}
#[inline]
fn write_u64(&mut self, i: u64) {
self.add(i);
}
#[inline]
fn write_usize(&mut self, i: usize) {
self.add(i as u64);
}
#[inline]
fn write_i32(&mut self, i: i32) {
self.add(u64::from(i as u32));
}
#[inline]
fn finish(&self) -> u64 {
// The multiply leaves the low bits weakest; hash tables use them.
self.hash.rotate_left(26)
}
}
pub type FxBuild = BuildHasherDefault<FxHasher>;
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearby_chunks_spread_over_buckets() {
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
for x in -40..40 {
for z in -40..40 {
map.insert((x, z), x * z);
}
}
assert_eq!(map.len(), 6400);
assert_eq!(map[&(-7, 13)], -91);
let mut low = std::collections::HashSet::new();
for x in 0..64 {
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
}
assert!(low.len() > 32, "low bits vary: {}", low.len());
}
}
//! The Fx hasher for hot maps keyed by small integers: kept in horizon-core
//! (the scheduled ticks use it too), and reachable here under its old path.
pub use horizon_core::fast_hash::*;
+77 -1
View File
@@ -112,6 +112,8 @@ struct Region {
cursor: (u32, u32),
row: u32,
pending: Vec<Patch>,
/// A glyph did not fit since the last `Font::begin_frame`.
full: bool,
}
struct Cache {
/// Every font by id, the default one first (the common lookup).
@@ -546,6 +548,7 @@ impl Region {
self.row = 0;
}
if y + h > region.1 {
self.full = true;
return None;
}
let (ax, ay) = (x, region.0 + y);
@@ -603,6 +606,7 @@ impl Font {
cursor: (0, 0),
row: 0,
pending: Vec::new(),
full: false,
},
random: horizon_core::random::LegacyRandom::new(
std::time::SystemTime::now()
@@ -669,8 +673,19 @@ impl Font {
}
cache.region.cursor = (0, 0);
cache.region.row = 0;
cache.region.full = false;
self.generation.set(next_generation());
}
/// Call once per frame before any text is laid out. When the dynamic
/// region ran out of room (vanilla's glyph textures grow instead), the
/// glyphs are forgotten so they are rasterized again from the start; it
/// waits for the frame boundary because quads already queued hold the old
/// rects.
pub fn begin_frame(&self) {
if self.cache.borrow().region.full {
self.reset_cache();
}
}
/// Changes whenever a glyph may come out different: another font, a
/// cache reset (glyphs move in the atlas) or other font options. Text laid
/// out under one epoch can be kept until it changes.
@@ -706,7 +721,16 @@ impl Font {
if let Some(g) = set.glyphs.get(&c) {
return *g;
}
let glyph = self.resolve(set, region, c).unwrap_or(self.missing);
let was_full = std::mem::replace(&mut region.full, false);
let found = self.resolve(set, region, c);
// No room: the missing glyph for now, not remembered, so the
// character is tried again once `begin_frame` cleared the region.
let overflowed = std::mem::replace(&mut region.full, was_full);
region.full |= overflowed;
if overflowed {
return self.missing;
}
let glyph = found.unwrap_or(self.missing);
set.glyphs.insert(c, glyph);
glyph
}
@@ -1418,4 +1442,56 @@ mod tests {
font.reset_cache();
assert_eq!(font.glyph('a').advance, 2.0);
}
#[test]
fn a_full_glyph_region_recovers_at_the_next_frame() {
// Two 8x8 glyphs from a sheet outside the atlas; the region holds one row.
let sheet = png(16, 8, |x, _| x % 8 < 4);
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["ab"]}]}"#;
let root = root(
"full",
&[
("assets/minecraft/font/default.json", default),
("assets/pack/textures/font/s.png", &sheet),
],
);
// A 10 wide atlas fits one 8 pixel glyph per row; the region two rows.
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
let epoch = font.epoch();
let a = font.glyph('a');
assert!(a.rect.is_some());
// No room for 'b': missing for now, and asked again next time.
assert!(font.glyph('b').rect.is_none());
assert!(font.glyph('b').rect.is_none());
assert_eq!(font.epoch(), epoch);
font.begin_frame();
assert_ne!(font.epoch(), epoch);
let b = font.glyph('b');
assert!(b.rect.is_some());
assert_eq!(b.advance, a.advance);
// Nothing overflowed since: the next frame keeps the glyphs.
let epoch = font.epoch();
font.begin_frame();
assert_eq!(font.epoch(), epoch);
}
#[test]
fn a_character_without_a_provider_does_not_reset_the_region() {
let sheet = png(8, 8, |x, _| x < 4);
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["a"]}]}"#;
let root = root(
"nofill",
&[
("assets/minecraft/font/default.json", default),
("assets/pack/textures/font/s.png", &sheet),
],
);
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
let epoch = font.epoch();
assert!(font.glyph('z').rect.is_none());
font.begin_frame();
assert_eq!(font.epoch(), epoch);
}
}
File diff suppressed because it is too large. Load diff
@@ -72,6 +72,8 @@ pub(super) struct SpriteAnimator {
size: u32,
/// The layers the last `animate` rewrote, counted over all arrays.
changed: Vec<u32>,
/// Whether each sprite layer has an animation.
is_animated: Vec<bool>,
}
impl SpriteAnimator {
@@ -147,6 +149,11 @@ impl SpriteAnimator {
group,
});
}
let max_layer = animations.iter().map(|a| a.layer).max().unwrap_or(0);
let mut is_animated = vec![false; (max_layer + 1) as usize];
for a in animations {
is_animated[a.layer as usize] = true;
}
let Some((pipeline, layout)) = pipeline else {
let groups = keys
.iter()
@@ -168,6 +175,7 @@ impl SpriteAnimator {
},
size,
changed: Vec::new(),
is_animated,
};
};
let frames: Vec<_> = frames
@@ -247,9 +255,23 @@ impl SpriteAnimator {
writer: Writer::Gpu { jobs, pipeline },
size,
changed: Vec::new(),
is_animated,
}
}
/// Whether the sprite layer has an animation.
pub fn is_animated(&self, layer: u16) -> bool {
self.is_animated
.get(layer as usize)
.copied()
.unwrap_or(false)
}
/// Number of words needed to mark the animated layers visible this frame.
pub fn active_layer_words(&self) -> usize {
self.is_animated.len().div_ceil(u64::BITS as usize)
}
/// Whether the jobs can run on the GPU: compute shaders writing a
/// storage texture and reading two storage buffers (not OpenGL before
/// 4.3).
@@ -269,14 +291,17 @@ impl SpriteAnimator {
}
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
/// frame or mix changed (`SpriteContents.AnimationState.tick`), skipping
/// sprites not present in `active` when provided.
pub fn animate(
&mut self,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut Uploads,
tick: u64,
active: Option<&[u64]>,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if self.collect(tick) == 0 {
if self.collect(tick, active) == 0 {
return;
}
let (jobs, pipeline) = match &self.writer {
@@ -317,7 +342,7 @@ impl SpriteAnimator {
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Animated sprites"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Animated sprites")),
});
pass.set_pipeline(pipeline);
for group in &mut self.groups {
@@ -336,10 +361,18 @@ impl SpriteAnimator {
/// Queue a job for every sprite whose blend at `tick` differs from what
/// its layer shows; the number of jobs.
fn collect(&mut self, tick: u64) -> usize {
fn collect(&mut self, tick: u64, active: Option<&[u64]>) -> usize {
let mut count = 0;
self.changed.clear();
for sprite in &mut self.sprites {
if let Some(active) = active {
let layer = sprite.animation.layer as usize;
let word = layer / u64::BITS as usize;
let bit = 1_u64 << (layer % u64::BITS as usize);
if active.get(word).is_none_or(|active| active & bit == 0) {
continue;
}
}
let blend = sprite.animation.blend(tick);
if sprite.shown == Some(blend) {
continue;
@@ -827,7 +860,7 @@ mod tests {
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
let mut encoder = device.create_command_encoder(&Default::default());
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
animator.animate(&mut encoder, &mut uploads, tick);
animator.animate(&mut encoder, &mut uploads, tick, None, None);
writes.push(
animator
.sprites
@@ -864,4 +897,56 @@ mod tests {
// its frame turns over (every 2 ticks, but 12 shows 7's frame).
assert_eq!(writes, [3, 0, 2, 3, 2, 3, 3, 0, 2, 1]);
}
#[test]
fn inactive_sprites_are_skipped_by_collect() {
let Some((device, _)) = device() else {
return;
};
let size = 16;
let clips = [
(1, 16, r#"{"frametime":1}"#),
(3, 16, r#"{"frametime":1}"#),
(5, 16, r#"{"frametime":1}"#),
];
let mut mips = vec![Settings::default(); 6];
let animations: Vec<_> = clips
.iter()
.map(|&(layer, native, metadata)| {
mips[layer as usize] = settings(native, Strategy::Cutout);
let frames = (0..3)
.map(|f| crate::sprites::resample(&frame(native, layer * 7 + f), native, size))
.collect();
crate::sprites::test_frames(layer, frames, metadata)
})
.collect();
// Construct with CPU writer (empty textures is fine for collect testing)
let mut animator = SpriteAnimator::new(&device, &animations, size, &mips, &[], 2, false);
assert!(animator.is_animated(1));
assert!(!animator.is_animated(2));
assert!(animator.is_animated(3));
assert!(animator.is_animated(5));
// When active layer is [1], only sprite 1 is collected
let active = [1_u64 << 1];
let count = animator.collect(1, Some(&active));
assert_eq!(count, 1);
assert_eq!(animator.changed, vec![1]);
// When active is empty, 0 are collected
let count = animator.collect(2, Some(&[0]));
assert_eq!(count, 0);
assert!(animator.changed.is_empty());
// When active is [3, 5], both 3 and 5 are collected
let active = [1_u64 << 3 | 1_u64 << 5];
let count = animator.collect(3, Some(&active));
assert_eq!(count, 2);
assert_eq!(animator.changed, vec![3, 5]);
// When active is None, all out-of-date sprites update
let count = animator.collect(5, None);
assert_eq!(count, 3);
assert_eq!(animator.changed, vec![1, 3, 5]);
}
}
+497 -25
View File
@@ -1,12 +1,21 @@
//! One vertex buffer for every terrain section: sections own ranges of it, so
//! a frame binds it once and draws every section with indirect multi-draw.
//! Translucent quads can have an arena of their own (`Arena::translucent`),
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
//! page of each and draws in a few multi-draws.
use std::collections::BTreeMap;
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
pub const GRAIN: u64 = 256;
/// The largest page, in bytes.
/// The largest page of the section arena, and of arenas shaders bind whole
/// as storage, in bytes.
pub const PAGE_BYTES: u64 = 256 << 20;
/// The largest page of an arena no shader binds as storage: under 2 GiB, where
/// DirectX 12 and OpenGL cap buffers (`i32::MAX`), with room for the bytes
/// wgpu-core adds to vertex buffers; the device's own limit still applies.
pub const LARGE_PAGE_BYTES: u64 = (1 << 31) - (1 << 20);
/// Free vertex ranges: start -> length, never adjacent.
#[derive(Debug, PartialEq)]
@@ -76,8 +85,22 @@ struct Page {
/// Capacity in vertices.
capacity: u64,
free: FreeList,
/// The buffer went to system memory (`Arena::create`'s fallback): meshes
/// are never moved into it, and it is the first to empty.
host: bool,
}
impl Page {
/// Vertices in the page's free ranges.
fn free_vertices(&self) -> u64 {
self.free.0.values().sum()
}
}
/// An arena with this many bytes in free ranges, at least half of its pages,
/// has a page worth emptying (`Arena::sparsest`).
const COMPACT_MIN_FREE: u64 = 128 << 20;
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
/// buffer sizes): a section's vertices sit in one page.
pub struct Arena {
@@ -86,6 +109,8 @@ pub struct Arena {
page_limit: u64,
initial_capacity: u64,
usage: wgpu::BufferUsages,
/// A page being emptied (`Arena::close`): nothing is placed in it.
closing: Option<u32>,
/// A page had to go to system memory (logged once).
spilled: bool,
/// Tests: GPU-local pages fail as if video memory were full.
@@ -102,7 +127,7 @@ impl Arena {
} else {
wgpu::BufferUsages::VERTEX
};
Self::with_usage(device, stride, capacity, usage)
Self::with_usage(device, stride, capacity, usage, PAGE_BYTES)
}
/// Sorted translucent indices; `storage` when compute passes read them.
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
@@ -111,16 +136,54 @@ impl Arena {
} else {
wgpu::BufferUsages::INDEX
};
Self::with_usage(device, 4, capacity, usage)
Self::with_usage(device, 4, capacity, usage, PAGE_BYTES)
}
/// Translucent quads' vertices, which no shader reads as storage: pages
/// up to `LARGE_PAGE_BYTES`. The section arena's compaction never runs on
/// it (`Renderer::compact_arena`): a page this large has no others to
/// empty into, and the compaction moves sections' `page` ranges, not their
/// `translucent_at`.
pub fn translucent(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::VERTEX;
Self::with_usage(device, stride, capacity, usage, LARGE_PAGE_BYTES)
}
/// Sorted indices of the translucent arena's quads: pages up to
/// `LARGE_PAGE_BYTES` as its own, so its draws keep to one index page too.
pub fn translucent_indices(device: &wgpu::Device, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::INDEX;
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
}
/// The sorted-index arena and, when `split`, the translucent arena
/// (vertices of `stride`), both with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena, and the indices
/// in pages flat draws (`flat`) bind as storage.
pub fn translucent_arenas(
device: &wgpu::Device,
stride: u64,
flat: bool,
split: bool,
) -> (Self, Option<Self>) {
if split {
let indices = Self::translucent_indices(device, 1 << 16);
(indices, Some(Self::translucent(device, stride, 1 << 18)))
} else {
(Self::indices(device, 1 << 16, flat), None)
}
}
fn with_usage(
device: &wgpu::Device,
stride: u64,
capacity: u64,
usage: wgpu::BufferUsages,
page_bytes: u64,
) -> Self {
// 256 MiB pages, or less where the device's buffers are smaller.
let bytes = device.limits().max_buffer_size.min(PAGE_BYTES);
// Pages of `page_bytes`, or less where the device's buffers (or the
// storage bindings that read them whole) are smaller.
let limits = device.limits();
let mut bytes = limits.max_buffer_size.min(page_bytes);
if usage.contains(wgpu::BufferUsages::STORAGE) {
bytes = bytes.min(limits.max_storage_buffer_binding_size);
}
let page_limit = (bytes / stride) / GRAIN * GRAIN;
let capacity = capacity.next_multiple_of(GRAIN).min(page_limit);
Self {
@@ -129,16 +192,22 @@ impl Arena {
page_limit,
initial_capacity: capacity,
usage,
closing: None,
spilled: false,
#[cfg(test)]
video_memory_full: false,
}
}
/// A buffer of `capacity` vertices in video memory or, when that is full
/// and the device allows it, in system memory: Vulkan drivers fail an
/// allocation rather than page like OpenGL ones, and wgpu's default error
/// handler panics. `None` when neither has room.
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
/// A buffer of `capacity` vertices in video memory or, when that is full,
/// `spill` allows it and so does the device, in system memory: Vulkan
/// drivers fail an allocation rather than page like OpenGL ones, and
/// wgpu's default error handler panics. `None` when neither has room.
fn create(
&mut self,
device: &wgpu::Device,
capacity: u64,
spill: bool,
) -> Option<(wgpu::Buffer, bool)> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
#[cfg(test)]
let local = (!self.video_memory_full)
@@ -146,12 +215,14 @@ impl Arena {
.flatten();
#[cfg(not(test))]
let local = try_create(device, capacity * self.stride, usage);
let local = local.map(|buffer| (buffer, false));
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
// allocator takes from system memory once video memory is full.
if !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
if !spill
|| !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
{
return None;
}
@@ -166,7 +237,7 @@ impl Arena {
(lower the render distance if frames slow down)"
);
}
Some(buffer)
Some((buffer, true))
})
}
/// The rounded length a request of `vertices` occupies.
@@ -207,19 +278,16 @@ impl Arena {
vertices: u64,
) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
for (i, page) in self.pages.iter_mut().enumerate() {
if let Some(page) = page
&& let Some(start) = page.free.take(len)
{
return Some((i as u32, start));
}
if let Some(found) = self.alloc_existing(len) {
return Some(found);
}
let last = self.pages.len().saturating_sub(1);
if let Some(page) = self.pages.last().and_then(Option::as_ref)
&& page.capacity < self.page_limit
&& self.closing != Some(last as u32)
{
let current = page.capacity;
let grown = (current * 2).max(current + len).min(self.page_limit);
let grown = self.grown(current, len);
if (grown - current >= len || grown == self.page_limit)
&& self.grow(device, encoder, last, grown)
&& let Some(start) = self.pages[last].as_mut().unwrap().free.take(len)
@@ -235,9 +303,9 @@ impl Arena {
.max(len)
.min(self.page_limit);
// A page just large enough when memory is short.
let (buffer, capacity) = match self.create(device, capacity) {
let ((buffer, host), capacity) = match self.create(device, capacity, true) {
Some(buffer) => (buffer, capacity),
None if capacity > len => (self.create(device, len)?, len),
None if capacity > len => (self.create(device, len, true)?, len),
None => return None,
};
let mut free = FreeList::new(capacity);
@@ -246,6 +314,7 @@ impl Arena {
buffer,
capacity,
free,
host,
};
let index = self
.pages
@@ -258,6 +327,96 @@ impl Arena {
self.pages[index] = Some(page);
Some((index as u32, start))
}
/// A range of `vertices` in a page that exists already and is not being
/// emptied: never grows the arena.
pub fn alloc_existing(&mut self, vertices: u64) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
let closing = self.closing;
self.pages
.iter_mut()
.enumerate()
.filter(|(i, _)| closing != Some(*i as u32))
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
}
/// `alloc_existing` for a mesh moved off the closing page: only pages in
/// video memory, so compaction never pushes terrain into system memory.
pub fn alloc_moved(&mut self, vertices: u64) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
let closing = self.closing;
self.pages
.iter_mut()
.enumerate()
.filter(|(i, p)| closing != Some(*i as u32) && p.as_ref().is_some_and(|p| !p.host))
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
}
/// The page to empty when much of the arena is free ranges (video memory
/// that fragmentation keeps: a page goes back only once all of it is free):
/// the one with the fewest vertices in use that the other pages' free
/// ranges can take. `None` when the arena is dense enough or already
/// emptying a page.
pub fn sparsest(&self) -> Option<u32> {
self.sparsest_from(COMPACT_MIN_FREE)
}
/// `sparsest` for an arena with at least `min_free` bytes in free ranges.
fn sparsest_from(&self, min_free: u64) -> Option<u32> {
if self.closing.is_some() {
return None;
}
let pages = || {
self.pages
.iter()
.enumerate()
.filter_map(|(i, p)| Some((i, p.as_ref()?)))
};
let free: u64 = pages().map(|(_, p)| p.free_vertices()).sum();
let capacity: u64 = pages().map(|(_, p)| p.capacity).sum();
if free * self.stride < min_free || free * 2 < capacity {
return None;
}
// Moves only go to video-memory pages (`alloc_moved`).
let local_free: u64 = pages()
.filter(|(_, p)| !p.host)
.map(|(_, p)| p.free_vertices())
.sum();
pages()
.filter_map(|(i, p)| {
let used = p.capacity - p.free_vertices();
let own = if p.host { 0 } else { p.free_vertices() };
// The room elsewhere, less a quarter for first fit's gaps.
let elsewhere = (local_free - own) / 4 * 3;
// A page in system memory is emptied first.
(used > 0 && used <= elsewhere).then_some((!p.host, used, i as u32))
})
.min()
.map(|(_, _, i)| i)
}
/// Stops placing vertices in `page` until `reopen`.
pub fn close(&mut self, page: u32) {
self.closing = Some(page);
}
pub fn reopen(&mut self) {
self.closing = None;
}
pub fn closing(&self) -> Option<u32> {
self.closing
}
/// Records the copy of `vertices` from one range to another of other pages.
pub fn copy(
&self,
encoder: &mut wgpu::CommandEncoder,
from: (u32, u64),
to: (u32, u64),
vertices: u64,
) {
debug_assert_ne!(from.0, to.0);
encoder.copy_buffer_to_buffer(
self.buffer(from.0),
from.1 * self.stride,
self.buffer(to.0),
to.1 * self.stride,
vertices * self.stride,
);
}
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
let len = Self::span(vertices).min(self.page_limit);
self.pages[page as usize]
@@ -286,8 +445,17 @@ impl Arena {
}
}
}
/// The capacity the newest page grows to for a range of `len`: double, but
/// by at most `PAGE_BYTES` past that size, so a large page is never left
/// half empty (nor held twice during the copy) by hundreds of MiB.
fn grown(&self, current: u64, len: u64) -> u64 {
let step = current.min(PAGE_BYTES / self.stride);
(current + step).max(current + len).min(self.page_limit)
}
/// Moves a page into a larger buffer; false (the page unchanged) when
/// there is no memory for it.
/// there is no memory for it. A page past `PAGE_BYTES` grows in video
/// memory only: moving all of it to system memory would slow every frame
/// that draws it, so a new page takes the range instead (and may spill).
fn grow(
&mut self,
device: &wgpu::Device,
@@ -296,7 +464,8 @@ impl Arena {
capacity: u64,
) -> bool {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let Some(buffer) = self.create(device, capacity) else {
let spill = capacity * self.stride <= PAGE_BYTES;
let Some((buffer, host)) = self.create(device, capacity, spill) else {
return false;
};
let old = self.pages[page].as_mut().expect("live arena page");
@@ -304,6 +473,7 @@ impl Arena {
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
let previous = old.capacity;
old.buffer = buffer;
old.host = host;
old.capacity = capacity;
old.free.give(previous, capacity - previous);
true
@@ -318,6 +488,9 @@ impl Arena {
*page = None;
}
}
if self.closing.is_some_and(|page| !self.has_page(page)) {
self.closing = None;
}
// Keep live page indices stable; later allocations reuse interior holes.
while self.pages.last().is_some_and(Option::is_none) {
self.pages.pop();
@@ -325,6 +498,7 @@ impl Arena {
}
pub fn clear(&mut self) {
self.pages.clear();
self.closing = None;
}
}
@@ -363,6 +537,26 @@ mod tests {
.ok()
}
/// A device with the adapter's own limits, as the renderer opens it: page
/// caps past the default 256 MiB buffer limit show there.
fn device_at_limits() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.ok()
}
/// `device` and `device_at_limits`: the default limits bind 128 MiB of a storage
/// buffer, half their 256 MiB buffers, so storage pages shrink to the binding there;
/// the adapter's own let large pages past 256 MiB.
fn devices() -> Vec<(wgpu::Device, wgpu::Queue)> {
let devices = [device(), device_at_limits()];
devices.into_iter().flatten().collect()
}
/// `Arena::alloc`, with the copies of a grow submitted at once.
fn alloc(
arena: &mut Arena,
@@ -407,6 +601,189 @@ mod tests {
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
}
#[test]
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::new(&device, 4, GRAIN, false);
arena.page_limit = 4 * GRAIN;
// Eight ranges fill two pages, each holding its own number.
let ranges: Vec<(u32, u64)> = (0..8)
.map(|i| {
let range = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
queue.write_buffer(
arena.buffer(range.0),
range.1 * 4,
&(100 + i as u32).to_le_bytes(),
);
range
})
.collect();
assert_eq!(arena.pages.iter().flatten().count(), 2);
let (first, second) = (ranges[0].0, ranges[7].0);
assert_ne!(first, second);
// Fragmented: one range live in the first page, two in the second.
for (i, &(page, start)) in ranges.iter().enumerate() {
if ![0, 6, 7].contains(&i) {
arena.release(page, start, GRAIN);
}
}
// Dense enough arenas, and ones without room elsewhere, are left alone.
assert_eq!(arena.sparsest_from(u64::MAX), None);
assert_eq!(arena.sparsest_from(0), Some(first));
arena.close(first);
assert_eq!(
arena.sparsest_from(0),
None,
"one page is emptied at a time"
);
// New ranges avoid the closing page though it has room.
let (page, extra) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_eq!(page, second);
arena.release(page, extra, GRAIN);
// Its live range moves, data included, and the page goes back.
let (page, start) = ranges[0];
let to = arena.alloc_existing(GRAIN).unwrap();
assert_eq!(to.0, second);
let mut encoder = device.create_command_encoder(&Default::default());
arena.copy(&mut encoder, (page, start), to, GRAIN);
queue.submit([encoder.finish()]);
arena.release(page, start, GRAIN);
assert_eq!(read(&device, &queue, arena.buffer(to.0), to.1 * 4), 100);
// Emptied but not trimmed yet: it stays closed, so uploads can't refill it.
assert_eq!(arena.closing(), Some(page));
let refill = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_ne!(refill.0, page);
arena.release(refill.0, refill.1, GRAIN);
let before = arena.bytes();
arena.trim();
assert_eq!(arena.bytes(), before - 4 * GRAIN * 4);
assert_eq!(arena.closing(), None);
// The others kept their ranges.
for i in [6, 7] {
let (page, start) = ranges[i];
assert_eq!(
read(&device, &queue, arena.buffer(page), start * 4),
100 + i as u32
);
}
}
/// The renderer's half of that compaction (`move_off_page`) moves what each
/// section holds in the section arena, data and all. Split, that is its
/// solid quads alone, and nothing of a section without any, whose (0, 0)
/// names no range of page 0; else solid and translucent quads together,
/// `translucent_at` along.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{SectionGpu, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let section = |(page, start), translucent_at, solid, translucent| SectionGpu {
page,
start,
translucent_at,
solid,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
for split in [true, false] {
let mut arena = Arena::new(&device, stride, 4 * GRAIN, false);
arena.page_limit = 4 * GRAIN;
// Page 0: two sections' grains and two free ones; page 1: one taken.
let (_, a) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
let (_, b) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
let (_, rest) = alloc(&mut arena, &device, &queue, 2 * GRAIN).unwrap();
assert_eq!(alloc(&mut arena, &device, &queue, GRAIN), Some((1, 0)));
arena.release(0, rest, 2 * GRAIN);
// Split, a grain of solid quads each (the first's translucent ones, and
// all of a water section's, in the translucent arena); else a grain of
// solid and translucent quads, and one of translucent quads alone.
let mut sections = if split {
vec![
section((0, a), (0, 0), 64, 64),
section((0, 0), (0, GRAIN), 0, 64),
section((0, b), (0, 0), 64, 0),
]
} else {
vec![
section((0, a), (0, a + 128), 32, 32),
section((0, b), (0, b), 0, 64),
]
};
// A range's first and last vertex hold its section's number, and the
// first translucent one of a mixed range that plus 100.
let write = |(page, at): (u32, u64), value: usize| {
let bytes = (value as u32).to_le_bytes();
queue.write_buffer(arena.buffer(page), at * stride, &bytes);
};
for (i, s) in sections.iter().enumerate() {
if let Some((page, start, len)) = s.vertex_ranges(split).0 {
write((page, start), i);
write((page, start + len - 1), i);
if !split && s.solid > 0 && s.translucent > 0 {
write(s.translucent_at, 100 + i);
}
}
}
let before: Vec<_> = sections
.iter()
.map(|s| (s.page, s.start, s.translucent_at))
.collect();
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
assert!(move_off_page(
&mut arena,
&mut encoder,
None,
meshes,
0,
split,
u64::MAX
));
queue.submit([encoder.finish()]);
let word =
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in sections.iter().enumerate() {
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
assert_eq!(
(s.page, s.start, s.translucent_at),
before[i],
"split {split}"
);
continue;
};
assert_eq!(page, 1, "split {split}");
assert_eq!(word((page, start)), i as u32);
assert_eq!(word((page, start + len - 1)), i as u32);
if !split {
assert_eq!(s.translucent_at, (page, start + u64::from(s.solid) * 4));
if s.solid > 0 {
assert_eq!(word(s.translucent_at), 100 + i as u32);
}
}
}
// Page 0 empties without a range given twice, and page 1 took a grain
// per section moved: one is left.
arena.trim();
assert!(!arena.has_page(0), "split {split}");
assert!(arena.alloc_existing(GRAIN).is_some(), "split {split}");
assert_eq!(arena.alloc_existing(GRAIN), None, "split {split}");
}
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -449,6 +826,96 @@ mod tests {
assert!(arena.pages.is_empty());
}
/// Every arena's pages stay within its cap and the device's limits: the
/// whole-page storage bindings of flat draws and shader packs included.
#[test]
fn pages_stay_within_their_caps_and_the_device_limits() {
let devices = devices();
if devices.is_empty() {
eprintln!("no GPU adapter: skipped");
return;
}
let bytes = |a: &Arena| a.page_vertices() * a.stride;
for (device, _) in &devices {
let limits = device.limits();
for storage in [false, true] {
for arena in [
Arena::new(device, 24, GRAIN, storage),
Arena::indices(device, GRAIN, storage),
] {
assert!(bytes(&arena) <= PAGE_BYTES.min(limits.max_buffer_size));
let binding = limits.max_storage_buffer_binding_size;
assert!(!storage || bytes(&arena) <= binding);
}
}
for large in [
Arena::translucent(device, 24, GRAIN),
Arena::translucent_indices(device, GRAIN),
] {
assert!(bytes(&large) <= LARGE_PAGE_BYTES.min(limits.max_buffer_size));
assert!(!large.usage.contains(wgpu::BufferUsages::STORAGE));
}
}
}
/// The translucent arena comes with the split layout alone, and only then
/// are the index pages large. Otherwise, as while a shader pack's terrain
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
/// section arena, and flat draws (`flat`) bind the index pages as storage.
#[test]
fn translucent_arenas_follow_the_layout() {
let devices = devices();
if devices.is_empty() {
eprintln!("no GPU adapter: skipped");
return;
}
let mut exercised = false;
for (device, _) in &devices {
let limits = device.limits();
let large = limits.max_buffer_size > PAGE_BYTES;
exercised |= large;
for flat in [false, true] {
for split in [false, true] {
let case = format!("large {large}, flat {flat}, split {split}");
let (indices, translucent) = Arena::translucent_arenas(device, 24, flat, split);
assert_eq!(translucent.is_some(), split, "{case}");
let bytes = indices.page_vertices() * 4;
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
assert_eq!(storage, flat && !split, "{case}");
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
if let Some(t) = translucent {
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
}
}
}
}
if !exercised {
eprintln!("buffers end at PAGE_BYTES here: large pages not exercised");
}
}
/// Pages double up to `PAGE_BYTES`, then grow by `PAGE_BYTES` at most.
#[test]
fn large_pages_grow_by_bounded_steps() {
let Some((device, _)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, GRAIN);
// Exercise the policy past the test device's own buffer limit.
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
let step = PAGE_BYTES / 24;
assert_eq!(arena.grown(1 << 18, GRAIN), 1 << 19);
assert_eq!(arena.grown(step, GRAIN), 2 * step);
assert_eq!(arena.grown(3 * step, GRAIN), 4 * step);
assert_eq!(arena.grown(3 * step, step + GRAIN), 4 * step + GRAIN);
assert_eq!(
arena.grown(arena.page_limit - GRAIN, GRAIN),
arena.page_limit
);
}
#[test]
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
let Some((device, queue)) = device() else {
@@ -457,7 +924,9 @@ mod tests {
};
for mut arena in [
Arena::new(&device, 28, GRAIN, false),
Arena::translucent(&device, 24, GRAIN),
Arena::indices(&device, GRAIN, false),
Arena::translucent_indices(&device, GRAIN),
] {
alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_eq!(arena.bytes(), arena.stride * GRAIN);
@@ -572,6 +1041,9 @@ mod tests {
queue.write_buffer(arena.buffer(grown), next * 4, &9u32.to_le_bytes());
assert_eq!(read(&device, &queue, arena.buffer(page), start * 4), 7);
assert_eq!(read(&device, &queue, arena.buffer(grown), next * 4), 9);
// Compaction never moves terrain into a page in system memory.
assert!(arena.pages[page as usize].as_ref().unwrap().host);
assert_eq!(arena.alloc_moved(GRAIN), None);
}
#[test]
@@ -0,0 +1,224 @@
//! The colour of block light for Horizon's shader packs (`[[lights]]`, docs/api/shaders.md):
//! a grid of cells around the camera, each holding the colour of the light sources that
//! reach it, averaged by how near each is, which the material stage reads through the
//! prelude's `block_light_color`. Block light keeps its own brightness (the light engine's,
//! which walls stop): the grid only tints it, so it needs no occlusion of its own.
//!
//! The grid is rebuilt on the CPU when the camera moves on to another 16-block step or the
//! light sources change, from the sources the section meshes list (`SectionMesh::lights`).
use crate::fast_hash::FxHashMap;
use glam::{DVec3, Vec3};
use super::SectionKey;
/// Cells along each side of the grid.
pub const SIZE: u32 = 64;
/// The side of a cell, in blocks: the grid spans 128 blocks.
pub const CELL: f32 = 2.0;
/// The grid moves in steps of this many blocks.
const STEP: i32 = 16;
/// The most sources one rebuild splats, the nearest first (a lava lake lists thousands).
const MAX_SOURCES: usize = 2048;
/// `[[lights]]` colours go up to 4; the cells keep them over 4 in 8 bits.
const COLOR_RANGE: f32 = 4.0;
/// The light a block state gives off: its `[[lights]]` colour and its level, 1 to 15.
pub type LightTable = FxHashMap<u32, ([f32; 3], u8)>;
pub struct BlockLight {
texture: wgpu::Texture,
pub view: wgpu::TextureView,
/// `block_light_grid`: the grid's corner, camera-relative, and the side of a cell.
pub grid: wgpu::Buffer,
table: LightTable,
/// The world corner and the sources' revision the texture was last built for.
built: Option<([i32; 3], u64)>,
}
impl BlockLight {
pub fn new(device: &wgpu::Device, table: LightTable) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack block light colours"),
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: SIZE,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let grid = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack block light grid"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
view: texture.create_view(&Default::default()),
texture,
grid,
table,
built: None,
}
}
/// Places the grid around `camera`, rebuilding it when it moved or `revision` (of
/// `sections`' sources) changed.
pub fn update(
&mut self,
queue: &wgpu::Queue,
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
revision: u64,
) {
let half = (SIZE as f32 * CELL / 2.0) as f64;
let origin = ((camera - half) / f64::from(STEP))
.floor()
.as_ivec3()
.to_array()
.map(|v| v * STEP);
let corner = (glam::IVec3::from(origin).as_dvec3() - camera).as_vec3();
queue.write_buffer(
&self.grid,
0,
bytemuck::cast_slice(&[corner.x, corner.y, corner.z, CELL]),
);
if self.built == Some((origin, revision)) {
return;
}
self.built = Some((origin, revision));
let cells = build(&self.table, origin, camera, sections);
queue.write_texture(
self.texture.as_image_copy(),
&cells,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
self.texture.size(),
);
}
}
/// The cells of the grid with its corner at `origin`: RGBA8, x fastest, then y, then z.
fn build(
table: &LightTable,
origin: [i32; 3],
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
) -> Vec<u8> {
let n = SIZE as usize;
let mut cells = vec![0u8; n * n * n * 4];
if table.is_empty() {
return cells;
}
let span = SIZE as i32 * CELL as i32;
// Sections the grid or the light reaching into it (15 blocks) touches.
let lo = origin.map(|v| (v - 15).div_euclid(16));
let hi = origin.map(|v| (v + span + 15).div_euclid(16));
let mut sources: Vec<(Vec3, [f32; 3], f32, f64)> = Vec::new();
for (key, lights) in sections {
let key = [key.0, key.1 + crate::chunk::MIN_Y.div_euclid(16), key.2];
if (0..3).any(|i| key[i] < lo[i] || key[i] > hi[i]) {
continue;
}
for &(pos, state) in lights {
let Some(&(color, level)) = table.get(&state) else {
continue;
};
let center = glam::IVec3::from(pos).as_dvec3() + 0.5;
let local = (center - glam::IVec3::from(origin).as_dvec3()).as_vec3();
sources.push((
local,
color,
f32::from(level),
center.distance_squared(camera),
));
}
}
if sources.len() > MAX_SOURCES {
sources.select_nth_unstable_by(MAX_SOURCES, |a, b| a.3.total_cmp(&b.3));
sources.truncate(MAX_SOURCES);
}
let mut sum = vec![[0.0f32; 4]; n * n * n];
for (at, color, reach, _) in sources {
let first = ((at - reach) / CELL).floor().max(Vec3::ZERO).as_ivec3();
let last = ((at + reach) / CELL)
.ceil()
.min(Vec3::splat(SIZE as f32 - 1.0))
.as_ivec3();
for z in first.z..=last.z {
for y in first.y..=last.y {
for x in first.x..=last.x {
let cell = (Vec3::new(x as f32, y as f32, z as f32) + 0.5) * CELL;
let d = cell.distance(at);
if d >= reach {
continue;
}
// As block light falls off: strongest at the source, gone at its level.
let w = (1.0 - d / reach).powi(2);
let s = &mut sum[x as usize + n * (y as usize + n * z as usize)];
s[0] += color[0] * w;
s[1] += color[1] * w;
s[2] += color[2] * w;
s[3] += w;
}
}
}
}
for (cell, s) in cells.as_chunks_mut::<4>().0.iter_mut().zip(&sum) {
if s[3] <= 0.0 {
continue;
}
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
for i in 0..3 {
cell[i] = byte(s[i] / s[3] / COLOR_RANGE);
}
cell[3] = byte(s[3]);
}
cells
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_source_tints_the_cells_it_reaches_with_its_colour() {
let mut table = LightTable::default();
table.insert(7, ([2.0, 1.0, 0.0], 14));
let mut sections = FxHashMap::default();
// A torch at world (8, 64, 8): section y = (64 - MIN_Y) / 16.
let sy = (64 - crate::chunk::MIN_Y) / 16;
sections.insert((0, sy, 0), vec![([8, 64, 8], 7)]);
let camera = DVec3::new(8.0, 64.0, 8.0);
let origin = [-64, 0, -64];
let cells = build(&table, origin, camera, &sections);
let n = SIZE as usize;
let at = |p: [i32; 3]| {
let c = p
.iter()
.zip(origin)
.map(|(v, o)| ((v - o) as f32 / CELL) as usize)
.collect::<Vec<_>>();
let i = (c[0] + n * (c[1] + n * c[2])) * 4;
[cells[i], cells[i + 1], cells[i + 2], cells[i + 3]]
};
let near = at([8, 64, 8]);
// The colour over 4, whatever the weight: (2, 1, 0) / 4.
assert_eq!(&near[..3], &[128, 64, 0]);
assert!(near[3] > 150);
assert_eq!(at([8, 64, 40]), [0; 4], "past its level, nothing");
assert!(
build(&LightTable::default(), origin, camera, &sections)
.iter()
.all(|&b| b == 0)
);
}
}
+6 -1
View File
@@ -528,7 +528,11 @@ impl Cull {
/// Between the phases (`InShader`): draw the tested boxes in a pass of
/// their own that reads phase 1's depth, bound by `bind_depth`.
pub fn test_pass(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn test_pass(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let (Some(Group { group, .. }), Some(DepthGroup { view, group: depth })) =
(&self.group, &self.depth_group)
else {
@@ -539,6 +543,7 @@ impl Cull {
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Section culling"),
timestamp_writes: timing.and_then(|t| t.writes("Occlusion test")),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
@@ -20,6 +20,8 @@ const MATERIALS: usize = 10;
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
/// `breezeWind`). Only the improved transparency path reads it.
pub const OIT_ENTITY: u32 = 1 << 24;
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
@@ -46,12 +48,13 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
/// drawn like an ordinary entity, pulled toward the camera.
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
/// The material of a quad by its overlay (`MATERIALS` order).
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
fn material(layer: u32, oit: bool) -> usize {
if oit && layer & OIT_ENTITY != 0 {
return 9;
}
match layer {
match layer & 0xffff {
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
12 => 3,
@@ -78,6 +81,11 @@ pub(super) struct EntityBatch {
}
impl EntityBatch {
/// After `update`: quads only a native pack's shadow pass draws, at the buffer's end.
pub(super) fn append_shadow_only(&mut self, shadow_only: &[Vertex]) {
self.combined.extend_from_slice(shadow_only);
}
pub(super) fn update(
&mut self,
entities: &[Vertex],
@@ -213,6 +221,15 @@ mod tests {
);
}
#[test]
fn a_tier_in_the_high_bits_keeps_the_material() {
for layer in [0u32, 11, 12, 15, 32, 33, 35, OIT_ENTITY] {
for oit in [false, true] {
assert_eq!(material(layer | 4 << 16, oit), material(layer, oit));
}
}
}
#[test]
fn oit_entity_quads_are_a_last_material_only_when_on() {
let quad = |layer: u32, x: f32| {
@@ -258,13 +258,20 @@ impl EntityOutline {
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
{
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
let mut pass = begin(
encoder,
"Entity outline",
&t.outline,
clear,
timing.as_deref_mut(),
);
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -280,7 +287,7 @@ impl EntityOutline {
("Entity outline blit", frame, wgpu::LoadOp::Load),
];
for (i, (label, view, load)) in steps.into_iter().enumerate() {
let mut pass = begin(encoder, label, view, load);
let mut pass = begin(encoder, label, view, load, timing.as_deref_mut());
pass.set_pipeline(&self.passes[i]);
pass.set_bind_group(0, &t.groups[i], &[]);
pass.draw(0..3, 0..1);
@@ -293,6 +300,7 @@ fn begin<'e>(
label: &str,
view: &wgpu::TextureView,
load: wgpu::LoadOp<wgpu::Color>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
@@ -305,6 +313,7 @@ fn begin<'e>(
})];
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &attachments,
..Default::default()
})
+111 -21
View File
@@ -62,7 +62,15 @@ pub struct Call {
}
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
pub solid: wgpu::RenderPipeline,
/// The solid draws in two passes over the same draws (terrain.wgsl's `opaque_quad`):
/// the quads with no cutout first, through a shader that never discards, then the rest.
pub solid_opaque: wgpu::RenderPipeline,
pub solid_cutout: wgpu::RenderPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
@@ -117,29 +125,52 @@ pub struct Flat {
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
pub const MIN_SECTIONS: usize = 1600;
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
/// shaders can read storage buffers large enough for an index page.
/// Whether flat draws exist: vertex shaders can read storage buffers large
/// enough for an index page. A shader pack's terrain draws flat wherever they
/// do, since only there does it read each quad's face normal and `mc_Entity`;
/// frames without one draw flat only where `for_vanilla` says.
/// `HORIZON_FLAT_DRAWS=1` draws every frame flat, `0` none.
pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
match std::env::var("HORIZON_FLAT_DRAWS").as_deref() {
Ok("0") => return false,
Ok("1") => return supported(adapter, device),
_ => {}
}
let info = adapter.get_info();
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
&& supported(adapter, device)
std::env::var("HORIZON_FLAT_DRAWS").as_deref() != Ok("0") && supported(adapter, device)
}
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
/// and flicker.
/// Whether frames without a shader pack's terrain draw flat: where the backend
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|| info.backend == wgpu::Backend::Metal
|| is_broken_intel_vulkan(info)
}
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
/// Raptor Lake) ANV Vulkan drivers have broken `first_instance` for
/// instance-step vertex attributes (#3529, #4599), causing terrain sections to
/// be drawn with another section's origin and flicker.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (info.name.contains("ICL")
|| info.name.contains("Ice Lake")
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
&& (matches!(
info.device,
0x8a50..=0x8a71 // Gen11: Ice Lake
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
| 0xa700..=0xa7ff // Gen12: Raptor Lake
) || [
"ICL",
"Ice Lake",
"TGL",
"Tiger Lake",
"RKL",
"Rocket Lake",
"ADL",
"Alder Lake",
"RPL",
"Raptor Lake",
]
.iter()
.any(|tag| info.name.contains(tag)))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -261,7 +292,7 @@ fn plan(
}
impl Flat {
/// `pipeline(layout, label, vertex entry, fragment entry, blend)` builds a
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer.
pub fn new(
device: &wgpu::Device,
@@ -272,7 +303,9 @@ impl Flat {
&wgpu::PipelineLayout,
&str,
&str,
&str,
Option<wgpu::BlendState>,
Option<wgpu::Face>,
) -> wgpu::RenderPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
@@ -326,13 +359,48 @@ impl Flat {
let flatten = compute_pipeline(&compute_layout, "flatten");
let flatten_seen = compute_pipeline(&seen_layout, "flatten_seen");
let storage_usage = wgpu::BufferUsages::STORAGE;
let back = Some(wgpu::Face::Back);
Self {
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None),
solid: pipeline(
&layout,
"Terrain solid (flat)",
"vs_terrain_flat",
"fs_solid",
None,
back,
),
solid_opaque: pipeline(
&layout,
"Terrain solid opaque (flat)",
"vs_terrain_flat_opaque",
"fs_opaque",
None,
back,
),
solid_cutout: pipeline(
&layout,
"Terrain solid cutout (flat)",
"vs_terrain_flat_cutout",
"fs_solid",
None,
back,
),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
&layout,
"Terrain solid mirrored (flat)",
"vs_terrain_flat",
"fs_solid_mirror",
None,
Some(wgpu::Face::Front),
),
translucent: pipeline(
&layout,
"Terrain translucent (flat)",
"vs_terrain_flat",
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
),
flatten,
compute_layout,
@@ -454,6 +522,7 @@ impl Flat {
phase2: Option<&wgpu::Buffer>,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
let phase1_draws = if phase2.is_some() {
@@ -632,7 +701,7 @@ impl Flat {
.collect();
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten section draws"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
pass.set_pipeline(&self.flatten);
for (group, count) in &groups {
@@ -664,13 +733,17 @@ impl Flat {
true
}
/// After occlusion culling's test: copies the phase 2 draws it saw.
pub fn flatten_phase2(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn flatten_phase2(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some((group, count)) = &self.phase2_group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten phase 2 section draws"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
});
pass.set_pipeline(&self.flatten_seen);
pass.set_bind_group(0, group, &[]);
@@ -708,6 +781,23 @@ impl Flat {
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
}
}
/// Draws `calls` without their index buffer, `instances` times: a shader pack's geometry
/// stage reads the indices itself (`hz_indices`) and stands for one emitted triangle per
/// instance.
pub fn draw_geometry<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
instances: u32,
) {
for call in calls {
self.bind_page(pass, call.page, &vertices);
page(pass, call.page);
pass.draw(call.first..call.first + call.count, 0..instances);
}
}
/// Draws phase 2's calls, as `flatten_phase2` left them.
pub fn draw_phase2<'a>(
&self,
@@ -1,7 +1,11 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow; the interpolation and the setting
//! that runs them come next.
//! two real ones. So far this holds its optical flow, its interpolation and the motion
//! vectors of what moves on its own; the setting that runs them comes next.
// Unused until the interpolation and the renderer use it.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
#[allow(dead_code)]
mod motion;
#[allow(dead_code)]
mod optical_flow;
@@ -0,0 +1,862 @@
//! FSR 3.1's frame interpolation (AMD FidelityFX SDK 1.1.4, `ffx_frameinterpolation*`,
//! MIT), ported to WGSL (`interpolation.wgsl`): the frame halfway between the previous
//! real frame and this one, from the camera's motion vectors, the optical flow
//! (`optical_flow.rs`) and the depth.
//!
//! The passes and their order are `ffxFrameInterpolationPrepare` then
//! `ffxFrameInterpolationDispatch`'s: dilate the depth and motion vectors and estimate
//! the previous frame's depth; estimate the made frame's depth; splat the game's and the
//! optical flow's vectors to the made frame's pixels (atomic maximums of packed
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
//! frame can't see; blend the two frames along both fields; fill what is left from a
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
//! the game's motion vectors are the camera's, from the depth and `Inputs::reproject`,
//! except where `Inputs::object_motion` has an entity's own; textures AMD updates
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
use glam::Mat4;
/// One real frame's inputs.
pub struct Inputs<'a> {
/// This frame's colour without the hand and HUD: `display` size, a filterable
/// float texture (gamma-encoded RGB, the swapchain's values) that can be copied from.
pub color: &'a wgpu::TextureView,
/// This frame's depth: `Depth32Float` at `render_size`, reversed (1 at the near
/// plane, 0 at the far one), finite, jittered by `jitter`.
pub depth: &'a wgpu::TextureView,
pub render_size: [u32; 2],
/// The projection's sub-pixel jitter this frame, in render pixels (FSR's
/// `jitterOffset`); zero without TAA.
pub jitter: [f32; 2],
/// This frame's unjittered clip space to the previous frame's (camera-relative, as
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
/// the depth.
pub reproject: Mat4,
/// What moved on its own: `motion::FORMAT` at `render_size`, xy its motion vector
/// (FSR's convention, in UV) where z is 1, used over the camera's.
pub object_motion: &'a wgpu::TextureView,
pub near: f32,
pub far: f32,
/// Vertical field of view, radians.
pub fov_y: f32,
/// The previous frame is unrelated (first frame, resize, teleport): show this one.
pub reset: bool,
/// The optical flow from the previous frame's `color` to this one's
/// (`OpticalFlow::vectors`, `scene_change`).
pub flow_vectors: &'a wgpu::TextureView,
pub scene_change: &'a wgpu::Buffer,
}
/// `interpolation.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
reproject: [[f32; 4]; 4],
device_to_view: [f32; 4],
render_size: [i32; 2],
display_size: [i32; 2],
jitter: [f32; 2],
optical_flow_scale: [f32; 2],
optical_flow_size: [i32; 2],
reset: u32,
tan_half_fov: f32,
level_size: [i32; 2],
_pad: [u32; 2],
}
/// The game vector field pyramid's levels (`LoadInpaintedGameFieldMv`) and the
/// inpainting pyramid's (`ComputeInpainting`).
const GAME_LEVELS: u32 = 11;
const INPAINTING_LEVELS: u32 = 10;
struct Pipelines {
prepare: wgpu::ComputePipeline,
setup: wgpu::ComputePipeline,
reconstruct_previous_depth: wgpu::ComputePipeline,
game_motion_vector_field: wgpu::ComputePipeline,
game_field_pyramid_first: wgpu::ComputePipeline,
game_field_pyramid: wgpu::ComputePipeline,
optical_flow_vector_field: wgpu::ComputePipeline,
disocclusion_mask: wgpu::ComputePipeline,
interpolate: wgpu::ComputePipeline,
inpainting_pyramid_first: wgpu::ComputePipeline,
inpainting_pyramid: wgpu::ComputePipeline,
inpainting: wgpu::ComputePipeline,
}
/// A texture with a view of each of its levels (`levels`, for writing one from the one
/// above) and of all of them.
struct Pyramid {
all: wgpu::TextureView,
levels: Vec<wgpu::TextureView>,
sizes: Vec<[u32; 2]>,
}
/// What is sized like the world's render.
struct RenderTargets {
size: [u32; 2],
dilated_depth: wgpu::TextureView,
dilated_mv: wgpu::TextureView,
rec_prev_depth: wgpu::Buffer,
rec_interp_depth: wgpu::Buffer,
game_x: wgpu::Buffer,
game_y: wgpu::Buffer,
of_x: wgpu::Buffer,
of_y: wgpu::Buffer,
disocclusion: wgpu::TextureView,
game_pyramid: Pyramid,
}
pub struct Interpolation {
display: [u32; 2],
pipelines: Pipelines,
sampler: wgpu::Sampler,
/// The passes' constants, and each pyramid level's.
params: wgpu::Buffer,
game_level_params: Vec<wgpu::Buffer>,
inpainting_level_params: Vec<wgpu::Buffer>,
counters: wgpu::Buffer,
interpolated: wgpu::TextureView,
output: wgpu::TextureView,
inpainting_pyramid: Pyramid,
/// The last dispatch's colour (`PREVIOUS_INTERPOLATION_SOURCE`), made on the first.
previous: Option<wgpu::Texture>,
render: Option<RenderTargets>,
}
fn texture(
device: &wgpu::Device,
label: &str,
size: [u32; 2],
format: wgpu::TextureFormat,
levels: u32,
) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
}
fn storage(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size.max(4),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
fn uniform(device: &wgpu::Device) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame interpolation constants"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
/// Levels of `base >> (i + 1)` for `i` below `max`, while both sides are 1 or more.
fn pyramid(device: &wgpu::Device, label: &str, base: [u32; 2], max: u32) -> Pyramid {
let sizes: Vec<[u32; 2]> = (0..max)
.map(|i| base.map(|v| v >> (i + 1)))
.take_while(|s| s[0] >= 1 && s[1] >= 1)
.collect();
let levels = (sizes.len() as u32).max(1);
let t = texture(
device,
label,
base.map(|v| (v / 2).max(1)),
wgpu::TextureFormat::Rgba16Float,
levels,
);
Pyramid {
all: t.create_view(&Default::default()),
levels: (0..levels)
.map(|i| {
t.create_view(&wgpu::TextureViewDescriptor {
base_mip_level: i,
mip_level_count: Some(1),
..Default::default()
})
})
.collect(),
sizes,
}
}
impl RenderTargets {
fn new(device: &wgpu::Device, size: [u32; 2]) -> Self {
let view = |t: wgpu::Texture| t.create_view(&Default::default());
let field = |label| storage(device, label, u64::from(size[0] * size[1]) * 4);
Self {
size,
dilated_depth: view(texture(
device,
"Frame interpolation dilated depth",
size,
wgpu::TextureFormat::R32Float,
1,
)),
dilated_mv: view(texture(
device,
"Frame interpolation dilated motion vectors",
size,
wgpu::TextureFormat::Rg32Float,
1,
)),
rec_prev_depth: field("Frame interpolation previous depth"),
rec_interp_depth: field("Frame interpolation interpolated depth"),
game_x: field("Frame interpolation game vectors x"),
game_y: field("Frame interpolation game vectors y"),
of_x: field("Frame interpolation flow vectors x"),
of_y: field("Frame interpolation flow vectors y"),
disocclusion: view(texture(
device,
"Frame interpolation disocclusion",
size,
wgpu::TextureFormat::Rgba8Unorm,
1,
)),
game_pyramid: pyramid(
device,
"Frame interpolation game vector pyramid",
size,
GAME_LEVELS,
),
}
}
}
fn bind(
device: &wgpu::Device,
pipeline: &wgpu::ComputePipeline,
entries: &[(u32, wgpu::BindingResource)],
) -> wgpu::BindGroup {
let entries: Vec<_> = entries
.iter()
.map(|(binding, resource)| wgpu::BindGroupEntry {
binding: *binding,
resource: resource.clone(),
})
.collect();
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Frame interpolation"),
layout: &pipeline.get_bind_group_layout(0),
entries: &entries,
})
}
fn view(v: &wgpu::TextureView) -> wgpu::BindingResource<'_> {
wgpu::BindingResource::TextureView(v)
}
fn whole(b: &wgpu::Buffer) -> wgpu::BindingResource<'_> {
b.as_entire_binding()
}
/// `setupDeviceDepthToViewSpaceDepthParams` for inverted, finite depth, in blocks
/// (`viewSpaceToMetersFactor` 1).
fn device_to_view(near: f32, far: f32, fov_y: f32, aspect: f32) -> [f32; 4] {
let (f_min, f_max) = (near.max(far), near.min(far));
let q = f_max / (f_min - f_max);
let cot_half_fov = (0.5 * fov_y).cos() / (0.5 * fov_y).sin();
[-q, q * f_min, aspect / cot_half_fov, 1.0 / cot_half_fov]
}
impl Interpolation {
/// For frames of `display` size; the render size may change between dispatches.
pub fn new(device: &wgpu::Device, display: [u32; 2]) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Frame interpolation"),
source: wgpu::ShaderSource::Wgsl(include_str!("interpolation.wgsl").into()),
});
let pipeline = |entry: &str| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: None,
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
};
let view = |t: wgpu::Texture| t.create_view(&Default::default());
Self {
display,
pipelines: Pipelines {
prepare: pipeline("prepare_pass"),
setup: pipeline("setup_pass"),
reconstruct_previous_depth: pipeline("reconstruct_previous_depth_pass"),
game_motion_vector_field: pipeline("game_motion_vector_field_pass"),
game_field_pyramid_first: pipeline("game_field_pyramid_first_pass"),
game_field_pyramid: pipeline("game_field_pyramid_pass"),
optical_flow_vector_field: pipeline("optical_flow_vector_field_pass"),
disocclusion_mask: pipeline("disocclusion_mask_pass"),
interpolate: pipeline("interpolate_pass"),
inpainting_pyramid_first: pipeline("inpainting_pyramid_first_pass"),
inpainting_pyramid: pipeline("inpainting_pyramid_pass"),
inpainting: pipeline("inpainting_pass"),
},
sampler: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Frame interpolation"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
}),
params: uniform(device),
game_level_params: (0..GAME_LEVELS).map(|_| uniform(device)).collect(),
inpainting_level_params: (0..INPAINTING_LEVELS).map(|_| uniform(device)).collect(),
counters: storage(device, "Frame interpolation counters", 8),
interpolated: view(texture(
device,
"Frame interpolation",
display,
wgpu::TextureFormat::Rgba16Float,
1,
)),
output: view(texture(
device,
"Interpolated frame",
display,
wgpu::TextureFormat::Rgba16Float,
1,
)),
inpainting_pyramid: pyramid(
device,
"Frame interpolation inpainting pyramid",
display,
INPAINTING_LEVELS,
),
previous: None,
render: None,
}
}
/// The frame halfway between the previous dispatch's `color` and this one's, into
/// `output`. Keeps what it needs of this frame for the next dispatch.
pub fn dispatch(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
inputs: &Inputs,
) {
let color = inputs.color.texture();
let mut reset = inputs.reset;
if self
.previous
.as_ref()
.is_none_or(|p| p.size() != color.size() || p.format() != color.format())
{
self.previous = Some(device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame interpolation previous frame"),
size: color.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: color.format(),
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
}));
reset = true;
}
if self
.render
.as_ref()
.is_none_or(|r| r.size != inputs.render_size)
{
self.render = Some(RenderTargets::new(device, inputs.render_size));
reset = true;
}
let r = self.render.as_ref().expect("made above");
let previous_texture = self.previous.as_ref().expect("made above");
let previous = previous_texture.create_view(&Default::default());
let display = self.display.map(|v| v as i32);
let render = inputs.render_size.map(|v| v as i32);
let flow_size = self
.display
.map(|v| v.div_ceil(super::optical_flow::BLOCK_SIZE) as i32);
let base = Params {
reproject: inputs.reproject.to_cols_array_2d(),
device_to_view: device_to_view(
inputs.near,
inputs.far,
inputs.fov_y,
render[0] as f32 / render[1].max(1) as f32,
),
render_size: render,
display_size: display,
jitter: inputs.jitter,
optical_flow_scale: self.display.map(|v| 1.0 / v as f32),
optical_flow_size: flow_size,
reset: u32::from(reset),
tan_half_fov: (0.5 * inputs.fov_y).tan() * render[0] as f32 / render[1].max(1) as f32,
level_size: [0; 2],
_pad: [0; 2],
};
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(&base));
let level_params = |buffers: &[wgpu::Buffer], sizes: &[[u32; 2]]| {
for (buffer, size) in buffers.iter().zip(sizes) {
let params = Params {
level_size: size.map(|v| v as i32),
..base
};
queue.write_buffer(buffer, 0, bytemuck::bytes_of(&params));
}
};
level_params(&self.game_level_params, &r.game_pyramid.sizes);
level_params(
&self.inpainting_level_params,
&self.inpainting_pyramid.sizes,
);
for b in [
&r.rec_prev_depth,
&r.rec_interp_depth,
&r.game_x,
&r.game_y,
&r.of_x,
&r.of_y,
] {
encoder.clear_buffer(b, 0, None);
}
if reset {
encoder.clear_buffer(&self.counters, 0, None);
}
let p = &self.pipelines;
let params = || whole(&self.params);
let sampler = || wgpu::BindingResource::Sampler(&self.sampler);
let groups =
|pipeline, entries: &[(u32, wgpu::BindingResource)]| bind(device, pipeline, entries);
let prepare = groups(
&p.prepare,
&[
(0, params()),
(2, view(inputs.depth)),
(34, view(inputs.object_motion)),
(5, view(&r.dilated_depth)),
(6, view(&r.dilated_mv)),
(9, whole(&r.rec_prev_depth)),
],
);
let setup = groups(
&p.setup,
&[
(0, params()),
(22, whole(inputs.scene_change)),
(23, whole(&self.counters)),
],
);
let colors = || {
[
(3, view(inputs.color)),
(4, view(&previous)),
(1, sampler()),
]
};
let reconstruct = groups(
&p.reconstruct_previous_depth,
&[
(0, params()),
(7, view(&r.dilated_depth)),
(8, view(&r.dilated_mv)),
(10, whole(&r.rec_interp_depth)),
],
);
let mut entries = vec![
(0, params()),
(7, view(&r.dilated_depth)),
(8, view(&r.dilated_mv)),
(13, whole(&r.game_x)),
(14, whole(&r.game_y)),
];
entries.extend(colors());
let game_field = groups(&p.game_motion_vector_field, &entries);
let game_pyramid: Vec<_> = (0..r.game_pyramid.sizes.len())
.map(|i| {
let mut entries = vec![
(0, whole(&self.game_level_params[i])),
(26, view(&r.game_pyramid.levels[i])),
];
if i == 0 {
entries.extend([(15, whole(&r.game_x)), (16, whole(&r.game_y))]);
groups(&p.game_field_pyramid_first, &entries)
} else {
entries.push((25, view(&r.game_pyramid.levels[i - 1])));
groups(&p.game_field_pyramid, &entries)
}
})
.collect();
let mut entries = vec![
(0, params()),
(21, view(inputs.flow_vectors)),
(17, whole(&r.of_x)),
(18, whole(&r.of_y)),
];
entries.extend(colors());
let flow_field = groups(&p.optical_flow_vector_field, &entries);
let game_mv = || {
[
(15, whole(&r.game_x)),
(16, whole(&r.game_y)),
(27, view(&r.game_pyramid.all)),
]
};
let mut entries = vec![
(0, params()),
(7, view(&r.dilated_depth)),
(11, whole(&r.rec_prev_depth)),
(12, whole(&r.rec_interp_depth)),
(29, view(&r.disocclusion)),
];
entries.extend(game_mv());
let disocclusion = groups(&p.disocclusion_mask, &entries);
let mut entries = vec![
(0, params()),
(19, whole(&r.of_x)),
(20, whole(&r.of_y)),
(24, whole(&self.counters)),
(30, view(&r.disocclusion)),
(31, view(&self.interpolated)),
];
entries.extend(game_mv());
entries.extend(colors());
let interpolate = groups(&p.interpolate, &entries);
let inpainting_pyramid: Vec<_> = (0..self.inpainting_pyramid.sizes.len())
.map(|i| {
let mut entries = vec![
(0, whole(&self.inpainting_level_params[i])),
(26, view(&self.inpainting_pyramid.levels[i])),
];
if i == 0 {
entries.push((32, view(&self.interpolated)));
groups(&p.inpainting_pyramid_first, &entries)
} else {
entries.push((25, view(&self.inpainting_pyramid.levels[i - 1])));
groups(&p.inpainting_pyramid, &entries)
}
})
.collect();
let inpainting = groups(
&p.inpainting,
&[
(0, params()),
(28, view(&self.inpainting_pyramid.all)),
(32, view(&self.interpolated)),
(33, view(&self.output)),
],
);
let groups_for = |size: [u32; 2]| (size[0].div_ceil(8), size[1].div_ceil(8));
let (rx, ry) = groups_for(inputs.render_size);
let (dx, dy) = groups_for(self.display);
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Frame interpolation"),
timestamp_writes: None,
});
let mut run = |pipeline: &wgpu::ComputePipeline, group: &wgpu::BindGroup, (x, y)| {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(x, y, 1);
};
run(&p.prepare, &prepare, (rx, ry));
run(&p.setup, &setup, (1, 1));
if !reset {
run(&p.reconstruct_previous_depth, &reconstruct, (rx, ry));
run(&p.game_motion_vector_field, &game_field, (rx, ry));
for (i, (group, size)) in game_pyramid.iter().zip(&r.game_pyramid.sizes).enumerate() {
let pipeline = if i == 0 {
&p.game_field_pyramid_first
} else {
&p.game_field_pyramid
};
run(pipeline, group, groups_for(*size));
}
run(
&p.optical_flow_vector_field,
&flow_field,
groups_for(flow_size.map(|v| v as u32)),
);
run(&p.disocclusion_mask, &disocclusion, (rx, ry));
}
run(&p.interpolate, &interpolate, (dx, dy));
for (i, (group, size)) in inpainting_pyramid
.iter()
.zip(&self.inpainting_pyramid.sizes)
.enumerate()
{
let pipeline = if i == 0 {
&p.inpainting_pyramid_first
} else {
&p.inpainting_pyramid
};
run(pipeline, group, groups_for(*size));
}
run(&p.inpainting, &inpainting, (dx, dy));
drop(pass);
encoder.copy_texture_to_texture(
color.as_image_copy(),
previous_texture.as_image_copy(),
color.size(),
);
}
/// The interpolated frame: `display` size, `Rgba16Float`, sampled; its colour is
/// encoded as `Inputs::color`'s is.
pub fn output(&self) -> &wgpu::TextureView {
&self.output
}
}
#[cfg(test)]
mod tests {
use super::super::optical_flow::{OpticalFlow, tests as of};
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("interpolation.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
fn f16(bits: u16) -> f32 {
let (sign, exp, frac) = (bits >> 15, (bits >> 10) & 0x1f, bits & 0x3ff);
let v = match exp {
0 => f32::from(frac) / 1024.0 * 2f32.powi(-14),
31 => f32::INFINITY,
e => (1.0 + f32::from(frac) / 1024.0) * 2f32.powi(i32::from(e) - 15),
};
if sign == 1 { -v } else { v }
}
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
/// a camera that moves by `reproject` each frame, and returns the mean error of the
/// last made frame's interior against `expected`, and the last two real frames'.
/// `object`: every pixel's own motion vector (UV), as if all of it moved on its own.
fn interpolate(
seed: u32,
shifts: &[[i32; 2]],
reproject: Mat4,
object: Option<[f32; 2]>,
expected: [i32; 2],
) -> Option<[f32; 3]> {
let (device, queue) = of::device()?;
let size = [512, 288];
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let depth = depth.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.01),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
queue.submit([encoder.finish()]);
let object_motion = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::super::motion::FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
if let Some(mv) = object {
let texel = [mv[0], mv[1], 1.0, 0.0].map(|v| half::f16::from_f32(v).to_bits());
let texels: Vec<u16> = (0..size[0] * size[1]).flat_map(|_| texel).collect();
queue.write_texture(
object_motion.as_image_copy(),
bytemuck::cast_slice(&texels),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 8),
rows_per_image: None,
},
object_motion.size(),
);
}
let object_motion = object_motion.create_view(&Default::default());
let mut flow = OpticalFlow::new(&device, size);
let mut interpolation = Interpolation::new(&device, size);
let frames: Vec<_> = shifts
.iter()
.map(|s| {
let pixels = of::pattern(size, seed, *s);
// COPY_SRC as the frame generation's world texture has.
let t = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
queue.write_texture(
t.as_image_copy(),
&pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 4),
rows_per_image: None,
},
t.size(),
);
(t.create_view(&Default::default()), pixels)
})
.collect();
for (i, (color, _)) in frames.iter().enumerate() {
let mut encoder = device.create_command_encoder(&Default::default());
flow.dispatch(&device, &queue, &mut encoder, color, i == 0);
interpolation.dispatch(
&device,
&queue,
&mut encoder,
&Inputs {
color,
depth: &depth,
render_size: size,
jitter: [0.0; 2],
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
object_motion: &object_motion,
near: 0.05,
far: 512.0,
fov_y: 1.2,
reset: i == 0,
flow_vectors: flow.vectors(),
scene_change: flow.scene_change(),
},
);
queue.submit([encoder.finish()]);
}
let out = of::read(&device, &queue, interpolation.output.texture(), 8);
let made: Vec<f32> = out
.chunks(2)
.map(|c| f16(u16::from_le_bytes([c[0], c[1]])))
.collect();
let truth = of::pattern(size, seed, expected);
let n = frames.len();
let error = |pixels: &dyn Fn(usize) -> f32| {
let (mut sum, mut count) = (0.0, 0.0);
for y in 32..size[1] as usize - 32 {
for x in 32..size[0] as usize - 32 {
for c in 0..3 {
let i = (y * size[0] as usize + x) * 4 + c;
sum += (pixels(i) - f32::from(truth[i]) / 255.0).abs();
count += 1.0;
}
}
}
sum / count
};
Some([
error(&|i| made[i]),
error(&|i| f32::from(frames[n - 2].1[i]) / 255.0),
error(&|i| f32::from(frames[n - 1].1[i]) / 255.0),
])
}
/// Something moving on its own (the camera still): the optical flow puts the made
/// frame's blocks halfway where its colours match better than the still camera's
/// (FSR prefers the game's vectors unless they clearly don't: primaries make it
/// clear). The first 10 frames after a reset use the game's vectors only.
#[test]
fn moves_what_the_optical_flow_follows_halfway() {
let mut shifts = vec![[0, 0]; 20];
shifts.push([16, 8]);
let Some([made, previous, current]) =
interpolate(100, &shifts, Mat4::IDENTITY, None, [8, 4])
else {
return;
};
// Where the still camera's two colours happen to be the same primary (a third of
// the pixels), FSR keeps the game's vectors.
assert!(
made * 3.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
/// The camera turning: the game's motion vectors put the made frame halfway.
#[test]
fn moves_the_view_halfway_with_the_camera() {
let size = [512.0, 288.0];
// What is at a pixel now was 16 pixels to its right last frame.
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, None, [-40, 0])
else {
return;
};
assert!(
made * 4.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
/// Something moving on its own with its own motion vectors (the camera still): the
/// made frame is halfway, as with the camera's.
#[test]
fn moves_what_has_its_own_motion_vectors_halfway() {
let size = [512.0, 288.0];
let shifts = [[0, 0], [16, 8], [32, 16], [48, 24]];
let mv = [-16.0 / size[0], -8.0 / size[1]];
let Some([made, previous, current]) =
interpolate(1, &shifts, Mat4::IDENTITY, Some(mv), [40, 20])
else {
return;
};
assert!(
made * 4.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
}
@@ -0,0 +1,810 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// FidelityFX SDK 1.1.4's frame interpolation (ffx_frameinterpolation_*.h), ported to WGSL
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
// lists why):
// - the motion vectors are the camera's, from the depth (`prepare`), already unjittered,
// or an entity's own where `object_motion` has one;
// - the vector fields and reconstructed depths are storage buffers of atomics;
// - both inpainting pyramids are a pass per level, and textures of their own;
// - the interpolation writes one texture and the inpainting another (rgba16float can't
// be read_write everywhere);
// - NaNs AMD's hardware turns into 0 (max(0, NaN)) are avoided up front.
struct Params {
// This frame's unjittered clip space to the previous frame's.
reproject: mat4x4<f32>,
device_to_view: vec4<f32>,
render_size: vec2<i32>,
display_size: vec2<i32>,
// FSR's jitterOffset: render pixels.
jitter: vec2<f32>,
optical_flow_scale: vec2<f32>,
// GetOpticalFlowSize2.
optical_flow_size: vec2<i32>,
reset: u32,
tan_half_fov: f32,
// A mip level's size, for the pyramid passes.
level_size: vec2<i32>,
_pad: vec2<u32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var linear_clamp: sampler;
@group(0) @binding(2) var input_depth: texture_depth_2d;
@group(0) @binding(3) var current_color: texture_2d<f32>;
@group(0) @binding(4) var previous_color: texture_2d<f32>;
@group(0) @binding(5) var dilated_depth_out: texture_storage_2d<r32float, write>;
@group(0) @binding(6) var dilated_mv_out: texture_storage_2d<rg32float, write>;
@group(0) @binding(7) var dilated_depth: texture_2d<f32>;
@group(0) @binding(8) var dilated_mv: texture_2d<f32>;
@group(0) @binding(9) var<storage, read_write> rec_prev_depth: array<atomic<u32>>;
@group(0) @binding(10) var<storage, read_write> rec_interp_depth: array<atomic<u32>>;
@group(0) @binding(11) var<storage, read> rec_prev_depth_in: array<u32>;
@group(0) @binding(12) var<storage, read> rec_interp_depth_in: array<u32>;
@group(0) @binding(13) var<storage, read_write> game_field_x: array<atomic<u32>>;
@group(0) @binding(14) var<storage, read_write> game_field_y: array<atomic<u32>>;
@group(0) @binding(15) var<storage, read> game_field_x_in: array<u32>;
@group(0) @binding(16) var<storage, read> game_field_y_in: array<u32>;
@group(0) @binding(17) var<storage, read_write> of_field_x: array<atomic<u32>>;
@group(0) @binding(18) var<storage, read_write> of_field_y: array<atomic<u32>>;
@group(0) @binding(19) var<storage, read> of_field_x_in: array<u32>;
@group(0) @binding(20) var<storage, read> of_field_y_in: array<u32>;
@group(0) @binding(21) var optical_flow: texture_2d<i32>;
@group(0) @binding(22) var<storage, read> scene_change: array<u32, 3>;
@group(0) @binding(23) var<storage, read_write> counters: array<u32, 2>;
@group(0) @binding(24) var<storage, read> counters_in: array<u32, 2>;
// A pyramid's previous level, and the level a pyramid pass writes.
@group(0) @binding(25) var pyramid_in: texture_2d<f32>;
@group(0) @binding(26) var pyramid_out: texture_storage_2d<rgba16float, write>;
// The whole game vector field and inpainting pyramids.
@group(0) @binding(27) var game_pyramid: texture_2d<f32>;
@group(0) @binding(28) var inpainting_pyramid: texture_2d<f32>;
@group(0) @binding(29) var disocclusion_out: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(30) var disocclusion: texture_2d<f32>;
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
@group(0) @binding(32) var interpolated: texture_2d<f32>;
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
// What moved on its own (motion.wgsl): xy its motion vector where z is 1.
@group(0) @binding(34) var object_motion: texture_2d<f32>;
const EPSILON: f32 = 1e-03;
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
const COUNTER_FRAME_INDEX_SINCE_LAST_RESET: u32 = 1u;
fn render_index(p: vec2<i32>) -> u32 {
return u32(p.y * params.render_size.x + p.x);
}
fn is_on_screen(p: vec2<i32>, size: vec2<i32>) -> bool {
return all(vec2<u32>(p) < vec2<u32>(size));
}
fn is_uv_inside(uv: vec2<f32>) -> bool {
return uv.x > 0.0 && uv.x < 1.0 && uv.y > 0.0 && uv.y < 1.0;
}
fn min_divided_by_max(v0: f32, v1: f32) -> f32 {
let m = max(v0, v1);
return select(0.0, min(v0, v1) / m, m != 0.0);
}
fn normalized_dot3(v0: vec3<f32>, v1: vec3<f32>) -> f32 {
let m = max(length(v0), length(v1));
return select(1.0, dot(v0 / m, v1 / m), m > 0.0);
}
fn linear_from_srgb(c: vec3<f32>) -> vec3<f32> {
return select(pow(c / 1.055 + 0.055 / 1.055, vec3(2.4)), c / 12.92, c < vec3(0.04045));
}
fn raw_rgb_to_luminance(c: vec3<f32>) -> f32 {
return dot(linear_from_srgb(c), vec3(0.2126, 0.7152, 0.0722));
}
struct Bilinear {
base: vec2<i32>,
weights: vec4<f32>,
};
fn bilinear(uv: vec2<f32>, size: vec2<i32>) -> Bilinear {
let px = uv * vec2<f32>(size) - 0.5;
let f = fract(px);
var b: Bilinear;
b.base = vec2<i32>(floor(px));
b.weights = vec4(
(1.0 - f.x) * (1.0 - f.y),
f.x * (1.0 - f.y),
(1.0 - f.x) * f.y,
f.x * f.y,
);
return b;
}
fn bilinear_offset(i: i32) -> vec2<i32> {
return vec2(i & 1, i >> 1u);
}
fn device_to_view_depth(d: f32) -> f32 {
return params.device_to_view.y / (d - params.device_to_view.x);
}
fn view_space_position(px: vec2<i32>, size: vec2<i32>, d: f32) -> vec3<f32> {
let z = device_to_view_depth(d);
let ndc = vec2<f32>(px) / vec2<f32>(size) * vec2(2.0, -2.0) + vec2(-1.0, 1.0);
return vec3(params.device_to_view.z * ndc.x * z, params.device_to_view.w * ndc.y * z, z);
}
// MOTION VECTOR FIELD (ffx_frameinterpolation_common.h)
const PRIMARY_VECTOR_BIT: u32 = 0x80000000u;
const PRIORITY_LOW_MAX: u32 = 31u;
const PRIORITY_HIGH_MAX: u32 = 1023u;
const PRIORITY_LOW_OFFSET: u32 = 16u;
const PRIORITY_HIGH_OFFSET: u32 = 21u;
struct VectorFieldEntry {
motion_vector: vec2<f32>,
high_priority: f32,
low_priority: f32,
valid: bool,
primary: bool,
secondary: bool,
in_painted: bool,
velocity: f32,
neg_outside: bool,
pos_outside: bool,
};
fn new_entry() -> VectorFieldEntry {
return VectorFieldEntry(vec2(0.0), 0.0, 0.0, false, false, false, false, 0.0, false, false);
}
fn is_primary(packed: u32) -> bool {
return (packed & PRIMARY_VECTOR_BIT) != 0u;
}
// ffxF32ToF16 and its inverse in integer arithmetic: pack2x16float needs a capability
// some GPUs lack.
fn f32_to_f16(v: f32) -> u32 {
let x = bitcast<u32>(v);
let sign = (x >> 16u) & 0x8000u;
let e = i32((x >> 23u) & 0xffu) - 112;
let m = x & 0x7fffffu;
if e >= 31 {
return sign | 0x7c00u;
}
if e <= 0 {
if e < -10 {
return sign;
}
let full = m | 0x800000u;
let shift = u32(14 - e);
return sign | ((full >> shift) + ((full >> (shift - 1u)) & 1u));
}
return sign | (((u32(e) << 10u) | (m >> 13u)) + ((m >> 12u) & 1u));
}
fn f16_to_f32(h: u32) -> f32 {
let sign = (h & 0x8000u) << 16u;
let e = (h >> 10u) & 0x1fu;
let m = h & 0x3ffu;
if e == 0u {
let v = f32(m) * (1.0 / 16777216.0);
return select(v, -v, sign != 0u);
}
if e == 31u {
return bitcast<f32>(sign | 0x7f800000u);
}
return bitcast<f32>(sign | ((e + 112u) << 23u) | (m << 13u));
}
fn pack_entries(primary: bool, high: u32, low: u32, mv: vec2<f32>) -> vec2<u32> {
let priority = select(0u, PRIMARY_VECTOR_BIT, primary)
| ((high & PRIORITY_HIGH_MAX) << PRIORITY_HIGH_OFFSET)
| ((low & PRIORITY_LOW_MAX) << PRIORITY_LOW_OFFSET);
return vec2(priority | f32_to_f16(mv.x), priority | f32_to_f16(mv.y));
}
fn unpack_entries(packed: vec2<u32>) -> VectorFieldEntry {
var e = new_entry();
e.high_priority = f32((packed.x >> PRIORITY_HIGH_OFFSET) & PRIORITY_HIGH_MAX) / f32(PRIORITY_HIGH_MAX);
e.low_priority = f32((packed.x >> PRIORITY_LOW_OFFSET) & PRIORITY_LOW_MAX) / f32(PRIORITY_LOW_MAX);
e.primary = is_primary(packed.x);
e.valid = e.high_priority > 0.0;
e.secondary = e.valid && !e.primary;
if e.secondary {
e.high_priority = 1.0 - e.high_priority;
}
e.motion_vector = vec2(f16_to_f32(packed.x & 0xffffu), f16_to_f32(packed.y & 0xffffu));
return e;
}
fn load_game_field(p: vec2<i32>) -> vec2<u32> {
if !is_on_screen(p, params.render_size) {
return vec2(0u);
}
let i = render_index(p);
return vec2(game_field_x_in[i], game_field_y_in[i]);
}
fn compute_mv_inpainting_level(uv: vec2<f32>, level: i32, size: vec2<i32>) -> vec4<f32> {
let b = bilinear(uv, size);
var sum = 0.0;
var color = vec4(0.0);
for (var i = 0; i < 4; i++) {
let p = b.base + bilinear_offset(i);
if is_on_screen(p, size) {
let s = textureLoad(game_pyramid, p, level);
let weight = b.weights[i] * f32(s.z > 0.0) * s.z;
sum += weight;
color += s * weight;
}
}
return color / select(1.0, sum, sum > 0.0);
}
fn load_inpainted_game_field_mv(uv: vec2<f32>) -> VectorFieldEntry {
let px = vec2<i32>(uv * vec2<f32>(params.render_size));
var e = unpack_entries(load_game_field(px));
if !e.valid {
var size = params.render_size;
var v = vec4(0.0);
let levels = i32(textureNumLevels(game_pyramid));
for (var level = 0; level < min(11, levels) && v.w == 0.0; level++) {
size /= 2;
v = compute_mv_inpainting_level(uv, level, size);
}
e.motion_vector = v.xy;
e.high_priority = v.z;
e.low_priority = v.w;
e.in_painted = true;
}
e.neg_outside = !is_uv_inside(uv - e.motion_vector);
e.pos_outside = !is_uv_inside(uv + e.motion_vector);
e.velocity = length(e.motion_vector);
return e;
}
fn sample_optical_flow_field(uv: vec2<f32>) -> VectorFieldEntry {
let size = params.optical_flow_size;
let b = bilinear(uv, size);
var e = new_entry();
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let p = b.base + bilinear_offset(i);
if is_on_screen(p, size) {
let w = b.weights[i];
let j = render_index(p);
let s = unpack_entries(vec2(of_field_x_in[j], of_field_y_in[j]));
e.motion_vector += s.motion_vector * w;
e.high_priority += s.high_priority * w;
e.low_priority += s.low_priority * w;
weight_sum += w;
}
}
if weight_sum > 0.0 {
e.motion_vector /= weight_sum;
e.high_priority /= weight_sum;
e.low_priority /= weight_sum;
}
e.neg_outside = !is_uv_inside(uv - e.motion_vector);
e.pos_outside = !is_uv_inside(uv + e.motion_vector);
e.velocity = length(e.motion_vector);
return e;
}
fn has_scene_changed() -> bool {
return (scene_change[1] & 0xfu) != 0u;
}
// ffx_frameinterpolation_reconstruct_dilated_velocity_and_previous_depth.h, with the
// motion vectors computed from the depth and the camera.
fn camera_motion_vector(p: vec2<i32>, depth: f32) -> vec2<f32> {
let size = vec2<f32>(params.render_size);
let uv = (vec2<f32>(p) + 0.5) / size - params.jitter / size;
let ndc = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
let previous = params.reproject * vec4(ndc, depth, 1.0);
let previous_uv = vec2(0.5, -0.5) * previous.xy / previous.w + 0.5;
return previous_uv - uv;
}
fn load_input_depth(p: vec2<i32>) -> f32 {
if !is_on_screen(p, params.render_size) {
return 0.0;
}
return textureLoad(input_depth, p, 0);
}
@compute @workgroup_size(8, 8)
fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let offsets = array(
vec2(0, 0), vec2(1, 0), vec2(0, 1), vec2(0, -1), vec2(-1, 0),
vec2(-1, 1), vec2(1, 1), vec2(-1, -1), vec2(1, -1),
);
var nearest = load_input_depth(p);
var nearest_coord = p;
for (var i = 1; i < 9; i++) {
let q = p + offsets[i];
if is_on_screen(q, params.render_size) {
let d = load_input_depth(q);
if d > nearest {
nearest_coord = q;
nearest = d;
}
}
}
let object = textureLoad(object_motion, nearest_coord, 0);
var mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
if object.z > 0.5 {
mv = object.xy;
}
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
// ReconstructPrevDepth
let moving = mv * f32(length(mv * vec2<f32>(params.display_size)) > 0.1);
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let b = bilinear(uv + moving, params.render_size);
for (var i = 0; i < 4; i++) {
if b.weights[i] > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.render_size) {
atomicMax(&rec_prev_depth[render_index(q)], bitcast<u32>(nearest));
}
}
}
}
// ffx_frameinterpolation_setup.h: the frames since the last reset (the vector fields are
// cleared by the encoder).
@compute @workgroup_size(1)
fn setup_pass() {
if params.reset != 0u || has_scene_changed() {
counters[COUNTER_FRAME_INDEX_SINCE_LAST_RESET] = 0u;
} else {
counters[COUNTER_FRAME_INDEX_SINCE_LAST_RESET] += 1u;
}
}
// ffx_frameinterpolation_reconstruct_previous_depth.h: the interpolated frame's depth.
@compute @workgroup_size(8, 8)
fn reconstruct_previous_depth_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let mv = textureLoad(dilated_mv, p, 0).xy * 0.5;
let depth = textureLoad(dilated_depth, p, 0).x;
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let b = bilinear(uv + mv, params.render_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if b.weights[i] > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD && is_on_screen(q, params.render_size) {
atomicMax(&rec_interp_depth[render_index(q)], bitcast<u32>(depth));
}
}
}
// ffx_frameinterpolation_game_motion_vector_field.h
fn priority_from_view_depth(view_depth_in: f32) -> u32 {
let d = pow(view_depth_in, 0.33);
let factor = u32((1.0 - d * (1.0 / (1.0 + d))) * f32(PRIORITY_HIGH_MAX));
return max(1u, factor);
}
fn update_game_field(p: vec2<i32>, packed: vec2<u32>) -> u32 {
let i = render_index(p);
let x = atomicMax(&game_field_x[i], packed.x);
let y = atomicMax(&game_field_y[i], packed.y);
return max(x, y);
}
@compute @workgroup_size(8, 8)
fn game_motion_vector_field_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let size = vec2<f32>(params.render_size);
let uv = (vec2<f32>(p) + 0.5) / size;
let depth = textureLoad(dilated_depth, p, 0).x;
let mv = textureLoad(dilated_mv, p, 0).xy;
let half_mv = mv * 0.5;
let interpolated_uv = uv + half_mv;
let high_primary = priority_from_view_depth(device_to_view_depth(depth));
let previous = textureSampleLevel(previous_color, linear_clamp, uv + mv, 0.0).rgb;
let current = textureSampleLevel(current_color, linear_clamp, uv, 0.0).rgb;
let previous_luma = 0.001 + raw_rgb_to_luminance(previous);
let current_luma = 0.001 + raw_rgb_to_luminance(current);
var low = u32(round(min_divided_by_max(previous_luma, current_luma) * f32(PRIORITY_LOW_MAX)))
* u32(is_uv_inside(uv + mv));
let primary = pack_entries(true, high_primary, low, half_mv);
let b = bilinear(interpolated_uv, params.render_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.render_size) {
update_game_field(q, primary);
}
}
if length(half_mv * size) > EPSILON {
var write_secondary = true;
var primary_hits = 0u;
let step_scale = length(1.0 / size);
let step_mv = normalize(mv);
let break_dist = min(length(half_mv), length(vec2(0.5, 0.5)));
for (var s = step_scale; s <= break_dist && write_secondary; s += step_scale) {
let secondary_uv = interpolated_uv - step_mv * s;
let sb = bilinear(secondary_uv, params.render_size);
let high_secondary = max(1u, PRIORITY_HIGH_MAX - high_primary);
let to_centre_raw = vec2(0.5, 0.5) - secondary_uv;
let to_centre = select(vec2(0.0), normalize(to_centre_raw), length(to_centre_raw) > 0.0);
low = u32(max(0.0, dot(to_centre, step_mv)) * f32(PRIORITY_LOW_MAX));
let secondary = pack_entries(false, high_secondary, low, half_mv);
let q = sb.base;
write_secondary = write_secondary && is_on_screen(q, params.render_size);
if write_secondary {
let existing = update_game_field(q, secondary);
primary_hits += u32(is_primary(existing));
write_secondary = write_secondary && primary_hits <= 3u;
}
}
}
}
// ffx_frameinterpolation_compute_game_vector_field_inpainting_pyramid.h: SPD, a level per
// pass. Level 0 reads the field, the others the level above.
fn reduce_game_field(v: array<vec4<f32>, 4>) -> vec4<f32> {
var sum = vec4(0.0);
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let w = f32(v[i].z > 0.0);
sum += v[i] * w;
weight_sum += w;
}
return sum / select(1.0, weight_sum, weight_sum > EPSILON);
}
@compute @workgroup_size(8, 8)
fn game_field_pyramid_first_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
let e = unpack_entries(load_game_field(p * 2 + bilinear_offset(i)));
v[i] = vec4(e.motion_vector, e.high_priority, e.low_priority);
}
textureStore(pyramid_out, p, reduce_game_field(v));
}
@compute @workgroup_size(8, 8)
fn game_field_pyramid_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
v[i] = textureLoad(pyramid_in, p * 2 + bilinear_offset(i), 0);
}
textureStore(pyramid_out, p, reduce_game_field(v));
}
// ffx_frameinterpolation_optical_flow_vector_field.h
fn load_optical_flow(p: vec2<i32>) -> vec2<f32> {
let size = vec2<i32>(textureDimensions(optical_flow));
if !is_on_screen(p, size) {
return vec2(0.0);
}
return vec2<f32>(textureLoad(optical_flow, p, 0).xy) * params.optical_flow_scale;
}
@compute @workgroup_size(8, 8)
fn optical_flow_vector_field_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
let rect = vec2<f32>(params.display_size);
var avg = vec2(0.0);
var sw = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let vs = load_optical_flow(p + vec2(x, y));
let len = length(vs * rect);
let w = max(0.0, 512.0 - len) * f32(len > 1.0);
avg += vs * w;
sw += w;
}
}
// AMD divides by 0 here, and its max(0, NaN) below gives 0 weights: a zero vector.
if sw > 0.0 {
avg /= sw;
}
var v = vec2(0.0);
sw = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let vs = load_optical_flow(p + vec2(x, y));
let len = length(vs * rect);
let len_factor = max(0.0, 512.0 - len) * f32(len > 1.0);
let w = pow(max(0.0, dot(avg, vs)), 1.25) * len_factor;
v += vs * w;
sw += w;
}
}
if sw > EPSILON {
v /= sw;
}
// computeOpticalFlowFieldMvs
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.optical_flow_size);
let half_mv = v * 0.5;
let previous = textureSampleLevel(previous_color, linear_clamp, uv + v, 0.0).rgb;
let current = textureSampleLevel(current_color, linear_clamp, uv, 0.0).rgb;
let previous_luma = 0.001 + raw_rgb_to_luminance(previous);
let current_luma = 0.001 + raw_rgb_to_luminance(current);
let velocity = length(v * rect);
let high = u32(velocity > 1.0)
* u32(saturate(velocity / length(rect * 0.05)) * f32(PRIORITY_HIGH_MAX));
if high > 0u {
let low = u32(round(min_divided_by_max(previous_luma, current_luma) * f32(PRIORITY_LOW_MAX)))
* u32(is_uv_inside(uv + v));
let packed = pack_entries(true, high, low, half_mv);
let b = bilinear(uv + half_mv, params.optical_flow_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.optical_flow_size) {
let j = render_index(q);
atomicMax(&of_field_x[j], packed.x);
atomicMax(&of_field_y[j], packed.y);
}
}
}
}
// ffx_frameinterpolation_disocclusion_mask.h
fn load_estimated_depth(which: u32, p: vec2<i32>) -> f32 {
if which == 0u {
return bitcast<f32>(rec_prev_depth_in[render_index(p)]);
}
return textureLoad(dilated_depth, p, 0).x;
}
fn compute_depth_clip(which: u32, uv: vec2<f32>, current_depth: f32) -> f32 {
let current_view = device_to_view_depth(current_depth);
let b = bilinear(uv, params.render_size);
var depth = 0.0;
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
let w = b.weights[i];
if is_on_screen(q, params.render_size) && w > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD {
let previous_depth = load_estimated_depth(which, q);
let previous_view = device_to_view_depth(previous_depth);
let diff = current_view - previous_view;
if diff > 0.0 {
let plane_depth = min(previous_depth, current_depth);
let centre = view_space_position(params.render_size / 2, params.render_size, plane_depth);
let corner = view_space_position(vec2(0), params.render_size, plane_depth);
let half_viewport_width = length(vec2<f32>(params.render_size));
let threshold = min(current_view, previous_view);
let k_fov = length(corner) / length(centre);
let required = 1.37e-05 * k_fov * half_viewport_width * threshold;
depth += f32(required / diff >= 1.0) * w;
weight_sum += w;
}
}
}
return select(0.0, saturate(1.0 - depth / weight_sum), weight_sum > 0.0);
}
@compute @workgroup_size(8, 8)
fn disocclusion_mask_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let depth = bitcast<f32>(rec_interp_depth_in[render_index(p)]);
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let game = load_inpainted_game_field_mv(uv);
var mask = vec2(
1.0 - compute_depth_clip(0u, uv + game.motion_vector, depth),
1.0 - compute_depth_clip(1u, uv - game.motion_vector, depth),
);
mask = vec2<f32>(mask >= vec2(EPSILON));
let source_mv = game.motion_vector * 2.0;
let size = vec2<f32>(params.render_size);
let previous = vec2<i32>((uv + source_mv) * size);
mask.x = saturate(mask.x + f32(!is_on_screen(previous, params.render_size)));
let current = vec2<i32>((uv - source_mv) * size);
mask.y = saturate(mask.y + f32(!is_on_screen(current, params.render_size)));
textureStore(disocclusion_out, p, vec4(mask, 0.0, 0.0));
}
// ffx_frameinterpolation.h
struct SourceColor {
raw: vec3<f32>,
weight_sum: f32,
};
fn sample_bilinear(is_current: bool, uv: vec2<f32>, mv: vec2<f32>) -> SourceColor {
let size = params.display_size;
let b = bilinear(uv + mv, size);
var color = vec3(0.0);
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, size) {
let w = b.weights[i];
if is_current {
color += textureLoad(current_color, q, 0).rgb * w;
} else {
color += textureLoad(previous_color, q, 0).rgb * w;
}
weight_sum += w;
}
}
return SourceColor(select(vec3(0.0), color / weight_sum, weight_sum != 0.0), weight_sum);
}
@compute @workgroup_size(8, 8)
fn interpolate_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.display_size) {
return;
}
let frame_index = counters_in[COUNTER_FRAME_INDEX_SINCE_LAST_RESET];
if frame_index == 0u {
textureStore(interpolated_out, p, vec4(textureLoad(current_color, p, 0).rgb, 0.0));
return;
}
var in_painting = 0.0;
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.display_size);
let game = load_inpainted_game_field_mv(uv);
let flow = sample_optical_flow_field(uv);
var disocclusion_factor = vec2<f32>(
saturate(textureSampleLevel(disocclusion, linear_clamp, uv, 0.0).xy) == vec2(1.0),
);
let previous_game = sample_bilinear(false, uv, game.motion_vector);
let current_game = sample_bilinear(true, uv, -game.motion_vector);
let previous_of = sample_bilinear(false, uv, flow.motion_vector);
let current_of = sample_bilinear(true, uv, -flow.motion_vector);
disocclusion_factor.x *= f32(!game.pos_outside);
disocclusion_factor.y *= f32(!game.neg_outside);
in_painting = saturate(max(in_painting, f32(length(disocclusion_factor) <= EPSILON)));
var t = 0.5;
t += 0.5 * (1.0 - disocclusion_factor.x);
t -= 0.5 * (1.0 - disocclusion_factor.y);
var color = mix(previous_game.raw, current_game.raw, saturate(t));
let disoccluded = saturate(1.0 - min(disocclusion_factor.x, disocclusion_factor.y));
if previous_game.weight_sum == 0.0 {
color = current_game.raw;
} else if current_game.weight_sum == 0.0 {
color = previous_game.raw;
}
if previous_game.weight_sum == 0.0 && current_game.weight_sum == 0.0 {
in_painting = 1.0;
}
var of_t = 1.0;
if previous_of.weight_sum > 0.0 && current_of.weight_sum > 0.0 {
of_t = 0.5;
} else if previous_of.weight_sum > 0.0 {
of_t = 0.0;
}
let of_color = mix(previous_of.raw, current_of.raw, of_t);
let of_sim = normalized_dot3(previous_of.raw, current_of.raw);
var game_sim = normalized_dot3(previous_game.raw, current_game.raw);
game_sim = mix(max(EPSILON, game_sim), 1.0, disoccluded);
var game_bias = saturate(game_sim / max(EPSILON, of_sim));
game_bias = mix(game_bias, 1.0, f32(frame_index < 10u));
color = mix(of_color, color, saturate(game_bias));
textureStore(interpolated_out, p, vec4(color, in_painting));
}
// ffx_frameinterpolation_compute_inpainting_pyramid.h: SPD, a level per pass.
fn reduce_inpainting(v: array<vec4<f32>, 4>) -> vec4<f32> {
let sum = v[0].w + v[1].w + v[2].w + v[3].w;
if sum == 0.0 {
return vec4(0.0);
}
return (v[0] * v[0].w + v[1] * v[1].w + v[2] * v[2].w + v[3] * v[3].w) / sum;
}
@compute @workgroup_size(8, 8)
fn inpainting_pyramid_first_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
let q = p * 2 + bilinear_offset(i);
var c = vec4(0.0);
if is_on_screen(q, params.display_size) {
c = textureLoad(interpolated, q, 0);
c.w = saturate(1.0 - c.w);
}
v[i] = c;
}
textureStore(pyramid_out, p, reduce_inpainting(v));
}
@compute @workgroup_size(8, 8)
fn inpainting_pyramid_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
v[i] = textureLoad(pyramid_in, p * 2 + bilinear_offset(i), 0);
}
textureStore(pyramid_out, p, reduce_inpainting(v));
}
// ffx_frameinterpolation_inpainting.h (no HUD-less source: Horizon draws its HUD over
// the made frame itself).
fn compute_inpainting_level(uv: vec2<f32>, level: i32, size: vec2<i32>) -> vec4<f32> {
let b = bilinear(uv, size);
var color = vec4(0.0);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, size) {
let s = textureLoad(inpainting_pyramid, q, level);
let w = b.weights[i] * f32(s.w > 0.0);
color += vec4(s.rgb * w, w);
}
}
return color;
}
fn compute_inpainting(p: vec2<i32>) -> vec3<f32> {
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.display_size);
var color = vec4(0.0);
var size = params.display_size;
let levels = i32(textureNumLevels(inpainting_pyramid));
for (var level = 0; level < min(10, levels); level++) {
size /= 2;
let mip = compute_inpainting_level(uv, level, size);
if mip.w > 0.0 {
let weight = pow(1.0 - f32(level) / 10.0, 3.0) * mip.w;
color += vec4(mip.rgb / mip.w, 1.0) * weight;
}
}
return select(vec3(0.0), color.rgb / color.w, color.w > 0.0);
}
@compute @workgroup_size(8, 8)
fn inpainting_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.display_size) {
return;
}
var c = textureLoad(interpolated, p, 0);
if c.w > EPSILON {
c = vec4(mix(c.rgb, compute_inpainting(p), c.w), 1.0);
}
textureStore(output, p, c);
}
@@ -0,0 +1,306 @@
//! Motion vectors for what moves on its own, for frame generation: the camera's motion
//! vectors (from the depth) see mobs and dropped items as still. The game tags each
//! entity's vertices with its id (`Frame::entity_motion`); an entity whose vertices have
//! the same shape as last frame's (the same count, UVs and layers) gets each vertex's
//! own motion, limbs and spin included, drawn into a texture at the render size
//! (`motion.wgsl`) that the interpolation reads before the camera's.
use crate::mesher::Vertex;
use glam::{DVec3, Mat4, Vec3};
use std::collections::HashMap;
/// `motion.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
jittered: [[f32; 4]; 4],
current: [[f32; 4]; 4],
previous: [[f32; 4]; 4],
}
/// A vertex this frame and where it was last frame, both relative to this frame's camera.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct MotionVertex {
position: [f32; 3],
previous: [f32; 3],
uv: [f32; 2],
/// 1 for a translucent item, whose texture says where it shows.
translucent: u32,
}
pub struct ObjectMotion {
pipeline: wgpu::RenderPipeline,
atlas: wgpu::TextureView,
sampler: wgpu::Sampler,
params: wgpu::Buffer,
vertices: wgpu::Buffer,
/// Triangles of the entities matched this frame.
staged: Vec<MotionVertex>,
/// Last frame's vertices by entity, and its camera.
tracked: HashMap<u64, Vec<Vertex>>,
camera: DVec3,
target: Option<([u32; 2], wgpu::TextureView)>,
}
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
impl ObjectMotion {
/// `atlas`: the entities' texture atlas.
pub fn new(device: &wgpu::Device, atlas: wgpu::TextureView) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Frame generation motion"),
source: wgpu::ShaderSource::Wgsl(include_str!("motion.wgsl").into()),
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2, 3 => Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Frame generation motion"),
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_motion"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<MotionVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_motion"),
compilation_options: Default::default(),
targets: &[Some(FORMAT.into())],
}),
multiview_mask: None,
cache: None,
});
Self {
pipeline,
atlas,
sampler: device.create_sampler(&Default::default()),
params: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
vertices: vertex_buffer(device, 1 << 12),
staged: Vec::new(),
tracked: HashMap::new(),
camera: DVec3::ZERO,
target: None,
}
}
/// Forget last frame's entities: the next frame has no motion of its own.
pub fn reset(&mut self) {
self.tracked.clear();
}
/// Match this frame's `entities` (relative to `camera`) to last frame's by the ids
/// `spans` gives their vertices (start, id; 0 for none, each up to the next start), and
/// keep them for the next frame.
pub fn track(&mut self, entities: &[Vertex], spans: &[(u32, u64)], camera: DVec3) {
self.staged.clear();
let shift = (self.camera - camera).as_vec3();
let mut tracked = HashMap::with_capacity(self.tracked.len());
for (i, &(start, id)) in spans.iter().enumerate() {
let end = spans.get(i + 1).map_or(entities.len(), |s| s.0 as usize);
let Some(now) = entities
.get(start as usize..end)
.filter(|v| id != 0 && !v.is_empty())
else {
continue;
};
if let Some(then) = self.tracked.remove(&id)
&& then.len() == now.len()
&& then
.iter()
.zip(now)
.all(|(a, b)| a.uv == b.uv && a.layer == b.layer)
{
let pair = |i: usize| MotionVertex {
position: now[i].pos,
previous: (Vec3::from(then[i].pos) + shift).into(),
uv: now[i].uv,
translucent: u32::from(matches!(
now[i].layer,
super::super::entity_batch::TRANSLUCENT_ITEM_OVERLAY
| super::super::entity_batch::TRANSLUCENT_GLINT_ITEM_OVERLAY
)),
};
for q in (0..now.len() / 4).map(|q| q * 4) {
self.staged
.extend([q, q + 1, q + 2, q, q + 2, q + 3].map(pair));
}
}
tracked.insert(id, now.to_vec());
}
self.tracked = tracked;
self.camera = camera;
}
/// Draw the matched entities' motion vectors at `size` (the world's render size),
/// where `depth` (the world's) shows them; the rest of the texture is 0.
#[allow(clippy::too_many_arguments)]
pub fn draw(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
size: [u32; 2],
jittered: Mat4,
current: Mat4,
previous: Mat4,
) -> wgpu::TextureView {
if self.target.as_ref().is_none_or(|t| t.0 != size) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation motion"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
self.target = Some((size, texture.create_view(&Default::default())));
}
let target = self.target.as_ref().expect("made above").1.clone();
let bytes: &[u8] = bytemuck::cast_slice(&self.staged);
if bytes.len() as u64 > self.vertices.size() {
self.vertices = vertex_buffer(device, (bytes.len() as u64).next_power_of_two());
}
if !bytes.is_empty() {
queue.write_buffer(&self.vertices, 0, bytes);
}
let params = Params {
jittered: jittered.to_cols_array_2d(),
current: current.to_cols_array_2d(),
previous: previous.to_cols_array_2d(),
};
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(&params));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Frame generation motion"),
layout: &self.pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&self.atlas),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Frame generation motion"),
color_attachments: &attachments,
..Default::default()
});
if !self.staged.is_empty() {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..bytes.len() as u64));
pass.draw(0..self.staged.len() as u32, 0..1);
}
drop(pass);
target
}
}
fn vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion vertices"),
size,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("motion.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
fn quad(x: f32, uv: f32) -> Vec<Vertex> {
(0..4)
.map(|i| Vertex {
pos: [x + i as f32, 0.0, -5.0],
uv: [uv, 0.0],
..bytemuck::Zeroable::zeroed()
})
.collect()
}
/// An entity seen last frame gets its vertices' own motion, in this frame's camera
/// space; a new one, one whose shape changed and untagged geometry get none.
#[test]
fn tracks_each_entity_by_id() {
let Some((device, queue)) = super::super::optical_flow::tests::device() else {
return;
};
let atlas = super::super::optical_flow::tests::upload(&device, &queue, [1, 1], &[0; 4]);
let mut motion = ObjectMotion::new(&device, atlas);
let mut entities = quad(0.0, 0.0);
entities.extend(quad(10.0, 0.5));
motion.track(&entities, &[(0, 7), (4, 8)], DVec3::ZERO);
assert!(motion.staged.is_empty());
// Entity 7 moved 1 block right, the camera 2 blocks right; entity 8 became
// another item; 9 is new; then untagged geometry.
let mut entities = quad(-1.0, 0.0);
entities.extend(quad(10.0, 0.25));
entities.extend(quad(20.0, 0.0));
entities.extend(quad(30.0, 0.0));
motion.track(
&entities,
&[(0, 7), (4, 8), (8, 9), (12, 0)],
DVec3::new(2.0, 0.0, 0.0),
);
assert_eq!(motion.staged.len(), 6);
let v = motion.staged[0];
assert_eq!(v.position, [-1.0, 0.0, -5.0]);
assert_eq!(v.previous, [-2.0, 0.0, -5.0]);
assert_eq!(motion.tracked.len(), 3);
}
}
@@ -0,0 +1,58 @@
// Frame generation's motion vectors for what moves on its own (mobs, dropped items): each
// vertex drawn where the world pass drew it, with where it was last frame; only the pixels
// the world shows it at keep its vector: where the depth is its own, or, for translucent
// items (which don't write depth), where its texture shows and nothing is in front.
struct Params {
// The world's projection this frame (TAA's jitter in it), the same without the jitter,
// and last frame's projection from this frame's camera.
jittered: mat4x4<f32>,
current: mat4x4<f32>,
previous: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var depth: texture_depth_2d;
@group(0) @binding(2) var atlas: texture_2d<f32>;
@group(0) @binding(3) var atlas_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) current: vec4<f32>,
@location(1) previous: vec4<f32>,
@location(2) uv: vec2<f32>,
// 1: a translucent item.
@location(3) @interpolate(flat) translucent: u32,
};
@vertex fn vs_motion(@location(0) position: vec3<f32>, @location(1) previous: vec3<f32>,
@location(2) uv: vec2<f32>, @location(3) translucent: u32) -> Out {
var out: Out;
out.uv = uv;
out.translucent = translucent;
out.clip = params.jittered * vec4(position, 1.0);
out.current = params.current * vec4(position, 1.0);
out.previous = params.previous * vec4(previous, 1.0);
return out;
}
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
let tolerance = stored * 1e-3 + 1e-7;
// Not what the world shows here: a transparent texel, or behind something.
if in.translucent != 0u {
let alpha = textureSampleLevel(atlas, atlas_sampler, in.uv, 0.0).a;
if alpha < 0.1 || in.clip.z < stored - tolerance {
discard;
}
} else if abs(in.clip.z - stored) > tolerance {
discard;
}
if in.previous.w <= 0.0 {
discard;
}
let now = in.current.xy / in.current.w;
let then = in.previous.xy / in.previous.w;
return vec4((then - now) * vec2(0.5, -0.5), 1.0, 0.0);
}
+34 -4
View File
@@ -91,6 +91,19 @@ pub const TIERS: [Tier; 4] = [
full: 0.38,
},
];
/// Which pixels of a light source's sprite may shine (`hdr_stats.wgsl`), as a fraction of
/// the way from its darkest to its brightest opaque pixel: `parts` for one whose bright parts
/// shine (a torch's flame, not its handle), `whole` for one that shines all over
/// (`mesher::WHOLE`). The tier windows still decide how much.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Masks {
pub parts: f32,
pub whole: f32,
}
pub const MASKS: Masks = Masks {
parts: 0.32,
whole: 0.0,
};
/// A step of HDR calibration (`crate::hdr_calibration`): its test picture is drawn in nits
/// above the controls instead of the frame.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -133,6 +146,8 @@ pub struct Hdr {
pub tiers: [Tier; 4],
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
pub ui: f32,
/// Which pixels of a light source shine (`Masks`).
pub masks: Masks,
/// Calibration's test picture while it runs.
pub calibration: Option<Calibration>,
}
@@ -276,6 +291,7 @@ impl Hdr {
black: 0.0,
tiers: TIERS,
ui: 1.0,
masks: MASKS,
calibration: None,
}
}
@@ -350,7 +366,11 @@ impl Hdr {
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn keep_world(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
};
@@ -371,6 +391,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR UI mask"),
timestamp_writes: timing.and_then(|t| t.writes("HDR UI mask")),
color_attachments: &attachments,
..Default::default()
});
@@ -381,7 +402,11 @@ impl Hdr {
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
/// from the UI (UI Brightness).
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn mark_hand(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
};
@@ -397,6 +422,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR hand mark"),
timestamp_writes: timing.and_then(|t| t.writes("HDR hand mark")),
color_attachments: &attachments,
..Default::default()
});
@@ -418,6 +444,7 @@ impl Hdr {
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::Texture,
world: bool,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
@@ -427,7 +454,7 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
);
Self::draw(encoder, &self.pipeline, &t.group, target);
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
}
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
@@ -478,7 +505,7 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, true)),
);
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
Some(texture)
}
@@ -512,6 +539,7 @@ impl Hdr {
pipeline: &wgpu::RenderPipeline,
group: &wgpu::BindGroup,
target: &wgpu::Texture,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -525,6 +553,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR"),
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
color_attachments: &attachments,
..Default::default()
});
@@ -586,6 +615,7 @@ mod tests {
for source in [
include_str!("../hdr.wgsl"),
include_str!("../terrain.wgsl"),
include_str!("../hdr_stats.wgsl"),
&entity,
] {
let module = naga::front::wgsl::parse_str(source).unwrap();
@@ -0,0 +1,158 @@
//! HDR emission thresholds (`hdr_stats.wgsl`): for every block sprite, the brightness its
//! pixels must reach to shine in HDR output, measured on the GPU from the sprite's own
//! darkest and brightest opaque pixel when HDR output turns on. Until then (and with HDR
//! off) the texture holds zeros: every pixel of a light source may shine, and hdr.wgsl's
//! tier windows alone decide, as before.
use super::hdr::Masks;
pub struct EmissionStats {
/// One texel per sprite: x the layer in its array, y the array.
pub view: wgpu::TextureView,
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
params: wgpu::Buffer,
}
impl EmissionStats {
pub fn new(device: &wgpu::Device, split: u32, arrays: u32) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon HDR emission thresholds"),
size: wgpu::Extent3d {
width: split,
height: arrays,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR emission thresholds"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr_stats.wgsl").into()),
});
let mut entries: Vec<_> = (0..super::SPRITE_ARRAYS)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
})
.collect();
entries.push(wgpu::BindGroupLayoutEntry {
binding: super::SPRITE_ARRAYS,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon HDR emission thresholds"),
entries: &entries,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon HDR emission thresholds"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR emission thresholds"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_stats"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_stats"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR emission threshold parameters"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
view,
layout,
pipeline,
params,
}
}
/// Measures every sprite of `sprites` (terrain.wgsl's arrays) at `masks`.
pub fn measure(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
sprites: &[wgpu::TextureView],
masks: Masks,
) {
let mut params = [0u8; 16];
params[..4].copy_from_slice(&masks.parts.to_le_bytes());
params[4..8].copy_from_slice(&masks.whole.to_le_bytes());
queue.write_buffer(&self.params, 0, &params);
let mut entries: Vec<_> = sprites
.iter()
.enumerate()
.map(|(binding, view)| wgpu::BindGroupEntry {
binding: binding as u32,
resource: wgpu::BindingResource::TextureView(view),
})
.collect();
entries.push(wgpu::BindGroupEntry {
binding: super::SPRITE_ARRAYS,
resource: self.params.as_entire_binding(),
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR emission thresholds"),
layout: &self.layout,
entries: &entries,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR emission thresholds"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
queue.submit([encoder.finish()]);
}
}
@@ -83,7 +83,8 @@ fn geometry(seed: i64, uv: [f32; 2]) -> Vec<Vertex> {
normal: [0; 4],
pos: c,
uv,
layer: 2,
// HDR output: the bolt shines like the sun (entity.wgsl fs_hdr_mark).
layer: 2 | u32::from(crate::mesher::VERY_BRIGHT) << 16,
color,
light: 240 | 240 << 8,
@@ -57,7 +57,8 @@ fn reference(seed: i64, at: [f64; 3], camera: DVec3, uv: [f32; 2]) -> Vec<Vertex
normal: [0; 4],
pos: (o + glam::Vec3::from_array(c)).to_array(),
uv,
layer: 2,
// The HDR tier lightning.rs adds; the geometry is vanilla's.
layer: 2 | u32::from(crate::mesher::VERY_BRIGHT) << 16,
color,
light: 240 | 240 << 8,
+10 -17
View File
@@ -1,4 +1,4 @@
//! Far meshes share the terrain arena and passes, with their actual bounds
//! Far meshes share the terrain arenas and passes, with their actual bounds
//! rather than the 16³ boxes used by the near-section residency walk.
use super::{Frustum, Renderer, SectionGpu};
use crate::{
@@ -96,21 +96,8 @@ struct Bounds {
impl Bounds {
fn mesh(mesh: &SectionMesh) -> Option<Self> {
let mut positions = mesh
.solid
.iter()
.chain(&mesh.translucent)
.map(|v| Vec3::from(v.pos));
let first = positions.next()?;
let mut bounds = Self {
min: first,
max: first,
};
for pos in positions {
bounds.min = bounds.min.min(pos);
bounds.max = bounds.max.max(pos);
}
Some(bounds)
let [min, max] = mesh.bounds?;
Some(Self { min, max })
}
/// In view: the plan alone decides which tiles are in range (a circle),
@@ -165,6 +152,10 @@ impl Tile {
None => &mut self.mesh,
}
}
/// Both meshes it holds in the arena: its own and the stand-in, if any.
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
}
pub fn visible(
&self,
key: TileKey,
@@ -419,7 +410,8 @@ impl Renderer {
let mesh = pending.meshes.remove(&key).expect("pending tile");
let [x, _, z] = key.origin();
let size = key.size();
let keep = around_new.contains(&key)
let keep = mesh.keep
|| around_new.contains(&key)
|| taken.iter().any(|&(cx, cz)| {
(x..x + size).contains(&(cx * 16)) && (z..z + size).contains(&(cz * 16))
});
@@ -511,6 +503,7 @@ mod tests {
let mesh = SectionMesh {
solid: vec![vertex([0.0, -64.0, 0.0]), vertex([1024.0, 100.0, 1024.0])],
translucent: vec![vertex([512.0, 120.0, 512.0])],
bounds: Some([Vec3::new(0.0, -64.0, 0.0), Vec3::new(1024.0, 120.0, 1024.0)]),
..Default::default()
};
let bounds = Bounds::mesh(&mesh).unwrap();
@@ -18,6 +18,14 @@ const PREFIX: &str = "horizon_terrain_";
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
const ENTRIES: &str = "
// The manifest's `[material] fog`: false when the pack fogs its surfaces itself.
override horizon_material_fog: bool = true;
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
if horizon_material_fog {
return horizon_terrain_apply_fog(color, spherical, cylindrical);
}
return color;
}
struct HorizonMaterialOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -28,6 +36,9 @@ struct HorizonMaterialOut {
@location(5) position: vec3<f32>,
@location(6) spherical: f32,
@location(7) cylindrical: f32,
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
@@ -45,6 +56,19 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
out.position = pos;
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
// The colour's alpha byte (mesher.rs's `fluid_face` and `emitting`): a fluid quad's
// high nibble is 8 + its face (0..5), every other quad keeps 0xF there; the low nibble
// is 15 minus the face's HDR tier. Every corner of a quad carries the same byte, so the
// interpolated value rounds back exactly.
let flags = u32(round(color.a * 255.0));
let high = flags >> 4u;
let fluid = high >= 8u && (high & 7u) < 6u;
out.face = select(0u, high & 7u, fluid);
out.fluid_quad = u32(fluid);
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
let tier = 15u - (flags & 15u);
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
return out;
}
@vertex fn horizon_material_vs(
@@ -63,21 +87,74 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
}
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
// in the pack's frame group (3), one array each, a blank 1x1 without them.
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
@group(3) @binding(13) var horizon_pbr_specular: texture_2d_array<f32>;
fn horizon_block_pbr_present() -> bool {
return textureDimensions(horizon_pbr_normals).x > 1u;
}
fn horizon_block_pbr_size() -> vec2<f32> {
return vec2<f32>(textureDimensions(horizon_pbr_normals).xy);
}
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
if !horizon_block_pbr_present() || layer >= textureNumLayers(horizon_pbr_normals) {
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
}
let s = horizon_terrain_sprite_sampler;
// The maps may be smaller than the colour: the same footprint is fewer of their texels.
let scale = f32(textureDimensions(horizon_terrain_sprites).x)
/ f32(textureDimensions(horizon_pbr_normals).x);
let l = max(lod - log2(scale), 0.0);
if map == 0u {
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), l);
}
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), l);
}
// The prelude's `block_texture`: a sprite of the terrain's arrays at a mip level.
fn horizon_block_size() -> vec2<f32> {
return vec2<f32>(textureDimensions(horizon_terrain_sprites).xy);
}
fn horizon_block_sample(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
let s = horizon_terrain_sprite_sampler;
let l = i32(layer % horizon_terrain_SPRITE_SPLIT);
switch layer / horizon_terrain_SPRITE_SPLIT {
case 0u: { return textureSampleLevel(horizon_terrain_sprites, s, uv, l, lod); }
case 1u: { return textureSampleLevel(horizon_terrain_sprites_1, s, uv, l, lod); }
case 2u: { return textureSampleLevel(horizon_terrain_sprites_2, s, uv, l, lod); }
case 3u: { return textureSampleLevel(horizon_terrain_sprites_3, s, uv, l, lod); }
case 4u: { return textureSampleLevel(horizon_terrain_sprites_4, s, uv, l, lod); }
case 5u: { return textureSampleLevel(horizon_terrain_sprites_5, s, uv, l, lod); }
case 6u: { return textureSampleLevel(horizon_terrain_sprites_6, s, uv, l, lod); }
default: { return textureSampleLevel(horizon_terrain_sprites_7, s, uv, l, lod); }
}
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
horizon_block_layer = in.layer & 0x7fffu;
// The mip level of this point in the texture: its texels, as the sampler would pick.
let texels = in.uv * horizon_block_size();
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu))).color;
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
if color.a < 0.01 { discard; }
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
return horizon_material_fogged(color, in.spherical, in.cylindrical);
}
";
@@ -94,6 +171,16 @@ pub struct TerrainShape<'a> {
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
/// The pack's material id of each sprite layer with one, as `(layer, id)`
/// (`Manifest::sprite_materials`).
pub sprite_materials: &'a [(u32, u32)],
/// The entities' group 0 (their atlas), for the shadow pass's entity draw.
pub entities: &'a wgpu::BindGroupLayout,
/// The weather's groups 0 and 1 (the entities' atlas, the fall clock), for the weather
/// stage.
pub weather_groups: [&'a wgpu::BindGroupLayout; 2],
/// The resource packs' LabPBR maps (normals, specular), when they ship any.
pub pbr: Option<&'a [wgpu::Texture; 2]>,
}
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
@@ -113,12 +200,14 @@ fn vanilla_terrain() -> String {
)
}
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
fn sprite_tags(tags: &[(u32, u32)]) -> String {
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
let mut tags = tags.to_vec();
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
for group in tags.chunk_by(|a, b| a.1 == b.1) {
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
/// (`MaterialInput.material`).
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
let mut table = table.to_vec();
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
for group in table.chunk_by(|a, b| a.1 == b.1) {
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
out += &format!(
" case {}: {{ return {}u; }}\n",
@@ -129,18 +218,24 @@ fn sprite_tags(tags: &[(u32, u32)]) -> String {
out + " default: { return 0u; }\n }\n}\n"
}
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`.
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
vanilla_terrain(),
sprite_tags(tags)
layer_table("horizon_sprite_tags", tags),
layer_table("horizon_sprite_material", materials)
)
}
/// The material's naga module, its errors in the stage's file.
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags);
fn module(
stage: &CheckedStage,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -153,14 +248,16 @@ impl NativeMaterial {
frame: &NativeFrame,
stage: &CheckedStage,
constants: &[(String, f64)],
fog: bool,
) -> Result<Self, String> {
let module = module(stage, shape.sprite_tags)?;
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
"horizon_terrain_SPRITE_SPLIT",
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
@@ -317,17 +414,28 @@ mod tests {
fn apply_fog(c: vec4<f32>) -> vec4<f32> { return c * gain; }\n\
fn material(input: MaterialInput) -> Surface {\n \
let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \
return Surface(apply_fog(sprite(lit)));\n}\n";
let t = vec2<i32>(input.screen * frame.resolution);\n \
let behind = textureLoad(opaque_color, t, 0) * textureLoad(opaque_depth, t, 0);\n \
let mirrored = textureSampleLevel(reflection, reflection_sampler, input.screen, 0.0);\n \
let kind = f32(input.face + input.fluid) * input.emission * mirrored.a;\n \
let frame_of = vec4(cross(input.normal, input.tangent.xyz) * input.tangent.w, kind);\n \
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
let stage = stage(own);
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags).unwrap();
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
.unwrap_or_else(|e| {
panic!(
"{}",
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
)
});
let entries: Vec<&str> = module
.entry_points
.iter()
@@ -5,6 +5,7 @@ use horizon_shader_api::{Report, StageKind};
use super::native_material::{NativeMaterial, TerrainShape};
use super::native_post::{NativeFrame, NativePost};
use super::native_shadow::NativeShadow;
use super::native_sky::NativeSky;
use super::pack_timing::PassTiming;
@@ -13,6 +14,13 @@ pub struct NativePack {
pub sky: Option<NativeSky>,
pub material: Option<NativeMaterial>,
pub post: Option<NativePost>,
/// With a sky stage, what of Minecraft's sky still draws over it (`[sky]`).
pub vanilla_clouds: bool,
pub vanilla_sun: bool,
pub vanilla_stars: bool,
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -24,12 +32,65 @@ impl NativePack {
/// refused.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let frame = NativeFrame::new(device);
let textures = super::native_post::pack_textures(device, queue, &report);
let settings = report
.shadow
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let shadow = report
.shadow
.as_ref()
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
.transpose()?;
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
frame.reflects = report
.stages
.iter()
.find(|s| s.kind == StageKind::Material)
.is_some_and(|s| s.reads("reflection"))
&& option.is_none_or(|id| {
constants
.iter()
.find(|(k, _)| k == id)
.is_none_or(|(_, v)| *v != 0.0)
});
let fog = report.material_fog;
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
report.sky.as_ref().and_then(key).unwrap_or(true)
};
let vanilla_clouds = keeps(|s| s.clouds);
let vanilla_sun = keeps(|s| s.sun);
let vanilla_stars = keeps(|s| s.stars);
let weather = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|stage| {
super::weather::native_pipeline(
device,
format,
terrain.weather_groups[0],
terrain.weather_groups[1],
&frame,
stage,
constants,
)
})
.transpose()?;
let weather_label = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|s| s.label.clone());
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -42,10 +103,10 @@ impl NativePack {
let material = others
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, constants, format, &frame))
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
@@ -54,6 +115,7 @@ impl NativePack {
.filter(|s| material.is_some() && s.kind == StageKind::Material)
.map(|s| s.label.clone()),
);
labels.extend(weather_label);
if let Some(post) = &post {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
@@ -62,6 +124,11 @@ impl NativePack {
sky,
material,
post,
vanilla_clouds,
vanilla_sun,
vanilla_stars,
weather,
shadow,
timing: None,
};
Ok((pack, labels))
@@ -69,6 +136,9 @@ impl NativePack {
/// Whether the pack draws anything the game runs today.
pub fn draws(&self) -> bool {
self.sky.is_some() || self.material.is_some() || self.post.is_some()
self.sky.is_some()
|| self.material.is_some()
|| self.weather.is_some()
|| self.post.is_some()
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,74 @@
//! Render hook points for native mods (docs/native-mods.md): empty functions the renderer calls at
//! fixed places of a frame, which a mod's mixins hook like those of
//! `horizon-client/src/game/native_hooks.rs`, and listed with them in `native-hooks.json`. API
//! 58.
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
pub struct WorldDrawn<'a> {
pub device: &'a wgpu::Device,
pub queue: &'a wgpu::Queue,
pub encoder: &'a mut wgpu::CommandEncoder,
/// The frame's target and a view of it in `color_format`.
pub color: &'a wgpu::Texture,
pub color_view: &'a wgpu::TextureView,
pub color_format: wgpu::TextureFormat,
/// The size of `color`.
pub size: [u32; 2],
/// The world's depth (Depth32Float, sampleable), and its size: smaller than `size` when the
/// world draws at a lower resolution. It holds the frame's depth only when a mod asked for
/// it ([`RenderNeeds::world_depth`]) or something else kept it.
pub depth: &'a wgpu::TextureView,
pub depth_size: [u32; 2],
/// Camera-relative world to clip space, without TAA's jitter, and with it (what the world
/// was drawn with).
pub view_proj: Mat4,
pub jittered: Mat4,
/// The camera's position in the world.
pub camera: DVec3,
/// The camera-relative entity geometry of the frame (`Frame::entities`), quads of 4
/// vertices, and its runs by entity when a mod asked for them
/// ([`RenderNeeds::entity_spans`]) or a shader pack draws them.
pub entities: &'a [Vertex],
pub spans: &'a [EntitySpan],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
pub quad_indices: &'a wgpu::Buffer,
}
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
/// needs and leaves the rest, so several can ask.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RenderNeeds {
/// Record `Frame::entity_spans` without a shader pack: entity runs get a stable id from
/// their type's name, block entity runs their block state.
pub entity_spans: bool,
/// Keep the world pass's depth for [`native_world_drawn`] when nothing else does.
pub world_depth: bool,
}
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
#[inline(never)]
pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
let _ = world;
}
/// Asked as a frame is built and drawn: set what your mod needs.
#[inline(never)]
pub fn native_render_needs(needs: &mut RenderNeeds) {
let _ = needs;
}
/// What the mods need this frame ([`native_render_needs`]).
pub fn render_needs() -> RenderNeeds {
let mut needs = RenderNeeds::default();
native_render_needs(&mut needs);
needs
}
@@ -0,0 +1,673 @@
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
//! solid terrain and entities the light (the sun by day, the moon by night) sees are drawn into a square depth
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
const PREFIX: &str = "horizon_terrain_";
/// The shadow pass's entries: terrain's vertices through the shadow camera, distorted, and the
/// cutout test (leaves' and plants' holes let the light through).
const ENTRIES: &str = "
struct HorizonShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) layer: u32,
@location(2) tint: vec3<f32>,
@location(3) @interpolate(flat) fluid: u32,
}
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
// (mesher.rs's `fluid_face`).
fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>)
-> HorizonShadowOut {
let clip = frame.shadow_view_proj * vec4(pos, 1.0);
let xy = shadow_distort(clip.xy);
var out: HorizonShadowOut;
out.clip = vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0);
out.uv = uv;
out.layer = layer;
out.tint = color.rgb;
let high = u32(round(color.a * 255.0)) >> 4u;
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
return out;
}
@vertex fn horizon_shadow_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>,
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@vertex fn horizon_shadow_vs_flat(
@builtin(vertex_index) index: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
@location(3) color: vec4<f32>
) -> HorizonShadowOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
}
// Translucent terrain: its colour where it is the nearest thing the light passes through;
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
/// The entity draw of the shadow pass: the renderer's opaque entity quads (`EntityBatch`'s first
/// material) through the shadow camera, with their texture's cutout. The sky's bodies, clouds,
/// beacon beams and breaking cracks share that buffer but are unlit (entity.wgsl's overlays) and
/// cast nothing. A quad's layer holds the overlay in its low 16 bits.
const ENTITY_ENTRIES: &str = "
@group(0) @binding(1) var horizon_atlas: texture_2d<f32>;
@group(0) @binding(2) var horizon_atlas_sampler: sampler;
struct HorizonEntityShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) alpha: f32,
}
@vertex fn horizon_entity_shadow_vs(@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>, @location(2) layer: u32, @location(3) color: vec4<f32>)
-> HorizonEntityShadowOut {
let clip = frame.shadow_view_proj * vec4(position, 1.0);
let xy = shadow_distort(clip.xy);
let overlay = layer & 0xffffu;
// As entity.wgsl's `unlit`.
let unlit = overlay == 2u || overlay == 4u || overlay == 6u
|| (overlay >= 9u && overlay <= 12u);
var out: HorizonEntityShadowOut;
// Unlit geometry lands outside the clip volume.
out.clip = select(vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0), vec4(2.0, 2.0, 2.0, 1.0), unlit);
out.uv = uv;
out.alpha = color.a;
return out;
}
@fragment fn horizon_entity_shadow_fs(in: HorizonEntityShadowOut) {
let alpha = textureSampleLevel(horizon_atlas, horizon_atlas_sampler, in.uv, 0.0).a;
if alpha * in.alpha < 0.5 { discard; }
}
";
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
/// shadow, and ends past the build height and the slant across the map below the camera.
pub fn camera(settings: &Settings) -> Shadow {
Shadow {
resolution: settings.resolution,
distance: settings.distance,
render_mul: 1.0,
interval: SNAP,
euclidean_snap: true,
near: 256.0,
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
..Shadow::default()
}
}
/// `settings` with the resolution `device` can make.
pub fn fitted(device: &wgpu::Device, settings: &Settings) -> Settings {
Settings {
resolution: settings
.resolution
.min(device.limits().max_texture_dimension_2d),
..settings.clone()
}
}
/// The cell the shadow camera keeps to, in blocks along the world's axes: the player stays
/// within 7 blocks of the centre (half the cell on each axis), where the map is still over
/// three times as fine as at its edge.
const SNAP: f32 = 8.0;
/// The ground one shadow texel covers right under the camera, in blocks: a 32nd, half a texture
/// pixel, so shadow edges near the player stay as sharp as the blocks casting them.
const CENTRE_TEXEL: f32 = 1.0 / 32.0;
/// How strongly the map is distorted toward its centre (`Frame::shadow_distortion`).
/// `xy / (|xy| k + 1 - k)` magnifies the centre by 1 / (1 - k); an undistorted texel covers
/// 2 distance / resolution blocks, so k is what brings that down to `CENTRE_TEXEL`. None when
/// the map is already that fine; at most 0.95, past which the edge of the map gets too few texels.
pub fn distortion(distance: f32, resolution: u32) -> f32 {
let texel = 2.0 * distance / resolution.max(1) as f32;
(1.0 - CENTRE_TEXEL / texel).clamp(0.0, 0.95)
}
/// The shadow map: a square depth map of the pack's resolution, or a 1×1 one (cleared to "far",
/// so everything is lit) for a pack without `[shadow]`; the same with translucent terrain drawn
/// in, and that terrain's colour.
pub struct ShadowMap {
depth: wgpu::Texture,
pub view: wgpu::TextureView,
translucent: wgpu::Texture,
pub translucent_view: wgpu::TextureView,
pub color_view: wgpu::TextureView,
pub sampler: wgpu::Sampler,
/// The 1×1 map still holds the zeroes it was made with.
blank_uncleared: bool,
}
impl ShadowMap {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
let size = settings.map_or(1, |s| s.resolution);
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| usage,
view_formats: &[],
})
};
let depth = texture(
"Native shader pack shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_SRC,
);
let translucent = texture(
"Native shader pack translucent shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_DST,
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native shader pack shadow comparison"),
compare: Some(wgpu::CompareFunction::LessEqual),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
view: depth.create_view(&Default::default()),
depth,
translucent_view: translucent.create_view(&Default::default()),
translucent,
color_view: color.create_view(&Default::default()),
sampler,
blank_uncleared: settings.is_none(),
}
}
/// A depth pass into the map that clears it to "far".
pub fn begin<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0), timing)
}
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
/// copied: where one casts, the solid map is the darker and decides.
pub fn begin_entities<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Load, None)
}
fn depth_pass<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
load: wgpu::LoadOp<f32>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.view,
depth_ops: Some(wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// The translucent pass: the solid terrain's map copied into the translucent one, which it
/// goes on drawing into, and the colour cleared to white (nothing to pass through).
pub fn begin_translucent<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
encoder.copy_texture_to_texture(
self.depth.as_image_copy(),
self.translucent.as_image_copy(),
self.depth.size(),
);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.color_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.translucent_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// Clears the 1×1 map of a pack without `[shadow]` once.
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
if std::mem::take(&mut self.blank_uncleared) {
drop(self.begin(encoder, "Native shader pack shadow map clear", None));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear", None));
}
}
}
/// The pipelines that draw terrain into the shadow map, for the renderer's instanced draws and,
/// where they run, its flat ones, and the frame they read (group 3, the uniform alone: the
/// stages' own group 3 also holds the map being drawn).
pub struct NativeShadow {
pub solid: wgpu::RenderPipeline,
pub flat: Option<wgpu::RenderPipeline>,
/// The translucent terrain's draws into the translucent map and the colour, instanced and
/// flat.
pub translucent: wgpu::RenderPipeline,
pub flat_translucent: Option<wgpu::RenderPipeline>,
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
pub entity: wgpu::RenderPipeline,
pub group: wgpu::BindGroup,
}
/// The entity draw's shader.
fn entity_source() -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
)
}
/// The shadow pass's entity pipeline: the entities' vertices and group 0, the frame at group 3.
fn entity_pipeline(
device: &wgpu::Device,
entities: &wgpu::BindGroupLayout,
frame_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack entity shadow"),
source: wgpu::ShaderSource::Wgsl(entity_source().into()),
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[GROUP as usize] = Some(frame_layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack entity shadow"),
bind_group_layouts: &groups,
..Default::default()
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack entity shadow"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_entity_shadow_vs"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
// Both sides, as the terrain: thin parts (ears, item quads) cast from either.
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: wgpu::DepthBiasState {
constant: 0,
slope_scale: 1.5,
clamp: 0.0,
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_entity_shadow_fs"),
compilation_options: Default::default(),
targets: &[],
}),
multiview_mask: None,
cache: None,
})
}
/// `horizon_shadow_solid(layer)`: whether the sprite layer's id, out of `materials`
/// (`(layer, id)`), is one of the pack's `solid` ones.
fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
let layers: Vec<String> = materials
.iter()
.filter(|(_, id)| solid.contains(id))
.map(|(layer, _)| format!("{layer}u"))
.collect();
let cases = if layers.is_empty() {
String::new()
} else {
format!(" case {}: {{ return true; }}\n", layers.join(", "))
};
format!(
"fn horizon_shadow_solid(layer: u32) -> bool {{\n switch layer {{\n{cases} \
default: {{ return false; }}\n }}\n}}\n"
)
}
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
/// `solid` of `shadow_solid`.
fn shader_source(solid: &str) -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
)
)
}
impl NativeShadow {
pub fn new(
device: &wgpu::Device,
shape: &TerrainShape<'_>,
frame: &super::native_post::NativeFrame,
solid_materials: &[u32],
) -> Result<Self, String> {
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
let constants = [
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
];
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack shadow"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let frame_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack shadow frame"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
}],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack shadow frame"),
layout: &frame_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: frame.buffer.as_entire_binding(),
}],
});
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
Default::default();
groups[0] = Some(shape.globals);
groups[1] = flat;
groups[GROUP as usize] = Some(&frame_layout);
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack shadow"),
bind_group_layouts: &groups,
..Default::default()
})
};
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::TERRAIN_ATTRIBUTES,
};
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
translucent: bool| {
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack shadow"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vertex),
compilation_options: options.clone(),
buffers,
},
// Both sides: the quads that face the light are drawn (`facing_runs`), and
// plants' crossed quads cast from either side.
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: wgpu::DepthBiasState {
constant: 0,
slope_scale: 1.5,
clamp: 0.0,
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(if translucent {
"horizon_shadow_fs_translucent"
} else {
"horizon_shadow_fs"
}),
compilation_options: options,
targets: if translucent { &color_target } else { &[] },
}),
multiview_mask: None,
cache: None,
})
};
let instanced = layout(None);
let solid = pipeline(
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances.clone())],
false,
);
let translucent = pipeline(
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances)],
true,
);
let flat_layout = shape.flat.map(|group| layout(Some(group)));
let flat_pipeline = |translucent| {
flat_layout.as_ref().map(|layout| {
pipeline(
layout,
"horizon_shadow_vs_flat",
&[Some(vertices.clone())],
translucent,
)
})
};
let flat = flat_pipeline(false);
let flat_translucent = flat_pipeline(true);
let entity = entity_pipeline(device, shape.entities, &frame_layout);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("shadow pass: {e}"));
}
Ok(Self {
solid,
flat,
translucent,
flat_translucent,
entity,
group,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]));
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
#[test]
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
let materials = [(3, 7), (4, 8), (9, 7)];
assert!(shadow_solid(&materials, &[7]).contains("case 3u, 9u: { return true; }"));
assert!(!shadow_solid(&materials, &[]).contains("case"));
assert!(!shadow_solid(&materials, &[5]).contains("case"));
}
#[test]
fn the_entity_shader_validates() {
let text = entity_source();
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
#[test]
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
let settings = Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
};
let shadow = camera(&settings);
let (view, projection) = super::super::pack_uniforms::shadow_camera(
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
glam::DVec3::new(-2064.5, 246.0, -4304.5),
&shadow,
);
// Up to the build height's span below a player flying high, as well as above them.
for y in [64.0, 0.0, -64.0, -200.0, -384.0] {
let clip = projection * view * glam::Vec4::new(0.0, y, 0.0, 1.0);
assert!(clip.is_finite(), "{clip}");
assert!((-1.0..=1.0).contains(&clip.z), "{clip}");
}
}
#[test]
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
let shadow = camera(&Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
});
let light = glam::Vec3::Y;
for x in [5.5, -5.5, -2064.5, 2064.5] {
let camera = glam::DVec3::new(x, 64.0, x);
let (view, _) = super::super::pack_uniforms::shadow_camera(light, camera, &shadow);
let centre = view.inverse().w_axis.truncate();
assert!(centre.x.abs() <= SNAP / 2.0, "{x}: {centre}");
assert!(centre.z.abs() <= SNAP / 2.0, "{x}: {centre}");
}
}
#[test]
fn the_distortion_brings_the_centre_texel_to_a_32nd_of_a_block() {
// 2048 texels over 256 blocks: an 8th of a block each, magnified 4 times.
assert!((distortion(128.0, 2048) - 0.75).abs() < 1e-6);
// Already that fine: no distortion.
assert_eq!(distortion(32.0, 2048), 0.0);
assert_eq!(distortion(10_000.0, 1024), 0.95);
}
}
@@ -252,7 +252,7 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
+15 -3
View File
@@ -620,6 +620,7 @@ impl Oit {
depth: &wgpu::TextureView,
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
extras: &Extras<'_>,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else { return };
let attachment = |view, load| {
@@ -645,7 +646,7 @@ impl Oit {
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
if let Some((mask, _)) = water_mask {
self.execute_water_mask(encoder, t, color, depth, mask);
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
}
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
let [c0, c1] = &t.views.coefficients;
@@ -680,6 +681,7 @@ impl Oit {
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
@@ -692,6 +694,7 @@ impl Oit {
// the terrain's: the masks only hide water.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("OIT features")),
color_attachments: &[attachment(
match stage {
Stage::DepthBounds => &t.views.bounds,
@@ -710,7 +713,7 @@ impl Oit {
features(&mut pass, stage);
}
if stage == Stage::DepthBounds {
self.execute_depth_bounds_cull(encoder, t, depth);
self.execute_depth_bounds_cull(encoder, t, depth, timing.as_deref_mut());
}
if let Some((clouds, cloud_depth)) = cloud_depth {
self.render_clouds(
@@ -720,16 +723,18 @@ impl Oit {
depth,
(label, group, &then),
(clouds, cloud_depth),
timing.as_deref_mut(),
);
}
if stage == Stage::DepthBounds
&& let Some((mask, _)) = water_mask
{
self.execute_water_mask(encoder, t, color, depth, mask);
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
}
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT composite"),
timestamp_writes: timing.and_then(|t| t.writes("OIT composite")),
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
@@ -749,6 +754,7 @@ impl Oit {
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
/// the world's depth (after the cull) and draws into it, so the later
/// stages, which only read it, keep each pixel's nearest cloud face.
#[allow(clippy::too_many_arguments)]
fn render_clouds(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -764,6 +770,7 @@ impl Oit {
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
&Depth,
),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if stage == Stage::DepthBounds {
encoder.copy_texture_to_texture(
@@ -774,6 +781,7 @@ impl Oit {
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes("OIT clouds")),
color_attachments: targets,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &cloud_depth.view,
@@ -798,6 +806,7 @@ impl Oit {
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(terrain) = &t.terrain_depth else {
return;
@@ -810,6 +819,7 @@ impl Oit {
// The mask pipeline writes depth only but has a colour target.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT water mask"),
timestamp_writes: timing.and_then(|t| t.writes("OIT water mask")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
@@ -840,6 +850,7 @@ impl Oit {
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
depth: &wgpu::TextureView,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
encoder.copy_texture_to_texture(
t.bounds.as_image_copy(),
@@ -852,6 +863,7 @@ impl Oit {
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT cull depth bounds"),
timestamp_writes: timing.and_then(|t| t.writes("OIT cull depth bounds")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &t.views.culled,
resolve_target: None,
@@ -176,7 +176,7 @@ impl Features {
&entity_module,
entities,
&entity_buffers,
kind("text", "entity", None, offset),
kind("text", "entity", Some(wgpu::Face::Back), offset),
),
crumbling: set(
&entity_module,
@@ -188,7 +188,12 @@ impl Features {
&entity_module,
entities,
&entity_buffers,
kind("entity shadows", "shadow", None, shadow_bias),
kind(
"entity shadows",
"shadow",
Some(wgpu::Face::Back),
shadow_bias,
),
),
swirl: set(
&entity_module,
@@ -212,7 +217,12 @@ impl Features {
&entity_module,
entities,
&entity_buffers,
kind("additive entities", "lightning", None, PLAIN),
kind(
"additive entities",
"lightning",
Some(wgpu::Face::Back),
PLAIN,
),
),
weather: set(
&entity_module,
@@ -113,6 +113,16 @@ impl PackAtlas {
}
}
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
/// inputs, for vertex `hz_vertex_index`.
pub const PULL: &str = " uint hz_pw = hz_vertex_index * 6u;\n \
hz_v_pos = uintBitsToFloat(uvec3(hz_vertices[hz_pw], hz_vertices[hz_pw + 1u], hz_vertices[hz_pw + 2u]));\n \
hz_v_uv = uvec2(hz_vertices[hz_pw + 3u] & 65535u, hz_vertices[hz_pw + 3u] >> 16u);\n \
hz_v_layer = hz_vertices[hz_pw + 4u] & 65535u;\n \
uint hz_pl = hz_vertices[hz_pw + 4u] >> 16u;\n \
hz_v_light = uvec2(hz_pl & 255u, hz_pl >> 8u);\n \
hz_v_color = unpackUnorm4x8(hz_vertices[hz_pw + 5u]);\n";
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
@@ -194,8 +204,15 @@ impl PackAtlas {
} else {
"vec4(-1.0, -1.0, 0.0, 0.0)"
};
// The block a face belongs to: half a block behind its centre (the quad's diagonal).
let mid_block = if flat {
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, 0.0)"
} else {
"vec4(0.0)"
};
let attributes = [
("mc_Entity", entity),
("at_midBlock", mid_block),
(
"mc_midTexCoord",
"vec4((hz_cell + 0.5) * hz_scale, 0.0, 1.0)",
@@ -73,6 +73,8 @@ impl DrawUniforms {
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
// `blockEntityId` outside block entities, as Iris has it.
slot[8..12].copy_from_slice(&(-1i32).to_le_bytes());
}
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
@@ -106,7 +106,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
/// full sky light. Set 1 holds the clouds' uniform (`clouds::Uniform`).
/// full light, the face's normal. Set 1 holds the clouds' uniform (`clouds::Uniform`).
pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
@@ -115,7 +115,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
.to_owned();
let body = " hz_Vertex = vec4(hz_v_pos + hz_cloud_offset.xyz, 1.0);\n \
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = hz_cloud_colors[min(hz_v_light.x, 3u)];\n \
hz_Normal = dot(hz_v_normal.xyz, hz_v_normal.xyz) > 0.01 ? normalize(hz_v_normal.xyz) : vec3(0.0, 1.0, 0.0);\n"
@@ -123,7 +123,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
let attributes = [
("vaPosition", "hz_Vertex.xyz"),
("vaUV0", "hz_v_uv"),
("vaUV2", "ivec2(0, 240)"),
("vaUV2", "ivec2(240, 240)"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
@@ -349,10 +349,8 @@ impl PackEntities {
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
for (i, c) in entity_color(run[0].layer & !super::entity_batch::OIT_ENTITY)
.iter()
.enumerate()
{
// The overlay: the layer's low 16 bits (HDR output's tier rides in the high ones).
for (i, c) in entity_color(run[0].layer & 0xffff).iter().enumerate() {
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
@@ -411,12 +409,9 @@ pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity
/// shader draws in its own way; entity.wgsl).
fn plain(run: &[Vertex]) -> bool {
run.iter().step_by(4).all(|v| {
!matches!(
v.layer & !super::entity_batch::OIT_ENTITY,
2 | 4 | 6 | 9 | 10 | 11 | 12 | 34
)
})
run.iter()
.step_by(4)
.all(|v| !matches!(v.layer & 0xffff, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
}
/// The programs of entity runs, in the order they draw.
@@ -27,12 +27,11 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
inputs
}
/// The hand's projection in GL's conventions: `hud_fov` degrees, the view bob, depth scaled by
/// `MC_HAND_DEPTH`.
pub fn projection(hud_fov: f32, aspect: f32, far: f32, bob: Mat4) -> Mat4 {
/// The hand's projection in GL's conventions: `hud_fov` degrees, depth scaled by
/// `MC_HAND_DEPTH` (the view bob is in `gbufferModelView`).
pub fn projection(hud_fov: f32, aspect: f32, far: f32) -> Mat4 {
Mat4::from_scale(glam::Vec3::new(1.0, 1.0, HAND_DEPTH))
* Mat4::perspective_rh_gl(hud_fov.to_radians(), aspect, 0.05, far)
* bob
}
/// The hand's vertices (also read at set 1 for normals and tangents), its uniform and quad
@@ -153,7 +152,7 @@ mod tests {
/// apart by it), and x, y match the plain projection.
#[test]
fn hand_depth_is_scaled_around_the_middle() {
let p = projection(70.0, 16.0 / 9.0, 512.0, Mat4::IDENTITY);
let p = projection(70.0, 16.0 / 9.0, 512.0);
let plain = Mat4::perspective_rh_gl(70f32.to_radians(), 16.0 / 9.0, 0.05, 512.0);
for z in [-0.06f32, -0.5, -3.0, -400.0] {
let v = glam::Vec4::new(0.1, -0.2, z, 1.0);
@@ -0,0 +1,460 @@
//! A shader pack's own images (`image.<name>`) and storage buffers (`bufferObject.<n>`) on the
//! GPU: created at the render size they depend on, kept across frames, images cleared before a
//! frame when the pack asks.
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
pub(super) struct PackImage {
pub(super) def: CustomImage,
pub(super) format: wgpu::TextureFormat,
pub(super) dimension: wgpu::TextureViewDimension,
pub(super) view: Option<wgpu::TextureView>,
/// Last frame's contents, for a pass that samples and stores the image (`copied`).
pub(super) copy: Option<wgpu::TextureView>,
pub(super) copied: bool,
/// The contents before a compute pass that reads them so (`snapshot`), apart from `copy`,
/// which a later pass may still sample.
pub(super) before: Option<wgpu::TextureView>,
pub(super) snapshotted: bool,
/// The render size the image was made for (relative images follow it).
made_for: [u32; 2],
}
impl PackImage {
pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
self.format
.sample_type(None, Some(features))
.unwrap_or(wgpu::TextureSampleType::Float { filterable: false })
}
}
/// The images `image.*` declares.
pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
defs.into_iter()
.map(|def| PackImage {
format: format(def.format),
dimension: if def.is_3d() {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
},
def,
view: None,
copy: None,
copied: false,
before: None,
snapshotted: false,
made_for: [0; 2],
})
.collect()
}
/// The wgpu format of an image's internal format: a storage format of the same channels.
pub(super) fn format(f: Format) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let pick = |r, rg, rgba| match f.channels {
1 => r,
2 => rg,
_ => rgba,
};
match (f.kind, f.bits) {
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
(Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint),
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
(Kind::Float, _) => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
(Kind::Snorm, _) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
(Kind::Unorm, 16) => pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm),
(Kind::Unorm, _) => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
}
}
/// Whether this device can make `def`: its format's features, and a size in pixels within the
/// device's limits.
pub(super) fn makeable(device: &wgpu::Device, def: &CustomImage) -> Result<(), String> {
let format = format(def.format);
let missing = format.required_features() - device.features();
if !missing.is_empty() {
return Err(format!(
"{format:?} needs {missing:?}, which this GPU lacks"
));
}
let limits = device.limits();
let largest = if def.is_3d() {
limits.max_texture_dimension_3d
} else {
limits.max_texture_dimension_2d
};
let [width, height, depth] = def.extent([1, 1]);
let relative = matches!(def.size, horizon_shaders::ImageSize::Relative(_));
if !relative && width.max(height).max(depth) > largest {
return Err(format!(
"{width}x{height}x{depth} is larger than this GPU's {largest}"
));
}
Ok(())
}
/// Makes the images that are missing or sized for another render size; whether it made any.
pub(super) fn resize_images(
device: &wgpu::Device,
images: &mut [PackImage],
size: [u32; 2],
) -> bool {
let largest = device.limits().max_texture_dimension_2d;
let mut made = false;
for image in images {
let relative = matches!(image.def.size, horizon_shaders::ImageSize::Relative(_));
if image.view.is_some() && (!relative || image.made_for == size) {
continue;
}
made = true;
// A relative image larger than the render size stops at the device's limit.
let [width, height, depth] = image.def.extent(size);
let [width, height] = [width.min(largest), height.min(largest)];
let make = |usage| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(&image.def.name),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: depth,
},
mip_level_count: 1,
sample_count: 1,
dimension: if image.dimension == wgpu::TextureViewDimension::D3 {
wgpu::TextureDimension::D3
} else {
wgpu::TextureDimension::D2
},
format: image.format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | usage,
view_formats: &[],
})
.create_view(&Default::default())
};
// Atomics on a texture need it made for them (Metal ignores them otherwise).
let atomic = device.features().contains(wgpu::Features::TEXTURE_ATOMIC)
&& matches!(
image.format,
wgpu::TextureFormat::R32Sint | wgpu::TextureFormat::R32Uint
);
let atomic = if atomic {
wgpu::TextureUsages::STORAGE_ATOMIC
} else {
wgpu::TextureUsages::empty()
};
image.view = Some(make(
wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC
| atomic,
));
image.copy = image
.copied
.then(|| make(wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::STORAGE_BINDING));
image.before = image
.snapshotted
.then(|| make(wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::STORAGE_BINDING));
image.made_for = size;
}
made
}
/// The most images a pack may have.
pub(super) const IMAGES: usize = 64;
/// Copies `image` as it is before a compute pass whose loads read it so.
pub(super) fn snapshot(encoder: &mut wgpu::CommandEncoder, image: &PackImage) {
if let (Some(view), Some(before)) = (&image.view, &image.before) {
encoder.copy_texture_to_texture(
view.texture().as_image_copy(),
before.texture().as_image_copy(),
view.texture().size(),
);
}
}
/// Copies the images of `mask` (bit n: image n), which a pass samples and stores, for that pass
/// to sample.
pub(super) fn copy(encoder: &mut wgpu::CommandEncoder, images: &[PackImage], mask: u64) {
for (_, image) in images
.iter()
.enumerate()
.filter(|(k, _)| mask & 1 << k != 0)
{
if let (Some(view), Some(copy)) = (&image.view, &image.copy) {
encoder.copy_texture_to_texture(
view.texture().as_image_copy(),
copy.texture().as_image_copy(),
view.texture().size(),
);
}
}
}
/// Clears the images the pack clears before each frame: with `clear_texture` where the device
/// has it (`clear`), else by copying from `zeros`, a buffer of zeros made large enough.
pub(super) fn clear(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
images: &[PackImage],
clear: bool,
zeros: &mut Option<wgpu::Buffer>,
) {
for view in images
.iter()
.filter(|i| i.def.clear)
.filter_map(|i| i.view.as_ref())
{
let texture = view.texture();
if clear {
encoder.clear_texture(texture, &Default::default());
continue;
}
let size = texture.size();
let texel = u64::from(texture.format().block_copy_size(None).unwrap_or(4));
let row = (u64::from(size.width) * texel).next_multiple_of(256);
// Zeros for a slice of rows at a time, the whole image taken in slices, so a large
// volume doesn't need a buffer of its size.
let rows = (ZEROS / row).clamp(1, u64::from(size.height)) as u32;
let bytes = row * u64::from(rows);
if zeros.as_ref().is_none_or(|z| z.size() < bytes) {
// New buffers start zeroed.
*zeros = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack image zeros"),
size: bytes,
usage: wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
}));
}
let Some(buffer) = zeros.as_ref() else {
continue;
};
for z in 0..size.depth_or_array_layers {
for y in (0..size.height).step_by(rows as usize) {
let height = rows.min(size.height - y);
encoder.copy_buffer_to_texture(
wgpu::TexelCopyBufferInfo {
buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row as u32),
rows_per_image: Some(height),
},
},
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d { x: 0, y, z },
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d {
width: size.width,
height,
depth_or_array_layers: 1,
},
);
}
}
}
}
/// The most bytes of zeros kept to clear images without `CLEAR_TEXTURE`.
const ZEROS: u64 = 4 << 20;
/// The pack's storage buffers, bound in set 4.
pub(super) struct Buffers {
pub(super) layout: wgpu::BindGroupLayout,
objects: BTreeMap<u32, BufferObject>,
pub(super) group: Option<wgpu::BindGroup>,
buffers: Vec<wgpu::Buffer>,
made_for: [u32; 2],
}
impl Buffers {
/// The layout of `objects`, bound in `stages`.
pub(super) fn new(
device: &wgpu::Device,
objects: BTreeMap<u32, BufferObject>,
stages: wgpu::ShaderStages,
) -> Self {
let entries: Vec<_> = objects
.keys()
.map(|&binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: stages,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack buffers"),
entries: &entries,
});
Self {
layout,
objects,
group: None,
buffers: Vec::new(),
made_for: [0; 2],
}
}
/// Makes the buffers (again, when one depends on the render size and it changed): their
/// contents start zeroed and persist across frames.
pub(super) fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
let relative = self
.objects
.values()
.any(|o| matches!(o, BufferObject::PerPixel { .. }));
if self.group.is_some() && (!relative || self.made_for == size) {
return;
}
let limits = device.limits();
let largest = limits
.max_buffer_size
.min(limits.max_storage_buffer_binding_size)
& !3;
self.buffers = self
.objects
.iter()
.map(|(binding, o)| {
let wanted = o.size(size).next_multiple_of(4);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
binds at most {largest}"
);
}
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size: wanted.min(largest),
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
})
})
.collect();
let entries: Vec<_> = self
.objects
.keys()
.zip(&self.buffers)
.map(|(&binding, b)| wgpu::BindGroupEntry {
binding,
resource: b.as_entire_binding(),
})
.collect();
self.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack buffers"),
layout: &self.layout,
entries: &entries,
}));
self.made_for = size;
}
/// Whether the pack declares buffer `binding`.
pub(super) fn has(&self, binding: u32) -> bool {
self.objects.contains_key(&binding)
}
}
/// A debug readback of one of the pack's images (`HORIZON_SHADER_PACK_DUMP=<name>`): every 60
/// frames, how many of its texels are not zero, logged.
pub(super) struct Dump {
name: String,
frame: u32,
pending: Option<(wgpu::Buffer, usize, [u32; 3], u32)>,
}
impl Dump {
pub(super) fn new() -> Option<Self> {
let name = std::env::var("HORIZON_SHADER_PACK_DUMP").ok()?;
Some(Self {
name,
frame: 0,
pending: None,
})
}
/// Reads last frame's copy, once its commands ran.
pub(super) fn read(&mut self, device: &wgpu::Device) {
let Some((buffer, texel, [w, h, d], row)) = self.pending.take() else {
return;
};
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
let _ = device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
let Ok(data) = buffer.slice(..).get_mapped_range() else {
return;
};
let (mut set, mut total) = (0usize, 0usize);
for z in 0..d as usize {
for y in 0..h as usize {
let start = (z * h as usize + y) * row as usize;
for x in 0..w as usize {
let at = start + x * texel;
total += 1;
if data[at..at + texel].iter().any(|&b| b != 0) {
set += 1;
}
}
}
}
log::info!(
"shader pack image {}: {set} of {total} texels set",
self.name
);
}
/// Copies the image for `read` every 60 frames.
pub(super) fn copy(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
images: &[PackImage],
) {
self.frame += 1;
if !self.frame.is_multiple_of(60) {
return;
}
let Some(image) = images.iter().find(|i| i.def.name == self.name) else {
return;
};
let Some(view) = &image.view else { return };
let size = view.texture().size();
let texel = image.format.block_copy_size(None).unwrap_or(4) as usize;
let row = (size.width as usize * texel).next_multiple_of(256) as u32;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack image dump"),
size: u64::from(row) * u64::from(size.height) * u64::from(size.depth_or_array_layers),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
view.texture().as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: Some(size.height),
},
},
size,
);
let dims = [size.width, size.height, size.depth_or_array_layers];
self.pending = Some((buffer, texel, dims, row));
}
}
File diff suppressed because it is too large. Load diff
@@ -82,9 +82,9 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}}\n \
hz_Vertex = vec4(hz_p, 1.0);\n \
hz_MultiTexCoord0 = vec4(0.0, 0.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Normal = vec3(0.0, 1.0, 0.0);\n"
hz_Normal = vec3(0.0, 0.0, 1.0);\n"
);
horizon_shaders::GbuffersInputs {
declarations,
@@ -109,7 +109,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
.into(),
body: " hz_Vertex = vec4(hz_v_pos, 1.0);\n \
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = hz_v_color;\n \
hz_Normal = vec3(0.0, 0.0, 1.0);\n"
@@ -0,0 +1,339 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
View(wgpu::TextureView),
Lightmap,
Atlas,
}
pub(super) struct PackTexture {
pub(super) stage: String,
pub(super) name: String,
pub(super) dimension: wgpu::TextureViewDimension,
pub(super) sample_type: wgpu::TextureSampleType,
pub(super) blur: bool,
pub(super) clamp: bool,
pub(super) image: Image,
}
/// Uploads `textures`, with a note for each the game can't provide.
pub(super) fn upload(
device: &wgpu::Device,
queue: &wgpu::Queue,
textures: Vec<CustomTexture>,
notes: &mut Vec<String>,
) -> Vec<PackTexture> {
let features = device.features();
let mut out = Vec::new();
for t in textures {
let (image, dimension, sample_type, blur, clamp) = match t.source {
TextureSource::Image(texture) => {
let (view, sample_type) = match create(device, queue, &texture, features) {
Ok(created) => created,
Err(e) => {
notes.push(format!("texture.{}.{}: {e}", t.stage, t.name));
continue;
}
};
let dimension = match texture.dim {
TextureDim::D1 => wgpu::TextureViewDimension::D1,
TextureDim::D2 => wgpu::TextureViewDimension::D2,
TextureDim::D3 => wgpu::TextureViewDimension::D3,
};
(
Image::View(view),
dimension,
sample_type,
texture.blur,
texture.clamp,
)
}
TextureSource::Resource(location) => {
let path = location.strip_prefix("minecraft:").unwrap_or(&location);
let image = if path.starts_with("dynamic/lightmap") {
Image::Lightmap
} else if path.starts_with("textures/atlas/blocks") || path == "dynamic/atlas" {
Image::Atlas
} else {
notes.push(format!(
"texture.{}.{}: `{location}` is not provided yet",
t.stage, t.name
));
continue;
};
let float = wgpu::TextureSampleType::Float { filterable: true };
(image, wgpu::TextureViewDimension::D2, float, true, true)
}
};
out.push(PackTexture {
stage: t.stage,
name: t.name,
dimension,
sample_type,
blur,
clamp,
image,
});
}
out
}
/// The stage name of `texture.<stage>.*` a program of `group` reads.
fn stage(group: Group) -> &'static str {
match group {
Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
Group::Prepare => "prepare",
Group::Deferred => "deferred",
Group::Composite | Group::Final => "composite",
Group::ShadowComp => "shadowcomp",
}
}
/// The custom texture a `ty name` sampler of a `group` program reads: same stage, the same
/// name (or another name of the same buffer, `gaux1` for `colortex4`) and dimension. A unit
/// may hold one texture per dimension.
pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str) -> Option<usize> {
let dimension = if ty.contains("1D") {
wgpu::TextureViewDimension::D1
} else if ty.contains("3D") {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
})
}
/// `texture.noise`, if the pack has one.
pub(super) fn noise(textures: &[PackTexture]) -> Option<&wgpu::TextureView> {
textures
.iter()
.find(|t| t.stage == "noise" && t.dimension == wgpu::TextureViewDimension::D2)
.and_then(|t| match &t.image {
Image::View(v) => Some(v),
_ => None,
})
}
/// The wgpu format a texture of `texture.format` is stored in: sampled, never rendered to, so
/// any format whose texels hold the values.
fn format(texture: &Texture) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let f = texture.format;
let pick = |r, rg, rgba| match texture.components() {
1 => r,
2 => rg,
_ => rgba,
};
match (f.kind, f.bits) {
(Kind::Unorm, b) if b <= 8 => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
(Kind::Snorm, 8) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
(Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint),
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
// 16-bit and packed normalized formats, half floats and the small floats: halves hold
// them closely enough.
_ => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
}
}
/// `texture`'s texels as the bytes of `format`, row after row.
fn bytes(texture: &Texture, format: wgpu::TextureFormat) -> Vec<u8> {
match (&texture.texels, format) {
(Texels::Float(v), f) if f.block_copy_size(None) == Some(texture.components() as u32) => {
if matches!(
f,
wgpu::TextureFormat::R8Snorm
| wgpu::TextureFormat::Rg8Snorm
| wgpu::TextureFormat::Rgba8Snorm
) {
v.iter()
.map(|x| (x.clamp(-1.0, 1.0) * 127.0).round() as i8 as u8)
.collect()
} else {
v.iter()
.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8)
.collect()
}
}
(Texels::Float(v), f)
if f.block_copy_size(None) == Some(4 * texture.components() as u32) =>
{
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
(Texels::Float(v), _) => v
.iter()
.flat_map(|&x| half::f16::from_f32(x).to_le_bytes())
.collect(),
(Texels::Int(v), f) => {
let width = f.block_copy_size(None).unwrap_or(4) as usize / texture.components();
v.iter()
.flat_map(|x| x.to_le_bytes().into_iter().take(width))
.collect()
}
(Texels::Uint(v), f) => {
let width = f.block_copy_size(None).unwrap_or(4) as usize / texture.components();
v.iter()
.flat_map(|x| x.to_le_bytes().into_iter().take(width))
.collect()
}
}
}
fn create(
device: &wgpu::Device,
queue: &wgpu::Queue,
texture: &Texture,
features: wgpu::Features,
) -> Result<(wgpu::TextureView, wgpu::TextureSampleType), String> {
let format = format(texture);
let bytes = bytes(texture, format);
let [width, height, depth] = texture.size;
let limits = device.limits();
let largest = match texture.dim {
TextureDim::D1 => limits.max_texture_dimension_1d,
TextureDim::D2 => limits.max_texture_dimension_2d,
TextureDim::D3 => limits.max_texture_dimension_3d,
};
if [width, height, depth].iter().any(|&n| n > largest) {
return Err(format!(
"{width}x{height}x{depth} is larger than this device's {largest}"
));
}
let row = width * format.block_copy_size(None).unwrap_or(4);
if bytes.len() as u64 != u64::from(row) * u64::from(height) * u64::from(depth) {
return Err(format!(
"{} bytes of texels for {width}x{height}x{depth}",
bytes.len()
));
}
let size = wgpu::Extent3d {
width,
height,
depth_or_array_layers: depth,
};
let dimension = match texture.dim {
TextureDim::D1 => wgpu::TextureDimension::D1,
TextureDim::D2 => wgpu::TextureDimension::D2,
TextureDim::D3 => wgpu::TextureDimension::D3,
};
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack custom texture"),
size,
mip_level_count: 1,
sample_count: 1,
dimension,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
tex.as_image_copy(),
&bytes,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: Some(height),
},
size,
);
let sample_type = format
.sample_type(None, Some(features))
.unwrap_or(wgpu::TextureSampleType::Float { filterable: false });
Ok((tex.create_view(&Default::default()), sample_type))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_shaders::Format;
fn lightmap(stage: &str, name: &str, dimension: wgpu::TextureViewDimension) -> PackTexture {
PackTexture {
stage: stage.into(),
name: name.into(),
dimension,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
blur: false,
clamp: false,
image: Image::Lightmap,
}
}
fn texture(format: &str, texels: Texels) -> Texture {
Texture {
dim: TextureDim::D2,
size: [1, 1, 1],
format: Format::parse(format).unwrap(),
texels,
blur: false,
clamp: false,
}
}
#[test]
fn samplers_find_their_stage_name_and_dimension() {
use wgpu::TextureViewDimension as D;
let textures = [
lightmap("composite", "colortex4", D::D2),
lightmap("composite", "noisetex", D::D3),
lightmap("gbuffers", "gaux2", D::D2),
lightmap("composite", "noisetex", D::D1),
];
assert_eq!(find(&textures, Group::Final, "sampler2D", "gaux1"), Some(0));
assert_eq!(
find(&textures, Group::Composite, "sampler3D", "noisetex"),
Some(1)
);
assert_eq!(
find(&textures, Group::Composite, "sampler1D", "noisetex"),
Some(3)
);
assert_eq!(
find(&textures, Group::Composite, "sampler2D", "noisetex"),
None
);
assert_eq!(
find(&textures, Group::Shadow, "sampler2D", "colortex5"),
Some(2)
);
assert_eq!(
find(&textures, Group::Deferred, "sampler2D", "colortex4"),
None
);
}
#[test]
fn texels_pack_into_their_format() {
let t = texture("RGBA8", Texels::Float(vec![0.0, 0.5, 1.0, 2.0]));
assert_eq!(format(&t), wgpu::TextureFormat::Rgba8Unorm);
assert_eq!(bytes(&t, format(&t)), [0, 128, 255, 255]);
let t = texture("RG8_SNORM", Texels::Float(vec![-1.0, 0.5]));
assert_eq!(format(&t), wgpu::TextureFormat::Rg8Snorm);
assert_eq!(bytes(&t, format(&t)), [0x81, 64]);
let t = texture("R32F", Texels::Float(vec![1.0]));
assert_eq!(bytes(&t, format(&t)), 1.0f32.to_le_bytes());
let t = texture("R16F", Texels::Float(vec![1.0]));
assert_eq!(format(&t), wgpu::TextureFormat::R16Float);
assert_eq!(bytes(&t, format(&t)), [0x00, 0x3c]);
let t = texture("RG16I", Texels::Int(vec![-2, 3]));
assert_eq!(format(&t), wgpu::TextureFormat::Rg16Sint);
assert_eq!(bytes(&t, format(&t)), [0xfe, 0xff, 3, 0]);
let t = texture("R8UI", Texels::Uint(vec![7]));
assert_eq!(format(&t), wgpu::TextureFormat::R8Uint);
assert_eq!(bytes(&t, format(&t)), [7]);
}
}
@@ -1,5 +1,6 @@
//! GPU time of each pass of a shader pack, of either kind: shown in the F3 overlay while it is
//! open, and logged every `REPORT` timed frames with `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! GPU time of each pass of a shader pack, of either kind, and of Horizon's own passes: shown in
//! the F3 overlay while it is open, and logged every `REPORT` timed frames with
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
//! write them), then read back when mapped. The next frame after that is timed again.
@@ -139,6 +140,12 @@ impl PassTiming {
Some(stamps(&self.queries, i))
}
/// Timestamp writes for the compute pass `name`, in a timed frame.
pub fn compute_writes(&mut self, name: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let i = self.push(name)?;
Some(compute_stamps(&self.queries, i))
}
/// The index of the timed frame's next pass, `name`.
fn push(&mut self, name: &str) -> Option<u32> {
let i = self.names.len() as u32;
@@ -274,6 +281,15 @@ fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'
}
}
/// The compute pass's twin of `stamps`.
fn compute_stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::ComputePassTimestampWrites<'_> {
wgpu::ComputePassTimestampWrites {
query_set: queries,
beginning_of_pass_write_index: Some(2 * i),
end_of_pass_write_index: Some(2 * i + 1),
}
}
/// Each pass's time in milliseconds from its (beginning, end) timestamps, `None` for a pass
/// the GPU left unstamped. Passes overlap on tiled GPUs (a pass begins before the one before it
/// ends): a pass costs the time from the later of its beginning and the previous pass's end.
@@ -30,9 +30,38 @@ pub struct PackInputs {
pub biome_precipitation: i32,
pub temperature: f32,
pub rainfall: f32,
/// Whether the biome at the camera is known (its chunk is loaded).
pub biome_known: bool,
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
/// `isRightHanded` (the Main Hand option) and `is_on_ground`.
pub right_handed: bool,
pub on_ground: bool,
/// The dimension type's lowest block (`bedrockLevel`), build height (`heightLimit`),
/// logical height, sky light and ceiling.
pub min_y: i32,
pub height: i32,
pub logical_height: i32,
pub has_skylight: bool,
pub has_ceiling: bool,
/// `isElytraFlying`: the player is fall flying.
pub elytra_flying: bool,
/// `cloudHeight`, see [`cloud_height`].
pub cloud_height: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
/// has alpha (`LevelExtractor`); a dimension without clouds has no height, which the uniform
/// reports as NaN. NaN is not vanilla's own value: packs map it to "no clouds".
pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
if crate::clouds::has_color(cloud_color_alpha) {
height
} else {
f32::NAN
}
}
/// One uniform's value: up to 16 floats or 3 integers.
@@ -160,6 +189,24 @@ pub const NAMES: &[&str] = &[
"shadowModelViewInverse",
"shadowProjection",
"shadowProjectionInverse",
"cameraPositionInt",
"cameraPositionFract",
"previousCameraPositionInt",
"previousCameraPositionFract",
"eyePosition",
"relativeEyePosition",
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
"heightLimit",
"logicalHeightLimit",
"hasSkylight",
"hasCeiling",
"isElytraFlying",
"cloudHeight",
];
/// Values carried from frame to frame.
@@ -170,7 +217,7 @@ pub struct FrameUniforms {
start: std::time::Instant,
last: Option<std::time::Instant>,
frame: u32,
previous: Option<(Vec3, Mat4, Mat4)>,
previous: Option<(glam::DVec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
/// The pack's shadow settings (7.1).
@@ -234,7 +281,7 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
/// not of `shadowModelView` (Iris's matrices, measured).
const SHADOW_EYE: f32 = 100.0;
pub const SHADOW_EYE: f32 = 100.0;
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
@@ -253,7 +300,12 @@ pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4
* DMat4::from_rotation_x(tilt);
let step = f64::from(shadow.interval);
let origin = if step > 0.0 {
DVec3::splat(step / 2.0) - camera % step
let remainder = if shadow.euclidean_snap {
camera.rem_euclid(DVec3::splat(step))
} else {
camera % step
};
DVec3::splat(step / 2.0) - remainder
} else {
DVec3::ZERO
};
@@ -282,19 +334,33 @@ impl FrameUniforms {
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
self.last = Some(now);
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
// Packs take gbufferProjection for a plain perspective (they invert only its perspective
// terms): the view bob and the nausea spin belong to the model-view, as GL drew them.
let view = frame.bob * frame.spin * crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = Mat4::perspective_rh_gl(
frame.fov_degrees.to_radians(),
aspect,
0.05,
frame.depth_far,
) * frame.bob
* frame.spin;
);
let camera = frame.camera.as_vec3();
let (previous_camera, previous_view, previous_projection) =
let (previous_exact, previous_view, previous_projection) =
self.previous
.unwrap_or((Vec3::ZERO, Mat4::ZERO, Mat4::ZERO));
self.previous = Some((camera, view, projection));
.unwrap_or((glam::DVec3::ZERO, Mat4::ZERO, Mat4::ZERO));
let previous_camera = previous_exact.as_vec3();
self.previous = Some((frame.camera, view, projection));
// The camera's whole blocks and the rest, exact however far from the origin.
let whole = |p: glam::DVec3| p.floor();
let int = |p: glam::DVec3| Value::i(&whole(p).as_ivec3().to_array());
let fract = |p: glam::DVec3| Value::f(&(p - whole(p)).as_vec3().to_array());
// The player's, not the camera's: in a third-person view the camera is behind (or in
// front of, turned around) the eye the pack lights from.
let [yaw, pitch] = frame.eye_rotation.map(f32::to_radians);
let look = Vec3::new(
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
);
let to_view = |v: Vec3| view.transform_vector3(v);
let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0);
@@ -339,6 +405,22 @@ impl FrameUniforms {
match name {
"cameraPosition" => v3(camera),
"previousCameraPosition" => v3(previous_camera),
"cameraPositionInt" => int(frame.camera),
"cameraPositionFract" => fract(frame.camera),
"previousCameraPositionInt" => int(previous_exact),
"previousCameraPositionFract" => fract(previous_exact),
// The camera is the eye in first person.
"eyePosition" => v3(frame.eye.as_vec3()),
"relativeEyePosition" => v3((frame.camera - frame.eye).as_vec3()),
"playerLookVector" | "playerBodyVector" => v3(look),
"firstPersonCamera" => Value::i(&[i32::from(frame.first_person)]),
"isRightHanded" => Value::i(&[i32::from(inputs.right_handed)]),
"is_on_ground" => Value::i(&[i32::from(inputs.on_ground)]),
"bedrockLevel" => Value::i(&[inputs.min_y]),
"heightLimit" => Value::i(&[inputs.height]),
"logicalHeightLimit" => Value::i(&[inputs.logical_height]),
"hasSkylight" => Value::i(&[i32::from(inputs.has_skylight)]),
"hasCeiling" => Value::i(&[i32::from(inputs.has_ceiling)]),
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
@@ -398,6 +480,8 @@ impl FrameUniforms {
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
"heldBlockLightValue2" => Value::i(&[inputs.held_light[1]]),
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
// renderStage is NONE outside gbuffers draws.
_ => Value::i(&[0]),
}
@@ -450,6 +534,14 @@ fn components(name: &str) -> usize {
match name {
"cameraPosition"
| "previousCameraPosition"
| "cameraPositionInt"
| "cameraPositionFract"
| "previousCameraPositionInt"
| "previousCameraPositionFract"
| "eyePosition"
| "relativeEyePosition"
| "playerLookVector"
| "playerBodyVector"
| "upPosition"
| "sunPosition"
| "moonPosition"
@@ -484,6 +576,25 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
#[test]
fn cloud_height_needs_a_cloud_color() {
assert_eq!(cloud_height(0.8, 192.33), 192.33);
assert_eq!(cloud_height(0.8, 120.0), 120.0);
assert!(cloud_height(0.0, 192.33).is_nan());
}
#[test]
fn elytra_and_cloud_height_uniforms_are_written() {
assert!(NAMES.ends_with(&["isElytraFlying", "cloudHeight"]));
let mut out = vec![0u8; 16];
Value::i(&[1]).write("bool", &mut out, 0);
assert_eq!(i32::from_ne_bytes(out[0..4].try_into().unwrap()), 1);
Value::f(&[192.33]).write("float", &mut out, 4);
assert_eq!(f32::from_ne_bytes(out[4..8].try_into().unwrap()), 192.33);
Value::f(&[f32::NAN]).write("float", &mut out, 8);
assert!(f32::from_ne_bytes(out[8..12].try_into().unwrap()).is_nan());
}
#[test]
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
assert!(body(0.0, 0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
@@ -2,7 +2,9 @@
//! per-frame culling walk: a column whose box misses the frustum or lies past
//! the render distance rejects all of its sections with one test, and the
//! sections that remain are reached by index rather than by hashing keys.
use super::{Frustum, SectionKey};
#[cfg(test)]
use super::Frustum;
use super::{Cull, SectionKey};
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
use glam::{DVec3, Vec3};
@@ -59,6 +61,9 @@ impl<T> Resident<T> {
pub fn values(&self) -> impl Iterator<Item = &T> {
self.items.iter().map(|(_, v)| v)
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// `key`'s value, when it is resident.
pub fn find(&self, key: SectionKey) -> Option<&T> {
self.slots.get(&key).map(|&slot| self.get(slot))
@@ -226,7 +231,7 @@ fn in_frustum(
key: SectionKey,
min_y: i32,
camera: DVec3,
frustum: &Frustum,
frustum: &impl Cull,
inside: bool,
bounds: Option<[Vec3; 2]>,
) -> Option<(f64, Vec3)> {
@@ -258,7 +263,7 @@ impl<T> Resident<T> {
&self,
camera: DVec3,
radius: i32,
frustum: &Frustum,
frustum: &impl Cull,
reachable: impl Fn(SectionKey) -> bool,
beyond: impl Fn(i32, i32) -> bool,
out: &mut Vec<Visible>,
+192 -9
View File
@@ -3,6 +3,10 @@ use crate::weather::{Time, Vertex};
pub(super) struct Weather {
pipeline: wgpu::RenderPipeline,
/// The layouts of groups 0 (the entities') and 1 (the fall clock), for a native pack's
/// weather pipeline.
pub entity_layout: wgpu::BindGroupLayout,
pub time_layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
@@ -53,13 +57,7 @@ impl Weather {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
depth_stencil: Some(depth_stencil()),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
@@ -90,6 +88,8 @@ impl Weather {
});
Self {
pipeline,
entity_layout: entities.clone(),
time_layout: bindings,
group,
uniform,
vertices: Self::buffer(device, 4),
@@ -144,11 +144,24 @@ impl Weather {
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
}
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
/// Draws the streaks, through a native pack's weather stage when `native` is given (its
/// pipeline and the pack's frame group).
pub fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
entities: &wgpu::BindGroup,
native: Option<(&wgpu::RenderPipeline, &wgpu::BindGroup)>,
) {
if self.count == 0 {
return;
}
pass.set_pipeline(&self.pipeline);
match native {
Some((pipeline, frame)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
}
None => pass.set_pipeline(&self.pipeline),
}
pass.set_bind_group(0, entities, &[]);
pass.set_bind_group(1, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
@@ -157,11 +170,181 @@ impl Weather {
}
}
/// RenderPipelines.WEATHER: depth-tested, but no depth write, so the water
/// and glass drawn behind the rain still show through it.
fn depth_stencil() -> wgpu::DepthStencilState {
wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}
}
/// The entries around a native pack's `weather`: Horizon's weather vertex, then the texture
/// as `fs_weather` reads it, handed to the pack with the light and the position.
const NATIVE_ENTRIES: &str = "
struct HorizonWeatherOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) @interpolate(flat) rect: vec4<f32>,
@location(3) light: vec2<f32>,
@location(4) position: vec3<f32>,
@location(5) @interpolate(flat) snow: u32,
}
@vertex fn horizon_weather_vs(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
@location(6) light: vec4<u32>, @location(7) snow: u32) -> HorizonWeatherOut {
let phase = horizon_entity_weather_phase(animation, bases, snow,
horizon_entity_weather_time.values);
let v = horizon_entity_entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
var out: HorizonWeatherOut;
out.clip = v.clip;
out.uv = v.uv;
out.color = v.color;
out.rect = rect;
// entity_vertex's light: block in y, sky in x, 0..240.
out.light = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
out.position = position;
out.snow = snow;
return out;
}
@fragment fn horizon_weather_fs(in: HorizonWeatherOut) -> @location(0) vec4<f32> {
let texel = horizon_entity_weather_texel(in.uv, in.rect,
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
)
}
/// A native pack's weather pipeline: its `stage` (with the option values `constants`), then
/// entity.wgsl with every name prefixed, then the entries above. Group 0 is the entities',
/// 1 the fall clock (`time_layout`), 3 the pack's frame.
pub(super) fn native_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
entities: &wgpu::BindGroupLayout,
time_layout: &wgpu::BindGroupLayout,
frame: &super::native_post::NativeFrame,
stage: &horizon_shader_api::check::CheckedStage,
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[1] = Some(time_layout);
groups[GROUP as usize] = Some(&frame.layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack weather"),
bind_group_layouts: &groups,
..Default::default()
});
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let options = wgpu::PipelineCompilationOptions {
constants: &present,
..Default::default()
};
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack weather"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_weather_vs"),
compilation_options: options.clone(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(depth_stencil()),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_weather_fs"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendState::ALPHA_BLENDING.color,
// The frame's alpha stays as the surfaces left it (entities mark it 0).
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
Ok(pipeline)
}
#[cfg(test)]
mod tests {
use super::native_source;
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude()
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
#[test]
fn weather_writes_no_depth() {
let depth = super::depth_stencil();
assert_eq!(depth.depth_write_enabled, Some(false));
assert_eq!(
depth.depth_compare,
Some(wgpu::CompareFunction::GreaterEqual)
);
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Case {
@@ -335,8 +335,11 @@ impl Post {
view_formats: &[],
})
};
let sampled =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
// COPY_SRC: a native pack's material stage reads copies of them (`opaque_depth`,
// `opaque_color`).
let sampled = wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC;
let color = make_tex("Scaled world", size, self.format, sampled)
.create_view(&Default::default());
// TEXTURE_BINDING: occlusion culling and TAA read it.
@@ -434,14 +437,15 @@ impl Post {
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(target) = &self.target else { return };
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
self.finish_with(encoder, screen, &target.group);
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let Some(res) = &self.taa_resources else {
self.finish_with(encoder, screen, &target.group);
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let i = (self.taa_frame % 2) as usize;
@@ -457,6 +461,7 @@ impl Post {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("TAA")),
color_attachments: &attachments,
..Default::default()
});
@@ -464,7 +469,7 @@ impl Post {
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
self.finish_with(encoder, screen, &taa.show[i]);
self.finish_with(encoder, screen, &taa.show[i], timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
@@ -473,6 +478,7 @@ impl Post {
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: screen,
@@ -485,6 +491,7 @@ impl Post {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon post"),
timestamp_writes: timing.and_then(|t| t.writes("Post")),
color_attachments: &attachments,
..Default::default()
});
+74 -1
View File
@@ -313,6 +313,8 @@ pub fn reduced_far_detail_suggested() -> bool {
/// The name the options screen waits on to bind `Graphics::hdr_key`.
pub const HDR_KEY: &str = "horizon.hdrSettings";
/// The Screenshot Quality choices, times the game's resolution.
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
@@ -323,6 +325,19 @@ pub fn hdr_offered() -> bool {
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
/// the renderer starts; not without indirect multi-draw or on OpenGL, which loops over a
/// multi-draw's entries on the CPU, nor where vanilla frames draw flat (they read the section
/// arena's translucent quads).
pub fn offer_translucent_batching(on: bool) {
TRANSLUCENT_BATCHING_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn translucent_batching_offered() -> bool {
TRANSLUCENT_BATCHING_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -362,10 +377,16 @@ pub struct Graphics {
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
pub upload_budget_kib: u32,
/// The block textures' video memory (their top mip level), in MiB; 0 leaves it to
/// Horizon (`sprites::DEFAULT_TEXTURE_BUDGET`). Applies when resources next load.
pub texture_budget_mib: u32,
pub sky: bool,
pub fog: bool,
pub weather: bool,
pub animated_textures: bool,
/// Dithers the small mip levels of cutouts from 64 texels up, so a high-resolution pack's
/// fine plants keep their coverage in the distance; off is vanilla's look.
pub dither_cutout_mips: bool,
pub present_mode: PresentMode,
pub pacing: Pacing,
/// Chunk worker threads; 0 uses every core but one. Applies at the next start.
@@ -379,6 +400,8 @@ pub struct Graphics {
pub taa: bool,
/// TAA upscaling; anything but `Native` comes with `taa` on.
pub upscaling: Upscaling,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
@@ -399,8 +422,17 @@ pub struct Graphics {
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
pub battery: bool,
/// Reuse the shader pack's last shadow map when sun, view, scene revision and
/// pack uniforms still match. Off by default: vanilla loaders redraw every frame
/// (or every `shadowInterval`), and a hit with a stale key shows old shadows.
pub shadow_cache: bool,
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// Translucent quads in vertex buffers of their own (`gfx`'s translucent arena), so their
/// back-to-front draws go out in a few calls, for a little more video memory. Off by
/// default for now; read when a renderer is made (start, resource-pack reload), where it is
/// offered (`translucent_batching_offered`).
pub translucent_batching: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
@@ -416,10 +448,12 @@ impl Default for Graphics {
entity_culling: false,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
sky: true,
fog: true,
weather: true,
animated_textures: true,
dither_cutout_mips: false,
present_mode: PresentMode::Auto,
pacing: Pacing::Efficient,
workers: 0,
@@ -428,6 +462,7 @@ impl Default for Graphics {
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
screenshot_quality: 1,
hdr: false,
hdr_paper_white: 0,
hdr_peak: 0,
@@ -435,7 +470,9 @@ impl Default for Graphics {
hdr_ui_brightness: 100,
hdr_key: "key.keyboard.unknown".into(),
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: false,
shader_pack: String::new(),
}
}
@@ -480,10 +517,14 @@ impl Graphics {
"entityCulling" => g.entity_culling = flag(g.entity_culling),
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
g.texture_budget_mib = number(g.texture_budget_mib).min(16384);
}
"sky" => g.sky = flag(g.sky),
"fog" => g.fog = flag(g.fog),
"weather" => g.weather = flag(g.weather),
"animatedTextures" => g.animated_textures = flag(g.animated_textures),
"ditherCutoutMips" => g.dither_cutout_mips = flag(g.dither_cutout_mips),
"presentMode" => g.present_mode = PresentMode::parse(value),
"framePacing" => {
g.pacing = if value == "precise" {
@@ -497,6 +538,11 @@ impl Graphics {
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"screenshotQuality" => {
g.screenshot_quality = Some(number(0))
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
.unwrap_or(1)
}
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -516,6 +562,8 @@ impl Graphics {
}
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"translucentBatching" => g.translucent_batching = flag(g.translucent_batching),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -532,7 +580,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -540,10 +588,12 @@ impl Graphics {
bool_text(self.entity_culling),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
bool_text(self.sky),
bool_text(self.fog),
bool_text(self.weather),
bool_text(self.animated_textures),
bool_text(self.dither_cutout_mips),
self.present_mode.name(),
match self.pacing {
Pacing::Efficient => "efficient",
@@ -559,6 +609,7 @@ impl Graphics {
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
self.screenshot_quality,
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
@@ -566,8 +617,10 @@ impl Graphics {
self.hdr_ui_brightness,
self.hdr_key,
bool_text(self.battery),
bool_text(self.shadow_cache),
self.api.name(),
self.shader_pack,
bool_text(self.translucent_batching),
)
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
@@ -691,10 +744,12 @@ mod tests {
entity_culling: true,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
sky: false,
fog: false,
weather: false,
animated_textures: false,
dither_cutout_mips: true,
present_mode: PresentMode::Mailbox,
pacing: Pacing::Precise,
workers: 6,
@@ -703,6 +758,7 @@ mod tests {
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
screenshot_quality: 4,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
@@ -710,16 +766,33 @@ mod tests {
hdr_ui_brightness: 60,
hdr_key: "key.keyboard.h".into(),
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: true,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is opt-in for now: only the file turns it on.
assert!(!Graphics::default().translucent_batching);
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
// Only the button's choices.
assert_eq!(
Graphics::parse("screenshotQuality:8\n").screenshot_quality,
8
);
assert_eq!(
Graphics::parse("screenshotQuality:3\n").screenshot_quality,
1
);
assert_eq!(
Graphics::parse("screenshotQuality:16\n").screenshot_quality,
1
);
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
@@ -147,6 +147,34 @@ impl Style {
dragging && active,
);
}
pub fn slider_ext(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
focused: bool,
can_change_value: bool,
active: bool,
dragging: bool,
) {
ui::slider_ext(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
focused,
can_change_value,
active,
dragging && active,
);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
&self,
Loaded 100 of 1162 files, more files were not shown because too many files have changed in this diff. Show more