Compare commits

..
Author SHA1 Message Date
Ludovic Marie 4649ff391b Merge pull request #9637 from KBroneZ/claude/fix-9628-copy-from
🤖 Agent: The CurseForge rows now reuse #9583's curseforge_instance_target (minecraftinstance.json first, manifest.json only if it has no version), so copy-from and `instance others` read the same triple the pack import does. A loader Horizon doesn't name, or one without a version, stays none, as before.
2026-11-05 04:42:10 +01:00
Ludovic Marie d594f449d8 Merge pull request #9639 from KBroneZ/claude/fix-9630-errors
fix(launcher): say what to do next in three common errors
2026-11-05 04:41:10 +01:00
Ludovic Marie bd054c2682 Merge pull request #9648 from horizon-rust/fix/9311-review-leftovers
🤖 Agent: verified on playhorizon merged with main: launcher fmt, clippy -D warnings, 541 lib + 102 binary tests. The failure file's tag line is what keeps a launcher already at that release from showing a stale 'update failed'; files from older installers have no tag and still show.
2026-11-05 04:33:32 +01:00
Horizon agent 6b54c6f50b Merge remote-tracking branch 'origin/main' into cg40b/review-fixes-launcher-update 2026-11-05 04:31:12 +01:00
Ludovic Marie 7ac27e71f0 Merge pull request #9473 from horizon-rust/fix/known-packs-as-vanilla
🤖 Agent: #9298 merged; main merged in (clean, nothing since the verified tree touches net.rs, remote.rs or the wire tools). Verified on playhorizon at the stack's top 72f38687b: all five vanilla_wire_e2e rules pass against vanilla 26.3 with the Login held 1 s, known-packs-as-vanilla and known-data-used included; fmt, clippy -p horizon-client --all-targets -D warnings, 910 horizon-client tests.
2026-11-05 04:30:35 +01:00
Ludovic Marie c626fc0b1a Merge pull request #9428 from martipops/fix/launcher-checks
Launcher: run the binary's tests in PR checks
2026-11-05 04:30:07 +01:00
Horizon agent f03a34e508 Merge remote-tracking branch 'origin/main' into cg40b/9473b 2026-11-05 04:28:56 +01:00
Horizon agentandClaude Opus 5.5 d160024a85 Launcher: an update failure isn't shown once its release is in; a data move drops a staged update
The failure file now starts with the release that failed, and a launcher
already at it or newer (another copy, the command line or the site put it
in) doesn't say its update failed. Moving the data folder quit the window
with an update still staged, so the installer could start while the
relocation worker copied the folder holding the stage; the move now
drops the staged update and its stage, and the restarted launcher checks
again. From martipops's review of #9311.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:27:42 +01:00
Ludovic Marie fb683da6ac Merge pull request #9631 from TheMrZZ/fix/pufferfish-player-contact
🤖 Agent: The contact now follows Player.aiStep (inflate 1/0.5/1, or the vehicle union inflated 1/0/1) and Pufferfish.playerTouch (doTeamsAllowDamage, hurt first, then poison with the fish as source), and it runs per guest even with a frozen level. One gap left for later: vanilla sends PUFFER_FISH_STING to the player only when the hurt lands, while the sting sound here still comes from the fish AI every 10 ticks.
2026-11-05 04:26:16 +01:00
Ludovic Marie 89ea0a8c81 Merge pull request #9627 from Vaspyyy/fix/launcher-mod-list-paths
🤖 Agent: Every place that removes or renames a listed file now goes through Entry::path, which accepts only plain names under the five install folders (mods, resourcepacks, shaderpacks, horizon-mods, horizon-native-mods), and imports from packs or other launchers can no longer bring their own mod-browser.json. Data packs never enter that list, so nothing the browser installs today is dropped.
2026-11-05 04:26:12 +01:00
Ludovic Marie a12a1a156d Merge pull request #9626 from Vaspyyy/fix/friends-relay-guess-limit
🤖 Agent: The wrong-guess total and the retired-code count now last for the whole sharing session, and only as many relayed key exchanges run at once as wrong guesses are left, so re-registering no longer resets the relay's chances (at most 50 guesses at 20-bit secrets). Counting every failed exchange as wrong is the right call: the host sends its confirmation first, so a peer that hangs up after checking it has had its guess.
2026-11-05 04:26:08 +01:00
Ludovic Marie 86a2f2516e Merge pull request #9298 from horizon-rust/fix/client-information-as-vanilla
🤖 Agent: #9297 merged; main and its harness fix merged in (clean). Verified on playhorizon on the stack's top (#9473, which carries this head): the five vanilla_wire_e2e rules against vanilla 26.3 with the Login held 1 s pass, including brand-before-information and no-play-information from this PR; fmt, clippy -p horizon-client --all-targets -D warnings, 910 horizon-client tests.
2026-11-05 04:22:48 +01:00
Ludovic Marie d26c7fd7de Merge pull request #8498 from utophii/fix/2282-musl-build
feat: add musl linux release build
2026-11-05 04:21:41 +01:00
Ludovic Marie 41ace47be5 Merge pull request #9433 from martipops/fix/staged-update-install
🤖 Agent: verified merged with main (81dad4693, with #9428 on top) on playhorizon: launcher fmt, clippy --all-targets --locked -D warnings, 538 lib tests and 101 of 102 binary tests (the one failure is main's stale docs/launcher-commands.md, fixed on #9428). One point checked by hand: an older window hands the new installer no HORIZON_LAUNCHER_UPDATE_SHA256 only if the file in place changed under it, and then already_in_place returns Ok before the empty hash is looked at, so a mixed-version pair restarts instead of failing. Real placement on macOS and Windows is still unrun.
2026-11-05 04:18:07 +01:00
Ludovic Marie 9540aaeb86 Merge pull request #9435 from martipops/fix/data-remove-loot-table
🤖 Agent: verified merged with main 81dad4693 on the desktop: clippy -p horizon-client --lib --tests -D warnings, 29 command tests, and the ignored loot_table_tests run against prepared 26.3 resources (passes; with take_loot_table's old bare retain put back it fails with the table still in the saved chest, so the test pins the fix). Matches vanilla: RandomizableContainer.tryLoadLootTable loads a tag without LootTable as a plain, empty container. The run-parity label adds opened_loot as asked.
2026-11-05 04:15:17 +01:00
Horizon agentandClaude Opus 5.5 1f6cd059ce docs: regenerate launcher-commands.md (game-architecture key)
The reference test lives in the launcher's binary, which --bins now runs in
CI: main's copy missed the game-architecture setting from #8274, so this
PR's own check would fail on it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:14:14 +01:00
Horizon agent 7dc38db5bd Merge remote-tracking branch 'origin/main' into cg40b/9428 2026-11-05 04:14:13 +01:00
KBroneZandClaude Sonnet 5.5 9cf673ffb9 fix(launcher): fewer language-table lines for #9630
Insert the new keys before each table's last entry (no comma changes), fold
the instance-name sentence into one key, and give copy-from its --as hint as
plain text after it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-05 05:11:21 +02:00
KBroneZandClaude Sonnet 5.5 a4c0f82c7a fix: read the recorded target when --folder names one CurseForge instance
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-05 05:06:05 +02:00
Ludovic Marie 0c62a47e9a Merge pull request #9297 from horizon-rust/fix/client-tick-end-needs-level
🤖 Agent: requeued after martipops's harness finding: vanilla_wire_e2e now records the held Login when the client gets it. On playhorizon against vanilla 26.3 with a 1 s hold, this head passes no-play-before-login and the same tree with main's r.joined condition fails it with 27 client_tick_end before Login. Merged with main 81dad4693 (impl Remote conflict, both sides kept): fmt, clippy -p horizon-client --all-targets -D warnings, 910 horizon-client tests.
2026-11-05 04:01:32 +01:00
Horizon agent 72f38687bd Merge branch 'cg40b/9298' into cg40b/9473 2026-11-05 04:00:26 +01:00
Horizon agent af15fa9bba Merge branch 'cg40b/9297' into cg40b/9298 2026-11-05 04:00:15 +01:00
KBroneZandClaude Sonnet 5.5 f6e407e16c fix(launcher): say what to do next in three common errors
No version installed: name `horizon-launcher install latest`.
Instance name taken: ask for another name; `instance copy-from` names `--as`.
Play gate: name where to sign in (Settings in the window, `account sign-in` on the CLI).
No change in behaviour; messages go through tr()/trf().

Fixes #9630

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-05 04:59:42 +02:00
Ludovic Marie a295df1fd7 Merge pull request #9277 from martipops/controller/menu-actions
🤖 Agent: verified merged with main 81dad4693 on the desktop: fmt, clippy -D warnings on the four client crates (all targets) and the --no-default-features server build, 2750 horizon-client/-lib tests and 1358 render tests pass. One point checked by hand: with no glyph a button's label is centred exactly where it was (x + floor(w/2) equals the old x + 2 + floor((w - 4)/2)), so mouse players see vanilla's buttons unchanged. pad_menu_e2e not rerun here (windowed, no display over ssh).
2026-11-05 03:51:41 +01:00
Florian ERNSTandCodex 81c9870f01 Keep pufferfish and moving-piston test options together
Refresh from main without changing the H311 runtime fixture or contact implementation.

Co-Authored-By: Codex <[email protected]>
2026-11-05 03:50:06 +01:00
KBroneZandClaude Sonnet 5.5 663c1efb70 fix: copy-from reads a CurseForge instance's Minecraft version and loader
The CurseForge app's instances were listed with no version and no loader, so
`instance copy-from` demanded --minecraft and the copy lost its loader. They
now use curseforge_instance_target (minecraftinstance.json, else manifest.json).
--minecraft still overrides; the Modrinth App path is unchanged.

Fixes #9628

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-05 04:47:54 +02:00
Horizon agentandClaude Opus 5.5 5a10e12a10 vanilla_wire_e2e: hold the Login before recording it
The proxy wrote the Login's line and then slept, so whatever the client sent
during the hold landed after the Login in the capture, where
no-play-before-login stops looking: a client sending tick ends for the whole
hold could pass. Sleep first, then record (martipops's review on #9297).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 03:41:43 +01:00
Horizon agent 042c8b4b44 Merge remote-tracking branch 'origin/main' into cg40b/9297
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-11-05 03:41:42 +01:00
Ludovic Marie d158e44736 Merge pull request #9632 from DUzzL/fix/9607-scaffolding-sight
🤖 Agent: Line of sight now clips with the viewer's collision context, as LivingEntity.hasLineOfSight's ClipContext(COLLIDER, entity) does, so scaffolding is open from below and solid from above. I ran the new test against prepared resources too: it builds the world and passes, not vacuously.
2026-11-05 03:40:07 +01:00
Ludovic Marie 81dad4693e Merge pull request #9617 from TheMrZZ/fix/h309-live-piston-data
🤖 Agent: Moving-piston NBT is read from the live tick entry with the existing piston serializer, and writes are still refused before any detached block entity exists. The ordered pretty-print context applies only to a live piston's requested subtree, so other /data get output is unchanged.
2026-11-05 03:32:32 +01:00
Ludovic Marie 122149ae5c Merge pull request #9625 from Vaspyyy/fix/relay-hosts-per-address
🤖 Agent: The per-address host cap mirrors the existing rate limits (off when the relay can't see addresses; IPv6 counted per /64 with a 4x /48 cap). HostSlot gives the slot back on every way out of host(), and the integration test holds connections open to prove it.
2026-11-05 03:32:29 +01:00
Ludovic Marie 8a8b5674db Merge pull request #9624 from Vaspyyy/fix/client-malformed-server-data
🤖 Agent: The tree check follows ClientboundCommandsPacket.validateEntries: one pass over redirects (canBuild) and one over children (canResolve), so vanilla's own execute-run-to-root redirect still passes. It is iterative, so a long chain cannot overflow either.
2026-11-05 03:32:25 +01:00
Ludovic Marie 8edbc6dbe5 Merge pull request #9623 from Vaspyyy/fix/dialog-url-scheme
🤖 Agent: Dialog open_url now goes through the same untrusted_http_url check as chat click events (ExtraCodecs.UNTRUSTED_URI: http/https only), so file:, UNC and protocol-handler links never reach open_uri.
2026-11-05 03:32:22 +01:00
Ludovic Marie 76a26cc628 Merge pull request #9622 from Vaspyyy/fix/lan-owner-login
🤖 Agent: The owner's window always joins its integrated server through memory, so refusing the owner's UUID over the network costs the host nothing and closes the offline-name takeover. Splitscreen seats already had this rule.
2026-11-05 03:32:18 +01:00
Ludovic Marie 24f537709a Merge pull request #9621 from Vaspyyy/fix/item-nesting-depth
🤖 Agent: The depth guard is a thread-local counter around read_added and effect_details, released on drop, so an error leaves nothing counted. 64 levels still decode, and one guarded Cell per component costs nothing next to the decoding itself.
2026-11-05 03:32:13 +01:00
Ludovic Marie 926b36ee9d Merge pull request #9611 from Vaspyyy/perf/ore-sphere-loop
🤖 Agent: Along each axis the rounded squares fall and then rise, so the cells inside a sphere form one run per row. Skipping the rest changes no draw or query (vanilla only draws for inner cells), and your snapshots of 4 decorated worlds match byte for byte.
2026-11-05 03:32:09 +01:00
Florian ERNSTandCodex 6328ba1dc5 Refresh H311 after the current proof batch
Preserve all landed fixture routes and one declaration of the shared scratch/reference options. Runtime pair remains qualified to 21c7f4597.

Co-Authored-By: Codex <[email protected]>
2026-11-05 03:27:17 +01:00
Ludovic MarieandClaude Opus 5.5 a82b1427c6 Merge #9617 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 03:22:37 +01:00
Ludovic Marie c20465a997 Merge pull request #9597 from TheMrZZ/fix/boat-item-exact-hit
🤖 Agent: Boat items now clip like Item.getPlayerPOVHitResult (float view vector, OUTLINE, Fluid.ANY) through the shared ray code instead of 0.05-block fluid sampling; the new boat_item_hit harness runs in CI parity.
2026-11-05 03:22:09 +01:00
Florian ERNSTandCodex 6ae2f17402 Dispatch pufferfish player contact from the live player tick
Keep accepted-hit gating and attach the actual fish to poison effect criteria, independently of fish AI and frozen world ticks.

Co-Authored-By: Codex <[email protected]>
2026-11-05 03:21:58 +01:00
Ludovic Marie 2cd9e2c910 Merge pull request #9598 from Vaspyyy/perf/collision-scan-skip-missing
🤖 Agent: The same hits come in the same order, with the budget kept (both tests compare against the cell-by-cell walk). Measured: +0.07 ms per tick with 1500 ground mobs (palette check per section), noise for flying mobs, and the far-teleport worst tick goes 2290 → 160 ms.
2026-11-05 03:17:36 +01:00
VaspyyyandClaude Opus 5.5 d342803768 Dialogs: open_url takes only http and https links
A server dialog's open_url action, from a show_dialog packet or a
show_dialog click in chat, took any string. A click went to open_uri, which
hands it to ShellExecute on Windows and to xdg-open or open elsewhere. A
button naming file:///C:/.../cmd.exe, a UNC path to an .exe on the
attacker's share, or a custom protocol handler could start a program. The
link prompt is on by default but shows only the raw string; with "Prompt on
Links" off it opened at once.

Vanilla's ClickEvent.OpenUrl takes an ExtraCodecs.UNTRUSTED_URI (http and
https only). Chat components already applied that check; dialogs now share
it, and a refused link leaves the button with no action.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 03:16:27 +01:00
Ludovic MarieandClaude Opus 5.5 47fb24cdfb Merge main into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 03:15:52 +01:00
DUzzL 7588ad80ea Use the viewer's collision context for mob line of sight (scaffolding) 2026-11-05 03:13:44 +01:00
Ludovic Marie 24008dfaa4 Merge pull request #9608 from KBroneZ/claude/fix-9585-trapdoor
🤖 Agent: Vanilla's trapdoor ladder rule is instanceof TrapDoorBlock, so a tagged block with no open or facing is now just not a trapdoor. A real ladder still needs valid facing, and the tests cover the three non-trapdoor shapes.
2026-11-05 03:08:50 +01:00
Ludovic Marie ffd9ffb995 Merge pull request #9601 from rison1337/native-client-services
🤖 Agent: The client services are owned snapshots with no authoritative writes, and every lifecycle hook is empty without a mod. The menu_background result is clamped to a finite 1..64, so a mod cannot hand the blur a bad radius. I dropped the enable_older_servers helper (see my comment).
2026-11-05 03:08:46 +01:00
TheMrZZandCodex gpt-6.1-sol 5f4cff260b Merge current main while preserving piston command coverage
Keep upstream corpus groups and opt-in loaded-fixture controls alongside the live-piston contract and full presentation diagnostic.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 03:05:13 +01:00
VaspyyyandClaude Opus 5.5 ac66f3bdf9 LAN: the owner joins only through memory
A host without a Microsoft account opens the world in offline mode, where a
player's UUID is offline_uuid(name). The owner's UUID is offline_uuid of the
host's name too, and the login gate let that UUID in from anywhere. So a LAN
or friends player who logged in under the host's name passed the gate, the
duplicate-login handling kicked the host's own window, and the guest played
on as the owner: operator level 4, the owner's player data, and the
movement checks the owner skips.

Apply the splitscreen seats' rule to the owner: the owner's UUID is let in
only over the in-memory connection. Vanilla never reaches this state because
IntegratedServer always authenticates LAN guests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 03:03:33 +01:00
VaspyyyandClaude Opus 5.5 d9b42b1267 Protocol: refuse item data nested past 64 levels
Item stacks can hold stacks in their components (bundle contents, charged
projectiles, use_remainder, container), and a mob effect can hold a hidden
effect. The decoders called each other with no depth limit. A payload a few
hundred thousand levels deep fits in one 2 MiB frame and overflows the
decoding thread's stack. In Rust that aborts the whole process instead of
failing the packet: one creative-slot packet from any player stopped a
dedicated server or a LAN host (with the host's own window), and one slot
packet from a server stopped the client. Vanilla's decoder turns the
StackOverflowError into a disconnect.

Count nesting per thread in read_added, which every stack-in-a-stack passes
through, and in effect_details, and return an error past 64 levels. The
packet then fails like any other bad packet and only that connection
closes. Survival bundles nest about 16 deep at most.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 03:03:33 +01:00
VaspyyyandClaude Opus 5.5 98cec87e34 relay: cap the hosts one address holds open
The relay only rate-limited host registrations (6 a minute per
address) against the global `max_hosts` (1000). A host that answers
pings keeps its slot as long as it likes, so one client could register,
hold every control connection open and fill all slots: in under three
hours from one IPv4 address, in seconds from an IPv6 /48 (limits count
by /64, and a /48 holds 65536 of them). Every real host then got
`Full`.

Now the limiter also counts the hosts each address key holds open:
at most 4 (`RELAY_MAX_HOSTS_PER_ADDRESS`), and an IPv6 /48 at most four
times that, however many /64s it spreads over. A registration past the
cap gets the existing `Full` reply. The count goes up when a host
registers and back down through an RAII guard (`HostSlot`) when its
connection ends, on every way out of `host`, panics included. Like the
other per-address limits it is off with `RELAY_PER_ADDRESS_LIMITS=0`.

Filling the default 1000 slots now takes 250 IPv4 addresses or 63 IPv6
/48s, not
one client.

Tests: an integration test registers the cap's worth of hosts from one
address, sees the next refused with `Full`, closes one and gets its
slot back; a limiter test covers the /64 and /48 caps, a freed slot and
the limits switched off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:58:08 +01:00
VaspyyyandClaude Opus 5.5 4f682369c4 friends: bound a relay's guesses at the short code's secret
A short code's secret is 20 bits, and the docs promise the relay can't
get into the world. But the host gave a relay (or anyone driving its
joins) unlimited online guesses:

- after five wrong secrets the code was retired, but the wrong count
  was reset on every registration and `run_relay` registered again at
  once, so each cycle bought five fresh guesses for one round trip;
- a guess was only counted when the guest's confirmation failed to
  verify. The host sends its own confirmation first, so a peer could
  check it against its guess offline and hang up: an uncounted guess;
- the five-guess limit was checked after the fact, so joins sent all
  at once ran any number of key exchanges in parallel.

Now the host keeps the wrong guesses for the whole sharing session
(`Guesses`): a count per code (reset when it registers), a total that
never resets, and a reservation for each key exchange in progress, so
only as many run at once as wrong guesses are left. Every relayed key
exchange that ends without a confirmation counts. After a retired code
the host waits 1 s, 2 s, 4 s ... (at most 5 minutes, `retire_delay`)
before registering again, and after 50 wrong guesses in one session it
leaves the relay (`HostEvent::RelayStopped`; the game says so through
a new translation key). The full code keeps working directly.

The math: each short code has a fresh secret, so every guess hits with
probability 2^-20 and guesses don't narrow anything down.
- With the cap, a relay gets at most 50 guesses per sharing session:
  50 / 2^20, about 1 in 21,000. Spending them takes at least
  1+2+...+256 s = 511 s of waits.
- With the backoff alone (capped at 5 min, 5 guesses a cycle), the
  steady rate is 60 guesses an hour; a 50 % chance needs
  ln 2 * 2^20 = ~727,000 guesses, about 12,100 hours (~505 days) of
  continuous sharing. Before: one round trip a guess, hours.

Tests: the per-code count starts over at each registration while the
total stays, the waits double (1 s ... 256 s) until the host stops at
50; exchanges in progress count against the limit; `retire_delay`
doubles up to 5 minutes. The relay's tunnel test still sees a new code
after five wrong secrets (now after 1 s).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:57:12 +01:00
VaspyyyandClaude Opus 5.5 4cdbd962a3 Ore placement: visit only the cells inside each sphere
OreFeature.doPlace scanned each sphere's whole box and divided for every
cell, although a cell's square along one axis depends on that coordinate
alone. Each axis's squares are now worked out once per sphere with
vanilla's expression, so every sum is bit for bit the same, and each row
visits only its run of cells inside the sphere: along an axis the squares
fall and then rise, so the passing cells form one run around the smallest.

The cells tested, the world queries and the random draws stay the same
and in the same order. A test compares the walk with vanilla's full box
scan over 20,000 spheres, including ones whose edge sums to exactly 1.

Fixes #9609

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:56:53 +01:00
VaspyyyandClaude Opus 5.5 6b1d342294 Client: refuse light layers, command trees and dimension shapes vanilla refuses
Three things a server sends were decoded without vanilla's checks, and each
let a server crash or stall the client:

- A light layer shorter than 2048 bytes: the column-top sky light indexes the
  layer directly, so an empty layer panicked the main thread. Vanilla's
  DataLayer constructor refuses any other length; Layers now does too.
- A command tree whose children (or redirects) loop: the usage hint followed
  the loop when the player typed "/" and overflowed the stack. Commands now
  runs ClientboundCommandsPacket.validateEntries' check ("Server sent an
  impossible command tree"), as an O(n) depth-first walk so a long chain
  can't make the check itself slow.
- A dimension type's min_y and height went unchecked into the chunk format,
  so a height of 2^31 made the client allocate gigabytes for light. A shape
  outside DimensionType's codec (multiples of 16, at least 16 high, inside
  -2032..=2031) now keeps the named type's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:55:57 +01:00
Ludovic MarieandClaude Opus 5.5 6d54bb04f8 Merge main into native-client-services
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:52:21 +01:00
Ludovic MarieandClaude Opus 5.5 1f8f9f8467 Native client services: leave the older-server switch to HORIZON_OLDER_SERVERS
The older-server adapters are off by default while they are incomplete, and
only the environment turns them on. A Host helper that turns them on for the
whole process would let any installed mod do that for its players; it is
left out of this change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:52:20 +01:00
Ludovic Marie 78a7e6a712 Merge pull request #9562 from degepie/fix/player-predicate-stats
🤖 Agent: The player predicate now tests stats, advancements, recipes and level, and player_entity_for_any stays &self, so #9494's compound is added only by the &mut self caller.
2026-11-05 02:49:43 +01:00
Ludovic Marie 743aad7dc3 Merge pull request #9482 from TheMrZZ/fix/execute-loaded-ticket-status
🤖 Agent: /execute if loaded now also needs the entity-ticking status and saved entities applied, which matches ExecuteCommand.isChunkLoaded (FullChunkStatus.ENTITY_TICKING && areEntitiesLoaded). The simulation permission tracker is untouched.
2026-11-05 02:49:39 +01:00
Ludovic Marie 354cf20b02 Merge pull request #9592 from janibert1/host-send-map
🤖 Agent: This is a fragment only: Host::send_map wraps the existing native_send_map, and tests.test_stamp_native_hooks passes with it.
2026-11-05 02:48:41 +01:00
Ludovic Marie 32e5a325a7 Merge pull request #9563 from xxaxio/feat/9547-sources-dawn-client
🤖 Agent: Dawn Client is only one more place to find the player's own 26.3 jar and assets (cache/minecraft under .dawn). The tests check that nothing is fetched from Mojang.
2026-11-05 02:48:37 +01:00
Ludovic Marie 15be9febca Merge pull request #9534 from Joansitoh/fix/9528-server-pack-replace-loop
🤖 Agent: A replaced pack becomes a new entry and the old one is unloaded, as ServerPackManager.pushNewPack marks it SERVER_REPLACED, so the reload ends instead of looping.
2026-11-05 02:48:33 +01:00
Ludovic Marie 9207297b65 Merge pull request #9589 from DUzzL/fix/9569-ghast-seats
🤖 Agent: The four seats match the passengerAttachments of HAPPY_GHAST in 26.3's EntityTypes, and the index clamps as EntityAttachments.getClamped does.
2026-11-05 02:48:30 +01:00
Ludovic Marie 5efec909a5 Merge pull request #9519 from Delete228/data-driven-damage
🤖 Agent: No-pack worlds keep the existing preload. Only an overlay with damage-type definitions seeds SyncData at world entry, so the default path is unchanged.
2026-11-05 02:48:26 +01:00
VaspyyyandClaude Opus 5.5 cb9205a34f launcher: keep the mod browser's list from deleting files outside the game folder
The mod browser records what it installed in `mod-browser.json` in the instance folder, and
Update, Remove and turning a mod off act on each entry's `file` through `home.join(file)`.
Nothing checked that path: an absolute path replaces `home`, and `..` climbs out of it. A Java
modpack (`.mrpack` with a loader) copies every relative path from `overrides/` into the new
instance, so a pack could plant its own `mod-browser.json` with an entry such as
`"file": "/home/me/.ssh/id_ed25519"`, and the launcher would delete or rename that file when the
player clicked Update, Remove or the on/off toggle on the fake entry. Copying a Prism/MultiMC
instance folder could plant the same file.

- `Installed::load` now leaves out (and logs) any entry whose `file` isn't a relative path of
  plain names inside a folder the browser installs into (`mods/`, `resourcepacks/`,
  `shaderpacks/`, `horizon-mods/`, `horizon-native-mods/`), with at least one name below it.
- `Entry::path` applies the same check where files are removed or renamed (update, remove,
  toggle, Java ports, registry installs), so an entry that didn't come from `load` can't reach
  outside either.
- A modpack import never writes `mod-browser.json` (any case), and the other-launcher copy skips
  it.

Tests: a planted list with an absolute path, a `..` path, a path climbing out through `mods/`
and a bare `horizon-mods` keeps only the valid `mods/x.jar` entry; Remove and toggle leave a
file outside the instance untouched. A Java `.mrpack` with `overrides/mod-browser.json` imports
its mods but not the list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:48:10 +01:00
Ludovic MarieandClaude Opus 5.5 f87de22790 Merge main into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:46:31 +01:00
Ludovic Marie 3242adcfbb Merge pull request #9596 from xxaxio/fix/9391-native-mods-conflicting-pins
🤖 Agent: The mod id is read from the mixins.incoming/<id>/ path that stage_mixins writes, so the culprit named for E_MODULE_CONFLICT is right. I removed the Fixes lines for #9016 and #9414, which this PR does not fix.
2026-11-05 02:43:17 +01:00
Ludovic Marie 3c41dc04b8 Merge pull request #9525 from valkdzx/perf/behaviour-snapshot-key
🤖 Agent: The snapshots now key on a build.rs hash of the world sources, so a relink no longer throws them away, while a change to those sources still does.
2026-11-05 02:43:13 +01:00
Ludovic Marie 8ab7482d2f Merge pull request #9595 from Mayonesu0/fix/9465-launcher-game-tools-fallback
🤖 Agent: The launcher's own endpoint still wins when both games answer. A crashed game's file no longer counts (a TCP endpoint must accept its token), and a change of endpoint restarts the horizon-ctl child, whose Drop kills the old one.
2026-11-05 02:43:09 +01:00
Florian ERNSTandCodex 2ab3fcd728 Refresh H311 paired-proof checkpoint
Co-Authored-By: Codex <[email protected]>
2026-11-05 02:42:28 +01:00
Florian ERNSTandCodex 8738762519 Refresh H311 fixture branch from main
Co-Authored-By: Codex <[email protected]>
2026-11-05 02:38:45 +01:00
Ludovic Marie 21c7f45978 Merge pull request #9544 from dfrnoch/fix/atlas-animated-any-path
🤖 Agent: Animated strips are now cut to their first frame for every atlas directory source, not only item/ and block/, so a pack's own folder (wynn/) animates as vanilla's does.
2026-11-05 02:38:39 +01:00
Florian ERNSTandCodex 5bdab6c046 Add a frozen-player pufferfish contact fixture
Co-Authored-By: Codex <[email protected]>
2026-11-05 02:36:59 +01:00
Ludovic Marie 7716494c9a Merge pull request #9512 from CocofireHD/fix/9434-mcpr-crash-recovery
🤖 Agent: Recovery keeps only whole frames: the crashed .tmcpr.tmp is cut at its last complete packet record before it becomes an .mcpr, and a writer that may still be alive (its pid is checked) is left alone.
2026-11-05 02:35:29 +01:00
Ludovic Marie 249f21e8ba Merge pull request #9476 from itarqos5/perf/far-detail-off-the-frame
🤖 Agent: Applying detail now stops at DETAIL_BUDGET (3 ms) per frame and the rest waits in detail_backlog; with #9515 the widths reduction runs on the worker, so neither the burst nor its first read lands on the frame.
2026-11-05 02:35:24 +01:00
Ludovic Marie 6804926c56 Merge pull request #9483 from voidsipp/fix/9284-server-entity-sounds
🤖 Agent: Captured server sounds keep their entity, category and excluded player and go out between the entity's add and remove, so a client always knows the emitter when the sound arrives.
2026-11-05 02:35:20 +01:00
Ludovic Marie 6cdb23ac34 Merge pull request #9561 from degepie/fix/entity-killed-player
🤖 Agent: entity_killed_player fires from the guest's death with the blow read before the death message, as ServerPlayer.die does; the killer view stays on &self on top of #9494.
2026-11-05 02:35:15 +01:00
Ludovic Marie f8b542ecd3 Merge pull request #9478 from voidsipp/fix/end-gateway-passengers
🤖 Agent: End gateways now keep a same-level vehicle live and teleport it with its passengers. As you note, #9461's temporary mob_can_use_gateway restriction is no longer needed with this.
2026-11-05 02:35:11 +01:00
Ludovic Marie dd31fddcbf Merge pull request #9474 from voidsipp/fix/passenger-dimension-transitions
🤖 Agent: The whole vehicle tree moves through Entity.teleportCrossDimension's calculatePassengerTransition, with UUIDs and mount links restored, so riders keep their vehicles across /execute in … run tp.
2026-11-05 02:35:07 +01:00
Ludovic Marie 651e4de726 Merge pull request #9462 from voidsipp/fix/carpet-farm-regression
🤖 Agent: The coral-fan carpet fixture is coverage only (all 96 observations already matched on main), and it stays in the command-exec stack.
2026-11-05 02:35:03 +01:00
Ludovic Marie 541a186944 Merge pull request #9583 from KBroneZ/claude/cf-instance-metadata-1896a7
🤖 Agent: The version and loader are read from the instance's own files only when mmc-pack.json gave none, so MultiMC and Prism imports read as before.
2026-11-05 02:34:59 +01:00
Ludovic Marie 6589f8b5ac Merge pull request #9532 from DUzzL/fix/9509-crumbling
🤖 Agent: ModelBakery.DESTROY_STAGES_OIT exists in 26.3. The OIT crack sprites are picked only with improved transparency on, so the default look is unchanged.
2026-11-05 02:34:55 +01:00
Ludovic Marie 837df857b7 Merge pull request #9529 from Delete228/fix/macos-native-metadata
🤖 Agent: native-mods.json is refreshed through a fresh hardlink and an atomic rename, so nothing is written through the old link. native-data is a relative symlink inside the app bundle.
2026-11-05 02:34:51 +01:00
Ludovic Marie 708374adb8 Merge pull request #9527 from CocofireHD/fix/9454-started-riding-all-passengers
🤖 Agent: started_riding now fires for every ServerPlayer the vehicle carries (Entity.getIndirectPassengersStream), not only for the player who boarded.
2026-11-05 02:34:47 +01:00
Ludovic Marie 275566a6e9 Merge pull request #9579 from GabrielNichols/codex/native-build-culprit
Launcher: name mods in injected-helper compiler errors
2026-11-05 02:32:16 +01:00
Ludovic Marie 77c2adcc03 Merge pull request #9480 from megabytesme/codex/issue-9408-launcher-arch-choice
Launcher: choose game architecture independently on Windows ARM
2026-11-05 02:32:10 +01:00
Ludovic Marie e39fd11f76 Merge pull request #9510 from CocofireHD/fix/9332-world-name-space
🤖 Agent: The focused field now gets the space before any screen shortcut sees it, so typing a world name no longer switches the Create New World tab.
2026-11-05 02:31:26 +01:00
Ludovic Marie f4e1f960f3 Merge pull request #9504 from Ragbie/fix/summoned-dragon-gateway
🤖 Agent: The fight keeps dragon_uuid (vanilla's save key) and ticksSinceDragonSeen, as EnderDragonFight.tick does. A summoned dragon that dies after the fight no longer opens a gateway or gives 12000 XP.
2026-11-05 02:31:22 +01:00
Ludovic Marie e16d3d514d Merge pull request #9486 from loicpiller/bugfix/9472-pack-float32-targets-gl
🤖 Agent: renderable_float swaps only a float target the adapter cannot render (to 16-bit float, then Rgba8Unorm). Vulkan, Metal and DX12 keep the pack's declared format.
2026-11-05 02:31:18 +01:00
Ludovic Marie 00ad11cc8f Merge pull request #9485 from loicpiller/bugfix/9472-shader-pack-gl-rgba32float
🤖 Agent: The native pack surface only holds bit patterns, written with blend: None and read with textureLoad. Rgba32Uint keeps the same bits and can be rendered on wgpu's GLES backend.
2026-11-05 02:31:14 +01:00
Ludovic Marie d35137a08a Merge pull request #9481 from vnsmoreira/fix/lava-complementary-investigation
🤖 Agent: Fluid blocks win the material vote only for their own still and flowing sprites. Voting for every other sprite is unchanged, and the new test covers the shared fluid sprite.
2026-11-05 02:31:10 +01:00
Ludovic Marie e1d3361a55 Merge pull request #9468 from CocofireHD/fix/9455-dualsense-hid-crash
🤖 Agent: Any hidapi error now drops the output handle, so hid_write is never called on a handle whose pad has gone. Input reading is untouched.
2026-11-05 02:31:06 +01:00
Ludovic Marie 8aaec9379b Merge pull request #9466 from CocofireHD/fix/9088-management-protocol-timeout
🤖 Agent: The harness now fails on a server that is still up after server/stop (the #8245 regression the issue names), and it runs the same check on Windows paths.
2026-11-05 02:31:02 +01:00
Ludovic Marie cd62fe430a Merge pull request #9460 from loicpiller/bugfix/9089-peaceful-summon-zombie-horse-health
🤖 Agent: At midnight monsters_burn is false, so isSunBurnTick returns before its RNG draw on both sides. The zombie horse reference stops being random, and the comparison itself is unchanged.
2026-11-05 02:30:59 +01:00
Ludovic Marie f99c7f026a Merge pull request #9578 from Cheezer1656/native/client-entity-refs
🤖 Agent: On a client, entity handles read only what the server synced. Writes (set_velocity, merge_nbt, tags) return false, and a handle from another level's epoch reads None, so a client copy changes only when the server says so.
2026-11-05 02:30:54 +01:00
Ludovic Marie eaf8caba79 Merge pull request #9575 from Vaspyyy/labpbr-map-budget
🤖 Agent: With the default texture budget, map_bytes is still exactly 64 MiB, so nothing changes until a player raises textureBudgetMiB. The unit test pins both cases.
2026-11-05 02:30:50 +01:00
Ludovic Marie f31d71f9e3 Merge pull request #9568 from Cheezer1656/feat/world-primitives-hook
🤖 Agent: The primitives get their own pipeline at the end of the world pass, before the hand. With no subscribing mod the batch is empty and prepare() returns before any upload or draw.
2026-11-05 02:30:47 +01:00
Ludovic Marie 24c3886a70 Merge pull request #9567 from Cheezer1656/feat/mod-entity-outline
🤖 Agent: native_entity_outline starts from vanilla's own answer (team colour or white) and costs nothing without a listener; masking above 24 bits keeps the colour in range.
2026-11-05 02:30:43 +01:00
Ludovic Marie f748e51444 Merge pull request #9560 from degepie/fix/lenient-lang
🤖 Agent: Language.loadFromJson parses with a lenient Gson reader in 26.3, so packs with comments and unquoted keys now load their lang files instead of being dropped silently.
2026-11-05 02:30:38 +01:00
Ludovic Marie 9e86daa984 Merge pull request #9549 from Vaspyyy/fix/8049-entity-team
🤖 Agent: Team joins happen in remember_uuid, which every entity load goes through. An entity loaded without a UUID gets one first, since the scoreboard keys entities by UUID, as Entity.load does.
2026-11-05 02:30:35 +01:00
Ludovic Marie 662dafce77 Merge pull request #9548 from Vaspyyy/fix/8048-hoglin-cannot-be-hunted
🤖 Agent: The hoglin reads CannotBeHunted and writes it back only when set (Hoglin.addAdditionalSaveData), so bastion hoglins from the template are no longer hunted by their own piglins.
2026-11-05 02:30:31 +01:00
TheMrZZandCodex gpt-6.1-sol 0722c2498b Merge current main while preserving piston read coverage
Keep the upstream piston entity-tick-order group and the independent
live-piston presentation diagnostic.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 02:30:12 +01:00
TheMrZZandCodex gpt-6.1-sol 72535840fc Expose live moving-piston NBT to read commands
Read the authoritative tick entry with command metadata and source-aware
pretty ordering. Preserve default output and reject unsupported writes.
Cover the original pulse, a normal sticky pull, and scoped diagnostics.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 02:29:43 +01:00
Ludovic Marie 4724fc5d4f Merge pull request #9580 from TheMrZZ/fix/h307-named-smoke-predicate
🤖 Agent: The named predicate now uses the existing campfire smoke helper (CampfireBlock.isSmokeyPos: lit tagged campfire, first-blocker exception), and the real game command harness covers it.
2026-11-05 02:25:41 +01:00
Ludovic Marie db3193f31b Merge pull request #9556 from rison1337/native-gui-render-post
🤖 Agent: gui_render_post has a read-only host and draws nothing without a subscriber; the fragment is pending, so the API number is assigned at build time.
2026-11-05 02:25:37 +01:00
Ludovic Marie 841076d774 Merge pull request #9554 from gigamans442/fix/callable-macro-boundary-20261104
🤖 Agent: The replacement is rescanned together with the following source, as the C preprocessor does, and per-token hiding still stops recursion.
2026-11-05 02:25:33 +01:00
Ludovic Marie 63b5c2bbf7 Merge pull request #9545 from gigamans442/fix/terrain-chunk-fade-20261104
🤖 Agent: mc_chunkFade is injected only when the renderer supplies it and the pack's vertex program reads it, so packs that don't ask compile as before. The disclosed inputs are an MIT pack's API names only.
2026-11-05 02:25:28 +01:00
Ludovic Marie 36070a818e Merge pull request #9543 from JohnUzoka/perf/worldgen-noise-fill
🤖 Agent: The values are bit-identical (tabulated perlin corners, 4-column interpolation rows, branch-free range choice), and your noise snapshots compare byte for byte.
2026-11-05 02:23:14 +01:00
Ludovic Marie 5c2322455b Merge pull request #9541 from JohnUzoka/perf/worldgen-surface-pass
🤖 Agent: The surface pass gives the same blocks: positive_y_bounds only skips bands where the vein density is provably <= 0, and the material_condition probe covers the column reads.
2026-11-05 02:23:10 +01:00
Ludovic Marie a7e2844af7 Merge pull request #9538 from Vaspyyy/fix/8055-sound-ids
🤖 Agent: The three new ids exist in 26.3's sounds.json and SoundEvents, and the old ones were silent. Only the event names change.
2026-11-05 02:23:06 +01:00
Ludovic Marie 0201bf30db Merge pull request #9537 from JohnUzoka/fix/in-bed-chat-keyboard
🤖 Agent: Only the in-bed chat is left out of pad_auto_keyboard. Opening chat by hand still brings up the on-screen keyboard.
2026-11-05 02:23:02 +01:00
Ludovic Marie 5bbd72231e Merge pull request #9535 from JohnUzoka/feat/pad-nintendo-letters
🤖 Agent: nintendoLetters is off by default, and splitscreen's join prompts still go by position, so no existing pad layout changes.
2026-11-05 02:22:58 +01:00
Ludovic Marie d59b1e6dfc Merge pull request #9533 from JohnUzoka/fix/leave-bed-focus
🤖 Agent: Leave Bed is now recorded as a widget, so pad focus, the D-pad and Tab all reach it. The drawing is unchanged.
2026-11-05 02:22:53 +01:00
Ludovic Marie 21f674bce9 Merge pull request #9514 from TheMrZZ/fix/command-fixture-floor-isolation
🤖 Agent: Tooling only. Clearing the floor to air right before the exact stone fill makes the 256-block guard independent of the scenarios that ran earlier.
2026-11-05 02:22:48 +01:00
Ludovic Marie b1cf1085ba Merge pull request #9499 from degepie/fix/data-merge-vehicles
🤖 Agent: /data merge and /data modify now reload minecarts (the new Minecart::load) and boats, which matches EntityDataAccessor.setData running Entity.load on every entity but a player.
2026-11-05 02:22:44 +01:00
Ludovic Marie c369c0d07f Merge pull request #9498 from degepie/fix/minecart-display-offsets
🤖 Agent: MinecartChest.getDefaultDisplayOffset is 8 in 26.3. The exhaustive match over CartKind makes any new kind choose its own offset.
2026-11-05 02:22:40 +01:00
Ludovic Marie b9c47ac7d9 Merge pull request #9497 from degepie/fix/loot-location-check
🤖 Agent: Biome tags now load with the same pack overlay as the other loot tags, and location_check reads biomes through holder_set (#tag, one id or a list), the HolderSet codec LocationPredicate uses.
2026-11-05 02:22:36 +01:00
Ludovic Marie 033bd81cff Merge pull request #9496 from degepie/fix/item-component-bare-keys
🤖 Agent: keyed() compares full(key) for the four keys item predicates read, and the potion_contents value is normalised the same way, as the registry codecs behind ItemPredicate do.
2026-11-05 02:22:32 +01:00
Ludovic Marie af0ad4003a Merge pull request #9495 from degepie/fix/chicken-egg-event
🤖 Agent: This matches Chicken.aiStep: the sound and entity_place come only after a drop. One small RNG note: the pitch floats are still drawn before the loot roll, which only differs for a data pack that empties chicken_lay.
2026-11-05 02:22:29 +01:00
Ludovic Marie 8572e1a893 Merge pull request #9494 from degepie/fix/entity-nbt-predicate
🤖 Agent: No vanilla advancement has an nbt or reference predicate, so mob_to_nbt only runs for data packs that ask for it (Tree::index_nbt). player_entity_for now takes &mut self; #9561 and #9562 are stacked on that.
2026-11-05 02:22:25 +01:00
Ludovic Marie 9d48f1a3ce Merge pull request #9463 from TheMrZZ/fix/h299-mob-rider-navigation
🤖 Agent: riders::tick returns at once for a mob with no mount, so lending the vehicle's navigation costs nothing outside ridden vehicles. #9597's branch also carries this head (their oracles meet in the same switch).
2026-11-05 02:22:20 +01:00
Ludovic Marie 95dc9bddb8 Merge pull request #9458 from voidsipp/fix/piston-entity-tick-order
🤖 Agent: Piston block entities now tick after entity travel, the order ServerLevel.tick uses (entities, then blockEntityTicks). The new 6-tick wither skeleton fixture covers both stone and slime, and it matches all 24 observations.
2026-11-05 02:22:15 +01:00
rison1337andCodex GPT-6 5b1505425b Native mods: client Host services and lifecycle events
Expose owned client/window, entity/head and profile snapshots, GUI painting, persisted mod bindings, local action bars and older-server opt-in through Host. Add startup, disconnect, shutdown and menu-background notifications without changing default gameplay.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 06:19:22 +05:00
KBroneZandClaude Sonnet 5.5 b97abf48b4 docs: fix the block count in the climbing test fixture comment
The fixture lists three non-trapdoor blocks (bridge, pier, odd), not four.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-05 03:14:56 +02:00
Ludovic MarieandClaude Opus 5.5 ed84f8c172 Merge #9597 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:14:04 +01:00
Ludovic MarieandClaude Opus 5.5 bfe4d37504 Merge #9463 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:13:47 +01:00
KBroneZandClaude Sonnet 5.5 fad60b1924 fix: a mod block tagged as a trapdoor without open or facing no longer stops the start
ClimbingRegistry::new returned "invalid trapdoor open state" (or "invalid
climbing facing") for any state of a block in minecraft:trapdoors that lacked
a valid open or facing property. Macaw's Bridges adds blocks that are not
trapdoors (rail bridges with facing/powered, a pier with no properties) to
minecraft:wooden_trapdoors, so All the Mods 10 and Craftoria exited before the
window opened.

Vanilla's LivingEntity.onClimbable tests `instanceof TrapDoorBlock`, not the
tag, so such a block is not a trapdoor and never climbs. A tagged state now
counts as an open trapdoor only with facing and open=true; otherwise it is
skipped. The ladder's facing check is unchanged.

Fixes #9585

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-05 03:07:28 +02:00
Ludovic MarieandClaude Opus 5.5 3da7f7a2a5 Merge #9562 onto #9561 and #9494
Both #9494 and #9562 index advancement triggers (nbt, player facts) and widen
the player's view: kept both. player_entity_for adds the compound on &mut self;
player_entity_for_any (#9562) stays &self for its &self callers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:59 +01:00
Ludovic MarieandClaude Opus 5.5 af07813fe1 Keep the killer's view on &self after #9494
#9494 makes player_entity_for take &mut self to build the player's compound;
guest_entity_view (#9561) is &self, so it reads what the player looks at only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:39 +01:00
Ludovic MarieandClaude Opus 5.5 d4c48baf8c Merge #9561 onto #9494
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:32 +01:00
VaspyyyandClaude Opus 5.5 25b8c5d099 Collision scan: pass the cells of missing chunks and empty sections a row segment at a time
A far move (one move packet thousands of blocks away) made the server's
collision sweep walk every cell of the swept box, one query per cell:
a 2.3 s tick for a hop of 8,000 blocks, growing with the square of the
distance.

Two opt-in provider hooks let the scan pass, after one query, the rest of
a row inside the same chunk (at most 16 cells):

- missing_chunks_stay_missing: a chunk missing once stays missing for the
  scan (SceneCollisions, EntityCollisions, EmptyCollisions).
- section_is_empty: no state in the section's palette has a collision
  shape (SceneCollisions, cached per section in the scan).

Both default to false, so the parity probe, which records every query,
sees the same calls. Skipped cells yield nothing, so the collisions and
their order are unchanged.

Fixes #9594

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:19 +01:00
Ludovic MarieandClaude Opus 5.5 bd8d2916b7 Merge #9588 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:07 +01:00
Ludovic MarieandClaude Opus 5.5 793f2ead2b Merge #9552 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:07 +01:00
Ludovic MarieandClaude Opus 5.5 7ae746e633 Merge #9550 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:07 +01:00
Ludovic MarieandClaude Opus 5.5 080fd9da02 Merge #9531 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:06 +01:00
Ludovic MarieandClaude Opus 5.5 76f1c176f1 Merge #9516 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:06 +01:00
Ludovic MarieandClaude Opus 5.5 870e7b1cdd Merge #9513 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:06 +01:00
Ludovic MarieandClaude Opus 5.5 e45e23fc36 Merge #9482 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:05 +01:00
Ludovic MarieandClaude Opus 5.5 4e32a8ba35 Merge #9478 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:05 +01:00
Ludovic MarieandClaude Opus 5.5 f889e41fee Merge #9503 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:05 +01:00
Ludovic MarieandClaude Opus 5.5 de275afd6d Merge #9474 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:04 +01:00
Ludovic MarieandClaude Opus 5.5 08db1a74b8 Merge #9462 into the command-exec stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:05:04 +01:00
Florian ERNSTandCodex cdd00e29a3 Place boat items at the exact server POV hit point
Co-Authored-By: Codex <[email protected]>
2026-11-05 02:01:51 +01:00
Ludovic MarieandClaude Opus 5.5 d687fe6cad Merge #9515 (worker-side widths reduction) into the detail budget
Both add a test at the end of lod's tests; kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:58:17 +01:00
Ludovic MarieandClaude Opus 5.5 a97b284ddb Studio: drop a needless return (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:56:49 +01:00
Ludovic MarieandClaude Opus 5.5 530635f250 Format the LabPBR budget test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:53:01 +01:00
AxxioandAntigravity 57c29198e5 launcher: name the mod in E_MODULE_CONFLICT and log conflicting pins (fixes #9391, #9016, #9414)
Managed, architected and reviewed by Axxio.

Co-Authored-By: Antigravity <[email protected]>
2026-11-05 01:52:07 +01:00
Ludovic MarieandClaude Opus 5.5 8944278cdb Merge origin/main into fix/9284-server-entity-sounds
dispense_fixture: main's event_sound lines with this branch's CapturedSound fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:50:03 +01:00
JanandClaude Sonnet 5.5 f9e5774b2c Native mods: Host::send_map, the drawn-map sender (native_send_map) as a Host helper
native_send_map (API 132) sends a 128 x 128 map picture to a player but was
not in the helper catalog, so a trait mod could not call it. One catalog
fragment adds it to Host as send_map(uuid, map_id, colors) -> bool.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-05 01:44:46 +01:00
Ludovic Marie b368b50c60 Merge pull request #9577 from xxaxio/fix/9505-dx12-terrain-flat-fxc
🤖 Agent: binding the slot origin to a let first matches vs_section_flat_opaque/cutout; it is compatible with #9584's packed_corner change, which fixes the other X3500 site in the same shader.
2026-11-05 01:40:01 +01:00
Ludovic Marie 1936541d03 Merge pull request #9584 from DUzzL/fix/9551-fxc-terrain
🤖 Agent: checked the select() rewrite of packed_corner: rel = (i - axis) mod 3 gives the axis component +side, the next +du, the last +dw, the same values as the old indexed stores, with no dynamic l-value for FXC.
2026-11-05 01:39:56 +01:00
Mayonesu0andClaude Opus 5.5 7975ff7942 Launcher MCP: reach a game started from its window with Agent Control on
The launcher's game_* tools only looked at the endpoint in its data folder,
so a game the player started, which listens on the game's default endpoint,
answered "not running under agent control". Each call now goes to the first
endpoint where a game answers, the launcher's own first, restarting the
horizon-ctl child when that changes: an installed horizon-ctl keeps the
endpoint it started with.

Whether a game answers is now tested on Windows too, with the endpoint's port
and token, rather than by the file existing: a crashed game leaves the file.

Fixes #9465

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:39:00 +01:00
Florian ERNSTandCodex 194324b652 Support live item component contents slot sources
Co-Authored-By: Codex <[email protected]>
2026-11-05 01:34:11 +01:00
Ludovic Marie 0d0bf67a24 Merge pull request #9436 from xxaxio/fix/8689-change-instance-icon
🤖 Agent: Queued. The home icon and the new ⋯ Edit instance item open the existing edit dialog. In the palette, the instance's saved picture stays offered after another icon is picked, so the player can go back to it without choosing the file again. Both new labels already have translations in launcher/lang.
2026-11-05 01:32:57 +01:00
Ludovic Marie 422ad93339 Merge pull request #9518 from xxaxio/fix/9507-launcher-join-button-overlap
🤖 Agent: Queued. The invite field's right padding now follows the Join label's width (14 px padding each side, about 8.5 px per character at 14 px), so the longest translations (Bulgarian and Latvian, 15 characters) get 175 px instead of 84 and the button no longer covers the code. In the wide layout the field grows by the same amount (340 to 431 px at most), which comes out of the row's flexible gap. English stays at 84 px.
2026-11-05 01:32:53 +01:00
DUzzL 69f4bdb829 Seat each happy ghast passenger at its own attachment point 2026-11-05 01:29:29 +01:00
Ludovic Marie 1f2658e5a5 Merge pull request #9487 from xxaxio/fix/9438-container-loot-tables
🤖 Agent: Queued after a small fix on the branch. On main, barrels, shulker boxes, hoppers, dispensers and droppers already load as BlockEntity::Chest, so #9189 already covered them. This PR's real change is that a LootTable id the codec rejects no longer counts as a table, which matches vanilla 26.3's tryLoadLootTable (KEY_CODEC read, then null). I folded the four copied tests into one table test and made roll_loaded_loot read the id the same way, so an invalid table with no items rolls nothing. Desktop run: clippy -D warnings clean, horizon-client-core 63 tests, horizon-client 895 tests, horizon-client-lib 1718 tests.
2026-11-05 01:29:18 +01:00
Ludovic Marie b8d113b761 Merge pull request #9251 from horizon-rust/pvp/preset
🤖 Agent: the preset only sets PvP & HUD options and vanilla's toggle-crouch, and turns on the official fps/keystrokes mods only if they are installed. Verify passes at 41c0e4080 (the stack's top: workspace clippy -D warnings clean on the desktop).
2026-11-05 01:29:14 +01:00
Cheezer1656 5ea6d5415e Merge remote-tracking branch 'upstream/main' into feat/world-primitives-hook
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-04 17:27:48 -07:00
Ludovic MarieandClaude Opus 5.5 604991a5c9 save: one table test for the randomizable containers, and roll_loaded_loot reads the table as tryLoadLootTable does
The barrel, shulker, hopper, dispenser and dropper copies of the chest test
become one table-driven test. A loaded container whose LootTable the codec
rejects now rolls nothing, as vanilla keeps no table for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:27:21 +01:00
Ludovic Marie 16ea866df9 Merge pull request #9250 from horizon-rust/pvp/chat
🤖 Agent: after martipops's review, the chat search puts the typed text aside and gives it back on every exit path, a compact repeat keeps a reader's scroll, and search and copy skip legacy § codes (tests for the last two). Compact chat still never merges signed messages. Verify passes at 4984cc061.
2026-11-05 01:26:42 +01:00
Ludovic Marie 763e220a0f Merge pull request #9249 from horizon-rust/pvp/view-time-weather
🤖 Agent: the own time of day and weather change only what this client draws (sky, lightmap, rain); the world's day_time and weather, and everything that plays by them, stay the server's, and nothing is sent. Off by default. Verify passes at cd788cd4c.
2026-11-05 01:24:08 +01:00
Ludovic Marie c8e787810c Merge pull request #9248 from horizon-rust/pvp/visual-tweaks
🤖 Agent: the visual tweaks (low fire, clear water/lava fog) are off by default and stop at the clearest view vanilla itself gives. docs/pvp.md now says they can matter on strict servers (from martipops's review on #9250). Verify passes at d2dafa504.
2026-11-05 01:21:44 +01:00
Ludovic Marie 8db0b47cff Merge pull request #9247 from horizon-rust/pvp/numeric-enchants
🤖 Agent: numeric enchantment levels change only the tooltip's displayed numeral (off by default); the enchantment data and the rest of the tooltip stay vanilla. Verify passes at 6eae1c73b.
2026-11-05 01:19:19 +01:00
Ludovic Marie 7298c80f8f Merge pull request #9246 from horizon-rust/pvp/hud-info
🤖 Agent: after martipops's review, the combo counts a hit only when the server's damage event names this player as the direct entity (spear jabs included, teammates' hits and fire ticks not), the off server widget no longer allocates each frame, and ping shows nothing before the first latency update. Verify passes at 3842a7aee.
2026-11-05 01:16:56 +01:00
Ludovic Marie aff293b08a Merge pull request #9245 from horizon-rust/pvp/hud-items
🤖 Agent: the armour, held-item and effects widgets read only what the client already holds, and the effects widget replaces vanilla's icons only while it is on (all off by default). Verify passes at f9f1b5abf, with main 6319cb701 merged in; no harness is touched, so parity-main covers it.
2026-11-05 01:14:28 +01:00
TheMrZZandCodex gpt-6.1-sol 9d95fe645b Use campfire smoke rules in named location predicates
Delegate the predicate view to the existing lit and occlusion-aware smoke
helper. Cover both predicate signs, callback stores, slab exceptions and
positive world guards through the real command runtime.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 01:10:40 +01:00
DUzzL ae257923c1 Fix FXC X3500 in terrain shader: no dynamic vector component stores 2026-11-05 01:10:06 +01:00
KBroneZandClaude Sonnet 5.5 10c3813399 fix(port-pack): read version and loader from a CurseForge app instance
Importing a CurseForge app instance folder left minecraft, loader and
loader_version empty, because instance() read them only from
mmc-pack.json. Fill them from minecraftinstance.json (gameVersion,
baseModLoader.name), falling back to manifest.json via the existing
curseforge() parser. minecraftinstance.json wins: it records what the
instance runs, while manifest.json is the pack author's copy and goes
stale when the player changes version or loader in the app. A Fabric or
Quilt id's trailing Minecraft version is not part of the loader version.
Missing or broken files never fail the import; mmc-pack.json behaviour
is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-05 02:07:02 +02:00
MegaBytesMe 4631d96891 Merge remote-tracking branch 'upstream/main' into codex/issue-9408-launcher-arch-choice
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-05 00:01:36 +00:00
AxxioandAntigravity af3026b999 gfx: bind flat terrain slot origin to local variable for FXC (fixes #9505)
In vs_section_flat (crates/horizon-client-render/src/terrain.wgsl), passing the nested dynamic storage buffer expression slot_origins[block_slots[s.slot >> 8u]] directly into corner_vertex(s.c, ...) causes Direct3D 12's HLSL compiler (FXC for SM 5.1) to fail during 'Terrain solid (flat)' pipeline creation with: error X3500: array reference cannot be used as an l-value; not natively addressable.

Like vs_section_flat_opaque and vs_section_flat_cutout, bind the sampled origin to an intermediate local variable first, letting naga emit a load into a temporary r-value before the function call.

Co-Authored-By: Antigravity <[email protected]>
2026-11-05 01:01:18 +01:00
GabrielNichols 99035b09ad fix(launcher): attribute injected-helper compiler errors 2026-11-04 20:59:20 -03:00
Claude ce86945277 Size LabPBR maps from the texture budget
LabPBR normal and specular maps had a fixed 64 MiB budget, so with about
1300 block layers they always loaded at 64 px, however high
textureBudgetMiB was set. They now get a quarter of the current budget:
the default budget keeps the old 64 MiB, and 1536 MiB gives 256 px maps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QPo9BpqkYWfY6y5Nwu
2026-11-04 23:55:03 +00:00
Cheezer1656andClaude Opus 5.5 9002abcdea Native mods: entity handles and menu stacks on a client
native_entity and the entity_*/living_* reads now work against the
entities the server shows the window (own player, other players, mobs,
armor stands with their custom names and equipment, tracked-only
entities by type and position); a fresh epoch per client level keeps
handles from crossing levels or sides. Writes are refused and saved
data and tags read as None on a client. native_menu_stacks gives the
open server menu's stacks, with components, by server slot number.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:54:14 -07:00
Ludovic MarieandClaude Opus 5.5 41c0e40804 Merge 4984cc061b into pvp/preset
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:48:29 +01:00
Ludovic MarieandClaude Opus 5.5 4984cc061b Merge cd788cd4c7 into pvp/chat
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:48:29 +01:00
Ludovic MarieandClaude Opus 5.5 cd788cd4c7 Merge d2dafa504c into pvp/view-time-weather
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:48:28 +01:00
Ludovic MarieandClaude Opus 5.5 d2dafa504c Merge 6eae1c73b4 into pvp/visual-tweaks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:48:28 +01:00
Ludovic MarieandClaude Opus 5.5 6eae1c73b4 Merge 3842a7aee2 into pvp/numeric-enchants
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:48:28 +01:00
Ludovic MarieandClaude Opus 5.5 3842a7aee2 Merge f9f1b5abfc into pvp/hud-info
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:48:27 +01:00
Ludovic MarieandClaude Opus 5.5 f9f1b5abfc Merge 6319cb701e into pvp/hud-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:48:27 +01:00
Ludovic Marie 6319cb701e Merge pull request #9244 from horizon-rust/pvp/outline-hit
🤖 Agent: block outline and hit colour stay render-only and off by default; verify and parity pass at 4adfac92b. The composter fix #9508 is merged in. pack_screen's failures came from the playhorizon runner missing 142 vanilla lang files: the files were copied there and it passes now.
2026-11-05 00:48:10 +01:00
Ludovic Marie 11aa3b57ce Merge remote-tracking branch 'origin/main' into cg41/9487 2026-11-05 00:46:04 +01:00
Cheezer1656andClaude Opus 5.5 9897711a34 Native mods: world primitives hook (lines, boxes, beams, text)
native_world_primitives runs once per world frame and hands a client mod a
batch of world-coordinate primitives: lines with a width, box outlines and
filled boxes in ARGB, beacon-style beams and camera-facing text in the game
font (optionally scaling with distance). Each can be seen through walls.

The game tessellates them camera-relative and the renderer draws them with
its own pipelines (gfx/world_primitives) at the end of the world pass,
after the entities and before the first-person hand: the world's reversed
depth, draw resolution and TAA jitter. A GPU test shows depth-tested quads
and lines hidden behind a wall while see-through ones show.

Closes #9555

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:41:21 -07:00
Cheezer1656andClaude Opus 5.5 a23690ed84 Native mods: force or remove an entity's glowing outline; budget visuals per mod
native_entity_outline(entity_id, kind, outline) lets a client mod make any
mob, player, dropped item or projectile glow in its own colour, or take the
outline away (Fabric/NeoForge isCurrentlyGlowing and team colour overrides).
Vanilla's answer comes in; only what the client draws changes.

native_visuals no longer shares 8 rims and 256 edges between all mods: each
trait mod is dispatched on its own and gets 256 rims, 4096 rim edges and
8 lights. A panic in a mod dispatched alone still names the right mod.

Closes #9553

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:40:08 -07:00
AxxioandAntigravity d9869348a5 launcher: auto-detect Minecraft 26.3 installs from Dawn Client (fixes #9547)
Dawn Client stores its official vanilla Minecraft assets, libraries, and client jar in .dawn/cache/minecraft/ rather than the default .minecraft directory.

Add Dawn Client default folders to known_folders, include cache/minecraft in find(), resolve Dawn sync/instance directories upwards to the launcher root in resolve(), and update troubleshooting advice.

Co-Authored-By: Antigravity <[email protected]>
2026-11-05 00:37:16 +01:00
rison1337andCodex GPT-6 f48484a37a Native mods: final GUI overlay and current-frame camera
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 04:31:17 +05:00
Florian ERNSTandCodex 6077d43cc9 Round-trip generic mob home NBT through live state
Co-Authored-By: Codex <[email protected]>
2026-11-05 00:28:10 +01:00
TheMrZZandCodex gpt-6.1-sol 5639d42a34 Handle boat callers in execute on leasher
Read the boat's existing resolved leash state and reuse the holder
conversion already used for mobs. Keep pending saved UUIDs separate.

Cover pending, live and unleashed contexts through the real Game command
path, with typed subjects, callbacks, stores and measured clocks. Retain
movement as a separate raw diagnostic and isolate the harness directory.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 00:21:42 +01:00
degepieandClaude Opus 5.5 5b94ce351c Advancements: a guest's death triggers read the blow before the death message
The death message takes the combat record's entries and leaves them
empty, so entity_killed_player and kill_mob_near_sculk_catalyst, which run
after it in guest_die, saw no killing blow while show_death_messages was
on: any killing_blow condition failed. ServerPlayer.die reads the
CombatTracker for its triggers before recheckStatus clears it at the end,
so the blow is now taken before the message is built.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 04:13:33 +05:00
gigamans442andCodex 53aeeaff45 Preserve recursive hiding through empty paste operands
Keep the nonempty operand's token metadata when pasting with an empty operand.
Original GCC/Clang controls and the pristine-main control retain the following
call's parentheses; add that regression to the boundary suite.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-04 18:13:10 -05:00
dfrnochandClaude Opus 5.5 77c059b347 Resource packs: an animated item sprite outside item/ and block/ shows one frame in the GUI
A pack's items or blocks atlas can list its own folders (Wynncraft's `wynn/...`). Their animated
strips were packed whole, so a 16x32 two-frame item drew both frames squashed into its slot.
They now keep their first frame and animate, as vanilla does for every sprite of those atlases;
a still texture there stays whole.

Fixes #9540.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:12:00 +01:00
VaspyyyandClaude Opus 5.5 13766ed75d Entities join the team their Team key names when they load
Entity.load: a string Team adds the entity's UUID to that scoreboard team
when it exists, so /summon ... {Team:"red"} and structure or data pack
entities join it. Team is only read, as in vanilla, so the kept compound
drops it.

Fixes #8049

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:09:32 +01:00
VaspyyyandClaude Opus 5.5 f5af9d2d45 Hoglins keep CannotBeHunted, and piglins don't hunt the ones that have it
Hoglin.readAdditionalSaveData/addAdditionalSaveData keep the CannotBeHunted
flag (a bastion's hoglins are placed with it), and Hoglin.canBeHunted is
false for them: PiglinSpecificSensor never picks them as prey and a hit
from one doesn't rally the piglins around.

Fixes #8048

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:09:30 +01:00
gigamans442andCodex 47684e0f4a fix(shaders): route terrain fade through pulled geometry vertices
Initialize the implicit input per source vertex and isolate it from geometry-stage globals, with original paired and GPU controls.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-04 18:08:03 -05:00
John Uzoka 5ee6d9da54 Controllers: a Nintendo pad can go by its letters (A confirms)
A Nintendo pad's face buttons are lettered the other way round from an Xbox pad's: A sits on
the right and B at the bottom. Going by position (SDL's layout, the default), B confirms and
jumps and A goes back, while the Switch's own menus and keyboard confirm with A.

PadSettings::nintendo_letters (`nintendoLetters` in horizon-controller.txt; off unless set, on
in a build for the Switch itself) reads a Nintendo pad's face buttons by their letters: A plays
South (confirm, jump), B East (back, drop), X West, Y North (Button::by_letter,
PadState::by_letter). It applies to a player's pad state, read from the pad or fed by
splitscreen's router, before horizon-ctl's virtual pad is merged; splitscreen's join and
lost-controller prompts, which a controller nobody holds yet answers, stay by places. Bindings
still name the part, so they mean the same either way. With Nintendo glyphs, the binds list and
the on-screen keyboard's hints name the button that plays each part (OptionsUi::pad_by_letter).
docs/controllers.md describes the setting.

Test: horizon-client-input gamepad::tests::a_nintendo_pad_by_its_letters_confirms_with_a, and
the settings round trip.

(cherry picked from commit fb2490c02, reworked onto main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:37 -06:00
John UzokaandClaude Opus 5.5 e4d981d3d5 Controllers: Leave Bed takes the focus on the in-bed chat
On the in-bed chat (InBedChatScreen) the pad and the keyboard could only reach the chat box:
Leave Bed was drawn with screen_draw::button, which registers no widget, and focus only lands on
what a frame registers (key_focus::drawn_targets). Game::leave_bed_draws records it as a button
at the bounds the click checks, so the pad's focus starts on it (pad_first_target's first
button), D-pad up from the chat box reaches it, the keyboard's Tab reaches it as vanilla's does,
and A or Enter wakes the player.

Test: game::chat::tests::leave_bed_is_a_focus_target_above_the_chat_box.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:35 -06:00
John UzokaandClaude Opus 5.5 0d2fb5307e Controllers: the in-bed chat doesn't open the keyboard by itself
A sleeper gets InBedChatScreen, a chat screen the game opens on its own. With a pad in use,
pad_auto_keyboard opened the on-screen keyboard over it (Steam's on a Steam Deck) as if the
player had opened chat to type, every time the player lay down. The keyboard now waits for A
on the chat box there; chat the player opens still brings it up. docs/controllers.md says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:32 -06:00
John Uzoka fe47a52ef0 Storage: packed reads and writes inline into other crates
PackedStorage::read and write are the per-block word accesses of section
fills and lookups in horizon-world (BlockSection::set and fill_run, the
terrain fill's solid runs, surface column reads). Without #[inline] each
was an out-of-line call across the crate boundary that recomputed the
slot; inlined, the caller's loop keeps the slot arithmetic and word
access in place. Behavior is unchanged.

(cherry picked from commit 680cabdba245cd9c373fde94164377353c952785)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:30 -06:00
John Uzoka 2dbfdf9faf Density: a bulk range choice selects with a branch-free loop
The in-range/out-of-range merge stored the out-of-range value only where
the selector was outside the range, a conditional store LLVM leaves as a
branch per value (unpredictable for noise selectors). It now writes
every value as a select between the in-range and out-of-range samples,
which vectorizes; the selected values are the same.

(cherry picked from commit 5434c4113f94d6fa98a56f581eea1f8d27ac1352)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:30 -06:00
John Uzoka 671e36dd9e Density: interpolation fills a cell's four-column rows side by side
A block volume's interpolated values come from repeated addition down
each column, one dependent chain per column. A cell row four columns
wide (every row of a 4-wide cell inside the volume) now advances its
four chains together, writing each column's value before adding its
increment, so their additions overlap instead of waiting on each other.
Each column keeps the same operations in the same order. Corner samples
are read through precomputed column offsets instead of a checked 3D
index per corner. Partial rows, and rows whose columns fail validation,
keep the one-column-at-a-time loop and its error order.

(cherry picked from commit 11964a2c3ed6dc8752ef46f3a05ed27d0c36602d)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:30 -06:00
John Uzoka 5f39ba3424 Noise: a perlin volume tabulates its corner terms when y cells change often
A column's corner XZ dot products and Y gradients depend on the y cell
only through each corner's gradient index. When a volume's y samples
change cell at least four times, each column tabulates the sixteen
gradients' terms once (the same operations as before) and every cell
change reads them, instead of loading three gradient components and
computing two products per corner. The per-y cells, fades and offsets
are kept as separate lanes. High-frequency octaves (the top ten of the
blended noise's limits) gain the most; values are bit-identical.

(cherry picked from commit f3bf17341351a1e02028434765195f5c252b2d24)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:29 -06:00
John UzokaandClaude Opus 5.5 2af20b88de Docs: ore veins are sampled and applied only within the y range their density can be positive
(cherry picked from commit 066ecb8b40a849cb597ad484d01fd6926a182a5d)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:27 -06:00
John Uzoka 40971a96a5 Density: a y gradient fills a volume by copying one computed column
Volumes are laid out with y contiguous in each column, so the y-axis
gradient computes the first column and copies it into the others,
instead of storing each value at a stride across every column. The
values, and the error on a zero-period gradient, are as before.

(cherry picked from commit b01ee5606dc23caea2713954a71987aa76c37a00)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:27 -06:00
John Uzoka 3872944113 Worldgen: the surface walk reads each column by section runs and keeps block kinds
A column is filled from its sections: an all-air or one-state part at
once, a mixed section from its storage. Beside each id the cache keeps
whether the block is air, fluid or solid, so the walk and its
stone-depth look-ahead no longer look the properties up per read. The
band is stepped down as the walk descends instead of searched per block,
and the column's minimum surface level is read once.

(cherry picked from commit a88b3f32dfd283741de6e149708b5325a90a4386)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:27 -06:00
John Uzoka a5fec952dd Worldgen: ore veins leave the surface bands where their density cannot be positive
An ore vein places nothing where its density is not positive, and
returns before drawing its positional random there. Rule libraries read
from the density graph where that holds (DensityGraph::positive_y_bounds:
a range choice on a clamped y gradient whose out-of-range value is a
constant <= 0, as vanilla's copper and iron veins are), so datapack
ranges are followed. The surface pass splits its y bands at those
bounds, drops each vein from the bands outside them, makes each band's
variant on first use, and does not set up a block whose variant gives
nothing. The veins' up-front densities are sampled over one shared
volume cut at the highest y a vein reaches; cutting a volume's top
leaves the values below it as they were.

(cherry picked from commit 8c101d76d544e84987274b8c01b44a0811430eb9)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:27 -06:00
John Uzoka bf29005bb2 Worldgen: the surface pass skips heightmap updates that cannot change anything
A write at or below a heightmap's first available y - 2 leaves it as it
is (Heightmap::update returns early). Each surface column now carries
that bound per stage map, as fill_noise does, refreshes it when a map
changes, and calls Heightmap::update directly above it, without the
repeated registry and ensure_heightmap checks. A missing map still goes
through set_block_worldgen, which primes it.

(cherry picked from commit 2015977dfeb33f38d64be64b9b643a3059544d2a)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:59:27 -06:00
Florian ERNSTandCodex c44b53ebaf Apply server lava-contact damage to XP orbs
Co-Authored-By: Codex <[email protected]>
2026-11-04 23:58:27 +01:00
JoansitohandClaude Sonnet 5.5 5bcabf145e Server packs: a reload names its packs by identity, so a replaced pack ends (#9528)
ServerPackManager keeps packsToLoad and packsToUnload as ServerPackData
objects; the port kept ids, and a pack pushed again with an active pack's id
was set active again after every reload: an endless resource reload.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-04 23:52:13 +01:00
VaspyyyandClaude Opus 5.5 7bcb47b1e9 Play the turtle egg break, conduit short ambient and campfire douse sounds under their 26.3 ids
Three sounds were played under ids that 26.3's sounds.json doesn't have,
so they were silent: block.turtle_egg.break (entity.turtle.egg_break),
block.conduit.ambient_short (block.conduit.ambient.short) and
block.generic.extinguish_fire (entity.generic.extinguish_fire).

Fixes #8055

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 23:50:38 +01:00
DUzzL c1e5d33c42 Use the OIT destroy-stage sprites for breaking cracks with improved transparency 2026-11-04 23:50:18 +01:00
degepieandClaude Opus 5.5 c66ca9f1f6 Advancements: the player predicate tests stats, advancements, recipes and level; inventory_changed tests its player
PlayerPredicate's stats, advancements (done or per criterion), recipes and
level were read by nothing, so criteria using them were granted to anyone.
They are now tested as PlayerPredicate.matches does. The player's records
are copied into its view only for the triggers whose criteria may ask
(Tree::player_facts_asked) and for the command predicates that name them
(facts_asked_in); a field asked of records not filled fails.
inventory_changed now also tests the criterion's player conditions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKjxbXfo4yzskjMcNq
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 03:48:28 +05:00
degepieandClaude Opus 5.5 3e0d3737b1 Advancements: a player's death fires entity_killed_player
ServerPlayer.die calls awardKillScore on the entity
getKillCredit names, which fires ENTITY_KILLED_PLAYER (KilledTrigger) for
the dying player with that entity and the death's damage source. Nothing
fired it: guest_die, every server-side player death, now does, between
the deathCount scores and the deaths statistic.

The killer lookup and the killing blow the sculk catalyst trigger built
for the same death move into shared helpers, and KilledTrigger's matching
into advancements::killed, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKfuWtefRGy97WTU4a
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 03:47:20 +05:00
gigamans442andCodex 78e6faaf23 Merge accepted main for current-base qualification
Refresh the accepted base before publication. The shader/core/backend inputs
and contribution/build rules are unchanged; rerun required checks on this head.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-04 17:45:19 -05:00
CocofireHDandopencode e8f5473926 Fire started_riding for every player on the vehicle
Entity.startRiding(entityToRide, force, sendEventAndTriggers) runs
CriteriaTriggers.START_RIDING_TRIGGER for every ServerPlayer in
entityToRide.getIndirectPassengersStream() once the rider link is set, not
only for the entity that mounted. Horizon fired the trigger at each mount
site for the acting player alone.

Route every rider-link change through Game::start_riding /
note_started_riding, which queues the vehicle and fires
trigger_started_riding for each server player aboard (directly or through a
mob rider). A guest's turn defers the pass to tick_lan, where every player is
back in place. The per-site trigger_player calls are gone; cushions,
minecarts, /ride on a mob and mobs boarding boats/carts now fire it too.

The new boarding test pins the indirect-passenger set (direct rider, rider of
a rider, and an unrelated vehicle).

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 23:43:17 +01:00
Delete228andCodex 69e6298905 Fix macOS game bundle native build companions
Refresh the native build receipt independently of the executable and expose runtime data through a relative directory link. Remove stale companions for vanilla builds and preserve unexpected app-side data on errors.

Fixes #9521

Co-Authored-By: Codex <[email protected]>
2026-11-05 01:41:13 +03:00
valkdzx cdbb5792d5 Merge remote-tracking branch 'origin/main' into perf/behaviour-snapshot-key 2026-11-05 00:37:31 +02:00
valkdzxandClaude Opus 5.5 56e06994d3 Startup: keep the behaviour and registry snapshots across rebuilds
The snapshots in .horizon-cache keyed on the executable's modification
time, so every rebuild of the game threw them away and the first start
parsed facts.json and block-behaviours.json again. They now key on a hash
of the code that resolves them (horizon-world and horizon-core sources,
their manifests, Cargo.lock, profile, target and rustflags), computed by
horizon-world's build.rs.

Fixes #9522

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:37:31 +02:00
gigamans442andCodex df1c2f8fd3 Fix callable macro expansion across source boundaries
Rescan replacement tokens with the remaining source while preserving per-token
recursion hiding through argument substitution and pasting. Recognize calls
continued after expanded names and retain their original source locations.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-04 17:33:51 -05:00
Florian ERNSTandCodex e1255d97f0 Handle unprepared data in the quad-leash regression
Keep the resource-backed physics assertions active when the fixture runs, and follow the existing optional-data convention in ordinary CI.

Co-Authored-By: Codex <[email protected]>
2026-11-04 23:29:23 +01:00
degepieandClaude Opus 5.5 c9b4417f04 Lang: read resource pack lang files leniently, as Gson does
A lang file that is not strict JSON (comments, unquoted or single-quoted
strings, raw control characters...) was dropped without a word, so a
resource pack whose lang files carry comments showed nothing. Strict files
read as before; the others go through the existing Gson lenient JsonReader
port, as Language.loadFromJson is expected to read them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMLmvSqLicupMqfESW8
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 03:29:16 +05:00
gigamans442andCodex 7e7e6dc482 fix(shaders): expose terrain section visibility to fade-aware packs
Supply mc_chunkFade from the encoded section transition and advertise FADE_VARIABLE without changing native terrain rendering.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-04 17:27:53 -05:00
Ludovic MarieandClaude Opus 5.5 4adfac92b7 Merge origin/main into pvp/outline-hit
Brings in #9508 (composter fixture fix) so parity can pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 23:27:35 +01:00
Florian ERNSTandCodex c2973c43e9 Merge main before quad-leash CI repair
Co-Authored-By: Codex <[email protected]>
2026-11-04 23:25:22 +01:00
AxxioandAntigravity 882149912a launcher: dynamic right padding for friend invite code input (fixes #9507)
In localized versions of the launcher where the 'Join' button has a longer label (such as French 'Rejoindre', German 'Beitreten', Ukrainian 'Приєднатися'), the static right padding of 84 px caused the button to overlap the placeholder text and entered digits.

Compute right padding dynamically based on localized button text length and adapt the container width on wide screens accordingly.

Co-Authored-By: Antigravity <[email protected]>
2026-11-04 23:24:15 +01:00
Florian ERNSTandCodex c0f778ae98 Widen float constants in experience-orb underwater movement
Preserve the double operations around vanilla float drag, buoyancy and cap constants. Pin six executed Java method vectors and guard the real Game water and air tick paths.

Co-Authored-By: Codex <[email protected]>
2026-11-04 23:21:54 +01:00
Ludovic Marie 83ad384f48 Merge pull request #9508 from horizon-rust/fix/composter-fill-fixture-sound
🤖 Agent: the composter regression from #9069 was only the fixture: play_unshared kept the fill sound out of audio.capture, so the probe lost its S line on every fill; the S line now comes from level event 1500 (data > 0 = fill_success, LevelEventHandler case 1500). composter + dispenser harnesses pass on the desktop at this head; pr_ready --quick green except the nix-shell's missing uname.
2026-11-04 23:19:36 +01:00
Florian ERNSTandCodex gpt-6.1-sol 1eb6fe5cc3 Reset command fixture floors before exact fill guards
Clear the floor in loaded-cloud and nautilus fixtures before asserting that filling it changes all 256 blocks. Retain strict controls and cover fresh, partially stone, and fully stone prior states.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 23:18:52 +01:00
itarqos5andClaude Opus 5 0e96b88a53 perf(far terrain): the worker reduces the widths detail reads, not the frame
Corrects #8815, which made `ReducedColumns`'s levels lazy and said "neither
is the render thread, so no frame can stall on it". `Lod::apply_detail`
runs on the render thread and reads a reduction for every tile over the
chunk, so the first read of a width built it there: #8815 did not remove
that work, it moved it onto the frame.

A `lod_reduce` span inside the build (kept, it is what makes this
visible) separates the reduction from the column copying around it.
Flying over far terrain at lodDistance 256: of 14844 ms that `lod_detail`
spent over 140 slow frames, `lod_reduce` was 14140 -- 95% of it. The
`Arc::make_mut` copies are the other 5%.

The far terrain thread now builds the widths `apply_detail` reads before
it sends a chunk's columns on. Same world, same flight, limiter off:
average FPS 84.0 -> 164.0, 1% low 3.2 -> 77.6, p99 229.8 -> 9.3 ms,
`update` 6.4 -> 0.9 ms, slow frames 140 -> 2, and `lod_reduce` on the
render thread 14140 ms -> 0.

The widths are learned, not derived from the plan: `apply_detail` reads a
tile only where it is in `ready`, which the plan does not say, and taking
every level the plan holds would be the eager behaviour again.
`apply_detail` sets a bit as it reads, so the first chunk of a width
still reduces on the render thread and every chunk after it finds the
work done. Bits are never cleared, so the worst case is all four levels
-- the pre-#8815 cost, off the frame. `OnceLock` stays, so an unwarmed
width is still built on demand and the mask is an optimisation rather
than a correctness requirement.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-04 22:18:20 +00:00
CocofireHDandopencode 9833b68bf9 Studio .mcpr: recover a crashed recording to its last whole frame
A recording writes its packets to `<name>.mcpr.<pid>-<nanos>.tmcpr.tmp`,
but the session metadata only reached `metaData.json` inside the archive at
`finish`, so a crash left a stream nothing could identify. The writer now
writes the metadata to `<stream>.meta.json` when recording starts and flushes
the stream and the sidecar together about once a second of recording.

On the next recording in that folder, `recover_crashed_recordings` packages
each leftover stream into a valid `.mcpr` beside it, cut to its last whole
frame (the same `frame_at` definition the reader uses), stamped with the
sidecar (or defaults when there is none) and dated from the stream when it
has no metadata. A stream whose writer may still be running is skipped (own
pid, `/proc` on Linux, or the exclusive lock a live writer holds), and a
stream with no whole frame is kept with its metadata and logged rather than
deleted.

Part of #9434 (items 2-5 follow).

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 23:16:56 +01:00
Ludovic MarieandClaude Opus 5.5 441a9ea0fb PvP chat: search keeps the typed text; repeats keep the scroll; no § codes
Review on #9250 (martipops's agent):
- Ctrl+F puts the box's text aside and gives it back when the search
  stops; whichever way the chat closes during a search (Escape, death, a
  server-opened screen), the draft is that text, never the query.
- A compact-chat repeat no longer moves a reader scrolled up: only lines
  it adds do.
- Search and copy on click read the message without legacy § codes, as
  the chat shows it.
- docs/pvp.md says plainly that clear water, clear lava and a fixed time
  of day see further than vanilla at that moment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 23:15:03 +01:00
CocofireHDandopencode c34d0b2866 Text fields accept spaces while a field has focus
A focused EditBox is the screen's focus, so Space and Enter are its text (charTyped), as in vanilla. The keyboard focus the previous screen left on a widget (the Create New World tab bar, for one) no longer presses it before the key reaches the world name field. The resource-loading title shares this path, so the seed, a server's address and the rename field are fixed with it. Space still jumps in gameplay.

Fixes #9332

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 23:13:40 +01:00
Ludovic MarieandClaude Opus 5.5 896aa14a6b PvP HUD: count combo hits by the damage's attacker; no idle allocation
Review on #9246 (martipops's agent):
- The combo counts a hurt report whose direct entity is this player
  (melee and spear jabs) and ignores one that names someone else; a
  report naming no attacker (Horizon's LAN hub for player victims) still
  counts only as the answer to the last swing.
- The server-address widget copies the address only while it is on.
- The ping widget shows nothing while the tab entry still reads 0 (the
  server resends latency every 600 ticks).
- own_latency moved below remote_player_attack, whose doc comment it had
  taken.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 23:13:11 +01:00
TheMrZZandCodex gpt-6.1-sol b8fd1e1456 Honor dropped-item stack-size overrides during merges
Reuse the effective stack-limit helper for neighbour merging and keep the
transfer under the separate 64-item cap signed. Preserve age and pickup
delay updates for zero and negative transfers.

Add guarded Game command coverage for component limits, ordinary and
incompatible pairs, counter inheritance and count conservation, plus a
regression for overstacked transfers.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 23:11:58 +01:00
Ludovic MarieandClaude Opus 5.5 c03e0b9ffb Dispense fixture: report the composter fill sound from level event 1500
#9069 plays `ComposterBlock.handleFill`'s sound unshared (the client that
receives event 1500 plays it, `playLocalSound`), so the audio capture no
longer saw it and the composter harness lost its `S … composter.fill*`
line on every fill (use 570, hopper_in 266, dispense 209 differing on
main 1989b1b59). The fixture now adds that line from the level event, as
it already did for the lava fizz (1501): `data > 0` is fill_success, as
in `LevelEventHandler` case 1500.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 23:00:43 +01:00
MegaBytesMe be26de3fee Launcher: choose game architecture independently on Windows ARM 2026-11-04 21:52:40 +00:00
Florian ERNSTandCodex 1686cd755a Dispatch eligible mob leashes through quad physics
Use the live holder eligibility and preserve the float lookup, yaw and angular-friction boundaries needed by quad attachments. Keep single-spring arithmetic unchanged.

Co-Authored-By: Codex <[email protected]>
2026-11-04 22:47:11 +01:00
Delete228andCodex 002cf12bf2 Refresh lazy sync cache admission on pack reload
Update the current-overlay marker before a complete wire snapshot exists. Preserve frozen gameplay damage definitions while allowing the first no-pack wire load to be cached after all packs are removed.

Cover reuse through an absent resource root, route the existing pre-hub reload regression through the helper, and require the tenth prepared-resource case in the catalog parity harness.

Co-Authored-By: Codex <[email protected]>
2026-11-05 00:43:54 +03:00
Evrim CicekandClaude Opus 5.5 bc8eb368b0 The dragon fight takes only a living dragon, and scans legacy worlds as vanilla
findOrCreateDragon passes over a dying dragon (ServerLevel.getDragons
takes living ones). scanState makes a living dragon the fight's, and
removes it for a new one when there is no active exit portal. The
harness now runs with a player too: arriving in the End beside a dying
dragon, and after the fight is won.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:41:10 +03:00
AxxioandAntigravity 2d88181b0f save: give loot tables precedence over saved items for barrels, shulkers, hoppers, dispensers
Leave randomizable-container inventories empty when their NBT supplies a valid loot table, allowing chunk loading and command paths to retain the table. Barrels, shulker boxes (and colored variants), hoppers, dispensers and droppers now match chests and vanilla 26.3 RandomizableContainerBlockEntity behavior. Invalid or absent loot-table keys continue to preserve saved items.

Fixes #9438

Managed and reviewed by Axxio.

Co-Authored-By: Antigravity <[email protected]>
2026-11-04 22:26:43 +01:00
Evrim CicekandClaude Opus 5.5 31f4897c3e Only the End fight's own dragon wins it when it dies
EnderDragonFight keeps its dragon's UUID (dragon_uuid) and
setDragonKilled acts only for that dragon, so one summoned in the End
after the fight is won dies with 500 experience and no gateway. The
fight takes the End's dragon, or makes one, as findOrCreateDragon does:
at once without a dragon of its own, else 1200 ticks after it last saw it.

Closes #9464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:12:47 +03:00
Florian ERNSTandCodex 52c79cedb1 Respect entity-ticking loading status in execute loaded
Require completed saved-entity application and loading status without changing simulation or physical unload policy.

Co-Authored-By: Codex <[email protected]>
2026-11-04 22:07:56 +01:00
Loïc PILLERandClaude Sonnet 5.5 5c4ebb36c3 test(render): check the surface keeps NaN, signed-zero and denormal bits exactly
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-04 22:06:57 +01:00
Loïc PILLERandClaude Sonnet 5.5 c7ab3b851c docs(shaders): note scene_surface is texture_2d<u32> in the changelog
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-04 22:06:57 +01:00
Loïc PILLERandClaude Sonnet 5.5 8785a07edc fix(shaders): fall back to a renderable format for float pack targets on OpenGL
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-04 22:06:56 +01:00
Loïc PILLERandClaude Sonnet 5.5 4d90e9db43 fix(render): store native shader pack surface as Rgba32Uint
Rgba32Float is not a render attachment on wgpu's OpenGL backend without
EXT_color_buffer_float, so enabling a native shader pack crashed there
(#9472). The surface holds float bit patterns anyway; store them as
Rgba32Uint (always renderable), bitcast on write in the material stage and
on read in surface_at. Pack-facing API (set_surface, surface_at) is unchanged.
Adds a GPU test that a written surface reads back bit-exact through the post stage.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-04 22:02:13 +01:00
Florian ERNSTandCodex aef74cd095 Merge latest main for loaded-command checks
Co-Authored-By: Codex <[email protected]>
2026-11-04 21:58:37 +01:00
Ludovic Marie 63eccfc355 Merge pull request #9469 from horizon-rust/feat/moderation-commands-tree
🤖 Agent: verified: verify green; pr_ready --quick 2677 Rust tests + clippy pass (only Python failure is test_install_sh_signature: no uname on NixOS PATH); 2-client proof on a dedicated server with the SMP mod: ops' clients complete /tempban and /mutelist, non-ops don't; /mute with a duration blocks chat, /msg, /tell, /w, /me but not other commands; /tempbanlist lists timed bans; a 5 s ban ends by itself. Risk to watch: the extras' names are dropped from the mods' level map so a mod registering /mute still never sees it (the game intercepts first, as before).
2026-11-04 21:57:38 +01:00
Delete228andCodex 21f2b5381a Keep wire data lazy outside damage-definition packs
Freeze only the gameplay damage entries until the first hub needs complete synchronized data. Preserve no-pack preloads and prevent late helpers or pre-hub reloads from changing the session damage registry.

Co-Authored-By: Codex <[email protected]>
2026-11-04 23:56:25 +03:00
voidsipandCodex 2274fda8a7 Carry live vehicles and riders through End gateways
Co-Authored-By: Codex <[email protected]>
2026-11-04 14:53:31 -06:00
itarqos5andClaude Opus 5 cb6a80698c perf(far terrain): a frame spends 3 ms applying detail, not a whole burst
`Lod::receive` drained the whole results channel every frame. A detail
request asks for 512 chunks and cached ones answer at once, and applying
one copies the columns of every tile over it (`Arc::make_mut` on a
snapshot a mesh job shares) and reduces the chunk to that tile's cell
width. A burst in one frame cost 273 ms of it on the render thread.

`horizon-ctl stats` over far terrain at lodDistance 256 put the cost in
`update` (avg 29.9 ms, 540.5 on slow frames) and not in rendering (2.6
ms) or on the GPU (avg 9.1 ms), with a median frame of 6 ms;
`HORIZON_SPIKES` named the path: update_world / lod_update / lod_results
/ lod_detail, 104 slow frames in 20 seconds.

A frame now spends at most DETAIL_BUDGET on summaries and leaves the
rest in `detail_backlog`. Same binary, same flight, limiter off:
average FPS 94.4 -> 133.6, 1% low 3.7 -> 42.1, p99 156.2 -> 20.1 ms,
worst frame 739.7 -> 34.2 ms. p50 is 0.6 ms worse, the budget's work
spread out rather than dumped into one frame. The fill did not slow:
settling to fully refined took 19 polls against 45.

`request_detail` returns early while any summary waits, which is not
optional: without it a chunk whose summary is still waiting has its
mask clear, so it is asked for again and the cache answers at once. The
backlog then grew by a request every 250 ms and never drained -- 0.5
FPS, `update` averaging 9.7 s a frame.

`refreshed_since` moves from arrival to application: a refresh over the
chunk while its summary waited still drops it.

Corrects #8815, which said the lazy reduction could not stall a frame
because neither the far terrain nor the far meshing thread is the render
thread. `apply_detail` runs on the render thread and reads a reduction,
so the first read of a width builds it there. The budget bounds it;
warming the plan's widths on the worker thread is a separate change.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-04 20:49:06 +00:00
voidsipandCodex 8f5f91f2f0 Carry vehicle passengers through command dimension teleports
Co-Authored-By: Codex <[email protected]>
2026-11-04 14:48:13 -06:00
Florian ERNSTandCodex 6489310c36 Add guarded distance-two loaded-command reproduction
Co-Authored-By: Codex <[email protected]>
2026-11-04 21:47:27 +01:00
degepieandClaude Opus 5.5 abb4ac9c7e Commands: /data merge and modify write minecarts and boats
EntityDataAccessor.setData loads the merged compound into any entity but a
player; Horizon answered "Nothing changed" for carts and boats. Minecart
gets a load() that reads the saved fields over a live cart (from_nbt now
uses it), apply_entity_nbt gains Cart and Boat arms that keep the merged
compound, and guests get commands.data.entity.invalid like the player.

The test compound uses the 26.3 DisplayState form (a block name); a second
test checks that merging only a name keeps fuel, push, fuse and Enabled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMK3XzCYMyeKEdamets
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 01:46:30 +05:00
degepieandClaude Opus 5.5 1dd609c4c0 Minecarts: a chest cart's default display offset is 8
MinecartChest overrides getDefaultDisplayOffset with 8; Horizon gave the
chest cart the base 6, so its chest drew 2/16 too low, DisplayOffset saved
at the wrong value and the synched default was off. default_display_offset
now matches every CartKind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMK3XzCYMyeKEdamets
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 01:46:30 +05:00
ViníciusandCodex 5d9bf650a4 Fix fluid sprite material ownership
Prefer fluid blocks when resolving material IDs for their still and flow sprites, preventing a cauldron material from being assigned to fluid surfaces. Add regression coverage for shared fluid sprites and existing block voting.

Co-Authored-By: Codex <[email protected]>
2026-11-04 17:46:25 -03:00
voidsipandCodex 156ae180f8 Preserve entity-bound server sounds and late pairing
Co-Authored-By: Codex <[email protected]>
2026-11-04 14:41:51 -06:00
Delete228andCodex 7a3f407a54 Merge current main and retain world suffocation bindings
Keep main suffocation registration and the world damage catalog together when loading world tables. Preserve all intervening upstream changes without rewriting existing PR history.

Co-Authored-By: Codex <[email protected]>
2026-11-04 23:41:36 +03:00
CocofireHDandopencode 38487aed11 DualSense: drop the output handle on any hidapi error
hidapi's bundled C can crash inside hid_write on Windows once the pad has
gone (Bluetooth sleep or unplug), and flush() only handled a returned
error, not a crash inside the call. Treat the output as lossy:

- probe the pad with a non-blocking read before every write, so a gone
  device is noticed before hid_write is entered;
- drop the handle on any hidapi failure (read or write), never writing
  through it again;
- schedule a rescan in lost() so the writer goes back through the normal
  scan/reopen path instead of keeping a stale handle;
- watch_animation() now drops the pad on a failed read too.

The handle is a small trait so the writer's state machine is unit-tested
without hardware. There is no vanilla reference: this is Horizon-only
hardware output.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 21:40:30 +01:00
CocofireHDandopencode 92549691c2 Management harness: run on Windows, name a server up after stop
The regression behind #9088 was #8245: a stop that doesn't come from the
supervisor's stdin or the control API left the supervisor in its stopped
phase, so `horizon-server --dir … run` outlived the management
`server/stop` and the harness's `wait(timeout=180)` raised TimeoutExpired.
#9077 fixed that (`Shared::shut_down`), on the base of this branch, and the
harness passes here again.

What the harness still left open, and what this fixes:

- It could not run on Windows at all. It wrote the absolute keystore path
  into vanilla's server.properties with single backslashes, which Java's
  properties parser reads as escapes ("C:UserscocoAppData…"), so vanilla
  stopped before the first request. Values are now escaped as vanilla writes
  them, and the harness runs here.
- A server that stays up after `server/stop` was reported as a bare
  TimeoutExpired. `wait_for_exit` waits for the process to end, which is the
  condition the issue was about (vanilla's `ServerStateService.stop` submits
  `MinecraftServer.halt(false)`, so `runServer` returns and `main` ends the
  process), and its failure names the side and the last lines it logged.
  The seconds each server took are kept in report.json: vanilla 3.4 s and
  Horizon 0.5 s here, against the 180 s budget.

Fixes #9088

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 21:38:07 +01:00
TheMrZZandCodex gpt-6.1-sol 752b573fc7 Share vehicle navigation with controlling mob passengers
Lend the vehicle navigation to passenger goals using the vehicle's path
context, while retaining each mob's private navigation tick. Apply the
controller policy and ravager goal flags without requiring a combat target.

Add a guarded real Game/Java regression for patrol and stroll behavior,
including NoAI riders, non-controlling slime riders and a NoAI vehicle.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 21:34:22 +01:00
voidsipandCodex 9382056060 Compare carpet farm production against vanilla
Co-Authored-By: Codex <[email protected]>
2026-11-04 14:32:57 -06:00
degepieandClaude Opus 5.5 684215609b Advancements: item predicate component keys read as minecraft: ones
An item predicate's `components` and `predicates` are keyed by registry
ids (DataComponentType and DataComponentPredicate.Type, read through the
registries' name codecs), so "potion_contents" is
"minecraft:potion_contents". `fn item` looked the keys up only in their
full form: a bare key was skipped and let every stack pass. It now finds
them through `full()`, and compares the potion_contents value as a
registry id too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 01:26:41 +05:00
degepieandClaude Opus 5.5 4d613080c2 Chickens play the egg sound and post entity_place only when an egg drops
Chicken.aiStep rolls the chicken_lay gift table first and plays
entity.chicken.egg and posts GameEvent.ENTITY_PLACE only when
dropFromGiftLootTable dropped something; the egg timer resets either way.
Horizon played the sound before the roll and posted no game event, so a
sculk sensor never heard a laid egg.

A new MobEvent::LayEgg carries the pitch (drawn in the same place as
before); its handler rolls the loot and gates the sound and the game event
on a drop. drop_gift_loot now returns whether it dropped; the other
GiftLoot callers (turtle, panda, sniffer) are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 01:26:41 +05:00
degepieandClaude Opus 5.5 909d2e6960 Loot: location_check matches biome tags
A loot `location_check` whose `biomes` named a tag (`#minecraft:is_jungle`)
never matched: the loot data did not load biome tags. `LocationPredicate.biomes`
is a HolderSet, so the arm now reads it with `LootData::holder_set` (a
`#tag`, an id or a list of ids), and the loot data loads the
`worldgen/biome` tags with the world's packs over the game's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 01:20:19 +05:00
Loïc PILLERandClaude Opus 5.5 7ac56ee53c fix(parity): run peaceful_summon at midnight so zombie horses do not burn
Zombie horses are in the burn_in_daylight tag; Mob.isSunBurnTick rolls
every tick, so a horse could catch fire in the two-tick window and the
vanilla reference read 24 Health instead of 25 (#9089).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 21:14:10 +01:00
voidsipandCodex f063e900e1 Tick moving pistons after entity travel
Co-Authored-By: Codex <[email protected]>
2026-11-04 14:04:41 -06:00
Ludovic MarieandClaude Opus 5.5 4be5ea8a3a Server: moderation commands in operators' command tree, /mute [duration], /tempbanlist
/tempban, /mute, /tempmute, /mutelist and /unmute worked on dedicated servers but were left out of the
command tree, so operators' clients showed them as unknown commands with no completions, and the
official server's operators believed only permanent bans existed. They now reach the trees of
operators of level 3 (as a native mod's commands do), where the game runs them: the mute commands
when the server filters chat, the ban ones on a dedicated server.

/mute <player> [duration] [reason] takes a duration after the name (/tempmute stays), and
/tempbanlist lists the bans that end by themselves, soonest first, with who banned, the time left,
the end and the reason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 20:46:19 +01:00
utophii a0cf173b1f fix(ci): address musl release review feedback 2026-11-04 22:41:13 +03:00
degepieandClaude Opus 5.5 33bdcf863a Advancement entity predicates test the entity's nbt
`adv::entity` had no `minecraft:nbt` arm, so every entity predicate with
`nbt` fell to `_ => false`: a villager's profession, a mob's Variant, a
CustomName, the player's Inventory or SelectedItem never matched.

EntityNbtPredicate.matches: the entity's compound from
NbtPredicate.getEntityTagToCompare (saveWithoutId, plus SelectedItem for a
player) tested with NbtUtils.compareNbt(..., true), which
`nbt_path::compare_nbt` already is.

- `EntityView` gets the compound (`nbt`); `entity` parses the SNBT pattern
  and compares; no compound or an unparsable pattern fails.
- `Tree::index_nbt` notes the triggers whose criteria mention `nbt` (or a
  `minecraft:reference`), like `index_looking_at`, so the compound is
  built only when a criterion may ask.
- `mob_entity` builds the mob's compound (`mob_to_nbt`, riders under
  `Passengers`); `player_entity_for` the player's, through `player_nbt`,
  the player arm of `/data get entity` moved out of `entity_nbt_in_level`.
- A bred child's view, a copy of its parent's, drops the parent's data.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 00:40:54 +05:00
utophii 3f9b7bc8bb Merge remote-tracking branch 'upstream/main' into fix/2282-musl-build 2026-11-04 22:36:45 +03:00
Ludovic Marie 1989b1b595 Merge pull request #9210 from Ragbie/fix/silent-dragon-death
🤖 Agent: a dragon saved or summoned with Silent:1b no longer plays the global 1028 death roar at tick 1 of its death; Silent is read from the compound Horizon keeps for unmodelled fields, and the new dragon_death_roar harness compares the level events against vanilla's. The branch passed the cg-39 union run on playhorizon (horizon-client 887 tests); main merged to clear an unrelated lan::conn flake.
2026-11-04 20:31:56 +01:00
Ludovic Marie b50b218667 Merge pull request #9430 from horizon-rust/cg39/kept-text-epoch
🤖 Agent: follow-up to martipops's #9228 review: kept mod block-entity text is keyed on the font atlas epoch, so a resource reload rebuilds it instead of drawing stale glyphs (KeptKey test covers it); docs say what spin, draw_text and native_block_face_sprite really do. fmt, clippy and 7 tests on Linux; the larger points are #9431.
2026-11-04 20:29:38 +01:00
Ludovic Marie 53ea8a56f6 Merge pull request #9227 from TheLion102009/api/fluids
🤖 Agent: tanks take mods' own fluids; before the helpers get numbers, native_fluid_flow now names its dimension and answers none for an unloaded chunk, and a cauldron reports no room for a mod's fluid so native_move_fluids answers none as documented. Stamp test, clippy and 17 fluid tests pass on Linux; a per-tank filter is #9439.
2026-11-04 20:29:34 +01:00
Ludovic Marie c6e4d0a293 Merge pull request #9220 from voidsipp/fix/8848-raider-celebration-sync
🤖 Agent: Raider.IS_CELEBRATING (index 16, BOOLEAN) now reaches clients, and the new lan test pins it through mob_meta itself: with the mob_raider_meta call removed it fails, so the server half of #8848 can't silently drop out. Main merged (the #9271 part is gone from the diff); 4 celebration tests and clippy pass on Linux.
2026-11-04 20:29:29 +01:00
Ludovic Marie bb9bc915af Merge pull request #9278 from abit-foggy/codex/studio-mcpr
🤖 Agent: the .mcpr reader no longer trusts the file: entries are capped (1 MiB metadata, 1 GiB stream), one frame parser serves the index and playback so a huge or zero length ends the stream cleanly, and seeking starts at the first packet of a millisecond. finish renames a synced temp over the destination with Zip64 and keeps the packets on failure. 9 studio tests on Linux, verify green on 56fd928f5; the rest is in #9434.
2026-11-04 20:29:25 +01:00
Ludovic MarieandClaude Opus 5.5 655118b776 Mod fluids: a cauldron has no room for a mod's fluid; fluid_flow takes a dimension and answers none when unloaded
native_move_fluids promised none when the target has no room for that
fluid, but an empty cauldron reported a bucket of room for any fluid, so
a move drained the source and put it all back, answering 0 and marking
the chunk each time. native_fluid_flow is new here: it now names its
dimension, as native_fluid_tanks does, reads a server's parked levels,
and answers none for an unloaded chunk instead of a zero that looked like
still water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 20:25:48 +01:00
Axxio ebaf4e7d21 launcher: allow editing instance icon from home and preserve picture in dialog (fixes #8689)
Clicking the instance icon on Home or selecting 'Edit instance' in the
folder group more menu now opens the instance dialog. In the dialog,
clicking the large preview icon opens the image picker, and existing
pictures remain selectable in the palette.

Managed by Axxio.
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 20:23:35 +01:00
Ludovic MarieandClaude Opus 5.5 6b078a2bc7 Merge main into #9227
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 20:23:31 +01:00
Ludovic Marie 35e219c5de Merge pull request #9385 from Ragbie/fix/transfer-test-race
🤖 Agent: merging. Ignoring only the hello's send error is enough: data the server sent before closing stays readable after the reset, so the Disconnect is still read and the test still checks the refusal. Proof: on a union with main and #9382 the test passed five runs in a row; fmt, clippy -D warnings and the client-lib tests pass.
2026-11-04 20:23:20 +01:00
Ludovic Marie d65158a6af Merge pull request #9382 from voidsipp/fix/9284-entity-bound-sounds
🤖 Agent: merging. The order matches SoundEngine.tickInGame: stop when canPlaySound is false (the entity went silent), then tick, where EntityBoundSoundInstance.tick stops on isRemoved or else copies the entity's position as floats; the packet's seed now picks the variant, as handleSoundEntityEvent does. The per-tick cost is one entity lookup per playing bound sound. Proof: union with main and #9385: fmt, clippy -D warnings, Python tests, 2634 client and client-lib tests, the render audio (20) and sound_engine (10) tests, and the entity_bound_sound and sound_engine harnesses against vanilla pass.
2026-11-04 20:23:15 +01:00
Ludovic MarieandClaude Opus 5.5 742da4c639 Test that mob_meta sends a raider's celebration
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 20:20:31 +01:00
Ludovic MarieandClaude Opus 5.5 d4d8a5a4bc rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 20:17:00 +01:00
Ludovic MarieandClaude Opus 5.5 7df3a5dd64 Merge main into #9220
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 20:15:56 +01:00
Ludovic MarieandClaude Opus 5.5 56fd928f57 Merge main into #9278
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 20:14:04 +01:00
Ludovic MarieandClaude Opus 5.5 f41eeab87e Studio .mcpr: bounded reads, one frame parser, safe finish
- The reader caps metaData.json at 1 MiB and recording.tmcpr at 1 GiB,
  so a hostile archive can't exhaust memory, and never allocates from a
  length field: indexing and next_packet share one frame parser, stop at
  the first frame that isn't whole, and agree on the packet count.
- Seeking lands on the first packet of a millisecond (partition_point);
  a timestamp going backwards is indexed at the time before it.
- Fields ReplayMod can leave out read as defaults; protocol is optional;
  a file whose fileFormat isn't MCPR is refused.
- finish writes the archive to a sibling file, syncs and renames it over
  the destination, with Zip64 for the stream; on failure the stream file
  is kept and the error names it. A dropped writer keeps its packets, a
  failed write stops further frames, and the stream name can't collide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 20:13:59 +01:00
Ludovic Marie 1f90d6f9ae Merge pull request #9275 from lightcodegit/snbt-parser-depth-cap
🤖 Agent: the SNBT depth cap is now 256 rule frames (about 63 nested compounds or 13 levels of nested container items), measured on playhorizon: 384 already overflows a 2 MiB debug thread stack, the size the server thread runs on. New test deep_real_values_still_parse keeps 50 compounds and 10 container levels parsing; 42 horizon-command tests pass, verify green on 54142077f.
2026-11-04 20:10:01 +01:00
Ludovic Marie 11f72f81fc Merge pull request #7725 from deloskiytbackup/feature/realms-support
🤖 Agent: Ludovic allowed Realms with the player's own account (2026-11-04). At 08874a1622 the review's five points are fixed: the join runs on a worker, Leave asks first, the /join/pc address is checked as host:port, requests send only vanilla's sid cookie (a local-server test shows no Authorization header and the token missing from errors), and the world-pack question comes first. Proof: desktop fmt, clippy -D warnings, 1,700 client-lib tests; verify is green on this head.
2026-11-04 20:06:04 +01:00
Ludovic Marie a7ec4259d6 Merge pull request #9419 from horizon-rust/feat/claim-no-paddle
🤖 Agent: launcher side of the free Founder Set claim where Paddle doesn't sell; the service part is live (horizon-cosmetics#11). Risk: the button only shows when the service sends claim.one_click, so older services and other countries see no change.
2026-11-04 20:05:59 +01:00
Ludovic Marie 2e0ef2bd2f Merge branch 'fix/client-information-as-vanilla' into fix/known-packs-as-vanilla 2026-11-04 20:05:44 +01:00
Ludovic Marie 2d7d6303d0 Merge branch 'fix/client-tick-end-needs-level' into fix/client-information-as-vanilla
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-11-04 20:05:23 +01:00
Ludovic Marie 2fb505568c Merge remote-tracking branch 'origin/main' into fix/client-tick-end-needs-level
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-11-04 20:04:35 +01:00
Ludovic MarieandClaude Opus 5.5 68ebd29282 Mod block entity drawing: kept text follows the font, docs say what the helpers do
KeptKey gains Font::epoch, as a sign's kept text has: a glyph cache reset
(the dynamic region filling, Force Unicode or Japanese Glyphs toggled) moves
glyphs in the atlas, and kept mod text drew the cells' new contents until
its block entity changed. The docs now say native_draw_text draws plain
default-font spans, that spin's angle is absolute in game time (a new speed
needs a new phase), and that native_block_face_sprite returns vanilla names
without their namespace.

From martipops's review of #9228.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:59:28 +01:00
martipopsandClaude Opus 5.5 c0f6e4d4dc Launcher: a staged update has its own folder and is checked again at install
Fixes #9412. Every launcher process deleted and rewrote the one update folder, and the installer
only checked that the payload existed, so a second launcher could leave a partial file to copy over
the launcher.

- Each stage is a `<pid>-<nonce>` folder. Downloads get their name only after the hash check. Each
  process removes its own; stages whose process is gone are cleaned up after an hour, counted from
  the hand-over, because an installer may still be using them.
- The installer checks the archive against the signed SHA-256 again and places the launcher from
  those bytes. The hash travels in an environment variable: the installer is whatever file is in
  place, so the command line stays readable across versions. A window without one can't place.
- Nothing happens when the launcher in place is already that version or newer.
- On Windows a launcher running from the temp folder, however it is spelled, can't replace itself.
- Update no longer stops hosted servers. The new window doesn't stop them when it closes either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp7yEJwPTJ2kw29LWdk1
2026-11-04 13:54:58 -05:00
MegaBytesMe 84ac891a7a Merge remote-tracking branch 'upstream/main' into codex/issue-9408-launcher-arch-choice 2026-11-04 18:52:53 +00:00
Ludovic Marie 8dfc3f2e1e Merge pull request #9229 from TheLion102009/api/scenes
🤖 Agent: merged main through #9228's merge (mod lines and doc sections both kept). The scene's quads use the GUI's isolated depth (HudBatch::depth_since) and stay in the HUD batch, so nothing changes until a mod calls native_draw_scene. union of 15 contributor PRs on main 6ddd14e72 (playhorizon): fmt, clippy -D warnings, client 887 + client-lib 1682 + render 1355 tests green (the 4 clear_budget HDR failures also fail on bare main there).
2026-11-04 19:49:51 +01:00
martipopsandClaude Opus 5.5 2423ffc1ba Launcher: run the binary's tests in PR checks
CI's launcher job and pr_ready ran only --lib, so the tests in main.rs's
modules first ran in launcher.yml's cargo test at release. Add --bins to
both. The clippy failure at forge_install_tests.rs:4 is already fixed on
main by 812512980, so it needs no change here.

Fixes #9414

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp7yEJwPTJ2kw29LWdk1
2026-11-04 13:47:55 -05:00
martipopsandClaude Opus 5.5 e1e3c4f840 Data commands: removing a chest's LootTable keeps it from refilling on load
take_loot_table dropped a held roll without clearing the table from the
chest's source compound. The emptied chest encodes the same as its decoded
source, so the save overlay wrote the source back with its table and the next
load rolled again. It now goes through consume_container_loot, as opening does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp7yEJwPTJ2kw29LWdk1
2026-11-04 13:47:43 -05:00
Ludovic Marie 4d533c252a Merge main into #9227
# Conflicts:
#	docs/native-mods.md
2026-11-04 19:46:39 +01:00
Ludovic Marie 90fa94f2e3 Merge pull request #9239 from razzo715/perf/sleeping-furnaces
🤖 Agent: the sleep is only as safe as the wake paths, and every mutable path into BlockEntities' map (get_mut, insert, take/put, iter_mut, retain, load_chunk, state_changed) clears it, pinned by a_sleeping_block_entity_wakes_on_any_change. The cost on the tick loop is one FxHashSet lookup per ticker. Hoppers stay awake, so the big win is still ahead. union of 15 contributor PRs on main 6ddd14e72 (playhorizon): fmt, clippy -D warnings, client 887 + client-lib 1682 + render 1355 tests green (the 4 clear_budget HDR failures also fail on bare main there).
2026-11-04 19:46:02 +01:00
Ludovic Marie b1db34fa20 Merge pull request #9226 from TheLion102009/api/chunk-tickets
🤖 Agent: mod tickets live in Game.mod_chunk_tickets keyed by dimension while streaming.mod_forced travels with each level's Streaming, so a ticket survives the level swap; forced_chunks() dedups against /forceload so a chunk held both ways is ranked once. Tickets aren't saved, as the doc says: a mod that forgets to re-hold in native_level_load drops its chunks on restart. union of 15 contributor PRs on main 6ddd14e72 (playhorizon): fmt, clippy -D warnings, client 887 + client-lib 1682 + render 1355 tests green (the 4 clear_budget HDR failures also fail on bare main there).
2026-11-04 19:45:57 +01:00
Ludovic MarieandClaude Opus 5.5 08874a1622 Realms: background join, leave question, checked address, vanilla requests, world pack
Fixes the five points from the review of #7725:

- Play runs GetServerDetailsTask on a worker (with vanilla's Retry-After loop and
  an abort on Cancel/Escape) while the screen shows "Connecting to the Realm...";
  the title polls it once a frame and connects when the address is back. Every
  other Realms request (list, invites, join code, open/close, leave) is off the
  UI thread too.
- Leave Realm opens RealmsPopups.infoPopupScreen with
  mco.configure.world.leave.question.line1 (Continue/Cancel), as leaveClicked
  does; only Continue sends uninviteMyselfFrom. Our own Realm is never offered.
- The /join/pc address must be host:port (host name, IPv4 or [IPv6], numeric
  port, no scheme, path or credentials) before it becomes Action::Connect.
- Requests carry exactly what RealmsClient.execute adds: the sid/user/version
  cookies and Client-Ref, X-NetworkProtocolVersion, Is-Prerelease; no
  Authorization header, the JVM's Java/<version> user agent, no redirects.
  Errors are typed (RealmsError) and never hold a header or the token.
- A world pack in the join answer asks
  mco.configure.world.resourcepack.question first; Continue connects with the
  pack allowed and pushed under GetServerDetailsTask.generatePackId.

Also: the DTOs read vanilla's camelCase field names (daysLeft, ownerUUID,
resourcePackUrl... were silently dropped), ownership compares ownerUUID, the
legacy invite-link fallback endpoint is gone (vanilla only uses the frontend
invite-code service), links go through Action::OpenUri with vanilla's
CommonLinks, the Realms title button follows the multiplayer gate, and every
visible string is a vanilla mco.* key (one Horizon key for "not signed in").
No session (offline, expired token) means no request at all.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:45:45 +01:00
Ludovic Marie 8231317207 Merge main into #9210 2026-11-04 19:45:38 +01:00
Ludovic Marie 54142077f2 Merge main into #9275 2026-11-04 19:43:20 +01:00
Ludovic MarieandClaude Opus 5.5 3f529b7d80 SNBT depth cap: 256 rule frames, and a test for deep real values
128 frames let only 31 nested compounds, 17 levels of a component's
`extra` list and 6 containers in containers parse. 256 doubles that and
still stays under a 2 MiB debug thread's stack overflow (384 overflows the
400-deep tests in debug); release frames are smaller still.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:43:19 +01:00
Ludovic Marie 581fad0d32 Merge pull request #9310 from TheMrZZ/fix/h287-named-weather-predicate
🤖 Agent: `weather_check` now compares each present field with `Level.isRaining` (rain level > 0.2) and `isThundering` (rain × thunder > 0.9, dimension-aware), as `WeatherCheck.test` does; an omitted field doesn't constrain. We merged main twice so your corpus group runs after #9288's and #9309's; verify is green on that merge. Nothing else found.
2026-11-04 19:43:14 +01:00
Ludovic Marie 9a5a471297 Merge pull request #9321 from dontstopgorkingit/fix/4683
🤖 Agent: on Linux the stream now asks ALSA for a fixed 1024-frame buffer (about 23 ms at 44.1 kHz) instead of cpal's ~100 ms default. We pushed adf2038d7 so a device that refuses a fixed buffer falls back to the default one rather than playing nothing. Not measured on real hardware here; fmt, clippy and the client tests pass on a union with main.
2026-11-04 19:43:09 +01:00
Ludovic Marie 2599f46c1f Merge pull request #9311 from horizon-rust/fix/launcher-update-prompt
🤖 Agent: the launcher now offers "A new version is available" with Update, and copies that can't replace themselves say so instead of failing silently; Linux end to end on the ThinkPad (Update to 0.2.40 restarts, a read-only copy shows the can't-replace card), pr_ready --quick green.
2026-11-04 19:39:41 +01:00
Ludovic Marie 95247e709f Merge pull request #9225 from TheLion102009/api/container-accepts
🤖 Agent: pushed a fix: container_accepts.json listed native_hooks.rs in `comments`, which the stamp tool stamps by itself and refuses (test_stamp_native_hooks failed on this head). The hopper path pays nothing for vanilla containers, since mod_container_accepts returns at the Kind::Mod check. union of 15 contributor PRs on main 6ddd14e72 (playhorizon): fmt, clippy -D warnings, horizon-client 887 + client-lib 1682 + world/command tests green.
2026-11-04 19:39:35 +01:00
Ludovic Marie 52268eec25 Merge pull request #9223 from TheLion102009/api/machine-breaking
🤖 Agent: use_as_player swaps about 30 Game fields by hand and restores them. A per-player field added to Game later and not listed there would leak the fake player's state into the server shell, so one swap struct would be safer next time. Health, food and effects aren't swapped, which only works because a single use never finishes eating. union of 15 contributor PRs on main 6ddd14e72 (playhorizon): fmt, clippy -D warnings, horizon-client 887 + client-lib 1682 + world/command tests green.
2026-11-04 19:39:30 +01:00
Ludovic Marie a815f2f89d Merge pull request #9222 from TheLion102009/api/host-bridge
🤖 Agent: merged main (the mod_api use list now also has NativeReloadListener). native_sneaking reads KeyCode::ShiftLeft as use_hand does, so on a server it is the acting guest's only inside as_guest; from a block-entity tick it reads the server's empty key set. union of 15 contributor PRs on main 6ddd14e72 (playhorizon): fmt, clippy -D warnings, horizon-client 887 + client-lib 1682 + world/command tests green, stamp tests OK.
2026-11-04 19:39:26 +01:00
Ludovic Marie 57487ddd4a Merge pull request #9262 from voidsipp/fix/8702-breeze-fishing-deflection
🤖 Agent: merged main (its minecart_hook_targets and pull_hooked tests sit beside the breeze ones, and the test module imports Hook once). The deflect path matches on Species::Breeze rather than #deflects_projectiles, which is the same set with default tags but not with a data pack that adds to the tag. union of 15 contributor PRs on main 6ddd14e72 (playhorizon): fmt, clippy -D warnings, horizon-client 887 + client-lib 1682 + world/command tests green.
2026-11-04 19:39:21 +01:00
Ludovic Marie b2842e8f68 Merge pull request #9271 from voidsipp/fix/8279-mob-air-sync
🤖 Agent: checked max_air_supply against the 26.3 bytecode: only Dolphin (4800) and Axolotl (6000) override getMaxAirSupply, and Nautilus returns the default 300, so the two arms are complete. #9220 now carries this branch and shares its mob fixture. union of 15 contributor PRs on main 6ddd14e72 (playhorizon): fmt, clippy -D warnings, horizon-client 887 + client-lib 1682 + world/command tests green.
2026-11-04 19:39:16 +01:00
Ludovic Marie 3cbad717b2 Merge pull request #9264 from yosifdheef/fix/7992-shulker-open-support
🤖 Agent: shulker_support costs one block-tag lookup per sturdy/rigid/center query before the cached path, the same shape as moving_block_supports there. Not in this PR: vanilla's ShulkerBoxBlockEntity also calls doNeighborUpdates when the lid moves, so a torch already on an opening lid only drops at the next neighbour update. union of 15 contributor PRs on main 6ddd14e72 (playhorizon): fmt, clippy -D warnings, horizon-client 887 + client-lib 1682 + world/command tests green, the new level tests included.
2026-11-04 19:39:10 +01:00
Ludovic MarieandClaude Opus 5.5 949facfa7e Wardrobe: the shop tests' claim has one_click
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:35:51 +01:00
Ludovic Marie 262bdc0fef Merge pull request #9206 from degepie/fix/looking-at-all-triggers
🤖 Agent: checked every new trigger id against the trigger it guards; damage_view's two KilledTrigger ids are right, since its only callers (creatures.rs last_blow, dragon.rs) feed trigger_killed. Commands keep player_entity(), so a predicate= that names looking_at still sees the ray. union of 15 contributor PRs on main 6ddd14e72 (playhorizon): fmt, clippy -D warnings, horizon-client 887 + client-lib 1682 + world/command tests green.
2026-11-04 19:35:26 +01:00
Ludovic MarieandClaude Opus 5.5 bcdb34dcdd Multiplayer: answer the server's known packs with vanilla's core pack, as vanilla
KnownPacksManager.trySelectingPacks answers the server's SelectKnownPacks
with the packs the client has; vanilla's client names minecraft:core of its
version, and the server then leaves that pack's registry data out. Horizon
answered with no pack, so every server sent it all registries in full and
saw a client unlike vanilla's. Horizon has the core pack's data in its
prepared resources: it now names it, and fills the entries that come
without data from data/<namespace>/<registry>/<path>.json, as
RegistryDataCollector does from the client's own pack.

tools/vanilla_wire_e2e.py gains known-packs-as-vanilla and known-data-used
(a /say shows as the say_command chat type formats it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:34:56 +01:00
Ludovic Marie 498168e904 Merge pull request #9346 from voidsipp/fix/9303-hdr-clear-fixture
🤖 Agent: merging. On playhorizon (llvmpipe adapter) four of the six clear_budget tests fail on main with "fixture buffer 2 must have RGBA16F"; with this head all seven pass, the new adapter-free fixture check included, so CI without a GPU now catches a broken fixture too. Proof: cargo test -p horizon-client-render --lib clear_budget on main (4 failed) and on a union with this head (7 passed); fmt and clippy -D warnings pass on the union.
2026-11-04 19:33:11 +01:00
Ludovic Marie 2d7dd77d1d Merge pull request #9345 from xxaxio/fix/9074-title-wither-skeletons
🤖 Agent: merging. clear_entities runs when a world opens and in finish_close_world, after the closing save has already written every parked level (save_parked_levels), so the new levels.clear() drops nothing unsaved; leave_server now also resets persist and entity attachments, which a client never saves. Proof: union with main, #9341 and #9346: fmt, clippy -D warnings, Python tests and 2624 client and client-lib tests pass.
2026-11-04 19:33:07 +01:00
Ludovic Marie f92e450401 Merge pull request #9341 from Ragbie/fix/dragon-no-ai
🤖 Agent: merging. The 26.3 bytecode of EnderDragon.aiStep checks isNoAi right after Mth.wrapDegrees(getYRot()), sets flapTime to 0.5 and returns, which is where tick_dragon and the remote tick now stop; NoAI is bit 1 of Mob.DATA_MOB_FLAGS_ID (15), the index the host sends. Proof: union with main, #9345 and #9346: fmt, clippy -D warnings, Python tests and 2624 client and client-lib tests pass; CI parity runs the new dragon_no_ai harness against a vanilla server.
2026-11-04 19:33:03 +01:00
Ludovic Marie 0d7704f3f3 Merge pull request #9243 from horizon-rust/pvp/crosshair
🤖 Agent: rebased on main e0798a4224 (main made cosmeticsOthers optional in Extras::serialize; the crosshair keeps main's string and appends the pvp lines). Desktop: clippy -D warnings and rustfmt pass on the stack's top; before this rebase pr_ready passed 3946 tests on this commit (fails: main's 4 gfx:: clear_budget GPU tests on lavapipe, which CI passes, and NixOS's missing uname in a Python install.sh test). CI verify green on this head. Off by default; the crosshair mask is rebuilt only on a settings change.
2026-11-04 19:28:30 +01:00
Ludovic Marie 771541d109 Merge pull request #9378 from horizon-rust/fix/native-build-name-the-mod
🤖 Agent: verified b5c41f4 on playhorizon (launcher fmt, clippy -D warnings, 51 native_mods tests; the new test fails before the fix) and CI is green. Only the one-line form of horizon-mixins' diagnostic changes: it leads with mod_id (injection_id) when the tool names one; E_MODULE_CONFLICT carries no mod_id and reads as before.
2026-11-04 19:17:56 +01:00
Ludovic Marie 6b75c9cb79 Merge pull request #9280 from voidsipp/fix/9224-opened-loot-save
🤖 Agent: opening a rolled chest now also drops `LootTable`/`LootTableSeed` from its saved source compound, so an emptied chest no longer re-rolls after a reload; `consume_container_loot` touches nothing when the chest had no rolled marker. The 576 harness cases vary seed and table, but the result never depends on either: three modes would cover the same thing. Proven in our union run (client tests, desktop).
2026-11-04 19:17:51 +01:00
Ludovic Marie 2baf3a4847 Merge pull request #9300 from degepie/fix/minecart-display-sync
🤖 Agent: entries 11 (`DATA_ID_CUSTOM_DISPLAY_BLOCK`, optional block state, 0 = none) and 12 (`DATA_ID_DISPLAY_OFFSET`, default from the kind) now reach clients and are read back, so single player and vanilla servers show a cart's DisplayState. One gap noted in your body and left for later: `default_display_offset` gives 6 for a chest cart where 26.3's `MinecartChest` says 8. Proven in our union run (client tests, desktop).
2026-11-04 19:17:47 +01:00
Ludovic Marie b46279d5b5 Merge pull request #8274 from megabytesme/feat/windows-arm64-support
feat(windows): add native ARM64 build support
2026-11-04 19:14:26 +01:00
Ludovic MarieandClaude Opus 5.5 7f19c70db3 Wardrobe: "Claim for free" where Paddle doesn't sell
Players in countries Paddle doesn't serve (Russia, Belarus...) can't use the
shop's 0 CHF checkout. The cosmetics service now says so per request
(`claim.one_click` in /v1/me, decided by country on the service), and the
Wardrobe then shows a "Claim for free" banner that calls POST /v1/claim with
the existing Mojang-proven session. Everyone else keeps the shop link.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:13:25 +01:00
Ludovic Marie 80d4e1c9f2 Merge remote-tracking branch 'origin/main' into cg38/9310
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-04 19:12:25 +01:00
Ludovic Marie cee89b5498 Merge pull request #9309 from TheMrZZ/fix/advancement-reward-functions
🤖 Agent: `AdvancementRewards.grant`'s function now runs as the player with suppressed output and GAMEMASTER permission, and restores the outer callback so a reward's `return 9` can't replace the grant command's result. We merged main so your group runs after #9288's bell group in the corpus; verify is green on that merge.
2026-11-04 19:11:47 +01:00
Ludovic MarieandClaude Opus 5.5 1394ce1659 PvP & HUD: a PvP preset button
One click on the PvP & HUD page (and on Horizon Extras) turns on the custom
crosshair, the armour and effects widgets, vanilla's toggle sprint and toggle
sneak, lower fire, and the official FPS counter and keystrokes mods when they
are installed. It keeps each option's own settings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 c58cc9369c PvP & HUD: your own time of day and weather
Two options on the PvP & HUD page, off by default: a time of day (morning,
noon, sunset, midnight) and a weather (clear, rain, thunder) that only this
window draws. The sky, its light, the rain and its fog follow them; the
world's day time and weather, and the gameplay that reads them, stay the
server's. Nothing is sent to the server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 5468cd1c3b PvP & HUD: lower fire, shield and totem size, glint colour, clear water and lava
Visual tweaks on the PvP & HUD page, each off by default:
- Lower fire moves the first-person fire overlay down (0-80% of its height).
- Shield size (50-100%) and totem animation size (25-100%).
- Enchantment glint colour: the glint's brightest channel over a solid colour,
  in entity.wgsl and hud.wgsl, carried in free uniform lanes.
- Clear water (vanilla's 30-second water vision from the start) and clear lava
  (vanilla's Fire Resistance lava fog), never past what vanilla shows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 ff77855f6f PvP & HUD: enchantment levels as numbers
An option on the PvP & HUD page, off by default: enchantment names show their level
as a number ("Sharpness 5", and past vanilla's numerals, "Sharpness 255") wherever
the client names one: tooltips and the enchanting table's hint. A one-level
enchantment still has none. Enchantment.getFullname reads a process-wide switch the
game sets each frame from the option.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 441d4da364 PvP & HUD: armour, effects and held item count widgets
Three HUD widgets on the PvP & HUD page, each off by default, in a corner of its own
beside the minimap and mods' boxes (docs/pvp.md):
- Armour: each piece worn, head first, with its uses left as a number or a percent,
  coloured like its durability bar.
- Effects: icon, name and level, time left, in place of vanilla's top-right icons.
- Held item count: how many of the held item the inventory holds; arrows with a bow
  or crossbow.

pvp_hud::Widget keeps each widget's lines and rebuilds them only when what it shows
changes, so a frame that changes nothing allocates nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 46f7457082 PvP & HUD: block outline colour, thickness and fill; hit colour
Two more groups on the PvP & HUD page, off by default (docs/pvp.md):
- Block outline: a solid colour at 10-100%, 1-4 times vanilla's width, and an
  optional fill that tints the targeted block's shape boxes at 25%. Its first
  settings are vanilla's 0x66000000 stroke. With high contrast on, the colour
  replaces the bright stroke.
- Hit colour: the hurt overlay's colour and how much of it covers the entity.
  Its first settings give OverlayTexture's 0xB2FF0000 texel. The texel rides in
  colored_count.y of the frame uniforms, so it needs no pipeline rebuild.

FrameInputs gets one pvp::Frame field the later PvP options extend.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 2209fd9df0 PvP & HUD: chat timestamps, compact chat, copy on click and search
Four chat options on the PvP & HUD page, off by default:
- Timestamps: the local time before each message.
- Compact chat: a message repeated in a row shows once with its count (x3).
- Copy on click: a click on a message with no click action copies its text.
- Chat search: Ctrl+F in the open chat filters the messages by the box's text.

They only change how the kept messages are laid out, so turning one off
shows the chat as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 6ab8720792 PvP & HUD: ping, coordinates, server address and combo widgets
Four more HUD widgets on the PvP & HUD page, off by default (docs/pvp.md):
- Ping: the latency the server's player list gives this player. Multiplayer only.
- Coordinates: block position and facing (Direction.fromYRot), hidden whenever F3
  is reduced (option, game rule or server), so it never shows what a server hides.
- Server address: as net::shown_address shows it (invite codes hidden).
- Combo counter: hits in a row, each counted when the server reports the attacked
  entity hurt within a second of the swing; reset when this player is hurt or after
  3 s without a hit.

Each widget rebuilds its text only when its value changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 b208196224 PvP & HUD page: a custom crosshair
Horizon Settings > PvP & HUD... is a new page of options a PvP player sets, all off by
default (docs/pvp.md). The first is the crosshair: shape (cross, T, X, circle, square,
dot), size, gap, thickness, colour (inverted as vanilla, or solid), outline, centre dot,
and another colour over a living entity (vanilla's crosshairPickEntity). Its first
settings draw vanilla's 15-pixel cross; the attack indicator stays vanilla's.

The pixels are worked out once per settings change (CrosshairCache), so a frame only
adds their quads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 4bced57e08 Merge origin/main into feature/realms-support
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:01:37 +01:00
Evrim CicekandClaude Opus 5.5 cc613a02fd lan::conn test: a refused transfer's hello may find the connection closed
With transfers off the server turns a transfer away at the handshake and
closes (ServerHandshakePacketListenerImpl.handleIntention), so
transfer_login's hello raced the close and its unwrap failed with
"broken pipe" now and then (seen in CI). The hello's send error is now
ignored; the disconnect is still read. With a 200 ms pause before the
hello the test failed every time; with this change it passes.

Fixes #9376

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 21:01:01 +03:00
Ludovic MarieandClaude Opus 5.5 b5c41f49d8 Native builds: a horizon-mixins error starts with the mod that doesn't apply
A player's native build on 2026-11-04 failed with "horizon-mixins failed
(exit status: 1)" on Horizon 998b1d8, the commit fast_items 0.1.0 pins.
fast_items applies there; built together with a mod made for an older
Horizon (14 of the 72 registry native mods don't apply at 998b1d8, among
them appleskin, flair, mod_menu and freecam), the other mod's hook target
is gone. The diagnostic named that mod only deep in a file path. It now
leads with the mod and its injection: "appleskin (hud): E_TARGET_NOT_FOUND".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:00:22 +01:00
Ludovic Marie e0798a4224 Merge pull request #9281 from lightcodegit/fix/execute-parse-depth
🤖 Agent: caps `parse_nodes` recursion at 128 where `execute … run` redirects to the root, so the 400-deep chain from #9252 fails to parse instead of overflowing the server thread; the 400-deep test would abort the binary without it. Chosen over #9320, which capped execution only. Nothing else found.
2026-11-04 18:54:32 +01:00
Ludovic Marie 4921de8282 Merge origin/main into feat/windows-arm64-support
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	.github/workflows/launcher.yml
2026-11-04 18:54:00 +01:00
Ludovic Marie b319334189 Merge pull request #9292 from voidsipp/fix/9269-shulker-teleport-collision
🤖 Agent: attachment now uses the entity-context collision shape (`entityCanStandOnFace`), so soul sand only takes a shulker under it and scaffolding follows the shulker's height. We pushed a89eb944c, a full-cube fast path, because `canStayAt` runs every tick per shulker. Proven with the client tests on a union with main (desktop).
2026-11-04 18:52:19 +01:00
Ludovic Marie 555c69406f Merge pull request #9296 from sirrayi/fix/teleport-rider-dismount-and-turn
🤖 Agent: the dismount on teleport matches `Entity.teleport`'s `stopRiding()`. We pushed 23a886d93: a relative yaw is a delta in vanilla's transition, so riders turn by the vehicle's turn either way, and `facing` leaves them unturned. The ignored rider game test passes on a union with main (desktop), with clippy and the client tests.
2026-11-04 18:52:14 +01:00
Ludovic Marie 900416b606 Merge pull request #9305 from degepie/fix/dragon-breath-mobs
🤖 Agent: mobs now stop a dragon fireball and take its breath, through the lingering clouds' `cloud_affect` path (undead healed). We pushed 01634eab6 so the per-tick End scans gather mobs only while a fireball or breath cloud exists. Proven with fmt, clippy and the client tests on a union with main (desktop).
2026-11-04 18:52:10 +01:00
voidsipandCodex 164b298d35 Follow entity-bound sounds with their emitting entity
Keep the packet seed and category, update the bound position each tick,
and stop the old object when an entity is removed or its id is reused.
Compare the sound-engine lifecycle against the owned vanilla client.

Co-Authored-By: Codex <[email protected]>
2026-11-04 11:51:50 -06:00
Ludovic Marie 8045ecd4e7 Merge pull request #9259 from jaszkus/optimization-launcher
Launcher: a Performance mode for slower computers (and an accent that changes at once)
2026-11-04 18:51:39 +01:00
Ludovic Marie 63219718ff Merge remote-tracking branch 'origin/main' into cg38/9310
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-04 18:51:19 +01:00
Ludovic Marie c5fb2ba820 Merge remote-tracking branch 'origin/main' into cg38/9309
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-04 18:50:26 +01:00
Ludovic Marie 795024bfc4 Merge pull request #9263 from masa-masa-0314/chore/ci-bump-actions-node24
CI: run GitHub Actions on Node 24 (checkout v7, artifacts v7/v8, setup-python v7, setup-java v6)
2026-11-04 18:43:53 +01:00
Ludovic Marie 036a2a2cb6 Merge pull request #9328 from voidsipp/fix/9231-inline-mod-install
🤖 Agent: merging. The row button takes the detail card's own checks (installed, checking, busy, support.installable) and calls the same mods_install with the known project, so native-mod consent still comes first; stop_propagation keeps the click from selecting the row. Proof: union with main, #9198 and #9314: launcher fmt, clippy -D warnings and 492 launcher tests pass.
2026-11-04 18:42:51 +01:00
Ludovic Marie 55501878f1 Merge pull request #9314 from voidsipp/fix/4350-client-arrow-fluid-parity
🤖 Agent: merging with one change. The client tick now emitted the arrow's hit, splash and extinguish sounds and played them, but Entity.playSound passes no player to Level.playSound and ClientLevel.playSeededSound plays only for its own player (26.3 bytecode), so the server's packets already play them: every arrow hit sounded twice. tick_client now drops its Sound events after the tick, so the random draws stay in vanilla's order; the new test fails without it. The rest matches AbstractArrow.tick: the whole startFalling/tickDespawn branch is inside !isClientSide. Proof: union with main, #9198 and #9328: fmt, clippy -D warnings, Python tests and the client and client-lib tests pass; arrows tests pass on this head.
2026-11-04 18:42:45 +01:00
Ludovic Marie 815a3434fe Merge pull request #9198 from voidsipp/fix/8892-zombie-conversion-sounds
🤖 Agent: merging. Zombie.convertVillagerToZombieVillager and ZombieVillager.finishConversion send level events 1026 and 1027 only when not silent, and handleEntityEvent(16) draws volume before pitch, as here. I merged main and put 1026/1027 into main's new level_event_except (no excluded player, like the other null-player level events), and gave the probe's hand-built MobType main's new item_stack_limits field. Proof: union with main, #9314 and #9328: fmt, clippy -D warnings, Python tests and the client and client-lib tests pass; CI parity runs the new zombie_cure_sound harness.
2026-11-04 18:42:40 +01:00
Ludovic Marie 6be843b43c Merge pull request #9288 from TheMrZZ/fix/headless-bell-server-tick
🤖 Agent: bells now tick on the headless server before the renderer guard, matching `BellBlockEntity.serverTick`/`makeRaidersGlow`. Small cost to know: while any bell is in `be_anims.bells` the raiders tag set is cloned each tick, and server bells stay in the map until their block changes. Proven with the client tests on a union with main (desktop).
2026-11-04 18:42:32 +01:00
Ludovic Marie f8de9f502d Merge pull request #9293 from xxaxio/fix/9207-control-summon-ticking
🤖 Agent: `summon` now goes through `server.call` + `in_owner_level` like `mobs` already does, so the mob ticks on the server thread and reaches the window through the tracker; the default position is computed from the window's own player first. Nothing else found.
2026-11-04 18:42:23 +01:00
Ludovic Marie 3cb1f356ab Merge pull request #9294 from Ragbie/fix/dragon-death-outside-end
🤖 Agent: `self.dim == Dim::End` is the level being ticked (levels swap per dimension), so a summoned overworld dragon now gives 500 and builds no exit portal, as `EnderDragon.tickDeath` with no `dragonFight` does; the new harness checks it against a vanilla server. Nothing else found.
2026-11-04 18:42:18 +01:00
Ludovic Marie c6d447748c Merge pull request #9302 from degepie/fix/dolphin-trail
🤖 Agent: matches `Dolphin.tick` on a client (NoAI return, in water, lengthSqr > 0.03, four particles at ±0.3 of the yaw, one `1.2 - nextFloat()*0.7` draw a tick from the mob's own random). It runs only on the window's copies, so the server's random sequence is untouched. Nothing else found.
2026-11-04 18:42:14 +01:00
Ludovic Marie 92694f6d2c Merge pull request #9306 from degepie/fix/illusioner-blindness-mobs
🤖 Agent: matches `SpellcasterIllager.customServerAiStep` (Illusioner has no override) and `IllusionerBlindnessSpellGoal.performSpellCasting` blinding any LivingEntity target. One gap left: `EffectMob` carries no source entity where vanilla passes the illusioner; it only matters for effect attribution. Proven with the client tests on a union with main (desktop).
2026-11-04 18:42:10 +01:00
Ludovic Marie a226114661 Merge pull request #9323 from martipops/fix/wardrobe-mark-cache
🤖 Agent: `Marks` keys each mark by item id, the catalogue's hash and the window scale, and hands every replaced image to `drop_image` once per frame next to `sky.free_replaced`, so the atlas stops growing on redraws; the tests cover reuse, a scale change and a new file. Nothing else found.
2026-11-04 18:42:05 +01:00
Ludovic Marie 84e9ba93b5 Merge pull request #6652 from rdbtCVS/revert-ram-limit
🤖 Agent: Ludovic decided on 11-04 to remove Horizon's RAM limit, so this goes in. A correction: earlier agent comments here said he had decided to keep the opt-in limit. That was wrong; he never decided that. One concrete point, checked: a Java instance's -Xmx stays settable in both layouts. Classic: main.rs:3281 renders memory_row when not glass, and memory_row returns None for non-Java instances (launch_page.rs:250). Glass: the Edit dialog renders glass_memory_row (glass_instance.rs:678), and memory_instance filters to Java instances. java_launch.rs:427 still passes -Xmx{memory_mb}M. Our 13:57Z review was wrong about this. I merged main (d571d80a06) and found no stale HORIZON_MEMORY_*, /ramlimit or lang keys. Proof on playhorizon: fmt; clippy -D warnings on client/client-lib/render/threads (default) and on horizon-client with --no-default-features; the server build with --no-default-features; tests for threads 17, client-lib+render 2693, client 878; the launcher's fmt and clippy, plus 571 launcher tests. Thanks rdbtCVS.
2026-11-04 18:38:43 +01:00
Ludovic MarieandClaude Opus 5.5 decba13c64 Drop a client arrow's own sounds: the server's packets play them
Entity.playSound passes no player to Level.playSound and
ClientLevel.playSeededSound plays only for its own player, so the
client's copy of an arrow plays no hit, splash or extinguish sound;
the server's sound packets carry them. Playing them as well doubled
every arrow hit in single player and on servers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:37:50 +01:00
Ludovic MarieandClaude Opus 5.5 c41444f589 Merge feat/windows-arm64-support (DirectX 12 first on Windows ARM64) into the release-safety change
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:27:48 +01:00
Ludovic MarieandClaude Opus 5.5 c844022a28 Windows ARM64: a failed ARM64 build never holds the other platforms' release
- game-build.yml and launcher.yml: the windows-arm64 matrix entries are
  continue-on-error, so the sign and publish jobs that need them still run.
- nightly-build.yml: horizon-windows-arm64.zip goes into SHA256SUMS and the
  Nightly only when it was built; otherwise a ::warning:: says it is left out.
- launcher.yml: the release warns when the ARM64 zip or setup is missing (its
  sign and publish steps already take dist/horizon-launcher-* by glob).
- Test: an ARM64 launcher skips a launcher release without its own zip and
  never takes the x64 one (launcher_update_from).
- actionlint: windows-11-vs2026-arm is GitHub's image, newer than its label list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:15:21 +01:00
voidsipandCodex d93fa85595 Repair HDR clear-budget fixture format hints
Co-Authored-By: Codex <[email protected]>
2026-11-04 11:08:56 -06:00
Ludovic MarieandClaude Opus 5.5 ae0453ebba Merge main into chore/ci-bump-actions-node24
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:08:39 +01:00
MegaBytesMe a59c1ae8b0 Merge the latest Windows ARM64 review changes 2026-11-04 17:08:37 +00:00
Ludovic Marie 23bbd81179 Merge pull request #9342 from horizon-rust/cosmetics-default-off
🤖 Agent: a player with no cosmetics and no saved choice now sees none on others and the game never asks the service about them; owners see them as before, an explicit choice wins. Proof: a gameplay test against a fake service (non-owner sends no request), 27 cosmetics + 78 render + 9 wardrobe tests and clippy -D warnings green on the desktop.
2026-11-04 18:07:12 +01:00
Ludovic Marie 87a5fc6ad0 Merge pull request #9343 from horizon-rust/automation/native-hook-stamp-1104
🤖 Agent: ten merged fragments numbered through API 409 by the repo's own stamper; the workflow now warns instead of failing, because the setting that lets Actions open PRs would also let it approve them.
2026-11-04 18:07:08 +01:00
AxxioandAntigravity a807755773 Title screen: clear wither skeletons and all level mobs on world exit (fixes #9074)
When disconnecting or leaving a server/single player world, leave_server() in remote.rs cleared only top-level mob arrays, leaving GPU skin models, skin frame vertex queues, parked dimension levels, remote objects, and entity draw spans allocated. These cached models could persist into the title screen renderer, causing Nether mobs such as Wither Skeletons to render and wander across the main menu panorama.

- Delegate leave_server() entity cleanup to clear_entities().
- Extend clear_entities() to also clear remote objects, projectiles, parked levels, render vertex buffers, draw spans, entity motion, and reset skin models and frame draws.
- Implement Levels::clear().
- Expose SkinModels::restart() and reset skin draw frames on entity clear.

Fixes #9074.
Managed by Axxio.

Co-Authored-By: Antigravity <[email protected]>
2026-11-04 18:06:38 +01:00
Ludovic MarieandClaude Opus 5.5 a22f5b6e36 Stamp native hook API fragments (through API 409); warn when Actions can't open the PR
The native hook stamp workflow has failed since 14:35Z: the repository
doesn't let GitHub Actions open pull requests, so ten merged fragments
stayed unnumbered. This stamps them on current main with
tools/stamp_native_hooks.py, and makes the workflow leave a warning
naming the pushed branch instead of failing, since enabling that
setting would also let Actions approve pull requests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:03:58 +01:00
Ludovic MarieandClaude Opus 5.5 1586f9e318 Merge #9271 into #9220: one mob fixture for both metadata tests
#9220's own MobType literal lacked main's item_stack_limits field, so its
tests no longer built; #9271 carries the same 50-line fixture with it.
Both now use one fixture_mob that knows the air and the raider species.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:03:23 +01:00
Ludovic MarieandClaude Opus 5.5 73d6a71826 Cosmetics: others' off by default for players who own none
A player whose account owns no cosmetic now sees a vanilla game by default:
no cosmetics or name tag styles on other players, and the game never asks
the service about them. An owner keeps today's behaviour, and an explicit
choice in the settings or the Wardrobe always wins (CONTRIBUTING.md:
Horizon-only features that change the look are off by default).

- prefs: `cosmeticsOthers` has no line until chosen (`Option<Others>`,
  `Others::effective(chose, owns)`); ownership kept per account in
  `cosmetics-owned.txt`.
- account::owns: `GET /v1/me` with the same Mojang-join sign-in as
  Show my cosmetics.
- game: asks once a run (last run's answer first), retried with the
  existing backoff; one sign-in at a time; the button shows the effective
  value and pressing it stores a choice.
- launcher: the Wardrobe (visible to everyone, unchanged) shows the
  effective value and notes the account's ownership for the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:03:21 +01:00
Ludovic Marie c5cc8b8eee Merge main into #9220 2026-11-04 18:03:11 +01:00
Ludovic Marie fbbf21cb10 Merge main into #9229 (through #9228's main merge)
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-04 18:03:01 +01:00
Ludovic MarieandClaude Opus 5.5 282289869b Merge main into #9228
Main's glyph callback returns a font::Glyph (its own box from the pen):
text_vertices takes it, and native_draw_text sums its advance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:02:53 +01:00
Evrim CicekandClaude Opus 5.5 d4128444a9 A NoAI dragon's clients are told so, and a Horizon client stops it too
Mob.DATA_MOB_FLAGS_ID (15) carries NoAI as its first bit: the LAN and
dedicated host now sends it with the dragon, and a Horizon client reads
it and stops the dragon's tick where EnderDragon.aiStep does on a client
(no flap, phase or parts). The harness also checks what a vanilla-codec
client is paired with.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 20:00:36 +03:00
Ludovic Marie 6ddd14e727 Merge pull request #9233 from glitchsnake/fix/8703-breeze-invulnerable-to-breeze
🤖 Agent: matches Breeze.isInvulnerableTo (source.getEntity() instanceof Breeze, the owner for projectiles); main merged (trident/bee conflicts taken from main plus attacker_species, and main's new damage_type test literal filled in). The older wind-charge special case in game/wind.rs now duplicates breeze::accepts_damage: follow-up. verify green at this head.
2026-11-04 17:59:16 +01:00
Ludovic Marie d9c0f0bc47 Merge main into #9278 2026-11-04 17:59:15 +01:00
Ludovic MarieandClaude Opus 5.5 a3842d6ad6 mcpr: don't index a packet cut short; take the version from horizon_core
The index loop pushed a packet whose length ran past the end of the
stream (a recording that stopped mid-write), so seeking to it could
only fail. Metadata's mcversion follows VANILLA_VERSION instead of a
literal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:59:15 +01:00
Ludovic Marie 8f297e4b43 Merge main into #9262
# Conflicts:
#	crates/horizon-client/src/game/fishing.rs
2026-11-04 17:58:45 +01:00
Ludovic MarieandClaude Opus 5.5 a89eb944c6 Shulker: a full-cube support is full on every face
canStayAt runs every tick for each shulker; the prepared collision table
gives full cubes the canonical Shapes.block() identity, so skip the face
shape and XOR join for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:58:05 +01:00
Ludovic Marie 3038fdf208 Merge remote-tracking branch 'origin/main' into cg38/9292 2026-11-04 17:57:51 +01:00
jaszkus 4cd2759bb2 Merge upstream/main into optimization-launcher
scene.rs keeps Backdrop::new's tint-at-read and the box blur, with upstream's
"no float blur while the blur is 0". launcher/lang/*.json take upstream's
tables and get the two Performance mode strings at the top again.
2026-11-04 17:57:50 +01:00
Ludovic MarieandClaude Opus 5.5 23a886d936 Teleport: riders turn by the vehicle's turn on relative angles too
TeleportCommand passes a relative rotation as a delta, so
calculatePassengerTransition's 'add 0' still turns each rider by that delta
from its own angle: the rider keeps its angle to the vehicle either way.
Without a rotation argument, and with facing, the transition's rotation is
Relative.ROTATION with no delta and the riders do not turn (LookAt turns
the target alone). Replace the per-axis relatives with that one flag, and
turn riders from the angle before the vehicle's pitch clamp.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:57:40 +01:00
Ludovic Marie 862be0a0d2 Merge pull request #9279 from MSedgeMC/fix/lectern-page
🤖 Agent: a lectern with no book now reads page -1 as vanilla's Mth.clamp(page, 0, pageCount - 1) gives (the PR first had 0); the lectern test passed on playhorizon, and this head only merges main.
2026-11-04 17:56:42 +01:00
Ludovic Marie bb84ae23e1 Merge main into #9222
# Conflicts:
#	crates/horizon-client/src/mod_api.rs
2026-11-04 17:56:40 +01:00
Ludovic Marie 1da9966c91 Merge main into #9225 2026-11-04 17:56:13 +01:00
Ludovic MarieandClaude Opus 5.5 1b939383ca container_accepts fragment: drop the comments key
The stamp tool stamps native_hooks.rs itself and refuses it in `comments`
(test_stamp_native_hooks failed on this branch).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:56:13 +01:00
Ludovic MarieandClaude Opus 5.5 b2ebfbe833 Merge origin/main into feat/windows-arm64-support
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:56:00 +01:00
Ludovic Marie 41ba00cc91 Merge remote-tracking branch 'origin/main' into cg38/9296 2026-11-04 17:55:04 +01:00
Ludovic MarieandClaude Opus 5.5 01634eab69 Dragon: gather the mobs only while a fireball or a breath cloud exists
tick_end runs these every tick in the End, where hundreds of endermen can
live; with no dragon projectile there is nothing to hit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:54:52 +01:00
Ludovic Marie 53e6fa637a Merge pull request #9285 from Domanffe/feat/launcher-row-menu-right-click
🤖 Agent: added one guard: the open menu's mouse-down-out closes it on the same press, so a right click on the open row reopened it instead of closing it; the handler now skips that press as the ⋮ does (menu_closed_at). Desktop launcher run at this head: fmt, clippy -D warnings, all tests 0.
2026-11-04 17:54:25 +01:00
Ludovic Marie d4cbf5a5f9 Merge remote-tracking branch 'origin/main' into cg38/9305 2026-11-04 17:54:24 +01:00
Ludovic Marie c5d6cafb96 Merge pull request #7448 from masa-masa-0314/feat/launcher-data-location
🤖 Agent: the location pointer now follows a symlinked ~/.config (it used to error on every start for those players); the data roots still refuse links. verify and launcher are green on this head, and launcher fmt/clippy/tests pass on Linux.
2026-11-04 17:54:20 +01:00
Ludovic MarieandClaude Opus 5.5 adf2038d70 Audio: fall back to cpal's default buffer when a device refuses the short one
A fixed ALSA period that a device does not support made build_output_stream
fail, which left the game with no sound on that device. Retry with the
default configuration then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:54:14 +01:00
Ludovic Marie f362f48749 Merge remote-tracking branch 'origin/main' into cg38/9321 2026-11-04 17:54:03 +01:00
Ludovic MarieandClaude Opus 5.5 f6c4c0b17e Merge main into #9314
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:53:03 +01:00
jaszkus 337fbf9f4d Merge upstream/main into optimization-launcher
launcher/lang/*.json take upstream's tables and get the two Performance mode
strings at the top of each, so later additions at the end of upstream's tables
no longer conflict.
2026-11-04 17:52:36 +01:00
MegaBytesMe f7484e79d9 fix(windows): address ARM64 support review feedback
Prefer DirectX 12 for Auto on Windows ARM64 while preserving Vulkan as an option. Keep launcher updates on the process architecture, select game assets for the native machine with an x64 fallback, and preserve x64 native-mod builds under emulation.
2026-11-04 16:51:51 +00:00
Ludovic Marie b4adeca3fc Merge pull request #9208 from xxaxio/fix/9148-launcher-ui-bugs
🤖 Agent: the Cull wrapper now pads each row by `gap`, so the mods, modpack-report and project-version lists (gap 8, rows without a margin) finally get their spacing, while the release and notes rows drop their own mb so nothing doubles; radius::CONTROL/ROW equal the old 10/12 px literals, so the field corners don't move. Launcher clippy -D warnings and 476 tests green on this head.
2026-11-04 17:50:11 +01:00
Ludovic MarieandClaude Opus 5.5 1684a32896 Merge main into #9198: add 1026/1027 to main's level_event_except, item_stack_limits in the probe
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:49:35 +01:00
Ludovic Marie 8a170c33ec Merge pull request #9256 from martipops/fix/shop-window-clock
🤖 Agent: #[serde(other)] Unknown lets /v1/shop and /v1/me answers with a state a newer service adds still parse, so the Wardrobe's top-bar check (shop::shown, which reads open and the dates, not the state) keeps working; the test covers all three answers and the on-disk round trip.
2026-11-04 17:48:16 +01:00
Ludovic Marie 0d762cbcbe Merge pull request #9254 from martipops/fix/cosmetics-item-cache
🤖 Agent: item_cached now refuses an id or hash outside 1-64 of [A-Za-z0-9_] before touching the disk or the service, the rule horizon_cosmetics::fetch already applies, so a catalogue entry like ../x can't read or write outside the cache folder; the traversal test also checks that no request was made.
2026-11-04 17:48:12 +01:00
voidsipandCodex 8125129805 Keep mod installation beside search results
Co-Authored-By: Codex <[email protected]>
2026-11-04 10:45:21 -06:00
Ludovic Marie ae3823b97a Merge main into #9233
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/trident.rs
#	crates/horizon-client/src/mobs/bee.rs
2026-11-04 17:40:53 +01:00
Ludovic MarieandClaude Opus 5.5 6461774f70 Merge main into #9285
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:39:46 +01:00
Ludovic MarieandClaude Opus 5.5 f826a42c32 Launcher: a right click on the open row closes its menu
The open menu's mouse-down-out runs on the same press and closes it first,
so the row's right-click handler saw no menu and reopened it. Skip that
press, as the ⋮ does with closed_by_this_press.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:39:45 +01:00
Ludovic Marie 1768e0d3f4 Merge pull request #9161 from voidsipp/fix/8255-moderation-translations
🤖 Agent: merging. Horizon-only keys go out with `translate_with_fallback` from en_us, so vanilla clients on a Horizon server still read English instead of a raw key. Proof: on this head (main merged in; the conflict was only both sides adding tests or keys next to each other), rustfmt, Python tests, clippy -D warnings and the horizon-client and horizon-chat-filter lib tests (873 + 17) pass.
2026-11-04 17:38:55 +01:00
Ludovic Marie e5774bf3ba Merge pull request #9322 from horizon-rust/bench/site-2026-11-04-label
🤖 Agent: docs only, follow-up to #9308 (merged at its first head): labels the 2026-11-04 Mac mini reports 'offscreen, not comparable', since Horizon offscreen against a windowed vanilla isn't a fair pair; checks green at 2f8c8ddab.
2026-11-04 17:38:49 +01:00
Ludovic Marie 6275a3a20e Merge pull request #9195 from razzo715/fix/item-block-containers
🤖 Agent: merging. `/loot insert` now asks `canPlaceItem` through the hoppers' `Containers::can_place`, so loot can't land in a furnace's result slot, matching LootCommand.distributeToContainer. Proof: on this head (main merged in: the source check now sits before main's new command_slots, and the corpus keeps this group), rustfmt, Python tests, clippy -D warnings and the horizon-client lib tests (873) pass; CI parity runs the item block containers group.
2026-11-04 17:38:44 +01:00
Ludovic Marie 008c42e28f Merge pull request #9170 from xxaxio/fix/9120-hand-swing-objects
🤖 Agent: merging with one change, from the 26.3 client's bytecode: AbstractBoat.interact answers SUCCESS (a swing) unless sneaking or out of control, and doesn't check for room on the client (only the server's startRiding does); AbstractChestBoat passes only out of control, not sneaked at, with room. Minecart.interact is the one that answers CONSUME. Paintings and item frames were right. Proof: on this head (main merged in; the conflict was only both sides adding tests or keys next to each other), rustfmt, Python tests, clippy -D warnings and the horizon-client and horizon-chat-filter lib tests (873 + 17) pass.
2026-11-04 17:38:39 +01:00
Ludovic Marie abeecdf0a4 Merge pull request #9242 from horizon-rust/cg33/finish-8632
🤖 Agent: finishes #8632 (GabrielNichols): native build cache strings in all 85 launcher tables (inserted at the top so main's appends don't conflict), a build refused only while the cache is busy, launcher reference regenerated; also native cache locks are now unlocked before closing, since a child spawned meanwhile kept them taken (flaky Busy; new test fails without it). Desktop run at this head: fmt, clippy -D warnings, all launcher tests 0.
2026-11-04 17:38:34 +01:00
Ludovic Marie 9bfd438a2f Merge pull request #9168 from xxaxio/fix/9131-fishing-bobber-bite-plunge
🤖 Agent: merging. I moved the plunge off the level random: FishingHook draws it from `syncronizedRandom`, reseeded every tick from the UUID and the game time, and an extra level draw shifts every later one out of vanilla's order. It is also computed in float, as Mth.nextFloat does. rdbtCVS's agent then shared the helper with remote hooks. Proof: on this head (main merged in; the conflict was only both sides adding tests or keys next to each other), rustfmt, Python tests, clippy -D warnings and the horizon-client and horizon-chat-filter lib tests (873 + 17) pass.
2026-11-04 17:38:29 +01:00
Evrim CicekandClaude Opus 5.5 b111b38750 An ender dragon with NoAI stays where it is
EnderDragon.aiStep returns after the flap and checkCrystals when
isNoAi() (flapTime = 0.5): no flight history, phase, steering or parts.
Horizon's dragon never read NoAI: a NoAI dragon flew off, out of a
force-loaded chunk, and a save could then lose it. The dragon now loads
and saves NoAI, and its tick stops there.

tools/test_dragon_no_ai_parity.py compares the dragon's position and NoAI
after 100 ticks and after a restart with vanilla's dedicated server.

Fixes #9316

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:38:16 +03:00
Ludovic MarieandClaude Opus 5.5 d571d80a06 Merge origin/main into revert-ram-limit (#6652)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:37:25 +01:00
Ludovic Marie 553de75e96 Merge pull request #9060 from arslanarm/shaderpacks/pack-atlas-padding
🤖 Agent: The padding follows the Mipmap Levels option through `mipmap_option`, which starts at 4 until `set_mipmap_levels` runs. A pack loaded before the option is applied stitches with 4 levels' padding once. Minor. I pushed cddb5526: merged main after #9059 landed (clean). Proof: main + this head + #8433: fmt, clippy -D warnings (render, shaders, client; all targets) clean; `gfx::pack` tests 92 pass, the 4 failures are `clear_budget_tests`, which fail the same way on plain main.
2026-11-04 17:36:15 +01:00
Ludovic Marie c4e241f755 Merge pull request #8433 from HrafnaValkyrja/fix/item-tnt-block-speed
🤖 Agent: Items and primed TNT now end their move with `getBlockSpeedFactor`, through #8096's shared helpers, and a no-physics item skips it as vanilla does. Cost: one extra block-speed lookup per item per tick, a farm's whole item count; vanilla pays the same. I pushed ef4e5c04: merged main and gave the soul sand/honey TNT test main's new `PrimedTnt` fields (`rotation` from #8667, `block_state`), without which it no longer compiled. Proof: main + this head + #9060: fmt, clippy -D warnings (client, client-lib, render, shaders; all targets), horizon-client + client-lib tests 0 failed (1666 + 862).
2026-11-04 17:36:10 +01:00
Ludovic MarieandClaude Opus 5.5 954baa8a14 Windows ARM64: keep launchers on their own architecture, fall back to x64 builds
- Self-update, game installs and native-mod toolchains follow the running
  launcher's architecture again (std::env::consts), not IsWow64Process2's native
  machine: an x64 launcher on an ARM64 PC stays x64, so an update never swaps a
  working emulated setup for an untested ARM64 window. The ARM64 launcher comes
  from horizon-launcher-setup-arm64.exe.
- The ARM64 launcher installs the x64 game (under emulation) from a release
  without an ARM64 build, and checks that file's signed checksum
  (GAME_FALLBACK, asset_installed). Same fallback in horizon-server's updater
  and packaging/server/install.ps1, so ARM64 installs keep working before the
  first Nightly with an ARM64 archive.
- ci.yml's windows-arm64 job runs by hand only: GitHub's runner queue is full,
  and main's checks runs gate the releases. The Nightly and launcher releases
  build the ARM64 archives every run.
- launcher.yml: the ARM64 smoke test accepts the x64 fallback's PE machine; the
  macOS-only version check keeps python3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:35:57 +01:00
Ludovic MarieandClaude Opus 5.5 871a75e6f2 Merge origin/main into fix/lectern-page
#9189's chest loot-table test landed beside the lectern test: both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:35:26 +01:00
Ludovic MarieandClaude Opus 5.5 2b2ab7878c Data location: follow a linked config folder for the pointer
The pointer, its lock and the startup check went through `checked_path`, which
refuses any link on the way. A Linux player whose ~/.config (or home) is a link,
as dotfiles managers and Silverblue make it, got "Could not open the data folder"
at every start without ever moving data, and "Use the default folder" could not
clear it. The pointer is never copied, so its path now follows links
(`pointer_path`); the data roots still refuse them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:34:36 +01:00
Ludovic MarieandClaude Opus 5.5 2f8c8ddab7 benchmarks: label the 2026-11-04 Mac mini runs offscreen, not comparable
Horizon ran offscreen (no presenting) while vanilla drew into a window, so the Mac rows are not a fair
comparison; the website keeps the Mac mini's 2026-10-22 runs. The reports stay as a record, labelled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:34:20 +01:00
Ludovic MarieandClaude Opus 5.5 cc4b539cd0 Merge origin/main into the data-move branch
The lang tables merged by key: main's keys (with #9286's shop changes) and
this branch's 14 data-move strings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:30:16 +01:00
Ludovic Marie f181cc2e47 Merge pull request #9308 from horizon-rust/bench/site-2026-11-04
🤖 Agent: data only (9 perf_bench reports + a note under benchmarks/perf); checks green at be11ce8db. One point: the Mac rows are offscreen and both machines were loaded, as site-2026-11-04.md says, so don't read them as clean ratios.
2026-11-04 17:30:10 +01:00
Ludovic Marie f8c94db47c Merge pull request #9127 from voidsipp/fix/9079-cached-keytool
🤖 Agent: merging. `shutil.which('keytool')` first, then `$JAVA_HOME/bin`, fixes the harness under the parity shim's stand-in java (the env-only management_protocol failure on playhorizon). Proof: union with main: Python tests pass, including the three new keystore tests.
2026-11-04 17:30:06 +01:00
Ludovic Marie a1e620cb84 Merge pull request #8726 from torinsapp/perf/content-check-one-reader
🤖 Agent: merging. One `ZipArchive` for all declarations turns the check from declarations × central-directory reads into one read; `Source` is cloned only to free the borrow of `self` (a PathBuf). Proof: union with main: fmt, clippy -D warnings and horizon-mods tests pass.
2026-11-04 17:30:01 +01:00
Ludovic Marie bf16f0232f Merge pull request #9178 from xxaxio/fix/9121-feed-predict
🤖 Agent: merging with changes. Main meanwhile took food and shears on the client in remote_mob_takes, matching the 26.3 bytecode (Animal.mobInteract and Sheep.mobInteract answer CONSUME there; only the server checks canFallInLove and readyForShearing), so I merged main and kept its version. What this PR still adds is the dye rule, now `sheep_takes`: DyeItem.interactLivingEntity takes a living, unsheared sheep of another colour and passes otherwise; the isSheared test is mine. Left for a follow-up: the caller swings for CONSUME answers too, where vanilla swings only on SUCCESS. Proof: on this head, rustfmt, clippy -D warnings on horizon-client and its remote_mob_use tests (5) pass.
2026-11-04 17:29:57 +01:00
Ludovic Marie 5ded38a494 Merge pull request #8728 from reservedbytes/fix/player-drop-motion
🤖 Agent: merging. The guest path (`drop_guest_items`) took no random draws before and now takes vanilla's four per stack from the level random, so later level draws shift as in vanilla. Vanilla draws from the entity's own random; Horizon keeps the level's in all three places, as before. Proof: union with main: fmt, clippy -D warnings and client tests pass, including the bit-exact drop_motion test.
2026-11-04 17:29:53 +01:00
Ludovic MarieandClaude Opus 5.5 2fa97798c2 Merge main into #9161: keep both sides' lang keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:28:31 +01:00
Ludovic MarieandClaude Opus 5.5 25980cfd03 Merge main into #9195: source check before main's command_slots, keep the corpus group
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:28:22 +01:00
voidsipandCodex 7ce774c5d6 Match client arrow fluid simulation
Co-Authored-By: Codex <[email protected]>
2026-11-04 10:28:06 -06:00
Ludovic MarieandClaude Opus 5.5 5c6cdd645b Merge main into #9170: keep both sides' remote tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:27:56 +01:00
Ludovic MarieandClaude Opus 5.5 575cf3e0e7 Merge main into #9168: keep both sides' fishing tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:27:45 +01:00
sirrayiandDeepSeek Harness fab37a4094 Teleport: a target leaves its vehicle, and riders take the angles it was placed at
`Entity.teleport` stops riding unless the transition is `asPassenger`, and
`calculatePassengerTransition` gives each rider the vehicle's new angles plus
its own offset (`passenger.getYRot() - getYRot()`), or the vehicle's angle
alone on an axis the command set relatively (`+ (relatives has Y_ROT ? 0 : ...)`).
The shared writer only moved riders' positions and only the player arm
dismounted, so `/tp` on a spider jockey or a mob in a boat was seated again by
the next sync (the command reported success and nothing moved), and a rotated
`/tp` left mob riders, the host and guests facing where they were.

- `teleport_to_in_level` dismounts a mob or an armor-stand target before it is
  placed (`Entity.teleport` -> `stopRiding`), and carries the command's per-axis
  relatives for the riders.
- `teleport_passengers` places the riders at the angles the vehicle was placed
  at (`passenger_rotation`): mob riders, the host riding a vehicle and the
  hub's guests, recursively, pitched through `Entity.setXRot`'s clamp. A
  relative axis gives the rider the vehicle's own angle. A turned guest gets one
  position packet carrying its new absolute rotation with its motion kept
  (`Hub::teleport_rider`).

Fixes #9289

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-11-04 16:27:32 +00:00
Ludovic Marie 8069aa8ced Merge pull request #9237 from NerfPhaselock/perf/gpu-culling-g3
🤖 Agent: the shader's facing test compares order-preserving u32 keys of the same f32 thresholds visible_from uses, MARGIN included, so no driver's subnormal flush can change a run; a grouped mesh keeps one whole draw, and the 4-run cap fits the 3-bit count in runs.
2026-11-04 17:27:25 +01:00
martipopsandClaude Opus 5.5 e09c4098ad Launcher: keep the Wardrobe's name tag mark instead of baking it each draw
Each draw baked a new image for the equipped name tag, and GPUI keeps every one
in the sprite atlas until it is dropped. Marks are now kept per item id, its
file's hash and the window scale. One replaced by a scale change or a new file
is dropped from the atlas on the next frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp7yEJwPTJ2kw29LWdk1
2026-11-04 11:25:26 -05:00
gorkandClaude Code a53483eeb0 Reduce Linux CPAL audio buffer latency
Fixes #4683

Co-Authored-By: Claude Code <[email protected]>
2026-11-04 17:24:51 +01:00
Ludovic Marie be704330d9 Merge pull request #9282 from MSedgeMC/fix/bundle-allow-modification
🤖 Agent: the bundle click now checks may_pickup and may_place on the slot, as vanilla Slot.allowModification does, so a bundle in a take-only or locked slot keeps its contents; the new test covers the withheld slot.
2026-11-04 17:23:27 +01:00
Ludovic MarieandClaude Opus 5.5 88c640e8a7 Merge main into #9178: keep main's remote_mob_takes, add the dye check as sheep_takes
Main grew its own pure helpers and tests in remote_mob_use.rs (nautilus,
golems, allay, armadillo). This keeps main's version and adds only what
this PR got right: DyeItem.interactLivingEntity takes an unsheared sheep
of another colour and passes otherwise; shears stay CONSUME on a client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:23:12 +01:00
Ludovic MarieandClaude Opus 5.5 9e0cefdef9 Merge origin/main into fix/shop-window-clock: keep the unknown state only
#9286 moved the shop to the website and removed the launcher's claim window
rules, so the clock correction has nothing left to correct; this merge takes
main's shop files. ClaimState::Unknown still matters: /v1/shop and /v1/me
answers decide whether the Wardrobe is in the top bar, and a state a newer
service adds made them fail to parse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:21:30 +01:00
Ludovic MarieandClaude Opus 5.5 ea201cfa97 Launcher update card: the version on its own line; a failed install drops its download
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:21:29 +01:00
Ludovic MarieandClaude Opus 5.5 8d09551a20 Merge origin/main into fix/cosmetics-item-cache
#9286 removed Entry::lines; the merge keeps Entry::plain and drops lines
from the tests' entry helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:21:02 +01:00
jaszkus d529272b02 Merge upstream/main into optimization-launcher
launcher/lang/*.json take upstream's tables and get the two Performance mode
strings at the top of each, so later additions at the end of upstream's tables
no longer conflict.
2026-11-04 17:20:04 +01:00
Evrim Cicek e7288a7b9b Merge remote-tracking branch 'upstream/main' into fix/silent-dragon-death
# Conflicts:
#	mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java
2026-11-04 19:19:18 +03:00
Ludovic MarieandClaude Opus 5.5 a82ff8ba68 Merge origin/main into feat/windows-arm64-support
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:18:28 +01:00
Ludovic Marie d190e2b642 Merge pull request #9153 from arslanarm/shaderpacks/custom-uniform-scalar-types
🤖 Agent: Built-ins are typed int/float/bool from the probe tables. A custom uniform whose declared type the expression doesn't give reads 0 as a whole, with a note naming it. Nothing else found. I pushed 5eef8152: merged main.
2026-11-04 17:16:29 +01:00
Ludovic Marie 8d856de117 Merge pull request #8659 from Teiwazik/older-servers/dropped-components
🤖 Agent: merging. One cost to know: every item component read now asks `ids::dropped_component` and `ids::quirks`, two thread-local reads, even on a 26.3 server; cheap next to the component decode itself. Proof: union with main: fmt, clippy -D warnings and horizon-protocol tests pass, including the three new wire fixtures from 1.21.11 registries.
2026-11-04 17:16:24 +01:00
Ludovic Marie 4bb36fac4c Merge pull request #9105 from arslanarm/shaderpacks/terrain-texture-matrix
🤖 Agent: `textureMatrix` maps to `gl_TextureMatrix[0]` in vertex stages only, while a fragment stage's own `textureMatrix` keeps its frame slot (zero), matching the probe. Merged with #9180's tuple form of `core_matrix` (type `mat4`). Nothing else found. I pushed e8d6861b: merged main (#9057/#9180 landed).
2026-11-04 17:16:20 +01:00
Ludovic MarieandClaude Opus 5.5 40e4d970b0 Load a bookless lectern's page as -1, as vanilla's Mth.clamp does
LecternBlockEntity.loadAdditional clamps Page to 0..getPageCount(book) - 1,
and getPageCount is 0 without a book, so Math.min(Math.max(page, 0), -1)
gives -1. Every Horizon reader of the page applies max(0) first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:13:46 +01:00
Ludovic MarieandClaude Opus 5.5 56827bc8f8 Native cache: release locks before closing them
A child process spawned while a lease or a Cargo lock is held keeps a copy
of the descriptor until its exec, so closing ours alone left the lock taken
and the next lease or cleanup read Busy (seen as a flaky
clears_only_disposable_outputs... test while other tests spawned Cargo).
Unlock explicitly on drop: that frees it for every copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:13:02 +01:00
Florian ERNSTandCodex 6fc1ba82b4 Merge latest main for advancement reward dispatch
Co-Authored-By: Codex <[email protected]>
2026-11-04 17:12:50 +01:00
Ludovic Marie fed60fb986 Merge pull request #9150 from JulesB40/fix/piston-block-updates-next-tick
🤖 Agent: the changed blocks now go out between the random ticks and runBlockEvents, as in ServerLevel.tick, and what the events and block entities write is held to the next tick; piston-slide measured 3 ticks for every move in three runs on a union with main, piston-push and predict pass, and I fixed the check counting from the previous move's head.
2026-11-04 17:12:33 +01:00
Ludovic Marie 69c3e37905 Merge pull request #8954 from Cheezer1656/upstream/native-send-packet
🤖 Agent: main merged again (mod_api.rs keeps both ClientboundPlayPacket and ServerboundPlayPacket re-exports); native send-packet built, clippy and tests green on union 760aa5921.
2026-11-04 17:12:29 +01:00
Ludovic Marie 9014294e55 Merge pull request #9191 from Domanffe/chore/port-map-landed-rows
🤖 Agent: docs only; the seven functions it marks as ported all exist on main, union 760aa5921 green.
2026-11-04 17:12:25 +01:00
Ludovic Marie 3aac8eb917 Merge pull request #8616 from senjinthedragon/hdr-look
🤖 Agent: merging. Off, `hdr.lighting && hdr.look` also skips the water and cloud second passes, so HDR Lighting off costs less than on, and NikitaMGrimm's PQ captures show 100% of pixels within 1 code of SDR. I moved `set_hdr`'s doc comment back onto `set_hdr` (their nit). Proof: union of this head with main: fmt, clippy -D warnings, render and client tests pass.
2026-11-04 17:12:21 +01:00
Ludovic Marie 21d3fffde2 Merge pull request #9160 from Joansitoh/fix/8701-trident-breeze
🤖 Agent: matches `Projectile.hitTargetOrDeflectSelf` + `Breeze.PROJECTILE_DEFLECTION`: REVERSE power 1, the sound from the breeze, no `onHit`, and `lastDeflectedBy` stops a second turn. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 17:12:17 +01:00
Ludovic MarieandClaude Opus 5.5 111692c509 Launcher: "A new version is available" with Update; copies that can't update say so (#9287)
Launcher 0.2.0 reaches 0.2.40 by itself wherever it can write its own folder (checked with the real
0.2.0 builds on macOS and Linux: tags, signing key, legacy list and asset names all still work). A copy
in a read-only place (the disk image, macOS's translocated copy of an app never moved out of
Downloads, a .zip opened in Explorer) fails at every check and said so only on Settings' Launcher
line, so the player never saw it.

- The window downloads and checks the new launcher (backend::stage_update), then the sidebar says
  "A new version is available" with Update: the window closes, the launcher's own file runs as the
  installer (`apply-update`, update_handoff.rs), waits for the window's process to exit, puts the new
  launcher in place with the same code `horizon-launcher update` uses (place_staged) and starts it.
  Closing the window with an update downloaded installs it too, so launchers still update themselves.
- A copy that can't replace itself (backend::cannot_replace_itself) says so with Download again (the
  website's install page); a failed install starts the old launcher, which says why.
- Docs: launcher/README.md, the "download again once" row in docs/versioning.md; README's release
  link no longer filters on the old launcher-v tags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:11:37 +01:00
Ludovic MarieandClaude Opus 5.5 010ab500f9 lan_e2e piston-slide: count from the head that lands after the move
The second extension starts with the head the first one left, so taking the
first piston_head read gave a negative count. The landed head is now the first
after the moving block. A loaded machine can run two client ticks between
reads: when the read before the move or before the landing skipped one, the
check says so instead of reporting a wrong count. On playhorizon (load 25-47)
three runs measured 3 ticks for every move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:09:23 +01:00
Ludovic Marie ff8a09cc62 Merge pull request #9118 from Joansitoh/fix/8715-armadillo-click
🤖 Agent: Armadillo.mobInteract is FAIL while isScared() before Animal.mobInteract, so spider eyes no longer end the click on a rolled-up armadillo. Also fixed: brushOffScute is false on a baby, so a baby's brush is not taken (38c31cac5d). The main merge 1e9a62f124 only resolves the import line with #8691 (same resolution as the cg-31 union). Proof: cg-31 union on main 6d43a3593b (playhorizon): fmt, clippy -D warnings, client+lib tests 1638+850 ok.
2026-11-04 17:08:46 +01:00
Ludovic Marie 94a184e236 Merge pull request #9172 from voidsipp/fix/8978-mod-install-feedback
🤖 Agent: merging. `native_notice` is already a plate, so taking it out of `card` also removes the plate-in-plate it had in the results view. Proof: union with main: launcher fmt, clippy -D warnings and lib tests pass.
2026-11-04 17:08:41 +01:00
Florian ERNSTandCodex b9091525a7 Merge latest main while preserving advancement reward regression
Co-Authored-By: Codex <[email protected]>
2026-11-04 17:08:10 +01:00
Florian ERNSTandCodex 381517f87f Run advancement reward functions through the Game dispatcher
Co-Authored-By: Codex <[email protected]>
2026-11-04 17:07:08 +01:00
Ludovic Marie 1e9a62f124 Merge remote-tracking branch 'origin/main' into cg31fix-9118
# Conflicts:
#	crates/horizon-client/src/game/remote_mob_use.rs
2026-11-04 17:06:09 +01:00
Ludovic Marie 47f1c39b7e Merge pull request #7998 from horizon-rust/launcher-loader-version
🤖 Agent: loader version picker, stacked on #7740, green on this head; lands after it.
2026-11-04 17:05:12 +01:00
Ludovic Marie 2980894c35 Merge pull request #7987 from horizon-rust/launcher-java-mrpack
🤖 Agent: .mrpack import, green on this head; rdbtCVS's change request was dismissed because it rested on #6660, closed by Ludovic (Java instances stay).
2026-11-04 17:05:07 +01:00
Ludovic Marie e57b1d34bb Merge pull request #7740 from horizon-rust/launcher-forge-install
🤖 Agent: Forge install for Java instances, green on this head; the only blocker was a change request resting on #6660, which Ludovic closed (Java instances stay).
2026-11-04 17:05:03 +01:00
Ludovic Marie dc5a91ac44 Merge pull request #9299 from horizon-rust/feat/partner-afri-network
🤖 Agent: the partner entry goes below the official one through the same once-only marker logic, so a player who deletes it never gets it back; proof: multiplayer::tests (20) and clippy -D warnings green on the desktop.
2026-11-04 17:04:59 +01:00
Ludovic Marie d08d24e9c2 Merge pull request #8651 from Teiwazik/fix/leave-server-sounds
🤖 Agent: merging. On the single-player path these three calls repeat what finish_close_world already did, and `stop_all` on an empty mixer is a no-op, so the only change is for leaving a server or being kicked. Proof: union with main: fmt, clippy -D warnings and client tests pass.
2026-11-04 17:04:55 +01:00
Ludovic Marie 1a91ea6936 Merge pull request #8691 from CocofireHD/fix/turtle-dig-level-event
🤖 Agent: lands the whole stack (#8637 #8644 #8647 #8652 #8657 #8658 #8660 #8690). Concrete fix: be258bc235 sent every ParticleAt as a speed-0.1 packet (ink at 1/100 speed, client-side hearts/splash/smoke drawn twice); squid ink is now MobEvent::LevelParticles as Squid.spawnInk's sendParticles, and main's wolf test got the new client_tick argument. Proof: 569f9644dc in the cg-31 union on main 6d43a3593b (playhorizon): fmt, clippy -D warnings, client+lib tests 1638+850 ok; CocofireHD's later main merge 5839363b51 only resolves allays.rs (main-hand check + swing kept on main's Option<ItemStack> inventory).
2026-11-04 17:04:50 +01:00
Ludovic MarieandClaude Opus 5.5 cddb55263c Merge main into shaderpacks/pack-atlas-padding
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:03:31 +01:00
Ludovic MarieandClaude Opus 5.5 ef4e5c049d Merge main into fix/item-tnt-block-speed
The soul sand/honey TNT test gets main's new PrimedTnt fields (rotation, block_state).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:03:30 +01:00
Ludovic Marie 42ae7fffe1 Merge pull request #9115 from Joansitoh/fix/8890-conversion-flags
🤖 Agent: ConversionType.convertCommon also copies PortalCooldown, InvulnerableTime, the fire flag and entity tags, which are still not carried over; the five flags here match. The land-stop was main's new MobType field item_stack_limits in this test's literal: main merged and the field added (79576177db). Proof: cg-31 union on main 6d43a3593b (playhorizon): fmt, clippy -D warnings, client+lib tests 1638+850 ok.
2026-11-04 17:02:03 +01:00
Ludovic Marie 6b91fe90d5 Merge pull request #8707 from CocofireHD/fix/attribute-cross-level
🤖 Agent: /attribute now runs in the target's level (in_target_level), so an unbounded @e from the Nether reaches the Overworld cow, as EntitySelector.findEntities searches every level. Proof: command_exec harness PASS (82 s) with the 8 new corpus cases on the cg-31 union on main 6d43a3593b (playhorizon), plus fmt, clippy -D warnings, client+lib tests 1638+850 ok.
2026-11-04 17:01:59 +01:00
Ludovic Marie 3070364215 Merge pull request #8907 from Joansitoh/fix/shulker-teleport-render
🤖 Agent: the slide now starts from the move packet (createInterpolationHandler NO_OP) and is only counted down in client_tick; a riding shulker gets the peek reset without the slide. First parity run died in 2 s on the runner's dead .horizon-prepare.lock link (environment); rerun 41856251948 queued. Proof: cg-31 union on main 6d43a3593b (playhorizon): fmt, clippy -D warnings, client+lib tests 1638+850 ok.
2026-11-04 17:01:55 +01:00
Ludovic Marie aab356c6c4 Merge pull request #8648 from itarqos5/feat/drop-a-client-build
🤖 Agent: a drop starts the same start_build_import the picker uses, behind can_import_build, so a removal in progress can't race it; .exe/.gz/.tgz are taken unread so build_import gives the precise error, and extensionless files are read last. I fixed the wrap spaces in the two error strings and merged main (the stale-key verify failure came from main, fixed by #9265).
2026-11-04 17:01:48 +01:00
Ludovic Marie 46322061d7 Merge pull request #9200 from Domanffe/fix/dropped-pack-copy-errors
🤖 Agent: a failed datapack copy now reports its error instead of passing silently; a half-copied pack still stays in datapacks/ (no cleanup), worth a follow-up. Union 760aa5921 tests green.
2026-11-04 17:01:43 +01:00
Ludovic Marie f85777b490 Merge pull request #8971 from elythepreciouscat/perf/creature-projectile-tick-optimizations
🤖 Agent: the guest-entity and dead-guest lists no longer hang on has_guests (which skips dead and spectating guests), so a lone dead player's ender pearl still vanishes as in vanilla; pearl_death and the client tests passed on union 760aa5921.
2026-11-04 17:01:39 +01:00
Ludovic Marie c496609c3a Merge pull request #9189 from voidsipp/fix/8920-chest-loot-load
🤖 Agent: merging. Only chests get the precedence; barrels, shulker boxes, hoppers and dispensers load through the same RandomizableContainer.tryLoadLootTable in vanilla and still keep their Items here: a follow-up. Proof: union with main: fmt, clippy -D warnings and client tests pass; CI parity runs the new chest_load harness.
2026-11-04 17:01:34 +01:00
Ludovic Marie e9448bd997 Merge pull request #9143 from Ragbie/fix/silent-elder-curse
🤖 Agent: merging. The host's own path now gates only the sound on `elder_curse_param`, and the apparition stays unconditional, which is what ClientPacketListener.handleGameEvent does for GUARDIAN_ELDER_EFFECT. Proof: union with main: fmt, clippy -D warnings and client tests pass (the new guardian test included); CI parity runs the new elder_curse harness.
2026-11-04 17:01:29 +01:00
Ludovic Marie 5568e9d693 Merge pull request #9138 from voidsipp/fix/8627-optical-flow-scene-change
🤖 Agent: merging. Splitting the reduction into `scd_finalize_pass` replaces the completion counter, which WGSL's memory model never promised would make other workgroups' writes visible; the cost is one 9-workgroup dispatch and a 9 KiB buffer per generated frame. Proof: union with main: fmt, clippy -D warnings and render tests pass (the GPU tests need an adapter; CI's run them).
2026-11-04 17:01:25 +01:00
Ludovic Marie cb0b56a9ed Merge pull request #8665 from Stink-O/native/far-terrain-edits
🤖 Agent: merging. Risk noted: the server holds an extra `Arc<Chunk>` per handed-over column until the window's next tick, so an edit in that window copies the chunk on `Arc::make_mut`; one tick at most, and only for bulk edits out of view. Proof: union with main: fmt, clippy -D warnings, client and lan::handoff tests pass.
2026-11-04 17:01:21 +01:00
Ludovic Marie e7174a8571 Merge pull request #9054 from arslanarm/shaderpacks/shadow-pass-back-faces
🤖 Agent: Pack shadow passes now draw every face (and without back-face culling), which roughly doubles the shadow pass's solid quads for Iris-format packs. Horizon's own packs keep the light-facing selection (`every_face: false`). `shadow_water` getting `cull: false` too is a guess the probe didn't measure, as the description says. I pushed 9fac526f: merged main.
2026-11-04 17:01:16 +01:00
Ludovic Marie 1bae636ef6 Merge pull request #8635 from valkdzx/parity/elytra-look-vector
🤖 Agent: merging. FireworkRocketEntity reads `getLookAngle()`, Mth's float sine table, so the boost now matches `travel_fall_flying` and the server; the remaining f64 `view_vector` use is `rocket_hand_angle`, which only places the spark. Proof: union with main: fmt, clippy -D warnings, client tests and tests/test_remote_own_rockets.py pass.
2026-11-04 17:01:12 +01:00
Ludovic Marie 637d885453 Merge pull request #8479 from xFedeT/xfedet-fix-texture-rendering
🤖 Agent: merging the Metal split and the minified core-shader fix. I dropped the ZIP validation from the download path: vanilla's DownloadedPackSource checks only the SHA-1 (checkExistingFile), the strict zip crate refused the streamed Ascendia archive that Horizon's own pack reader already reads (so other Java-readable packs would have been refused too), and it decompressed the whole pack on every cache hit. Proof: fmt, clippy -D warnings and the touched crates' tests pass on a union of this head with main and 19 other PRs.
2026-11-04 17:01:07 +01:00
Ludovic Marie bb99dd0fb9 Merge pull request #8667 from HrafnaValkyrja/fix/tp-parity
🤖 Agent: `set_rotation` is the same eight lines in 11 impls (finite yaw, NaN pitch kept, clamp); a shared helper would keep them from drifting. Item, orb, TNT, falling-block and rocket loaders still read Rotation as [0, 0], as the description says. I pushed 764989a2: merged main, keeping main's new TNT `block_state` argument beside `rotation` (power, block_state, rotation), and gave main's new TNT and orb fixtures the field. Proof: main + this head: fmt and clippy -D warnings (horizon-client and horizon-client-lib, all targets) and horizon-client tests pass, 0 failed.
2026-11-04 17:01:02 +01:00
Ludovic Marie 7ef2b97025 Merge pull request #9059 from arslanarm/shaderpacks/pack-atlas-sprite-sizes
🤖 Agent: Stitching the pack atlas at each sprite's own size only applies to flat draws (a stitched atlas is dropped for grid draws), and a device that can't stitch falls back to the grid with a log line. Nothing else found. I pushed 02e68e15: merged main (#9057/#9180 landed).
2026-11-04 17:00:58 +01:00
Ludovic MarieandClaude Opus 5.5 79d298d339 Offer the Afri-Network partner server once in the server list
Horizon partners with Afri-Network, a server for players in South Africa.
At start the game now adds it once below "Horizon Official" in
servers.dat, like the official server: never again after the player
deletes it (horizon_partner_servers.txt), and not when the list already
has its address. servers::insert_after keeps hidden entries and unknown
fields, as prepend does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:00:38 +01:00
Ludovic Marie 8733f83fd1 Merge main into #9242
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-04 16:57:52 +01:00
TheMrZZandCodex gpt-6.1-sol d15541aa39 Merge main while retaining inline and weather predicate fixtures
Keep the independent inline parser and its fixture definitions alongside the named weather regression.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 16:56:01 +01:00
Ludovic Marie 59d8cc38a2 Merge pull request #9192 from CocofireHD/fix/build-limit-actionbar
🤖 Agent: `InteractionResult.FAIL.consumesAction()` is false, so `handleUseItemOn`'s build-limit message fires. ANY_BLOCK_USE keeps its old condition. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:55:56 +01:00
Ludovic MarieandClaude Opus 5.5 be11ce8db1 benchmarks: Horizon Nightly 2026-11-04 vs vanilla 26.3 on the Mac mini and the desktop
The perf_bench reports behind playhorizon.dev/benchmarks as of 2026-11-04 (horizon-site#58): RD 12 and 32,
three games each, 1280x720, VSync off. The Mac's session was locked (Horizon offscreen) and both machines were
busy (load 17-50 on the Mac, 28-70 on the desktop); site-2026-11-04.md says how they were made and what that costs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:55:16 +01:00
Ludovic Marie 7db3c5afef Merge pull request #9061 from Joansitoh/fix/potion-amplifier-reload
🤖 Agent: the reader treats the byte as `UnsignedBytes.toInt`; the writer clamps to 0..=255 before the byte cast, so 255 and 128 round-trip. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:55:09 +01:00
yosifdheef 5d9886fb34 Merge remote-tracking branch 'upstream/main' into fix/7992-shulker-open-support 2026-11-04 18:54:46 +03:00
Ludovic Marie 1bacc5cee0 Merge pull request #9067 from Joansitoh/fix/end-portal-xp-orbs
🤖 Agent: after the cleanup, `clear_remote_objects` (new connection, level, dimension) drops the orbs with the rest of the old ClientLevel. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:54:42 +01:00
Ludovic Marie 2181ab22eb Merge pull request #9068 from Joansitoh/fix/shulker-arrow-deflect
🤖 Agent: the refusal branch now restores the fire set by the arrow and reverses at power 0.2, as `AbstractArrow.onHitEntity` does when `projectileReceivesSideEffectsOnHit(false)`. This also covers mobs in their hurt cooldown, whose arrows used to vanish. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:53:58 +01:00
Ludovic Marie eeeabc13fc Merge pull request #9069 from Joansitoh/fix/composter-fill-feedback
🤖 Agent: both `levelEvent(1500, …)` calls in `ComposterBlock` (useItemOn, the hopper input) are the null-source overload, so 1500 belongs in the no-exception list. `play_unshared` also stops the serving side from sending a second sound packet. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:53:15 +01:00
Ludovic MarieandClaude Opus 5.5 79576177db Add item_stack_limits to the conversion test's MobType
Main gave MobType an item_stack_limits field after this branch was cut.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:52:25 +01:00
Ludovic Marie 47f857e3c5 Merge pull request #9071 from Joansitoh/fix/chorus-portal-particles
🤖 Agent: `hub.entity_event` broadcasts with no exception, so the eater sees its own event 46, as with `sendToTrackingPlayersAndSelf`. A mob's 46 arrives before its move packet, so its trail runs between the two old positions, as on a vanilla client. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:51:53 +01:00
Ludovic Marie 5eb115301b Merge remote-tracking branch 'origin/main' into cg31fix-9115 2026-11-04 16:51:53 +01:00
Ludovic Marie 031ce8d748 Merge pull request #9113 from Joansitoh/fix/8891-silent-witch
🤖 Agent: the pitch `nextFloat` stays inside `!isSilent()` at both call sites (`Witch.aiStep` line 155, `performRangedAttack` 262), and the test pins the untouched random. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:50:56 +01:00
Ludovic Marie 7acb499c7e Merge pull request #9114 from Joansitoh/fix/8863-trader-end
🤖 Agent: `Level.isDarkOutside`/`isBrightOutside` are both false under `hasFixedTime`. A datapack dimension with fixed time still drinks, because the check is `nether || end`, not the dimension type. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:50:20 +01:00
Ludovic MarieandClaude Opus 5.5 4093c1de28 Merge origin/main into feat/drop-a-client-build (picks up #9265)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:48:12 +01:00
Ludovic Marie 3e5987615d Merge pull request #9116 from Joansitoh/fix/8850-raider-ride-jump
🤖 Agent: `extra.mount` is the vehicle (`getVehicle`), not the rider, so the guard is `!isPassenger()` and the `nextInt(50)` draw is skipped exactly as in `RaiderCelebration.tick`. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:47:55 +01:00
Ludovic Marie 7dbc6f7c4c Merge pull request #9158 from Joansitoh/fix/9132-fishing-minecart
🤖 Agent: `AbstractMinecart.isPickable` is `!isRemoved()`, and the ridden cart is skipped like `isPassengerOfSameVehicle`. The pull reuses `pull_hooked` for every hooked kind. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:47:19 +01:00
Ludovic MarieandClaude Opus 5.5 da82eadb2a Remote mob takes tests: MobType's new item_stack_limits
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:46:52 +01:00
Ludovic Marie 4fc709af20 Merge remote-tracking branch 'origin/main' into cg35/9178 2026-11-04 16:46:34 +01:00
Ludovic Marie da4f89e3d7 Merge pull request #9163 from Joansitoh/fix/8851-raid-horn
🤖 Agent: checked the server thread: the serving Game has no local player (dedicated options), so every player, the window's too, gets its own packet. The 'in raid' set is still one player (`bar.player`), while vanilla's `raidEvent.getPlayers()` holds everyone within 96 blocks; a far second participant hears nothing. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:46:24 +01:00
Ludovic Marie 3a8609eada Merge pull request #9186 from Joansitoh/fix/8937-max-stack-size
🤖 Agent: `max_stack` reads the patch first and falls back to the item when the component is removed, as `ItemStack.getMaxStackSize` does. The wire test covers the add and the `!` removal. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:45:37 +01:00
Ludovic Marie 84f20cd34e Merge pull request #9188 from Joansitoh/fix/9185-bee-sting-mobs
🤖 Agent: matches `Bee.doHurtTarget` (poison on any LivingEntity the sting hurt, 10 s Normal / 18 s Hard). Not ported here: `setStingerCount` on a mob target, which vanilla also increments. Proof: union of all cg-31 PRs on main 6d43a3593b (playhorizon): fmt, clippy -D warnings (client/lib/render all-targets + server lib), client+lib tests 1638+850 ok.
2026-11-04 16:44:52 +01:00
Ludovic Marie 88f0faf67d Merge pull request #9286 from horizon-rust/feat/launcher-wardrobe-only
🤖 Agent: Ludovic's call (11-04): the shop lives on playhorizon.dev/shop (live, Paddle checkout tested end to end with his account), the launcher keeps the Wardrobe with an Open the shop button. launcher job green on this head; verify running.
2026-11-04 16:44:17 +01:00
Ludovic Marie 25f65c8441 Merge main into #9242
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-04 16:44:15 +01:00
Evrim CicekandClaude Opus 5.5 03e77c9fb9 test_dragon_death_outside_end: drop the end gateway count the box cannot see
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:43:08 +03:00
Ludovic Marie c909ca58e6 Merge main into #8954
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mod_api.rs
2026-11-04 16:42:13 +01:00
voidsipandCodex 5150ff66bf Match shulker teleport collision and attachment rules
Co-Authored-By: Codex <[email protected]>
2026-11-04 09:41:51 -06:00
Axxio 9f346f9794 control: horizon-ctl summon spawns server-ticking mobs (fixes #9207)
When running single player on an integrated server, `horizon-ctl summon`
spawned mobs directly into the client window's entity list rather than
delegating to the server thread. Because the client window does not tick
mobs (running `tick_remote` instead of `tick_world`), summoned mobs
never executed AI goals, took no environmental damage, and were not
visible to server queries.

Delegate `summon` to the integrated server in the owner player's level
when `self.integrated` is active, matching other control world commands
and allowing the server to tick the mob and broadcast it via standard
entity tracking.

Managed by Axxio.
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 16:41:46 +01:00
Ludovic MarieandClaude Opus 5.5 9f69fa38e3 Regenerate docs/launcher-commands.md for native-cache
the_launcher_reference_is_up_to_date: the native-cache command and the
native-cache-gib setting were missing from the generated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:40:49 +01:00
Ludovic MarieandClaude Opus 5.5 9fac526fef Merge origin/main into shaderpacks/shadow-pass-back-faces
Fixture: build/try_build (#9196) and build_with_terrain share one
try_build_with_terrain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:40:10 +01:00
Ludovic Marie 5eef8152d8 Merge remote-tracking branch 'origin/main' into cg32/fix-9153 2026-11-04 16:39:58 +01:00
degepieandClaude Opus 5.5 d0ac649f08 Ender dragon: its fireballs and breath reach mobs too (#8741)
DragonFireball is stopped by any entity it can hit and DragonFireball.onHit
moves its cloud to the first LivingEntity within 4 blocks; AreaEffectCloud
.serverTick applies its instant damage to every LivingEntity in it. Horizon
only looked at players. Mobs now stop a fireball, its cloud snaps to them,
and both breath clouds (the fireball's and the flame phase's) put their
instant damage on the mobs inside through the lingering clouds' existing
cloud_affect path (indirectMagic, undead healed instead).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 20:38:51 +05:00
Ludovic Marie 5d8f9fc2fc Merge pull request #9084 from arslanarm/shaderpacks/raw-texture-defaults
🤖 Agent: I pushed 19b0fd00 and 27d625d8: merged main and filled `Resources.packed` (#9090), `Interface.initial_values` and `Resources.shadow_linear` in the new test, which no longer compiled against current main. Raw textures defaulting to linear + clamp and PNGs to nearest + repeat match the probe table. Nothing else found.
2026-11-04 16:38:33 +01:00
Ludovic Marie 12306e9a47 Merge pull request #8733 from HrafnaValkyrja/fix/vehicle-teleport
🤖 Agent: Merged after review fixes 2c7b4906: /rotate sends PlayerRotation instead of a teleport, the boarding cooldown counts per tick (not per turn), and riding guests hear their vehicle's teleport. Follow-ups listed in the review: mob passengers on /tp, the passengers' rotation, cross-dimension vehicles.
2026-11-04 16:37:49 +01:00
TheMrZZandCodex gpt-6.1-sol e3940bc5f0 Adapt the predicate probe and clarify weather context citations
Give the entity-only probe its dry weather context and keep the new fixture beside stable command groups. Cite the predicate read context without treating abstract reads as complete Level method ports.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 16:37:38 +01:00
Ludovic Marie 234bccad77 Merge pull request #7861 from horizon-rust/perf/sprite-cpu-copy
🤖 Agent: block sprite pixels lz4-packed once uploaded (-19 MB live heap); one risk to know: SpritePixels::get returns zeroed pixels if decompression ever failed, which would show as black textures on a rebuilt renderer (device loss, split screen), not a crash. rdbtCVS's scene_replay adaptation included; verify green on this head, merger waits for parity.
2026-11-04 16:36:50 +01:00
Evrim CicekandClaude Opus 5.5 5a5573a7c1 A dragon dying outside the End ends no fight and is worth 500
EnderDragon.tickDeath calls dragonFight.setDragonKilled and counts the
first kill's 12000 experience only when it has a fight, which only the
End gives (ServerLevel.getDragonFight). Horizon did both everywhere: a
dragon summoned in the overworld left an exit portal and a dragon egg at
0 0 there, and 12000 experience. /kill already checked the dimension.

tools/test_dragon_death_outside_end_parity.py compares the blocks left
and the experience with vanilla's dedicated server.

Fixes #9274

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:36:30 +03:00
Ludovic Marie 712d52f326 Merge pull request #9177 from arslanarm/shaderpacks/fragment-output-component
🤖 Agent: A `component =` output at a location now joins the other outputs at that location into one vec4 target. Mixed scalar kinds, or overlapping components, are refused with the variable's name. Nothing else found.
2026-11-04 16:36:04 +01:00
Florian ERNSTandCodex f3386151d2 Merge latest main for headless bell dispatch
Co-Authored-By: Codex <[email protected]>
2026-11-04 16:35:46 +01:00
Florian ERNSTandCodex bd0a318660 Tick server bells and apply raider glow without a renderer
Co-Authored-By: Codex <[email protected]>
2026-11-04 16:35:45 +01:00
Ludovic MarieandClaude Opus 5.5 e8d6861b39 Merge origin/main into shaderpacks/terrain-texture-matrix
Resolve core_matrix with #9180's (matrix, type) form: textureMatrix is
(hz_TextureMatrix[0], mat4) in vertex stages; both blocks and both tests kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:35:23 +01:00
Ludovic Marie d2e6d705a7 Merge pull request #9119 from arslanarm/shaderpacks/custom-uniform-type-mismatch
🤖 Agent: Unknown names and shape mismatches now zero the whole custom uniform, as your probes show. The correction comment about `round`/`ifb` (no such function, same 0) is noted. Nothing else found; #9153 builds on it.
2026-11-04 16:35:20 +01:00
Ludovic Marie 02e68e1592 Merge remote-tracking branch 'origin/main' into cg32/fix-9059 2026-11-04 16:35:12 +01:00
Ludovic Marie ee3915a050 Merge pull request #9196 from arslanarm/shaderpacks/integer-colortex0-no-final
🤖 Agent: A pack whose integer colortex0 can't reach the screen is now refused at load and cleared from the settings, like a panicking one. The old path cleared the screen every frame instead. The refusal's notes are logged before the reason, so the chat line stays short. Nothing else found.
2026-11-04 16:34:42 +01:00
Ludovic Marie 1ac9480b90 Merge pull request #9180 from arslanarm/shaderpacks/core-normal-matrix
🤖 Agent: `normalMatrix` now depends on `core_profile()`: a `#version 150`/`330` without a profile word counts as core, which matches the four probe pictures. Below 150 nothing changes, as the doc comment says. Nothing else found.
2026-11-04 16:33:41 +01:00
rdbtCVSandOpenAI Codex gpt-6.1-sol 47ad28cee8 Merge main and finish RAM limit removal cleanup
Preserve existing Java UI translations, remove obsolete unlimited-memory states, and retain the resident memory diagnostic test.

Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-11-04 15:29:07 +00:00
Ludovic MarieandClaude Opus 5.5 27d625d859 Test: Resources.packed (from #9090) in the raw-texture test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:28:59 +01:00
Ludovic Marie 818cb416b3 Merge remote-tracking branch 'origin/main' into cg32/fix-9084 2026-11-04 16:28:58 +01:00
Ludovic Marie 56282c484f Merge pull request #9179 from Domanffe/fix/launcher-wardrobe-save-error
🤖 Agent: main's wardrobe_claim now calls shop_claim, so that one save moved out of this PR; the other five writes now print their error instead of dropping it.
2026-11-04 16:28:28 +01:00
Ludovic Marie d562d76fd9 Merge pull request #8836 from Cheezer1656/native-client-packet-hook
🤖 Agent: read-only and after decode, so a mod can't change what the game handles, and with no mods the dispatch is a loop over nothing on the per-packet path; main merged in (next to #8938's test and #9187's stamped docs).
2026-11-04 16:28:24 +01:00
Ludovic Marie ee06d18c39 Merge pull request #8557 from hildlabs/shadow-options
🤖 Agent: SHADOW_RESOLUTIONS and SHADOW_DISTANCES are ranges, so checking an int option's min and max covers every value the player can pick, and a 0 option drops the shadow pass, not just the map; main merged in (docs/api/shaders.md changelog next to #8512's).
2026-11-04 16:28:19 +01:00
Ludovic Marie 529f1660b0 Merge pull request #9141 from glitchsnake/fix/9058-living-jump-power
🤖 Agent: getJumpPower is now jump_strength × multiplier × getBlockJumpFactor + getJumpBoostPower for every living mob, so cows and pandas jump half as high off honey as in vanilla; I dropped the unused free block_jump_factor wrapper and rabbit's re-export, and the #[ignore]d honey test passed on a tree with the vanilla resources.
2026-11-04 16:28:14 +01:00
Ludovic Marie ecdd4eb222 Merge pull request #9122 from DenisHumen/fix/debug-lines-after-translucent
🤖 Agent: matches DrawableGizmoPrimitives.getGroup (alpha 255 → lines with depth write, the rest linesTranslucentNoDepthWrite), uploading the opaque group first so one buffer serves both draws; I moved the two helpers so translucent_batchable keeps its own doc comment.
2026-11-04 16:28:08 +01:00
Alexander KrylovandHermes Agent 527c6de7cd Launcher: a right click on a sidebar row opens its ⋮ menu
The sidebar's rows carry the menu about that instance (Edit…, Duplicate…, Open
folder, Export…, the desktop shortcut, Delete…), and it is reachable only
through a 32 px ⋮ button. A right click anywhere on the row now opens the same
menu.

Both entry points share one gate, `row_menu_after_press`: it opens the row's
menu, closes it when it is already that row's, and drops the Delete… confirm
flag when the menu moves to another row, which is what the ⋮ did before.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-04 18:28:02 +03:00
Ludovic Marie c5ae81b41f Merge pull request #9057 from arslanarm/shaderpacks/pack-atlas-sprite-table
🤖 Agent: Small and contained: flat terrain reads each sprite's rectangle from a table instead of assuming a uniform grid, the base for #9059/#9060. Nothing found.
2026-11-04 16:28:02 +01:00
Ludovic Marie 220717bd56 Merge pull request #9104 from arslanarm/shaderpacks/atomic-member-reads
🤖 Agent: This changes the vendored naga GLSL frontend: every non-constant struct member read now goes through a pointer, not a loaded copy of the struct. Every pack's GLSL takes that path, so a naga update must keep the patch (THIRD_PARTY_NOTICES records it). Constant expressions keep the old form. Nothing else found.
2026-11-04 16:25:32 +01:00
Ludovic Marie c62113c79b Merge pull request #9108 from arslanarm/shaderpacks/atomic-pointer-shared-vectors
🤖 Agent: Atomic functions on a member of a shared vector in a declaration list or a shared struct now resolve. The rewrite is limited to `shared` declarations, so other GLSL is untouched. Nothing found.
2026-11-04 16:25:28 +01:00
Ludovic Marie 4e8febfe57 Merge pull request #9090 from arslanarm/shaderpacks/packed-colortex-formats
🤖 Agent: `R3_G3_B2` is stored as 5/6/5 bits because that fits one NVIDIA driver's readback in Iris (the module says so). Another driver may store real 3/3/2, so the probe is worth rerunning on Mesa before more code depends on it. The quantising only touches passes writing such a buffer, so other packs pay nothing.
2026-11-04 16:23:32 +01:00
Ludovic Marie e914dfb4b9 Merge remote-tracking branch 'origin/main' into feat/launcher-wardrobe-only
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-04 16:22:32 +01:00
Ludovic MarieandClaude Opus 5.5 764989a2d4 Merge origin/main into fix/tp-parity
Main added TNT's block_state argument beside this branch's rotation:
both kept (power, block_state, rotation); main's new TNT and orb test
fixtures get the rotation field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:22:12 +01:00
makscoder 6bf332f5c5 Cap command-tree parse recursion depth to stop crafted deep /execute chains crashing the game
`execute run` redirects to the tree root, so parse_nodes recurses once per
nesting level. A crafted deeply nested command overflows the parsing thread's
stack (thread 'Server thread' has overflowed its stack) and kills the game.

Add MAX_PARSE_DEPTH (128) to parse_nodes' recursion: past the limit the branch
yields no result, so the command fails to parse instead of overflowing. 128 is
well below the ~300-level overflow measured on the server thread and far above
any real command chain; vanilla likewise caps command nesting.

Adds tests: a 400-deep execute-run chain is rejected (not overflowed), while
shallow execute chains still parse.

Closes #9252
2026-11-04 18:21:27 +03:00
degepieandClaude Opus 5.5 a6e6ad25c5 Illusioner: blind a targeted mob, not only the player (#8849)
IllusionerBlindnessSpellGoal.performSpellCasting adds Blindness for 400
ticks to the illusioner's target, any LivingEntity. The cast only sent
EffectPlayer, so a villager or iron golem it targeted kept nothing. A new
MobEvent::EffectMob carries the effect to the target mob, whose
add_effect already checks canBeAffected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 20:19:32 +05:00
degepieandClaude Opus 5.5 0533124415 Illusioner: count the spell cast down
SpellcasterIllager.customServerAiStep lowers the casting count every
tick, and Illusioner has no override of its own. Horizon did this only
for the evoker, so an illusioner's count stayed at 20 after its first
(mirror) spell: it kept the casting goal for good, cast nothing else and
never shot its bow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 20:19:32 +05:00
MSedgeMC b7a1dd4e41 port_map: Slot.allowModification, and the guest bundle click that asks it
The ledger had no row for the method `BundleItem`'s click override gates
both branches on.
2026-11-04 15:19:31 +00:00
voidsipandCodex 90ecdc3dd0 Stop emptied loot chests refilling after reload
Co-Authored-By: Codex <[email protected]>
2026-11-04 09:19:20 -06:00
Ludovic Marie 2e5a2629a0 Merge pull request #8621 from itarqos5/feat/lod-detail-when-zoomed
🤖 Agent: the zoom cone only boosts tiles covers() says are looked at, and the test pins the plan behind the player as identical zoomed or not; I made lodZoomDetail off by default until a measurement shows the extra far tiles land while the zoom is held.
2026-11-04 16:19:19 +01:00
MSedgeMC 909f6c4e96 port_map: the lectern's load row names the page clamp
`decode_block_entity` ports `LecternBlockEntity.loadAdditional` including
the `Mth.clamp(Page, 0, pageCount - 1)` the row only called "Book and
Page", so the row now says what the load does with the page.
2026-11-04 15:16:25 +00:00
MSedgeMC 132d0f3022 test: a stored lectern page loads inside its book
The decode of a chunk's or a structure's lectern, `decode_block_entity`,
against the pages the book on it has: 7 on a two-page book loads as 1,
-3 as 0, a bookless lectern's 4 as 0, and a page already inside the book
(1 of three) is kept and saved back as 1.

Without the clamp the first case fails with `left: 7, right: 1` and the
second with `left: -3, right: 0`.
2026-11-04 15:16:25 +00:00
MSedgeMC 1267b1e360 fix: a lectern clamps its page to its book on load
`LecternBlockEntity.loadAdditional` reads `Page` through
`Mth.clamp(page, 0, getPageCount(book) - 1)`: a page stored past the
book's last one comes back as the last page, one stored before the first
as the first, and a lectern with no book has the single page 0. Horizon
kept the stored number as it was.

A negative page was the visible half: `LecternBlockEntity.getRedstoneSignal`
(`books.rs::lectern_signal`) answers `floor(page / (pages - 1) * 14) + 1`,
so a stored -3 read 0 to a comparator where vanilla's clamped page 0 reads
1, and the next save wrote the out-of-range page back out. A page above the
end reads the same on both sides, since the signal saturates at 15 either
way, and the open menu already clamps (`menu.rs::set_data`), so what changes
is the load and with it the round trip.
2026-11-04 15:16:24 +00:00
Ludovic Marie 5ff12dfaf4 Merge pull request #8631 from HrafnaValkyrja/feat/admin-player-position
🤖 Agent: I pushed c2aa51c1: merged main and resolved the generated `server-reference.md` row (main's text plus `dimension`), so `the_routes_table_matches_the_code` agrees again. Nothing else found.
2026-11-04 16:14:17 +01:00
martipopsandClaude Opus 5.5 07b1ea4f21 Controllers: menu buttons press on release; screens declare their buttons
A on a focused button now presses it when let go, if the focus is still on it.
Moving the focus, a cancel, a scroll, a dialog opening or another pad taking over
drops the press. On the title, X opens Options and B focuses Quit Game; on the
pause menu, Y focuses the exit button, and A confirms.

A screen says what a button is for (`ui::action_button`, `widgets::declare_action`)
and its pad button and glyph follow: Done, Back, Cancel, Apply, Options and Exit.
Nothing else names the screen. What a screen declared stops counting once another
is drawn over it or replaces it, and `pad status` lists what is declared, what
isn't and the glyphs drawn. tools/pad_menu_e2e.py checks these with the virtual pad.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82xRaic2UQfeBTnyhW
2026-11-04 10:13:55 -05:00
makscoder 16a4b6010b Cap SNBT parser nesting depth to stop crafted deep values crashing the game
The command SNBT parser (horizon-command) recursed on every nested
compound/list with no depth limit, so a crafted deeply-nested argument
(stack overflow: thread 'Server thread' has overflowed its stack). Both
ArgType::Nbt (summon/data/give NBT) and ArgType::Component (tellraw/text
components) route through this parser, so one guard covers both.

Add a MAX_DEPTH (128) recursion counter to the packrat State, checked in
State::parse: over-deep input now fails to parse (the argument is
rejected) instead of recursing until the stack overflows. 128 is far
below the measured ~300-level overflow on the smallest parsing thread and
far above any real command or data value. Adds tests that deep compound
and deep extra-style list input are rejected while shallow values still
parse.

Closes #9236
Fixes #9261
2026-11-04 18:13:01 +03:00
MSedgeMC 6e215078fe test: a guest's bundle keeps its contents in a refusing slot
The bundle sits in a chest slot whose rules refuse the pickup, with the
prepared catalog behind it so the stack really has bundle contents. A
left click with stone in hand adds nothing, a right click with an empty
hand takes nothing, and the empty bundle in the next slot still fills.
2026-11-04 15:12:42 +00:00
MSedgeMC 9cc1db4268 fix: a bundle in a slot that withholds it keeps its contents
`BundleItem.overrideOtherStackedOnMe` gates both of its branches on
`Slot.allowModification`, which is `mayPickup` and `mayPlace` of the
stack the slot holds - not the placement test alone. `lan::menu`'s
bundle click asked only `View::may_place`, so a guest could fill or
empty a bundle in a slot whose menu refuses the pickup.
2026-11-04 15:12:41 +00:00
Ludovic Marie 3c3d54137b Merge pull request #8679 from HrafnaValkyrja/fix/entity-save-rest
🤖 Agent: I pushed 1fb68131: merged main and added `fire_ticks: 0` to the new trident test, which no longer compiled once main gave `TridentSave` that field. Note that `LeftOwner: 1b` is now always written for tridents; vanilla omits it on a freshly thrown trident still inside its owner's box. Rarely visible.
2026-11-04 16:12:06 +01:00
Ludovic Marie 527152f6f4 Merge pull request #8704 from HrafnaValkyrja/fix/portal-parity
🤖 Agent: `as_console_in_overworld` enters the overworld only when another level is in place, and puts that level back after the line, so the next server tick sees the level it left. The unit test covers only `console_level`; the portal scan is the real proof. Nothing else found.
2026-11-04 16:12:01 +01:00
Ludovic Marie 56a2de7d42 Merge pull request #8722 from HrafnaValkyrja/fix/minecart-momentum
🤖 Agent: `AbstractMinecartContainer.applyNaturalSlowdown` port is exact, f32 arithmetic included, and it returns before the ridden/behaviour factor as vanilla does. Nothing found.
2026-11-04 16:11:57 +01:00
Ludovic Marie a61f360fa5 Merge pull request #8727 from HrafnaValkyrja/fix/carpet-dupe
🤖 Agent: Only a non-source moving block at progress 1.0 now counts as its moved state. One small gap: vanilla reads the moving piston's *collision* shape, so a moved block whose support shape differs from its collision shape (leaves) supports there in vanilla but not here. The carpet machine doesn't need it.
2026-11-04 16:11:52 +01:00
Ludovic Marie 8788891c88 Merge pull request #8859 from HrafnaValkyrja/feat/panel-bans-crashes
🤖 Agent: Crash reports are read only by a name the listing returns, so `../` and absolute paths can't reach outside `crash-reports/`. The test covers both, plus a non-`.txt` file. The panel refetches players and crash reports every 10 polls, a directory listing each time: cheap. Nothing else found.
2026-11-04 16:11:47 +01:00
Ludovic Marie d9901d9fb4 Merge pull request #8869 from HrafnaValkyrja/fix/shadow-non-full-tops
🤖 Agent: `isCollisionShapeFullBlock` is the right gate (`EntityRenderer.extractShadowPiece`), and the test now covers ten non-full blocks, including the hopper whose collision bounds reach y=1. Nothing found.
2026-11-04 16:11:42 +01:00
rdbtCVSandOpenAI Codex gpt-6.1-sol 908d81bd4c Keep Java heap memory controls when removing Horizon RAM limits
Restore the Home and glass instance editor controls only for Java instances. Keep memory_mb persistence and Java's default heap behavior.

Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-11-04 15:10:34 +00:00
abit-foggyandCodex 0b9a5a478c feat(studio): add mcpr recording archive container
Co-Authored-By: Codex <[email protected]>
2026-11-04 05:10:24 -10:00
Ludovic MarieandClaude Sonnet 5.5 bb1f287b39 Launcher: the shop lives on the website; the Wardrobe keeps owned items and purchases
Ludovic decided the cosmetics shop is only on https://playhorizon.dev/shop
(browsing, prices, the founder claim, purchases through Paddle). The launcher's
Wardrobe keeps what the account owns: equip / take off, "Show my cosmetics", and
the purchase history.

- Removed the Shop tab (wardrobe_page/shop.rs), the founder claim flow and its
  checkout polling, Service::claim, Checkout / CHECKOUT_PAGES, the Shop's
  window/countdown rules and the tests of all of it.
- Added an "Open the shop" button (opens the website's shop) beside the Wardrobe
  title, and in the empty state ("You don't own any cosmetics yet.").
- The purchase history ("Your purchases") moved into the Wardrobe page.
- Lang tables: two new strings in all 86, the stale ones removed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-04 16:08:49 +01:00
Ludovic MarieandClaude Opus 5.5 9e642fbe77 Merge main into #9179
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:07:50 +01:00
CocofireHDandDeepSeek V4.1 Flash 5839363b51 Merge origin/main into fix/turtle-dig-level-event
Resolve game/allays.rs: keep #8652's empty-main-hand gate and MAIN_HAND swing, and take main's Option<ItemStack> allay inventory for the returned stack.

Co-Authored-By: DeepSeek V4.1 Flash (OpenCode) <[email protected]>
2026-11-04 16:07:47 +01:00
Ludovic Marie 16acafe526 Merge pull request #8798 from horizon-rust/agent/rdbtcvs/server-shared-item-defaults
🤖 Agent: one items.json snapshot per load, keyed on the root path; overlapping loads of one root share the native hooks' answers, fine under the same-answer contract. Proof: union with #8751+#8763+#8798 on main a7df9ec: fmt, clippy -D warnings clean; item_defaults/loot/items/font/hud/item_render/cit tests pass (playhorizon).
2026-11-04 16:07:32 +01:00
Ludovic MarieandClaude Opus 5.5 6db44f17af Merge main into #8954
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:07:30 +01:00
Ludovic Marie cf667d7e21 Merge pull request #8763 from horizon-rust/agent/rdbtcvs/server-font-metrics
🤖 Agent: server fonts measure without an atlas; galactic_widths is now zero on a server but only the enchanting table's draw reads it. Proof: union with #8751+#8763+#8798 on main a7df9ec: fmt, clippy -D warnings clean; item_defaults/loot/items/font/hud/item_render/cit tests pass (playhorizon).
2026-11-04 16:07:27 +01:00
Ludovic Marie 7cf2b63e87 Merge pull request #8751 from horizon-rust/agent/rdbtcvs/server-skip-cit
🤖 Agent: a headless load now skips OptiFine CIT; every reader of item_renderer.cit is a draw path (armour stands, elytra, glint upload), so nothing server-side changes. Proof: union with #8751+#8763+#8798 on main a7df9ec: fmt, clippy -D warnings clean; item_defaults/loot/items/font/hud/item_render/cit tests pass (playhorizon).
2026-11-04 16:07:23 +01:00
Ludovic MarieandClaude Opus 5.5 a65ee808dc Merge main into #8836
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:07:07 +01:00
Ludovic MarieandClaude Opus 5.5 3b8896b3b9 Merge origin/main into feat/launcher-data-location (picks up #9265)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:07:02 +01:00
Ludovic MarieandClaude Opus 5.5 c0703a62bc Merge main into #8557
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:06:47 +01:00
TheMrZZandCodex gpt-6.1-sol c7f0b8d666 Supply dry weather in the native criterion test context
Adapt the test world introduced on main to the added predicate read context.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 16:01:14 +01:00
voidsipandCodex d0a1bf746b Sync mob air and frozen counters to clients
Co-Authored-By: Codex <[email protected]>
2026-11-04 09:00:13 -06:00
Delete228andCodex 28b9f949ff Use world landing damage and preserve vanilla sync preloads
Route fall and stalagmite hurt through the world catalog. Retain the identical no-pack preload, atomically replace it for world packs, and protect the frozen snapshot from late vanilla readers. Select all prepared-resource hurt, landing, registry and preload regressions from the damage catalog parity harness.

Co-Authored-By: Codex <[email protected]>
2026-11-04 17:59:23 +03:00
Ludovic Marie 137956911f Merge pull request #9232 from TheMrZZ/fix/inline-execute-predicates
🤖 Agent: Review by Ludovic's AI agent (Claude), not by Ludovic himself. check_predicate now decodes an argument starting with { as inline SNBT and sends it through the same evaluator, source entity and origin as a named predicate, as ExecuteCommand.checkCustomPredicate does; the codec now requires terms for all_of/any_of and term for inverted, so a malformed inline predicate fails at parse with no callback, as CompositeLootItemCondition.createCodec does. Merges cleanly into today's main; the merger waits for verify and parity on this head.
2026-11-04 15:59:07 +01:00
jaszkus 7740127129 Merge upstream/main into optimization-launcher
launcher/src/backend.rs keeps both Settings::theme (upstream) and
Settings::performance (this branch). Each launcher/lang/*.json takes upstream's
table and gets the two Performance mode strings added again.
2026-11-04 15:58:10 +01:00
Ludovic Marie 6c5cd897fc Merge pull request #9265 from horizon-rust/fix/stale-menu-navigation-keys
🤖 Agent: main fix: removes only the three keys #8873 dropped from en_us (menu_navigation, menu_focus, menu_pointer) from the 51 tables added after its base; afterwards no table in assets/horizon/lang has a key en_us lacks, which is what lang::tests checks.
2026-11-04 15:56:07 +01:00
Ludovic MarieandClaude Opus 5.5 c787285cfb Merge main into #9179
main's wardrobe_claim now calls shop_claim; its save error is out of this PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:55:21 +01:00
Ludovic MarieandClaude Opus 5.5 3b2da02d11 Lang: drop the removed Menu Navigation keys from 51 more tables
#8873 removed horizon.options.menu_navigation, menu_focus and menu_pointer from
en_us.json and the 35 tables its branch had; the 51 tables main gained since
kept them, so lang::tests::bundled_files_are_served_and_have_no_stale_or_repeated_keys
fails on main (cv_cu first).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:55:11 +01:00
Ludovic MarieandClaude Opus 5.5 4a0122986d Send the brand before the client information, and no ClientInformation at login
Vanilla's ClientHandshakePacketListenerImpl.handleLoginFinished sends the
brand payload, then ServerboundClientInformationPacket; Horizon sent them the
other way round. ClientPacketListener.handleLogin sends no client
information either: the configuration phase already did, and
broadcastClientInformation runs only when an option changes. Horizon sent a
second, identical ClientInformation in play right after Login.

tools/vanilla_wire_e2e.py gains the rules brand-before-information and
no-play-information.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:53:51 +01:00
Ludovic MarieandClaude Opus 5.5 1385a4353e rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:53:33 +01:00
Florian ERNSTandCodex gpt-6.1-sol b1d0b765d4 Merge main and preserve both corpus scenario additions for #9232
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 15:51:22 +01:00
TheMrZZandCodex gpt-6.1-sol 79ba2548e4 Merge current main into named weather predicates
Preserve upstream corpus additions and destroy-progress observations alongside the weather regression.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 15:50:24 +01:00
TheMrZZandCodex gpt-6.1-sol ccea38a806 Evaluate named weather predicates using effective weather
Read the existing dimension-aware weather levels in the predicate context and honor both optional weather fields. Add a guarded Game/Java command regression and a resource-free evaluator test without changing weather simulation.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 15:48:49 +01:00
Ludovic Marie 0805df6e08 Merge pull request #9219 from horizon-rust/fix/native-base-revision
🤖 Agent: launcher-only fix, CI green on this head (launcher, verify); main merged in (lang tables only); ThinkPad: fmt, clippy -D warnings, 480+89 launcher tests pass, incl. the new pin/on_base tests and the 86-language i18n check.
2026-11-04 15:48:33 +01:00
degepieandClaude Opus 5.5 86f9a3f780 A swimming dolphin leaves its trail of dolphin particles
Dolphin.tick on a client: a dolphin in water whose squared speed is
over 0.03 (and that has AI) puts four dolphin particles a tick behind it,
0.3 to either side, set back along its view vector by 0.5 to 1.2 drawn
from its own random. client_ai_step had no dolphin, so the window never
made them. It runs over the window's copies of the mobs, so single player,
LAN guests and servers all get the trail.

Fixes #8566

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 19:48:13 +05:00
Ludovic Marie ff63338824 Merge pull request #9173 from jaszkus/feat/launcher-update-installer
🤖 Agent: the setup now draws the launcher's scene and glass plate with the player's accent; I made the main actions Kind::Primary and slowed the settings poll from 16 ms to 500 ms so the setup does not redraw at 60 fps for it.
2026-11-04 15:47:19 +01:00
voidsipandCodex 4b8643a18f Let Breezes deflect fishing hooks
Co-Authored-By: Codex <[email protected]>
2026-11-04 08:44:55 -06:00
Ludovic Marie 411ace47fe Merge pull request #9197 from TheMrZZ/fix/decorated-pot-data
🤖 Agent: this head carries all 40 of TheMrZZ's open PRs as one fast-forward chain (age order, main merged), because 38 of them append to tools/command_exec_corpus.py and conflicted pairwise; each one's current head is an ancestor, so GitHub marks all 40 merged with this one. Two hand merges to check: #8847, #8986 and #9101 now share one canBeABaby == false branch in mob_to_nbt that drops Age, ForcedAge and AgeLocked (AgeableMob.addAdditionalSaveData writes none of them), and data_of tries #9174's comparator view, then this PR's pot view. Proof: verify passed on 94ccd9c8e (this head = that + main); clippy -D warnings (also --no-default-features) and horizon-client-core/horizon-world tests pass on the union on playhorizon; the merger waits for verify and parity / parity here.
2026-11-04 15:44:44 +01:00
Ludovic Marie 1a3e0f94e7 Merge pull request #8204 from Akirabane/feat/live-heartbeat-interval
🤖 Agent: Requeued after merging #8200's main merge (12e979554) into it, no conflict. Live status heartbeat interval; lands after #8200. Fixes in this PR: the log reads the interval after setting it, as vanilla does, and a set after a cancelled heartbeat is refused, as ManagementServer does (b6204bf860). management_protocol --fresh PASS on the union of this wave plus the fix. On this head: horizon-server fmt, clippy -D warnings and 140 tests clean. The rest of the selection runs in CI run-parity.
2026-11-04 15:44:40 +01:00
Ludovic MarieandClaude Opus 5.5 d52e1187b4 Merge main into #8954
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:43:00 +01:00
martipopsandClaude Opus 5.5 4682b7838c Merge upstream main into fix/shop-window-clock
Keeps the Paddle checkout from main and the clock and unknown-state fix. Both sides added tests at the end of wardrobe/tests.rs and an item to the same import in wardrobe_page/shop.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp7yEJwPTJ2kw29LWdk1
2026-11-04 09:42:37 -05:00
Ludovic MarieandClaude Opus 5.5 22a7070cdb Merge main into #8836
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:42:35 +01:00
Ludovic MarieandClaude Opus 5.5 5d06f30d10 Merge main into #8557
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:42:09 +01:00
Ludovic Marie 84b3bf3027 Merge pull request #8200 from Akirabane/feat/live-accept-transfers
🤖 Agent: Requeued at rdbt's main merge 12e979554 (clean, no conflict hunks; it carries #8128, now merged). Live accept-transfers, log line as setAcceptsTransfers. management_protocol --fresh on the union of this wave (with #8128 and #8204): every accept-transfers case matches vanilla. The union passed fmt, clippy -D warnings, 3388 tests and the Python suite; rdbt reran the management_methods and accepting_transfers tests on this head. The rest of the selection runs in CI run-parity.
2026-11-04 15:40:58 +01:00
Ludovic MarieandClaude Opus 5.5 168a30e617 Merge main into the TheMrZZ chain (cg-30)
tools/command_exec_corpus.py merged per function (main's new groups kept);
the harness keeps main's destroy_progress records and the chain's record-count check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:39:50 +01:00
jaszkus ab6d67da82 Launcher: a Performance mode, and an accent that changes at once
The launcher feels slow on older computers (i5-7500, Intel HD Graphics 630):
every frame rebuilds the page, the scene redraws its moving parts, and the
glass plates blur the scene again whenever the accent or the blur changes.

Settings > Launcher > Performance mode now turns on the cheaper path: the
scene does not restart on every mouse move, the sky is baked at a quarter of
the window's resolution, the plates' blur is three box passes instead of a
Gaussian, a window resize is baked in the background, and the plates are a
solid colour (no glass, no blur, no backdrop to sample). In that mode a new
accent reaches the background in the same frame as the buttons.

Outside the mode, the accent no longer re-blurs the plates: the backdrop is
blurred once and tinted as it is read, and a tint change is baked in the
background, one bake at a time, the last tint always landing.
2026-11-04 15:38:41 +01:00
Ludovic Marie 4fdf97368b Merge pull request #9203 from NikitaMGrimm/sprint-dust-own-random
🤖 Agent: matches Entity.spawnSprintParticle drawing from this.random; the window's own player has no client-side random, so it keeps the particle engine's, and the golem's nextInt(5) and offsets now come from the same random in vanilla's order.
2026-11-04 15:38:14 +01:00
Ludovic Marie 5d5de221a7 Merge pull request #8697 from itarqos5/perf/name-the-resource-loader-threads
🤖 Agent: the Windows thread_name reads Rust's own handle, and the BUSY guard already answers a nested allocation from thread::current(); I shortened the loader names to load-<step> so Linux's 15-byte limit keeps core and collision apart.
2026-11-04 15:35:36 +01:00
Ludovic Marie b815d63191 Merge pull request #8845 from Lyvt-Dev/fix/8832-warden-pushing
🤖 Agent: matches Warden.isPushable (!isDiggingOrEmerging && super), applied both in pushable() and on the receiving side of push_entities, while the warden still pushes others; I removed the two "digging" parity cases that actually spawned a standing warden.
2026-11-04 15:35:32 +01:00
Ludovic Marie 1e7cf8b5c8 Merge pull request #8216 from janibert1/player-address
🤖 Agent: address is set in all three places NativeServerPlayer is built (guest list, the acting player via lan_acting_uuid, and the command path), as IP only with no port; I added the missing API fragment (server_player_address.json) so it gets a number at stamping.
2026-11-04 15:35:28 +01:00
Ludovic MarieandClaude Opus 5.5 b156e0570c Import: keep the shortened error arm within rustfmt's width
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:33:56 +01:00
yosifdheef 30bce4e15a Read an open shulker box's real support shape
Vanilla asks the box's block entity for its support shape because a shulker box is a dynamic shape: an open box supports only through the one-pixel plate opposite FACING, while the exported state facts always see a closed full cube. Route sturdy, canSupportRigidBlock and canSupportCenter through the level's lid state when the block is a shulker box, keeping the cached path for every other block.
2026-11-04 17:32:49 +03:00
Ludovic MarieandClaude Opus 5.5 6d07525616 Data location: path lookups fall back instead of panicking
data_dir(), build_dir() and game_home() still expect()ed on a location error.
The quiet installer runs before data_location::startup(), and retry() clears
FORCE_DEFAULT before re-reading, so a background thread could hit the panic
the review asked to remove. A location that does not open now stands in as the
default folders there, as startup() makes it for the window. Also joins the
startup problems with "\n" instead of a literal line break.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:31:38 +01:00
Ludovic MarieandClaude Opus 5.5 c34cd71603 Merge origin/main into feat/launcher-data-location
Language tables: main's keys plus the PR's data-location keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:31:38 +01:00
Ludovic Marie b9dc4c1f96 Merge pull request #8873 from martipops/controller/phase1-input
🤖 Agent: release_keyboard_mouse now releases only what Source::Keyboard holds, so a splitscreen handoff no longer drops the pad's held jump or sneak, and pad_button replays the actions stored at press time on release, so a binding changed mid-hold can't leave a key stuck. Players who chose Pointer in the removed Menu Navigation option get focus in menus after this.
2026-11-04 15:30:51 +01:00
masa-masa-0314andZCode 67a19214c3 CI: bump GitHub Actions to Node 24 runtimes (checkout v7, artifacts v7/v8, setup-python v7, setup-java v6)
Every actions/* pin now runs on Node.js 24 instead of the deprecated Node.js 20 runtime the runners currently force:

- actions/checkout v4 -> v7: v5.1+ blocks fork-PR checkouts under pull_request_target/workflow_run; this repo only checks out ref: main in those workflows (pr-labels, overlap-note) and the workflow_run release checkout resolves to the default branch, so nothing changes. The PR-head checkout in ci.yml runs under plain pull_request.
- actions/upload-artifact v4 -> v7: v7's direct uploads are opt-in (archive: false) and the ESM migration is transparent to callers.
- actions/download-artifact v4 -> v8: v5's path change only affects artifact-ids downloads, which no workflow uses; v8's enforced digest checks and skip-decompress only surface for direct uploads.
- actions/setup-python v5 -> v7: the removed pip-install input is unused (only python-version: '3.12' is set).
- actions/setup-java v4 -> v6: v4 itself now emits a deprecation warning (v4.9.1); temurin/25 usage is unchanged in v6.

GitHub-hosted runners ship 2.327.1+, so the node24 minimum is met.

Co-Authored-By: ZCode <[email protected]>
2026-11-04 22:27:36 +08:00
Ludovic MarieandClaude Opus 5.5 17ce0331f8 Native cache: a build goes ahead when only the cleanup is refused; ca and 50 new locales
A filesystem without file locks or a native folder linked to another drive made
Cache::acquire fail, and logged() then refused the build itself, which worked
before the cache existed (martipops' review). Only Busy still refuses; any other
lease error is a build.log warning and skips the trim.

The launcher check failed on lang/ca.json: main added 51 locales since the last
main merge, so each gets the five cache messages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:27:35 +01:00
Ludovic Marie 08a514dc26 Merge pull request #9100 from repositorykreml1n/codex/fix-issue-9021
🤖 Agent: merging. Mining speed now reads the player's computed `minecraft:mining_efficiency` (Player.getDestroySpeed), and held Efficiency joins the equipment modifiers with its slot group, so an offhand Efficiency pickaxe no longer speeds mining. Proof: CI parity passed on this head, including the 9 new `mining_efficiency_9021` destroy-progress cases compared bit for bit with vanilla's getDestroyProgress.
2026-11-04 15:27:35 +01:00
Ludovic Marie c915f2a878 Merge pull request #9193 from Domanffe/chore/roadmap-end-city-links
🤖 Agent: nothing found; #1928, #1911 and #2037 are merged and are the fixes the new text cites.
2026-11-04 15:27:30 +01:00
Ludovic Marie e013d898ff Merge pull request #8399 from itarqos5/perf/lod-reduced-material-depth
🤖 Agent: only approximate tiles with width >= 16 stop at depth 2, and the test proves the Full tile still paints below depth 2, so the Reduced assertion cannot pass vacuously.
2026-11-04 15:27:27 +01:00
Ludovic Marie 99eb6630b7 Merge pull request #9107 from janibert1/merchant-offer-hook
🤖 Agent: the hook gets session.offers[index], the live offer, while trade_with_villager keeps the pre-hook copy; a mod raising max_uses on a shift-click only takes effect after that click, as the docs say.
2026-11-04 15:27:22 +01:00
Ludovic Marie c036a7473e Merge pull request #8935 from janibert1/death-drops
🤖 Agent: the hook runs after destroyVanishingCursedItems and before the random throws, and the retain only drops stacks a mod emptied, so with no mod the drops and their RNG draws are unchanged.
2026-11-04 15:27:17 +01:00
Ludovic Marie bdb45ed319 Merge pull request #8925 from minantiii/fix/armadillo-entity-action
🤖 Agent: matches Armadillo.rollUp/rollOut, gameEvent(ENTITY_ACTION) before makeSound and independent of isSilent; the new creatures.rs arm comes after the flap arm, so chicken/parrot flaps are unaffected.
2026-11-04 15:27:13 +01:00
Ludovic Marie 0492f8db4e Merge pull request #9142 from NerfPhaselock/fix/gpu-culling-dx12-packed
🤖 Agent: the guard keys on flat_min_sections.is_some(), so DX12 without flat page groups (no pulled pipelines) keeps GPU culling, and the test pins both DX12 cases.
2026-11-04 15:27:08 +01:00
Ludovic Marie 20ae91bbde Merge remote-tracking branch 'origin/main' into fix/native-base-revision
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-04 15:26:25 +01:00
Ludovic MarieandClaude Opus 5.5 153ef53e42 Merge #8200's main merge into the heartbeat interval (no conflict)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:25:38 +01:00
rdbtCVSandCodex gpt-6.1-sol 0e58346b64 test(mobs): mark jump-world regression as requiring vanilla resources
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 14:24:16 +00:00
Ludovic Marie 6f62647a23 Merge pull request #9184 from Domanffe/fix/options-txt-save-warning
🤖 Agent: nothing found.
2026-11-04 15:23:41 +01:00
Ludovic Marie 9fb6e4f20c Merge pull request #9235 from horizon-rust/feat/launcher-paddle-claim
🤖 Agent: launcher-only. The claim opens a checkout URL only on Horizon's own /shop/pay page (prefix allowlist, tested), and the 3 s poll stops after 20 min or once the set is claimed; without a checkout in the answer the one-click claim is unchanged.
2026-11-04 15:23:36 +01:00
Ludovic Marie 1aea26b3e0 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-04 15:23:33 +01:00
Ludovic Marie 7dfbd05ac3 Merge pull request #9110 from DenisHumen/fix/offhand-tempt
🤖 Agent: TemptGoal.shouldFollow and TemptingSensor test both hands; player_view now fills off_hand, and lan_guest_views swaps each guest's off hand in through the same function, so single player on the server thread gets it too.
2026-11-04 15:23:31 +01:00
Ludovic Marie 4ece2302d4 Merge pull request #9103 from DenisHumen/fix/unicode-font-sign-stretch
🤖 Agent: default ascii.png glyphs keep their 0..8 box, so sign text without Force Unicode draws exactly as before; only Unifont's half-scale glyphs change width.
2026-11-04 15:23:27 +01:00
Ludovic Marie 8453750c94 Merge pull request #8936 from Cheezer1656/upstream/native-send-chat
🤖 Agent: the message is trimmed and the Chat option (Commands Only, Hidden) applied as the chat screen does; the test needs prepared resources and is #[ignore]d, so CI does not run it.
2026-11-04 15:23:23 +01:00
Ludovic Marie 2f29699b0a Merge pull request #8938 from Cheezer1656/upstream/client-chat-received
🤖 Agent: Only Show Secure Chat now drops a line before mods hear it (ChatListener.showMessageToPlayer's order, then onClientPlayerChat), and chat_received returns at once with no mods loaded, so the chat-type registry lookup costs nothing without them.
2026-11-04 15:23:18 +01:00
Ludovic Marie 0073f3bb30 Merge pull request #8872 from Delete228/create-named-damage
🤖 Agent: controlled particles are dropped on a resource reload so a stale sprite-set index cannot pick another set, and the only cost for vanilla particles is one more match arm in tick and in the translucent test.
2026-11-04 15:23:14 +01:00
Ludovic MarieandClaude Opus 5.5 569f9644dc Pass the event sink to sync::client_tick in the wolf head-tilt test
sync::client_tick took an events vector in c0cd050fac; main's newer
remote_mob_data test still called it with two arguments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:23:12 +01:00
Ludovic Marie 31fac68df4 Merge pull request #8822 from AfrobamaYT/camera-pose-hook
🤖 Agent: one dispatch per frame, each non-finite value falls back to vanilla's on its own, and posed_rotation is only set when the pose differs, so billboards keep the vanilla rotation path when no mod moves the camera.
2026-11-04 15:23:09 +01:00
Ludovic Marie 07a1a4af76 Merge pull request #8698 from ErikKalikas/mod-menu-extra
🤖 Agent: the 32 KiB cap is enforced on both encode and decode, a vanilla client ignores the unknown horizon:menu_extra channel, and the lan_bot test pins that the payload arrives right before its own window's OpenScreen.
2026-11-04 15:23:04 +01:00
Ludovic Marie 1077a75e5f Merge pull request #8636 from GabrielNichols/codex/port-pack-harness-verdict
🤖 Agent: an explicit passed:false or a result with zero executed checks now stays Partial, and a legacy count-only result with passes still reads harness green, as carried_harness_keeps_current_and_legacy_successes pins.
2026-11-04 15:22:59 +01:00
Ludovic Marie c77c40b021 Merge pull request #8512 from hildlabs/shader-option-sliders
🤖 Agent: a drag saves the option and rebuilds the pack once, on release, not on every pointer move; the manifest already refuses min >= max and step <= 0, so slider_position and slider_value cannot divide by zero.
2026-11-04 15:22:54 +01:00
Ludovic Marie 12336db449 Merge pull request #8438 from ErikKalikas/mod-reload-listeners
🤖 Agent: the pack stack is rebuilt only when a mod registered a listener, so a /reload without native mods pays nothing, and folder_ok refuses '..' and absolute paths, so a listener cannot read outside data/<namespace>/.
2026-11-04 15:22:49 +01:00
Ludovic Marie f010091162 Merge pull request #7226 from Corbonat/fix/gui-glint-atlas-5851
🤖 Agent: GUI glint now takes vanilla's ITEMS-atlas rectangles (Stitcher's order, split and filtering padding reproduced) while CIT glint keeps sprite-local UVs; the cost is one more decode of the item textures per pack load in prepare_glint_atlas, nothing per frame.
2026-11-04 15:22:45 +01:00
Ludovic Marie c42b5b17f0 Merge pull request #9187 from Domanffe/chore/stamp-native-hooks-399
🤖 Agent: re-running tools/stamp_native_hooks.py on this PR's base (38df83828) reproduces the tree byte for byte, and main has not touched a fragment since; the native-hook-stamp workflow that should have opened this PR has failed its last five runs, which the orchestrator will look at.
2026-11-04 15:22:41 +01:00
Delete228andCodex 25dd488069 Merge current main while preserving world damage snapshots
Keep the successful falling-block hurt advancement, passenger frostbite tick and general world-pack tag synchronization.

Co-Authored-By: Codex <[email protected]>
2026-11-04 17:20:36 +03:00
Ludovic MarieandClaude Opus 5.5 ab3eceeeb9 Launcher Shop: the founder claim opens Paddle's free checkout; purchase history
- When the cosmetics service answers a claim with a checkout (the 0 CHF Paddle checkout
  Ludovic chose for founder claims), the launcher opens it in the browser, but only
  Horizon's own checkout page (https://playhorizon.dev/shop/pay?…). It then asks the
  service every 3 s, for 20 minutes at most, until Paddle's webhook has granted the set.
  The button says "Waiting for the checkout…" and a link opens the checkout again.
- The Wardrobe tab's claim button takes the same path.
- "Your purchases" under the founder panel: date, item, price (Free at 0), state (Paid,
  Partly refunded, Refunded, Charged back) and the order number from Paddle's receipt,
  for Mojang's purchase-history rule.
- The claim note no longer promises "no card asked" (unconfirmed at 0 CHF).
- 11 new strings in all 86 tables; the dropped one removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:20:33 +01:00
Ludovic Marie 14868bea32 Merge pull request #8575 from MSedgeMC/fix/xp-orb-light
🤖 Agent: matches ExperienceOrbRenderer: getBlockLightLevel is block+7 clamped with sky untouched (was a floor of 10 on both), the quad spans (-0.5,-0.25)..(0.5,0.75) at 0.3, and light is read at the eye (0.425).
2026-11-04 15:19:56 +01:00
Florian ERNST 75b6327008 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/inline-execute-predicates 2026-11-04 15:19:41 +01:00
Florian ERNSTandCodex cd8d1c5fd7 Evaluate inline execute predicate values
Co-Authored-By: Codex <[email protected]>
2026-11-04 15:19:40 +01:00
AxxioandAntigravity 5013115571 launcher: fix more button click, list gaps, input height, blur, and scroll propagation
- Prevent Play more button click from bubbling or launching the game
- Fix missing gaps between items in virtualized lists (cull::column) for versions and patch notes
- Match small field height (36px) and tokens for Motd, Port, Token, AddPlayer, and DeleteServer
- Do not apply 24px baseline blur when blur is set to 0
- Stop scroll event propagation on dropdowns, version lists, and nav tabs

Fixes #9148
Fixes #9169
Managed by Axxio.

Co-Authored-By: Antigravity <[email protected]>
2026-11-04 15:18:37 +01:00
Ludovic Marie 7950c14bba Merge pull request #8947 from NikitaMGrimm/offscreen-hdr
🤖 Agent: without HORIZON_HDR_OUTPUT, offscreen_output() is None and every new branch is skipped; the crash marker is only written with a surface, so an offscreen HDR run cannot turn HDR off for the next windowed start.
2026-11-04 15:18:06 +01:00
Ludovic MarieandClaude Opus 5.5 db733482fd Drop the unused block_jump_factor wrapper and re-export
Mob::block_jump_factor is the one callers use; the free function and rabbit's
re-export had no callers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:16:35 +01:00
Ludovic Marie 2c90c298c1 Merge pull request #8543 from NerfPhaselock/perf/gpu-culling-g1b
🤖 Agent: inert until gpu-culling is switched on (only control.rs and scene_replay do), and the fade-end heap means a frame with no fade due does no extra work; martipops' read found every fade start sets the flag before prepare.
2026-11-04 15:16:22 +01:00
rdbtCVSandCodex gpt-6.1-sol 6b33c32f7b fix(fishing): share synchronized random helper with remote hooks
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 14:15:53 +00:00
Ludovic MarieandClaude Opus 5.5 bf939d4926 Give translucent_batchable its doc comment back
The gizmo helpers went in between translucent_batchable's doc comment and the
function, so rustdoc showed the batching text on debug_line_opaque.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:15:44 +01:00
Ludovic Marie 5dc270d3f9 Merge pull request #8817 from itarqos5/test/lod-bench-span-histogram
🤖 Agent: example-only; the last histogram bucket (32) is printed as 32+ so a capped count is not misread as exact.
2026-11-04 15:15:08 +01:00
martipopsandClaude Opus 5.5 d36aaf9c0c Launcher: the Shop reads its window on the service's time, and an unknown claim state is safe
A fast clock closed a window the service still called open, hiding the Claim button on a set that's retired for good. Each answer's Date header now shows how far the clock is ahead, and the Shop reads the window's dates with that taken off. A clock on time or behind reads as before.

A claim state the launcher doesn't know now parses instead of failing the catalogue and the account's answer. It offers no claim and doesn't say the set is over.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp7yEJwPTJ2kw29LWdk1
2026-11-04 09:15:05 -05:00
Loki 613b1f9574 Merge remote-tracking branch 'origin/main' into fix/8703-breeze-invulnerable-to-breeze 2026-11-04 23:15:04 +09:00
Ludovic MarieandClaude Opus 5.5 14e59bf1ad Keep dead and spectating guests in projectile ownership
has_guests counts only guests a projectile can hit (alive, loaded, here, not
spectating), so gating dead_guests and guest_entities on it dropped a lone
dead player's pearl from ender_pearls_vanish_on_death (pearl_death harness)
and a dead shooter's arrow from its owner. Both lists are built every tick
again; only the guest target copy stays gated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:15:04 +01:00
Ludovic Marie 482d31394c Merge pull request #8766 from NikitaMGrimm/translucent-batching-checkbox
🤖 Agent: switch() now ANDs translucent_batching_offered() exactly as hdr_smooth_gradients already does, and the saved preference is untouched, so a device that can batch gets it back.
2026-11-04 15:15:03 +01:00
Ludovic Marie 3a2bc05ae2 Merge pull request #8738 from NikitaMGrimm/lan-close-test-windows
🤖 Agent: the loop now asserts the outbox really holds bytes (pending > 0 three times 50 ms apart) before sending Disconnect, which the old 64 MiB burst only assumed; the 256-MiB cap fails loudly instead of hanging.
2026-11-04 15:14:58 +01:00
Ludovic Marie 81d1cf35d1 Merge pull request #8671 from Qwerner88880/pr-ready-beside
🤖 Agent: only Public tree and Python tests move beside cargo, and every Python test that names cargo mocks it, so they never fight cargo for the target dir; Parity stays after cargo since it builds.
2026-11-04 15:14:54 +01:00
Ludovic Marie e1597349dd Merge pull request #8711 from NikitaMGrimm/install-sh-test-skip-windows
🤖 Agent: skipIf(os.name == "nt") sits above the bash/openssl skip, so Windows never reaches System32's WSL bash (#5383), and CI's Linux job still runs all signature cases.
2026-11-04 15:14:49 +01:00
martipopsandClaude Opus 5.5 58ffef7e6b Launcher: a cosmetic item's id and hash can't leave the cache folder
Service::item_cached built its path from catalogue values unchecked. It now refuses an entry
whose id or hash isn't 1 to 64 of a-z, A-Z, 0-9 and _, the rule the game already applies
(horizon_cosmetics::fetch), before the disk or the service is touched.

The hash isn't checked against the bytes: nothing in this repository says what it is a digest of.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp7yEJwPTJ2kw29LWdk1
2026-11-04 09:14:35 -05:00
Ludovic Marie 77f9739ab0 Merge remote-tracking branch 'origin/main' into cg31fix-9069
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
2026-11-04 15:14:09 +01:00
voidsipandCodex b27922de61 Synchronize raider celebration metadata
Send the celebration flag and its false default for all raiders, then apply it on clients. Exercise real tracking and packet decoding against native dirty metadata without loading shared resource state.

Fixes #8848

Co-Authored-By: Codex <[email protected]>
2026-11-04 08:14:02 -06:00
degepieandClaude Opus 5.5 22148d740e Minecarts: sync DisplayState and DisplayOffset to clients
The tracker sends AbstractMinecart's DATA_ID_CUSTOM_DISPLAY_BLOCK (entry
11, optional block state) and DATA_ID_DISPLAY_OFFSET (entry 12, the kind's
default offset), and the client reads them back into the cart, so a cart's
custom block shows over the network and in single player.

Fixes #8050.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 19:11:27 +05:00
Ludovic MarieandClaude Opus 5.5 405186aa36 Squid ink: send it as server particles, not every mob particle
be258bc235 sent every MobEvent::ParticleAt as a particle packet with a speed
of 0.1. The squid's ink velocity was already multiplied by 0.1, so clients got
a hundredth of it. The other ParticleAt events (hearts, splashes, wither smoke,
glow, bubbles) are client-side addParticle calls in vanilla that the clients
draw themselves, so they were drawn twice.

Squid.spawnInk is ServerLevel.sendParticles(count 0, speed 0.1): it is now a
MobEvent::LevelParticles, which draws it here and sends it to the players
within 32 blocks, and ParticleAt stays local.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:10:53 +01:00
LokiandAntigravity 89e8dd9c16 mobs: breeze damage invulnerability from other breezes (fixes #8703)
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 23:10:24 +09:00
Ludovic MarieandClaude Opus 5.5 e30618104b Setup: Primary main actions, and look for settings twice a second
Install/Update, Uninstall and Launch Horizon are each screen's one main action:
Kind::Primary, as the launcher marks them. The live-settings wake drew the
window every 16 ms and read settings.json's metadata each frame; 500 ms still
picks a change up while the setup is open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:10:14 +01:00
Ludovic MarieandClaude Opus 5.5 dfd4d41e63 Import: drop the line-wrap spaces inside two error messages
Both strings had a run of spaces where an editor wrapped them, so the note
read "a Horizon                  game build."

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:09:56 +01:00
Ludovic Marie 85d380ff3c Merge remote-tracking branch 'origin/main' into cg31/8691 2026-11-04 15:09:56 +01:00
Ludovic MarieandClaude Opus 5.5 640ff80c83 Merge the stack's own main merges (#8652, #8657, #8658, #8660)
rdbtCVS's agent merged main into four of the stacked branches. Merging those
heads here lands every PR of the stack with #8691.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:09:55 +01:00
Ludovic MarieandClaude Opus 5.5 695feba0bc Far terrain zoom detail: off by default
Up to about 45% more far tiles while a zoom is held, and a far tile takes
seconds: a quick spyglass look queues work that lands after it. The extra stays
in horizon-extras.txt (lodZoomDetail:true) until a measurement shows it pays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:09:42 +01:00
Ludovic MarieandClaude Opus 5.5 d0da65d108 Resource loaders: name threads load-<step>
Linux keeps 15 bytes of a thread name, so "horizon-load-core" and
"horizon-load-collision" both read "horizon-load-co" in the heap census and the
startup trace there. "load-<step>" keeps every loader apart.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:09:34 +01:00
Ludovic MarieandClaude Opus 5.5 6d9c504d06 Shulker: a riding shulker's setPos still closes the lid
Shulker.setPos resets DATA_PEEK on any block change after the first tick,
passenger or not; only the teleport slide is skipped for a passenger. The
slide is cleared by startRiding (on_start_riding), not by every move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:09:33 +01:00
Ludovic Marie 6d43a3593b Merge pull request #9212 from horizon-rust/fix/8669-useitemon-freeze
🤖 Agent: The freeze came from map searches opened from chests, which built full jigsaw starts and scanned every chunk of each saved region for each candidate. Same answers on main and this branch (24 targets × 5 points × 4 runs); on a pregenerated world the bamboo-camp map search drops from 2.1–3.6 s to 0.04–0.07 s of server-thread CPU, and main hit the 60 s watchdog in 4 of 9 runs. fmt, both clippies and the touched crates' tests pass, plus 10 harnesses incl. server_world_gen (/locate against vanilla); dedicated_server's vanilla oracle crashed on the loaded desktop, so CI runs it.
2026-11-04 15:09:32 +01:00
Ludovic MarieandClaude Opus 5.5 98e73cd84b rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:09:22 +01:00
Ludovic MarieandClaude Opus 5.5 d580df1589 mob_ai parity: drop the 'digging' warden push cases
They spawned a warden with no flag, which stands: nothing in the fixture starts
a dig, so they compared a standing warden under a digging name. The emerging
cases stay; the unit test covers digging through set_pose.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:09:18 +01:00
Ludovic MarieandClaude Opus 5.5 180e7b2631 Native builds: say which mod pins the commit, and rebuild on the picked Horizon
A player's native builds kept compiling 9d979ca (Nightly 2026-10-31)
whatever Nightly or build profile they picked: Right-Click Crops 1.0.0
pins that commit and names no hook points' version, so the pin chose the
build's commit while the Mods page said "built on Horizon <picked Nightly>".

The build record now keeps the picked version's own commit and the mods
whose pin chose another; the Mods page names them, and "Rebuild on
Horizon" builds every mod on the picked version's commit (on_base,
native-build --ignore-pins), which later rebuilds keep. Older records
whose commit differs from their Nightly's read the pins from the
instance's mods. Also translates "Paste a friend's invite code".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:09:12 +01:00
Ludovic MarieandClaude Opus 5.5 a4d2184bdf Clear the old level's orbs with the other remote objects
orbs_after_client_respawn was only ever called with true, so the helper
and its test only restated Vec::new(). clear_remote_objects now clears the
orbs itself, which also makes the connect path's own orbs.clear() redundant.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:09:09 +01:00
Ludovic MarieandClaude Opus 5.5 5752c7bb5b Native mods: give NativeServerPlayer.address its API fragment
The field said "API next" without a fragment, so stamping would never give it
a number. tools/native-hook-fragments/server_player_address.json requests one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:08:52 +01:00
Ludovic MarieandClaude Opus 5.5 38c31cac5d Armadillo: a baby's brush is not taken on the client
Armadillo.brushOffScute returns false for a baby, so the brush falls through
to FAIL (rolled up) or Mob.mobInteract's PASS instead of ending the click.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:08:41 +01:00
Ludovic MarieandClaude Opus 5.5 2c7b49062e Vehicle teleport: review fixes (rotation packet, cooldown per tick, rider's vehicle)
- /rotate on a server's player sends ClientboundPlayerRotationPacket
  (ServerPlayer.forceSetRotation) when its turn ends, riding or not,
  instead of a zero-motion teleport (or nothing for a rider).
- The guest's boarding cooldown counts down once per tick in
  guest_server_tick, not once per enter_guest; a vehicle dropped in
  guest_vehicle_tick also starts it.
- A teleported vehicle's riding guests hear the vehicle's teleport
  (ClientboundTeleportEntityPacket), so a driven boat is not put back
  by the client's next MoveVehicle.

Found by martipops's review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:06:22 +01:00
Ludovic MarieandClaude Opus 5.5 94ccd9c8ee Merge #9140 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:06:18 +01:00
Ludovic MarieandClaude Opus 5.5 15f1090ed1 Merge #9130 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:06:18 +01:00
Ludovic MarieandClaude Opus 5.5 c063a488ce Multiplayer: CLIENT_TICK_END only while the client has a level
Minecraft.tick sends ServerboundClientTickEndPacket only while it has a
level, which ClientPacketListener.handleLogin makes and
handleConfigurationStart clears. Horizon sent one every tick from the end
of configuration, so a client waiting for a busy server's Login (Paper
with plugins: seconds) sent dozens of play packets vanilla never sends.

tools/vanilla_wire_e2e.py joins a vanilla 26.3 server through
net_capture.py with the server's Login held back 1 s, and checks the
capture against rules of vanilla's client; no-play-before-login is the
first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:06:14 +01:00
Ludovic MarieandClaude Opus 5.5 4d7b9f28b7 Merge main into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:05:36 +01:00
rdbtCVSandCodex gpt-6.1-sol 12e9795542 Merge main into live transfer management branch
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 14:05:27 +00:00
Ludovic Marie 7bacc86a5e Merge remote-tracking branch 'origin/main' into cg32/fix-8733 2026-11-04 15:03:51 +01:00
Ludovic MarieandClaude Opus 5.5 e9109d87f4 Remote mob takes: keep food and shears as the 26.3 client answers them
The 26.3 client's Animal.mobInteract answers CONSUME for an adult's food
(SUCCESS for a baby that can age up), and Sheep.mobInteract answers CONSUME
for shears whether or not the sheep is sheared: only the server checks
canFallInLove and readyForShearing. So both keep taking the click. Keep the
dye change, which matches DyeItem.interactLivingEntity, and add its
isSheared check: a sheared sheep passes any dye.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:03:22 +01:00
Ludovic MarieandClaude Opus 5.5 39a8fc809b Merge #9197 into the TheMrZZ chain (cg-30)
Conflicts resolved per function: every top-level def matches one side (the
harness main() keeps both sides' options), every groups() entry is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:03:16 +01:00
Ludovic MarieandClaude Opus 5.5 7486e7ba45 Merge #9174 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches
one side, every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:03:03 +01:00
Ludovic MarieandClaude Opus 5.5 7a1256e51d Merge #9157 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches
one side, every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:03:01 +01:00
Ludovic MarieandClaude Opus 5.5 8cfa002c12 Merge #9145 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:03:00 +01:00
Ludovic MarieandClaude Opus 5.5 7704a05c0b Merge #9117 into the TheMrZZ chain (cg-30)
Conflicts resolved per function: every top-level def matches one side (the
harness main() keeps both sides' options), every groups() entry is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:03:00 +01:00
Ludovic MarieandClaude Opus 5.5 ae2d4b2442 Remote boats: swing when boarding, pass only as AbstractBoat.interact does
The 26.3 client's AbstractBoat.interact answers SUCCESS (a swing) whenever
the player isn't sneaking and the boat isn't out of control; only the
server's startRiding checks for room, so a full boat or a riding player
doesn't pass on the client. AbstractChestBoat passes only out of control,
not sneaked at, with room; otherwise it opens the chest. Minecart.interact
is the one that answers CONSUME and doesn't swing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:02:57 +01:00
razzo715andClaude Opus 5.5 c5b45b6bec Idle brewing stands and campfires sleep like idle furnaces
A brewing stand that isn't brewing, has no ingredient, has nothing to
refuel from and whose bottles already match its block state, a lit
campfire with nothing on it, and an unlit one with no cooking progress do
nothing in their server tick; they now sleep on the same terms as idle
furnaces. Lighting or putting out a campfire changes its block state, which
wakes it.

16,384 idle brewing stands cost 2.01 ms a tick instead of 8.14, and as
many unlit campfires 2.05 instead of 7.52.

Part of #8558

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:02:53 +01:00
razzo715andClaude Opus 5.5 386d37503d Idle furnaces sleep until their block entity or block state changes
An unlit furnace, blast furnace or smoker with no cooking progress and no
fuel and input pair to light does nothing in AbstractFurnaceBlockEntity's
server tick. Such a furnace now leaves the tick loop: BlockEntities keeps a
set of sleeping positions, and any change to one (get_mut, insert, take and
put, remove, a block state change, a chunk load, a bulk edit) wakes it at
once, so it ticks on the same game tick as before.

16,384 idle furnaces cost 2.07 ms a tick instead of 8.76 (2.11 for as many
chests, which have no ticker).

Part of #8558

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:02:53 +01:00
Ludovic MarieandClaude Opus 5.5 bc2adf84dc Merge #9101 into the TheMrZZ chain (cg-30)
Conflicts resolved per function: every top-level def matches one side (the
harness main() keeps both sides' options), every groups() entry is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:02:50 +01:00
Ludovic MarieandClaude Opus 5.5 19b0fd00ca Test: fill the fields main added since this branch
Merging main left a test initializer without the field main added
(TridentSave.fire_ticks / Interface.initial_values and
Resources.shadow_linear), so the test target stopped compiling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:02:16 +01:00
Ludovic MarieandClaude Opus 5.5 1fb681318d Test: fill the fields main added since this branch
Merging main left a test initializer without the field main added
(TridentSave.fire_ticks / Interface.initial_values and
Resources.shadow_linear), so the test target stopped compiling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:02:15 +01:00
Ludovic MarieandClaude Opus 5.5 48c2142692 Fishing: draw the bite's plunge from the hook's synchronized random
FishingHook takes the plunge from syncronizedRandom, reseeded each tick
from the hook's UUID and the game time, not from the level's random: a
draw there shifts every later level draw out of vanilla's order. The
plunge is also computed in float as Mth.nextFloat does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:02:13 +01:00
Ludovic Marie fed2d76e02 Merge remote-tracking branch 'origin/main' into cg32/fix-9084 2026-11-04 15:01:57 +01:00
Ludovic Marie bc9d7d3807 Merge remote-tracking branch 'origin/main' into cg32/fix-8679 2026-11-04 15:01:55 +01:00
Ludovic MarieandClaude Opus 5.5 bd72a6a4eb Merge #9078 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches
one side, every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:51 +01:00
Ludovic MarieandClaude Opus 5.5 6c593adc80 Merge #9053 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:50 +01:00
Ludovic MarieandClaude Opus 5.5 2f904b7019 Merge #9032 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches
one side, every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:50 +01:00
Ludovic MarieandClaude Opus 5.5 8719c32206 Merge #9029 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches
one side, every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:49 +01:00
Ludovic MarieandClaude Opus 5.5 3d2e7d93d4 Merge #9024 into the TheMrZZ chain (cg-30)
Conflicts resolved per function: every top-level def matches one side (the
harness main() keeps both sides' options), every groups() entry is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:49 +01:00
Evrim CicekandClaude Opus 5.5 aa9002e445 A saved dragon or crystal keeps the fields kept with it once it moved
The dragon and the crystals were saved over the compound they were loaded
with only while they stood where they were loaded (anon_raw looks it up
by position), and never over the fields remember_tags keeps for them.
A dragon always flies off, so its Silent, Tags or name were lost at the
next save. Both now save over persist.raw, as /data get already reads
them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:01:42 +03:00
Ludovic Marie 6475f62f3c Merge pull request #8844 from TiggerTakkyTTT/test/server-api-backup-goldens
🤖 Agent: Server API backup goldens: test-only, the download case reads the real streamed bytes. The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. management_protocol (--fresh, on that union): every case of this PR matches vanilla; the only 3 differences were #8204's heartbeat cases, fixed there. Other harnesses PASS on the union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. dedicated_server could not run locally (the vanilla oracle's world generation hit its watchdog under the desktop's memory pressure), so it and the rest of the selection run in CI run-parity.
2026-11-04 15:01:34 +01:00
Ludovic MarieandClaude Opus 5.5 4fc40ee8e0 Merge #9017 into the TheMrZZ chain (cg-30)
Conflicts resolved per function: every top-level def matches one side (the
harness main() keeps both sides' options), every groups() entry is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:33 +01:00
Ludovic MarieandClaude Opus 5.5 815b10e907 HDR: put set_hdr's doc comment back on set_hdr
NikitaMGrimm's test report noted set_hdr_lighting landed between set_hdr
and its doc comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:30 +01:00
Ludovic Marie 48684a9b1b Merge pull request #8834 from TiggerTakkyTTT/test/server-api-lifecycle-goldens
🤖 Agent: Server API lifecycle goldens: test-only, type-checked replacement of the volatile fields. The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. management_protocol (--fresh, on that union): every case of this PR matches vanilla; the only 3 differences were #8204's heartbeat cases, fixed there. Other harnesses PASS on the union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. dedicated_server could not run locally (the vanilla oracle's world generation hit its watchdog under the desktop's memory pressure), so it and the rest of the selection run in CI run-parity.
2026-11-04 15:01:28 +01:00
Ludovic MarieandClaude Opus 5.5 9c11a961b4 Merge #9001 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches
one side, every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:19 +01:00
Ludovic MarieandClaude Opus 5.5 72352fd257 Merge #9000 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches
one side, every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:18 +01:00
Ludovic MarieandClaude Opus 5.5 076ed2dd4a Merge #8997 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches
one side, every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:17 +01:00
Ludovic MarieandClaude Opus 5.5 fda782feeb Merge #8991 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches
one side, every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:16 +01:00
Ludovic MarieandClaude Opus 5.5 04542d1eff Merge #8986 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches
one side, every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:15 +01:00
Ludovic MarieandClaude Opus 5.5 fc381670ce Merge #8984 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches one side,
every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:14 +01:00
Ludovic MarieandClaude Opus 5.5 bb9d8c7d5b Server packs: keep vanilla's hash-only cache check
Drop the ZIP structure validation from the download path. Vanilla's
DownloadedPackSource checks only the SHA-1 (checkExistingFile), and the
strict zip-crate validator rejected the Java-compatible streamed archive
that started this PR, which Horizon's own pack reader already reads; any
other archive Java opens but the zip crate doesn't would have been refused
too. It also decompressed the whole pack on every cache hit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:01:10 +01:00
Ludovic MarieandClaude Opus 5.5 b8eaa4cb3c Merge #8981 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:00:47 +01:00
Ludovic MarieandClaude Opus 5.5 6e4d10fcd1 Merge #8979 into the TheMrZZ chain (cg-30)
Corpus conflict resolved per function: every top-level def matches one side,
every groups() entry of both sides is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:00:46 +01:00
Ludovic Marie e1fca55972 Merge pull request #8128 from TiggerTakkyTTT/fix/server-management-entity-range
🤖 Agent: Entity broadcast range: vanilla logs the requested percentage, then clamps 10..1000 and saves the clamped value. The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. management_protocol (--fresh, on that union): every case of this PR matches vanilla; the only 3 differences were #8204's heartbeat cases, fixed there. Other harnesses PASS on the union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. dedicated_server could not run locally (the vanilla oracle's world generation hit its watchdog under the desktop's memory pressure), so it and the rest of the selection run in CI run-parity.
2026-11-04 15:00:17 +01:00
Ludovic MarieandClaude Opus 5.5 aabda3e079 Merge #8970 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:43 +01:00
Ludovic MarieandClaude Opus 5.5 94b8b2fb47 Merge #8968 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:42 +01:00
Ludovic MarieandClaude Opus 5.5 492887393e Merge #8965 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:42 +01:00
Ludovic MarieandClaude Opus 5.5 f8fe006043 Merge #8959 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:42 +01:00
Ludovic MarieandClaude Opus 5.5 badc87b99e Merge #8956 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:42 +01:00
Ludovic MarieandClaude Opus 5.5 de08a70aa5 Merge #8953 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:41 +01:00
Ludovic MarieandClaude Opus 5.5 2f4de5382f Merge #8949 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:40 +01:00
Ludovic MarieandClaude Opus 5.5 81977ac47e Merge #8943 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:40 +01:00
Ludovic MarieandClaude Opus 5.5 b5efda5f7d Merge #8940 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:39 +01:00
Ludovic MarieandClaude Opus 5.5 e61dd55a1e Merge #8922 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:39 +01:00
Ludovic MarieandClaude Opus 5.5 d610f6ed98 Merge #8909 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:38 +01:00
Ludovic MarieandClaude Opus 5.5 4bcc83ce33 Merge #8888 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:37 +01:00
Ludovic MarieandClaude Opus 5.5 74e2e70389 Merge #8878 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:37 +01:00
Ludovic MarieandClaude Opus 5.5 7f39f7025c Merge #8860 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:36 +01:00
Ludovic MarieandClaude Opus 5.5 807b7a62f5 Merge #8847 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:36 +01:00
Ludovic MarieandClaude Opus 5.5 c74d24ac9d Merge #8841 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:35 +01:00
Ludovic MarieandClaude Opus 5.5 54ef85585d Merge #8821 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:35 +01:00
Ludovic MarieandClaude Opus 5.5 0d67cfd734 Merge #8799 into the TheMrZZ chain (cg-30)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:35 +01:00
GabrielNichols ec037e9e7f Merge upstream/main into codex/native-cache-cleanup-8028 2026-11-04 10:58:02 -03:00
Ludovic MarieandClaude Opus 5.5 b6204bf860 Management: a set after the heartbeat was cancelled is refused, as vanilla's
ManagementServer.scheduleHeartbeat cancels the running heartbeat and
schedules a new one only above 0. A set to 0 or less that stopped a
running heartbeat leaves the cancelled future in place, and cancel(true)
on it returns false, so vanilla refuses every later set until the server
restarts: it logs that the interval was not updated, keeps nothing and
answers with the interval in force. The management_protocol harness
showed it (vanilla answered 0 to the last set to 30, saved 0 and read 0
after the restart; Horizon took 30).

Horizon now keeps that state beside the interval. The harness cancels
with -5 instead of 0, so the value saved and read back after the restart
differs from the default, and expects vanilla's -5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:57:38 +01:00
Ludovic MarieandClaude Opus 5.5 c2aa51c15f Merge origin/main into feat/admin-player-position
Resolve the generated docs/api/server-reference.md row: main's text with
the new dimension field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:55:35 +01:00
Alexander KrylovandHermes Agent bac0d3ca47 Data packs: a dropped pack that cannot be copied is reported
`copy_pack` walks a dropped folder with `let _ = fs::copy(...)` in every
branch and answers `true` for anything that holds a `pack.mcmeta`, so a folder
that half-copied (a held file, a full disk, a path too long) was announced as
installed and the pack appeared with files missing.

`copy_dir` now returns whether every copy landed and logs each file it could
not write; `copy_pack` passes that answer up, and the drop handler says the
pack is one it could not copy. A pack that is no pack still answers false with
no log line.

The test copies a real pack into a path that cannot be made and asserts false,
then copies it into a real folder and asserts the file arrives.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-04 16:55:06 +03:00
voidsipandCodex 850c43dedb Synchronize zombie infection and cure sounds
Use the vanilla infection/conversion level events and the client cure-start entity event. Respect silent mobs, draw cure volume before pitch on each client and avoid duplicate captured sound packets.

Fixes #8892

Co-Authored-By: Codex <[email protected]>
2026-11-04 07:52:45 -06:00
Ludovic Marie 4d6f1253d2 Merge pull request #8620 from horizon-rust/feat/launcher-shop-ui
🤖 Agent: Ludovic approved the shop and the dark founder set (11-04). verify, launcher, parity-scope green on this exact head (rdbtCVS's options-test fix included); desktop fmt/clippy/tests passed on the shop changes. The claim window stays closed on the service until the Paddle 0 CHF checkout follow-up lands, so merging only shows the shop and worn cosmetics.
2026-11-04 14:48:32 +01:00
Ludovic Marie f521bd9eb1 Merge pull request #8194 from Akirabane/feat/prometheus-entities-chunks
🤖 Agent: Prometheus entity/chunk counts read only when the supervisor samples server-status; main merged (rdbt's d938f6a1dd). The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. Harnesses PASS on that union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. boat_submerged, boat_paddle_reset and dedicated_server did not run locally: the vanilla oracle's world generation hit its watchdog 3 times under the desktop's memory pressure, before any comparison. They and the rest of the selection run in CI run-parity.
2026-11-04 14:48:28 +01:00
Ludovic Marie 971ded7bc7 Merge pull request #8171 from Akirabane/fix/8156-boat-powder-snow
🤖 Agent: Boat powder snow matches PowderSnowBlock.entityInside (stuck 0.9/1.5/0.9, nextBoolean then two randomBetween draws). The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. Harnesses PASS on that union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. boat_submerged, boat_paddle_reset and dedicated_server did not run locally: the vanilla oracle's world generation hit its watchdog 3 times under the desktop's memory pressure, before any comparison. They and the rest of the selection run in CI run-parity.
2026-11-04 14:48:24 +01:00
Ludovic Marie c6734d6acb Merge pull request #8167 from Akirabane/fix/8156-rider-frost
🤖 Agent: Rider frost walker / block effects for passengers; old_position reset keeps the sweep to the vehicle's cell. The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. Harnesses PASS on that union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. boat_submerged, boat_paddle_reset and dedicated_server did not run locally: the vanilla oracle's world generation hit its watchdog 3 times under the desktop's memory pressure, before any comparison. They and the rest of the selection run in CI run-parity.
2026-11-04 14:48:20 +01:00
Florian ERNSTandCodex 7474284c81 Expose decorated pot state to block data commands
Co-Authored-By: Codex <[email protected]>
2026-11-04 14:48:09 +01:00
razzo715andClaude Opus 5.5 603bd9e4ad /item and /loot reach every container block entity's slots
/item ... block, execute if items block and /loot insert|replace block
only found slots in a chest, so a furnace, brewing stand, crafter,
chiseled bookshelf, shelf or jukebox answered "does not have slot". They
now take every block entity that is a Container in vanilla
(BlockItemAccessor.getContainer), and each one sets its slot through its
own Container.setItem: a furnace restarts cooking only for a new input, a
crafter enables the slot again, a bookshelf takes only books and a jukebox
starts or stops its song. Any other block answers "is not a container".
/loot checks canPlaceItem first, as LootCommand does.

Fixes #8685

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:46:56 +01:00
Alexander KrylovandHermes Agent c6093b3821 Roadmap: the two End city items link the pull requests that fixed them
Both items were filed help-wanted and their issues are closed, so
`python tools/roadmap.py check` answers "every linked issue and pull request is
closed: ship or drop it" for each: the work landed and the roadmap never
learned which pull request did it, so `sync` cannot move either item.

Links added: #1928 for end-city-banners, #1911 and #2037 for end-city-loot,
each merged on 2026-10-26, plus the sentence in `dev` that says what the fix
did. ROADMAP.md and roadmap/roadmap.json are what `roadmap.py build` writes
from the toml.

Status stays help-wanted: `now` needs an owner and a tracking issue and the Now
list is already at its 8-item cap, while `shipped` needs a release tag and
these went into Nightly builds after v0.2.x froze.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-04 16:45:04 +03:00
2a410bab80 Format the max-stack wire test the way cargo fmt checks it.
Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 14:42:29 +01:00
Alexander KrylovandHermes Agent f9e59fa328 Port map: seven rows whose methods the tree already ports
Each row below was marked `missing`, and the Rust function it should point at is
in the tree, with a doc comment naming the vanilla method it mirrors. Verified
by reading each function, not by the note the row carried.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-04 16:41:28 +03:00
Evrim CicekandClaude Opus 5.5 a8e50ef283 A silent ender dragon dies without the global death roar
EnderDragon.tickDeath sends globalLevelEvent(1028) at the death's first
tick only when !isSilent(). Horizon roared for every dragon. The dragon's
Silent is kept with the saved fields Horizon models nowhere
(remember_tags), so the death reads it from there.

tools/test_dragon_death_roar_parity.py compares the 1028 events a client
is sent for a dying loud and a dying silent dragon with vanilla's
dedicated server (VanillaProtocolClient's new level-events mode,
LevelEventTranscript).

Fixes #8739

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:41:20 +03:00
AxxioandAntigravity 671e8750e9 Remote mob takes: guard breeding cooldown, sheared sheep and matching dyes (fixes #9121)
In Vanilla Minecraft 26.3 (Animal.mobInteract and Sheep.mobInteract):
- Right-clicking an adult animal with breeding food when unable to breed
  returns PASS on the server; the client does not swing or spawn feed particles.
- Right-clicking a sheared sheep with shears returns PASS and does not swing.
- Right-clicking a sheep with matching dye returns PASS and does not swing.

Guard these interactions in client's remote_mob_takes so the client does not
falsely predict swing arm and feed particles.

Managed by Axxio.

Co-Authored-By: Antigravity <[email protected]>
2026-11-04 14:40:23 +01:00
degepieandClaude Opus 5.5 45bb5766eb Advancements: every trigger casts the looking_at ray only when asked
PlayerPredicate.matches looks for the entity under the player's view
only when the predicate has looking_at. #8687 skipped the ray for tick
and location only; every other trigger that builds the player's view
still cast it, against every entity, on each event.

Each of those call sites now passes its trigger id (player_entity_for),
so the ray runs only when one of that trigger's criteria may ask. A view
that serves two triggers (the thrown-item pickup, the KilledTrigger
damage source) checks both. Commands keep player_entity(), which always
looks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 18:39:51 +05:00
dbdfed59aa A bee sting poisons mobs, not only the player.
Vanilla poisons any living thing the sting hurts, so a villager or a fox was taking the hit and walking away clean.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 14:39:37 +01:00
voidsipandCodex f1566f80d3 Give chest loot tables precedence over saved items
Leave randomizable-container inventories empty when their NBT supplies a valid loot table, allowing the existing command and chunk loading paths to retain the table. Add a red/green regression and a real vanilla inventory-load comparison.

Fixes #8920

Co-Authored-By: Codex <[email protected]>
2026-11-04 07:38:04 -06:00
Alexander KrylovandHermes Agent b85e07a5de Stamp the pending native hook fragments through API 399
main carries 40 fragments whose markers have not been consumed, so
`python tools/stamp_native_hooks.py --check` exits 1 there. This is one run of
that tool on current main: it numbers the fragments in first-parent merge
order, writes the markers into the hand-written API types, and inserts each
`doc_summary` as an `API N adds ...` paragraph in docs/native-mods.md.

No hand-written line: the stamp pass owns native-hooks.json, NATIVE_HOOKS_API
and docs/native-mods.md, and editing any of them by hand is what the fragments
replaced.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-04 16:38:00 +03:00
67cfb2dc40 Keep a stack's own max stack size when it is sent to a client.
A cobblestone stack limited to 16 was merging to 64 after the component was dropped on the way to the window.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 14:37:17 +01:00
Arslan ArmanulyandClaude Opus 5.5 d75f5a4280 Shader packs: turn off a pack whose integer colortex0 can't reach the screen
A pack with an integer colortex0 and no `final` left to draw it (none, or
one that is skipped) has no colours the screen can show: #9007 made the
screen only clear, so Arc v0.15.2 drew a black world. `PackPost::new` now
refuses such a pack, `Renderer::set_shader_pack` returns the reason, and
the game turns the pack off with the usual log line and chat message, as
a pack that fails to load, so the world draws without it. An integer
colortex0 with a working `final` still loads and draws.

Fixes #9165

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:37:09 +05:00
rdbt 2c87445fff Merge branch 'main' into fix/golem-interactions 2026-11-04 13:31:54 +00:00
Alexander KrylovandHermes Agent 218c8db228 Client settings: a failed options.txt write is logged
Settings::save writes options.txt with `let _ = fs::write(...)`, so a write that
fails (a held file, a read-only folder, a full disk) leaves the player's
options, key bindings and volumes at whatever the last successful save put
there, and the game says nothing. Every option change goes through this call.

This logs the error where the rest of the crate warns (`log::warn!` in
`audio.rs`) and leaves the other files alone: `horizon.save`,
`controller.save`, `extras.save` and `mods.save` return no Result, so widening
their signatures is a separate change.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-04 16:31:10 +03:00
rdbt be4ee9131a Merge branch 'main' into fix/allay-take-back-main-hand 2026-11-04 13:29:41 +00:00
Ludovic Marie f95da0d5e7 Merge pull request #8619 from Akirabane/fix/8488-execute-on-owner
🤖 Agent: execute on owner resolves the owner guest first. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:49 +01:00
Ludovic Marie 837e0498c2 Merge pull request #8520 from Akirabane/perf/pool-idle-per-worker
🤖 Agent: Per-worker idle release; cross-worker idle is mutexed, per-job cost a lock + Instant::now. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:45 +01:00
CocofireHDandOpenCode 8fdb2ffce7 Build limit: say it when a placement above the world fails
BlockItem.place's FAIL ends the click but does not consume the action, so
the server must still send build.tooHigh/build.tooLow. The guest path
counted the failed hand use as consuming and skipped the message.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 14:28:41 +01:00
Ludovic Marie 89d08be7cf Merge pull request #8218 from Akirabane/fix/7843-server-pack-tags
🤖 Agent: World pack tags merged only for registries with tag files. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:41 +01:00
Ludovic Marie 398aa7aa09 Merge pull request #8188 from Akirabane/fix/8180-bucket-hollow-blocks
🤖 Agent: Bucket ray uses ClipContext.Block.OUTLINE like BucketItem.use; main merged. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:37 +01:00
Ludovic Marie 5c1f65bb9c Merge pull request #8182 from Akirabane/fix/8175-bool-std430
🤖 Agent: Shader bool/bvec buffer rewrite; rdbt's main merge 0e010734d9 (one mod-line hunk) re-tested: horizon-shaders 249 tests OK. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:32 +01:00
Ludovic Marie 7bc9b358dc Merge pull request #8170 from Akirabane/fix/2912-routing
🤖 Agent: Routing of flat worlds through the pipeline; lands after #8163 (carries its commits). the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:27 +01:00
Ludovic Marie abfb7aee9e Merge pull request #8163 from Akirabane/fix/2912-flat-features
🤖 Agent: Superflat features match FlatLevelGeneratorSettings.adjustGenerationSettings. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:22 +01:00
Ludovic MarieandClaude Opus 5.5 a13845ca22 Map searches: skip chunks no wanted start fits, and read one chunk to tell an old one
Opening a generated chest with an exploration map nobody searched yet (a camp chest's camp
or secret map, a shipwreck's treasure map) runs the map's structure search on the server
thread. Two things made one such UseItemOn cost seconds on the official server (#8669):

- Every candidate chunk got its full starts built (all of its sets, jigsaw layouts
  included). Vanilla's StructureCheck.canCreateStructure only places the wanted
  structure's start piece and checks its biome. `may_start` now does that for jigsaws
  (`place_start` / `lay_out_children` split out of `assemble_drawing`), and `locate`
  builds a chunk's starts only where a wanted structure may start.
- Each candidate asked whether its chunk was old through the blender's region scan,
  which decodes every chunk record of each region file it touches. Vanilla's
  StructureCheck reads the one chunk. `OldWorld::is_old_chunk` now answers from a
  scanned region's bits, else from that chunk's record alone.

The slow-event line also names the item, block, position and face of a use, and the use
is split into use_mods / use_block / use_item / open_loot / open_maps /
use_advancements spans (`spans::guard` ends a span where its scope does).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:28:06 +01:00
Alexander KrylovandHermes Agent b13109ea12 Launcher: a failed wardrobe or cosmetics save is printed
The Wardrobe page drops the result of every write it makes: the outfit it keeps
on disk (`wardrobe::Saved::save`), the game's cosmetics switches
(`horizon_cosmetics::prefs::Prefs::save`) and the line recording what the
service confirmed (`prefs::set_confirmed`). All three return an `io::Result`,
so a failed write left the page showing the new outfit while the file on disk
still held the old one, and nothing said so.

This prints the error where the launcher already speaks with `eprintln!`
(`instances.json`, `settings.json`, accounts) and changes nothing else: the
in-memory state still carries the change.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-04 16:27:22 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 210234f2ad Sprint dust of a mob comes from the mob's own random
Entity.spawnSprintParticle draws the crumb's x and z from this.random.
Horizon drew them from the particles' random for iron golems and other
players (the rabbit hop already uses the rabbit's own), so a golem walking
out of sight changed every particle drawn after it, such as a campfire's
smoke. The window's own player keeps the particles' random, which stands
in for its own as elsewhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:26:06 +01:00
rdbt 8237939970 Merge branch 'main' into fix/nautilus-interactions 2026-11-04 13:25:49 +00:00
Florian ERNSTandCodex e91ba5d21a Expose live comparator output to block data commands
Co-Authored-By: Codex <[email protected]>
2026-11-04 14:25:22 +01:00
rdbt 3f9ccee0fd Merge branch 'main' into fix/squid-ink-particles-broadcast 2026-11-04 13:25:02 +00:00
voidsipandCodex 87b5918538 Show native mod installation consent in both browser views
Keep the trust notice above the project and results, and scroll it into view when installation pauses for consent.

Fixes #8978

Co-Authored-By: Codex <[email protected]>
2026-11-04 07:23:59 -06:00
Arslan ArmanulyandClaude Opus 5.5 2358c282ca Shader packs: a fragment output's component qualifier picks its channels
Horizon read only `location` from a fragment output's layout, so
`layout(location = 1, component = 1) out uvec3 x;` was written from
channel 0, and two outputs splitting one location by component became two
declarations at that location, which naga rejects. Each such output is now
a private global copied into its components of one vector per location
after the pack's `main`; overlapping, mismatched or overflowing components
are errors, as glslangValidator reports them.

Fixes #9135

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:23:17 +05:00
AxxioandAntigravity e8c7ebb11f fix(remote): do not predict swing on non-interactive objects (fixes #9120)
In Vanilla Minecraft Java Edition 26.3, interacting with non-interactive
objects (paintings, fixed or empty item frames with empty hands, sneaking
or occupied boats without chests, and non-interactive minecarts) returns
InteractionResult.PASS, which leaves the click to the held item without
animating a hand swing. Furthermore, boarding a boat or rideable minecart
answers InteractionResult.CONSUME on the client, which ends the use
without predicting an arm swing.

Update remote_use_entity to check object interact passes and prevent
predicting hand swing animations on non-interactive objects or when
boarding vehicles.

Managed by Axxio.

Co-Authored-By: Antigravity <[email protected]>
2026-11-04 14:18:59 +01:00
AxxioandAntigravity f2a95400e3 Fishing bobber applies downward plunge impulse on bite (fixes #9131)
In vanilla 26.3 (FishingHook), when a bite occurs, DATA_BITING is set to true, triggering onSyncedDataUpdated on both server and client to apply an initial downward plunge impulse to velocity.y of -0.4F * Mth.nextFloat(syncronizedRandom, 0.6F, 1.0F) (-0.24 to -0.40).

Co-Authored-By: Antigravity <[email protected]>
2026-11-04 14:17:35 +01:00
rdbt 99f0a14b40 Merge branch 'main' into fix/xp-orb-light 2026-11-04 13:17:01 +00:00
jaszkusandOpenCode f0d96d8648 Launcher: the setup wears the launcher's look
The Windows setup window drew the Night sky and its violet pixel buttons, so
it looked nothing like the launcher it installs.

It now draws the redesign's scene behind the dark window (the dawn sky behind
the light one), tints the scene with the player's accent exactly as the
launcher does, and reads Glass, Blur, Accent and Appearance from settings.json
every frame, so a change made in the launcher applies on the next frame. The
card is a glass plate, its buttons are the launcher's rounded translucent ones
and its switch takes the accent.

Co-Authored-By: OpenCode <[email protected]>
2026-11-04 14:16:16 +01:00
3572bf84fd Every player near a raid wave hears the horn, not only the first.
Vanilla sends each player their own horn, thirteen blocks toward the wave, so people further along the raid were hearing nothing.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 14:12:38 +01:00
Arslan ArmanulyandClaude Opus 5.5 9255ab808d Shader packs: normalMatrix in a core-profile gbuffers stage is the geometry's
`uniform mat3 normalMatrix` in a gbuffers program stayed a frame uniform
nothing fills, so it read zero. In a stage whose #version is 150 or later
and doesn't name the compatibility profile it is now the normal matrix the
compatibility profile's gl_NormalMatrix already uses; in the compatibility
profile it still reads zero. Our probe pack's Iris 1.11.7 pictures fit
this at 330 core, 330, 150 and 330 compatibility.

The GPU test's draw code moves into a helper both matrix tests share.

Fixes #9136

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:11:59 +05:00
d3a1908ac1 A breeze turns a thrown trident away instead of taking the hit.
Vanilla deflects every projectile but a wind charge, so the trident was dealing its full damage and could call down lightning.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 14:08:59 +01:00
0c8a5431ab A fishing hook can catch a minecart and reel it in.
Vanilla treats a minecart as pickable, so the hook was flying through carts and a reel never pulled them.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 14:06:29 +01:00
voidsipandCodex f5a937cb4c Keep moderation replies translatable for each client
Formatting mute and temporary-ban replies as English discarded their translation structure before clients received them. Preserve nested components and duration units, with readable fallbacks for vanilla clients that do not have Horizon's language bundles.

Fixes #8255

Co-Authored-By: Codex <[email protected]>
2026-11-04 07:04:32 -06:00
Florian ERNSTandCodex c388810601 Clamp stacks inserted into empty hopper slots
Co-Authored-By: Codex <[email protected]>
2026-11-04 14:04:17 +01:00
Arslan ArmanulyandClaude Opus 5.5 f557d78c34 Shader packs: a custom uniform without its declared int, float or bool type reads 0
In Iris 1.11.7 a custom uniform or variable whose expression has another
scalar type than the declared one reads 0 as a whole: an int given 2.0
or 7 / 2, a float or int given a comparison, a bool given a number. So
does one naming `true` or `false`, which Iris's custom uniforms don't
know, or calling `round`, `ifb`, `lerp`, `print` or any other function
they don't have. Horizon evaluated all of these.

custom.rs: #9119's `Shape` becomes `Ty`, which tells int, float and bool
apart, and int vectors from vectors. Numbers keep whether they were
written as floats, the built-ins get the types our probes found in Iris
(INT_NAMES, BOOL_NAMES; the rest are floats), and the operators and
functions follow rules fitted to our probe packs' values and warnings in
Iris. `centerDepthSmooth` joins HIDDEN: Iris warns it is unknown.

Based on #9119 (shaderpacks/custom-uniform-type-mismatch). In main's
`expressions_follow_c_precedence_and_file_order`, `c` (`|| false`) and
`d` (`int = -7 / 2`) now read 0, and `i` is declared `bool`, which keeps
it covering the BIOME_* notes.

Fixes #9126

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 17:58:15 +05:00
Florian ERNST 1ab1d98c1c Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/end-clock-commands 2026-11-04 13:45:21 +01:00
Ludovic Marie 38df83828d Merge pull request #9087 from arslanarm/shaderpacks/ignored-format-declarations
🤖 Agent: clean-room check passed (black-box probes of their own, no source). Merged main and fixed #8999's test call to the removed Config::finish. Verified main + this head on the desktop: fmt, clippy -D warnings (shaders, render, all targets), 1270 tests, 0 failures; GPU fixtures not run.
2026-11-04 13:43:13 +01:00
Florian ERNSTandCodex 5dc8279a5d Support built-in End clock command state and defaults
Co-Authored-By: Codex <[email protected]>
2026-11-04 13:42:41 +01:00
rdbtCVSandCodex gpt-6.1-sol 663ce80174 Adapt scene replay to compressed sprite pixel storage
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:40:54 +00:00
rdbtCVSandCodex gpt-6.1-sol 7b300cc61e Initialize atlas sprite tables in renderer test fixtures
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:39:54 +00:00
rdbtCVSandCodex gpt-6.1-sol e60a96790d Initialize orb rotation in the upstream NBT fixture
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:39:54 +00:00
rdbtCVSandCodex gpt-6.1-sol 18f07a0304 Initialize atlas sprite tables in renderer test fixtures
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:39:53 +00:00
rdbtCVSandCodex gpt-6.1-sol 631ededd91 Initialize atlas sprite tables in renderer test fixtures
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:39:53 +00:00
rdbtCVSandCodex gpt-6.1-sol 6e602c4c2a Initialize material and item identity fields in shadow test fixtures
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:39:17 +00:00
TheLion102009andClaude Opus 5.5 1a4e2d208c mod API: scenes of blocks drawn in a screen
What Create's Ponder shows its machines in: native_draw_scene(batch,
rect, blocks, view) draws a few block states, at positions of the scene's
own (NativeSceneBlock), into a box of a screen, seen as NativeSceneView
says (centre, yaw, pitch, pixels per block). Each block's model is drawn
shaded by face, its faces against solid neighbours left out; a mod block
entity given with it is drawn through native_block_entity_submit, its
spin parts turning. The quads go in with the GUI's isolated depth and
scissor (HudBatch::depth_since), as the smithing screen's armour stand.

The view's matrix is a point reflection, so the unturned scene is seen
from the north as a player at yaw 0 sees it, unmirrored; the tests pin
which way yaw and pitch turn it.

Builds on the block entity drawing change (spin and place_turning).
Pending fragment: scenes_in_screens.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:39:12 +01:00
TheLion102009andClaude Opus 5.5 ccac8f85ff mod API: text, turning parts and copycats in block entity drawing
- native_draw_text(draw, text, at, color, glowing, outline): a Text under
  a block entity's pose as a sign draws its lines (Font.drawInBatch in a
  BlockEntityRenderer), each span in its colour, glowing text full bright,
  an optional outline; it returns the width. The sign's glyph quads are
  shared (block_entity_render::text_vertices).
- BlockEntityDraw::spin(axis, pivot, degrees_per_tick, phase) and still():
  parts that turn by themselves. The game turns them each frame from the
  game time and partial tick, so with keep() a shaft at a steady speed is
  drawn again with no call of native_block_entity_submit until its block
  entity changes (Flywheel's rotating instances, on the CPU).
- native_block_face_sprite(state, side): the texture a block state shows
  on a side, by the name native_draw_cube takes, for copycat blocks.

Connected textures need no code: a content mod ships OptiFine CTM rules
under assets/<id>/optifine/ctm/, as the docs now say.

Pending fragments: draw_text.json, spinning_geometry.json,
block_face_sprite.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:38:34 +01:00
TheLion102009andClaude Opus 5.5 9cffbd192d mod API: fluid currents, and a mod's own fluids in tanks
- native_fluid_flow(pos): FluidState.getFlow(level, pos) in the level in
  place, the normalised current an item in the fluid is pushed by
  (entities::fluid_flow, through the same flow_at items and boats use).
  A water wheel reads its speed from it.
- Tanks take a mod's own fluids (create:chocolate): native_fluid_insert
  now accepts any id in a namespace other than minecraft, besides water
  and lava; extract and move already carried names. Cauldrons still take
  only water and lava. NativeFluidTank becomes public and
  native_fluid_tanks joins Host.

The world itself still has only vanilla's fluids: no source or flowing
blocks or buckets of a mod's fluid.

Pending fragments: fluid_flow.json, mod_fluids.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:38:25 +01:00
TheLion102009andClaude Opus 5.5 05cb3702ad mod API: chunk tickets for native mods
NeoForge's TicketController.forceChunk: native_force_chunk(owner, chunk,
hold) holds a chunk of the level in place for a namespaced owner
(create:train/<uuid>), and native_forced_chunks(owner) lists what it holds.
A held chunk is kept as a /forceload'ed one is: Streaming has
mod_forced beside forced, Ranges reads both, the ticket levels give them
the FORCED level, and a level with any stays open. /forceload neither
lists nor releases them and they aren't saved with forced chunks: a mod
holds them again in native_level_load.

Pending fragment: chunk_tickets.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:37:25 +01:00
TheLion102009andClaude Opus 5.5 298e68aa52 mod API: container_accepts, a mod container's say on what goes in
NeoForge's IItemHandler.isItemValid for a mod's ModContainer: hoppers,
droppers and native_insert_item ask native_container_accepts(pos, entity,
slot, stack, face, cancel) before an item goes into a slot the container's
inputs and faces allow. `cancel` refuses it and the mover tries the next
slot, so a millstone takes only what it has a recipe for and a wrong item
can't clog it. tryMoveInItem now carries the face it goes in by.

Pending fragment: container_accepts.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:37:25 +01:00
rdbtCVSandCodex gpt-6.1-sol caf9530c20 Carry material identities into the double-sided shadow geometry
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:36:02 +00:00
rdbtCVSandCodex gpt-6.1-sol 24b89f986d Compare Forge installer argument paths consistently across Windows and Unix
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:34:05 +00:00
rdbtCVSandCodex gpt-6.1-sol e11b83fc8e Compare Forge installer argument paths consistently across Windows and Unix
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:33:02 +00:00
rdbtCVSandCodex gpt-6.1-sol ee746cf569 Supply the device when copying animated material atlas sprites
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:32:52 +00:00
LokiandAntigravity 6a627e54b1 mobs: calculate LivingEntity jump power from attributes, block factor and jump boost (fixes #9058)
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 21:32:44 +09:00
rdbtCVSandCodex gpt-6.1-sol 0a6527a5f3 Merge main and preserve both atlas and material-map rendering changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:32:34 +00:00
rdbtCVSandCodex gpt-6.1-sol f8a85f1bf9 Merge main and preserve both atlas and material-map rendering changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:32:34 +00:00
rdbtCVSandCodex gpt-6.1-sol 007df3944e Merge main and preserve both atlas and material-map rendering changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:32:33 +00:00
rdbtCVSandCodex gpt-6.1-sol ee85900b87 Merge main and preserve both atlas and material-map rendering changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:32:31 +00:00
NerfPhaselockandClaude Opus 5.5 64afd9f58a Renderer: GPU culling is not offered on DX12 while the solid pipelines read the page
Since f2c8e2ab1 the vanilla solid pipelines read their vertices and
records from the arena page by @builtin(vertex_index), per-section draws
too. DX12's count draws (multi_draw_indexed_indirect_count, which GPU
culling draws with) leave each draw's base vertex out of vertex_index:
wgpu-hal's draw_indexed_indirect_count sets its first-vertex constant to
0 and uses the command signature without it. GPU frames there read every
section's quads from the start of its page: most solid terrain missing,
stray quads in its place. Vulkan's vertex_index has the base vertex.

GPU culling is now not offered on DX12 with flat page groups (where the
pulled pipelines draw), so the word `gpu-culling on` is refused there,
until GPU culling's draws carry their base vertex some other way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:31:10 -05:00
Ludovic MarieandClaude Opus 5.5 705645a7ab Merge rdbtCVS's main merge of this branch (same content, kept the stacked resolution)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:30:12 +01:00
Ludovic MarieandClaude Opus 5.5 5ce693ae48 Merge the #8200 stack's latest head (no change to the tree)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:30:12 +01:00
voidsipandCodex 83380b3b30 Synchronize optical-flow scene-change reduction
Divergence workgroups must all finish reading the histograms before either is replaced. Finalize in a separate dispatch so the reduction also observes every partial sum, without relying on a relaxed atomic completion counter.

Fixes #8627

Co-Authored-By: Codex <[email protected]>
2026-11-04 06:29:02 -06:00
TheMrZZandCodex gpt-6.1-sol 33ba27d9f5 Omit default fields from saved map data
Follow the optional defaults in MapItemSavedData.CODEC while retaining
required fields, nondefault flags and nonempty banner/frame lists.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 13:28:54 +01:00
Ludovic MarieandClaude Opus 5.5 4439459e71 Merge #8200's stack (with #8128 and main) into the heartbeat interval
Resolves the management handlers, the port map and the harness side by side.
Also: management_int's Codec.INT doc comment back on management_int, and the
interval's log line as vanilla writes it (MinecraftServerSettingsServiceImpl
reads the interval after setting it, so both numbers are the new one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:28:38 +01:00
JulesB40andClaude Opus 5.5 e128384ac8 lan_e2e piston-slide: check the client moves the head itself
horizon-ctl block reports the client's own moving piston at a position
(moving: progress, extending). A head that only arrives as the server's
moving_piston block, with no move of the client's, doesn't count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:28:32 +01:00
JulesB40andClaude Opus 5.5 de3604215d lan_e2e piston-slide: power the repeating command block with redstone
A repeating command block placed by /setblock with auto:1b never got its
first tick in Horizon, so the second extension didn't happen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:28:31 +01:00
JulesB40andClaude Opus 5.5 4c7bdc63a6 Pistons: send the changed blocks at ServerChunkCache.tick's point
The early broadcast in tick_lan sent the blocks as they were after the
tick's block events: a block placed by a scheduled tick and then pushed
went out as the moving block, and the client couldn't run the push. The
blocks now go out in tick_blocks, after the random ticks and before the
block events, as ServerLevel.tick runs ServerChunkCache.tick. piston-slide's
second extension pushes a stone a command block placed that tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:28:31 +01:00
JulesB40andClaude Opus 5.5 58b0da9a87 Pistons: the held block changes go out before the next block events
ServerChunkCache.tick broadcasts the changed blocks before runBlockEvents,
so what the last tick's block entities changed (a retracted piston's base
put back) reaches the clients ahead of a block event that builds on it.
tick_lan now sends the changes before the block events, and holds the
tick's own late ones as before. piston-slide also powers the piston again
the tick after its base is back, with a repeating command block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:28:30 +01:00
JulesB40andClaude Opus 5.5 7dfcbcaa0b Pistons: a moved block reaches the clients a tick after it lands
ServerLevel.tick broadcasts the changed blocks (ServerChunkCache.tick)
after the scheduled and random ticks, before the block events, the
entities and the block entities, so what those change goes out with the
next tick's. Horizon sent it the same tick: the block a moving piston
placed reached the clients while their own copy was still a tick from the
end of its move, and at /tick rate 1 it jumped the rest. The writes after
that point are held out of the log while it is sent; the guest move check
still sees them as unsent. lan_e2e's piston-slide check covers it.

Fixes #8430

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:28:29 +01:00
rdbtCVSandCodex gpt-6.1-sol 5bfe7af0c1 Collapse the nested invite refresh condition for Rust 2024 Clippy
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:27:25 +00:00
rdbtCVSandCodex gpt-6.1-sol 12541fdeeb Merge main and retain sprite rectangles alongside shader material maps
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:26:20 +00:00
rdbtCVSandCodex gpt-6.1-sol c700853616 Retain the latest Shop updates alongside the options test correction
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:23:31 +00:00
rdbtCVSandCodex gpt-6.1-sol 84eb7390db Initialize item rotation in the new area-effects fixture
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:22:46 +00:00
rdbtCVSandCodex gpt-6.1-sol 5f7b5aa3f6 Adapt Realms connections and screen metadata to the current title API
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:22:16 +00:00
TheLion102009andClaude Opus 5.5 0f0a09f377 mod API: machines break blocks with a tool and use items as a fake player
What Create's drill, saw, harvester and deployer need, as Host helpers:

- destroy_block(pos, tool, collect): Level.destroyBlock(pos, true) with the
  loot rolled for `tool` and no player (Block.dropResources(state, level,
  pos, blockEntity, null, tool)). Particles, the block entity's contents
  in the drops, ore experience and the fluid left behind are vanilla's
  own path: GameLevel carries the tool and, with `collect`, keeps the
  drops instead of popping them. The mods' say on drops applies.
- set_break_progress(breaker, pos, progress): ServerLevel.destroyBlockProgress,
  the same broadcast a mob breaking a door uses (now shared,
  server_destroy_block_progress).
- use_as_player(stack, eye, yaw, pitch, target, sneaking): NeoForge's
  FakePlayer. useItemOn on a block, useItem in the air or interactOn with
  an entity, through the guest use path (forced_hit + guest_use), in
  survival with only `stack` in hand. The server's empty player shell is
  filled for the call and everything it touched is put back; it answers
  None while a player acts. NativeUseTarget and NativeFakeUse are new in
  mod_api.

Pending fragments: machine_breaking.json, fake_player.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:21:43 +01:00
rdbtCVSandCodex gpt-6.1-sol 9eac9f79b1 Match the world light type and parenthesize the remaining packed light expectation
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:21:41 +00:00
Ludovic MarieandClaude Opus 5.5 af6e2b1295 Read #8999's shadow test config without finish()
#9087 removes Config::finish (its only work was the gdepth rule); the test reads the settings as they are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:20:57 +01:00
Evrim CicekandClaude Opus 5.5 18498a7e2d A silent elder guardian's curse shows the apparition without the sound
ElderGuardian.customServerAiStep sends GUARDIAN_ELDER_EFFECT with
isSilent() ? 0.0F : 1.0F, and ClientPacketListener.handleGameEvent plays
the curse sound only for 1. Horizon sent 1 to guests and played the sound
for the host whatever the elder's Silent flag.

The curse event now carries the flag. tools/test_elder_curse_parity.py
compares the parameter a client is sent by a loud and a silent elder with
vanilla's dedicated server (VanillaProtocolClient's new elder-curse mode;
the goat-voices mode shares its phased-scene plumbing).

Fixes #8932

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:20:52 +03:00
Ludovic Marie 67992e9fed Merge remote-tracking branch 'origin/main' into cg28/9087 2026-11-04 13:20:51 +01:00
TheLion102009andClaude Opus 5.5 92397d9caf mod API: Host forms of the generic and private Game helpers
Trait mods couldn't reach several documented helpers through `dyn Host`:
their signatures were generic or used gameplay-private types. Host now has:

- draw_model (standalone models in block_entity_submit)
- mod_block_entity(pos) and change_mod_block_entity(pos, &mut change)
  (native_with_mod_block_entity's form; on a client mod_block_entity
  returns what the server sent through block_entity_update_tag)
- item_entities, level_random(dimension)
- play_sound and send_particles for the level in place, from any server
  hook (until now only on LevelWriteHost)
- block_name_in_tag and server_item_in_tag
- component, set_component, remove_component
- trigger_criterion with a &dyn Fn test
- equipment(slot): the player's own on a client, the acting player's on
  a server
- sneaking(): whether the acting player holds sneak
  (UseOnContext.isSecondaryUseActive in a use hook)

NativeItemEntity, NativeBlockReadError, NativeComponentError and
NativeCriterion become public and are re-exported from mod_api.
Pending fragment: tools/native-hook-fragments/host_bridge.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:19:30 +01:00
rdbtCVSandCodex gpt-6.1-sol bc6ac0ab30 Retain upstream game systems removed by the earlier XP orb branch update
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:19:11 +00:00
rdbtCVSandCodex gpt-6.1-sol e3806f3017 Format launcher changes using its Rust 2021 edition
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:17:49 +00:00
rdbtCVSandCodex gpt-6.1-sol 0e010734d9 Merge main and preserve both shader routing and tag changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:15:32 +00:00
Florian ERNSTandCodex 8b83cd8285 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/pig-saddle-shears
Co-Authored-By: Codex <[email protected]>
2026-11-04 13:15:11 +01:00
Florian ERNSTandCodex 553483c421 Shear an unoccupied pig saddle before mounting
Co-Authored-By: Codex <[email protected]>
2026-11-04 13:13:51 +01:00
Ludovic Marie c2b104827c Merge pull request #9097 from horizon-rust/dezer/logos
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this merge head; the merge keeps #9091's clickable edition tabs with the new grass block and brand icon.
2026-11-04 13:13:42 +01:00
Ludovic Marie 7481e652fc Merge pull request #9093 from horizon-rust/dezer/settings-spy
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this merge head; the merge keeps both new Launcher fields (settings_marked beside #9099's mods_sticky).
2026-11-04 13:13:38 +01:00
Ludovic Marie 2d4f8380f2 Merge pull request #9092 from horizon-rust/dezer/lists
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this merge head; the main merge only re-applied the 14 px inset to Home's new head layout from #9091.
2026-11-04 13:13:34 +01:00
rdbtCVSandCodex gpt-6.1-sol 04bed4f9d8 Merge main and preserve finite rotation guards in shared entity teleports
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:12:57 +00:00
rdbtCVSandCodex gpt-6.1-sol 380a2ec59c Merge main and move Realms into the reusable client library
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:12:14 +00:00
rdbtCVSandCodex gpt-6.1-sol 9a223ef014 Scroll the cosmetics cycle button into view in the options behavior test
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:10:38 +00:00
rdbtCVSandCodex gpt-6.1-sol bf460e86cf Merge main and retain live transfer and status settings with both parity cases
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:09:55 +00:00
Ludovic Marie a55b9a2e52 Merge pull request #9007 from arslanarm/shaderpacks/uint-colortex0-blit
🤖 Agent: updated onto main after #8042, #8510 and #9002 landed (merge of main; #8999 fixture gets item_id_declared/material_maps; #9007 is stacked on #9005 so the trailing test modules resolve once). Verified on the desktop, main + #8999 + #9007 (+ #9005): fmt, clippy -D warnings (shaders, render, all targets), 1268 tests, 0 failures; GPU fixtures not run (no adapter over ssh).
2026-11-04 13:09:03 +01:00
Ludovic Marie 8350e76e2a Merge pull request #9005 from arslanarm/shaderpacks/const-local-sample-offset
🤖 Agent: updated onto main after #8042, #8510 and #9002 landed (merge of main; #8999 fixture gets item_id_declared/material_maps; #9007 is stacked on #9005 so the trailing test modules resolve once). Verified on the desktop, main + #8999 + #9007 (+ #9005): fmt, clippy -D warnings (shaders, render, all targets), 1268 tests, 0 failures; GPU fixtures not run (no adapter over ssh).
2026-11-04 13:08:58 +01:00
Ludovic Marie e4fe4e8122 Merge pull request #8999 from arslanarm/shaderpacks/shadow-map-coverage
🤖 Agent: updated onto main after #8042, #8510 and #9002 landed (merge of main; #8999 fixture gets item_id_declared/material_maps; #9007 is stacked on #9005 so the trailing test modules resolve once). Verified on the desktop, main + #8999 + #9007 (+ #9005): fmt, clippy -D warnings (shaders, render, all targets), 1268 tests, 0 failures; GPU fixtures not run (no adapter over ssh).
2026-11-04 13:08:55 +01:00
Ludovic MarieandClaude Opus 5.5 f28f5b6d50 Launcher: the Shop's item wells lit for the dark founder set
The founder set is now navy and charcoal (Ludovic, 11-04). On the dark
appearance's near-black well they all but vanished (well 17,11,31 against a
tee front of 33,32,41), so the dark well is a violet grey, lighter at the
top like a showcase. The light well stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:08:37 +01:00
Florian ERNSTandCodex gpt-6.1-sol de877a581d Merge main and retain cloud duration scaling with owner attribution
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 13:07:11 +01:00
rdbtCVSandCodex gpt-6.1-sol 7ae5d35547 Merge main and reuse localized loader version labels in every language
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:06:42 +00:00
rdbtCVSandCodex gpt-6.1-sol b3678682e4 Merge main and reuse translated build import status messages
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:05:34 +00:00
rdbtCVSandCodex gpt-6.1-sol 217d39afd8 Merge main and pass block movement properties through new entity ticks
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:04:36 +00:00
rdbtCVSandCodex gpt-6.1-sol 80c02acdf1 Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:03:54 +00:00
rdbtCVSandCodex gpt-6.1-sol afe23fbba6 Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:03:53 +00:00
rdbtCVSandCodex gpt-6.1-sol 01bf400ff7 Merge main while preserving random tick caching and fixing light test lints
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:03:51 +00:00
rdbtCVSandCodex gpt-6.1-sol 04322a6f52 Adapt rocket behavior fixture to shared view direction helper
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:03:23 +00:00
Ludovic Marie ea06a53506 Merge pull request #9098 from horizon-rust/dezer/look
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head (473+85); menus open at once because an animation inside a deferred menu kept its invisible first frame under GPUI Fast (seen on the desktop run), pages and toasts keep the 200 ms pop.
2026-11-04 13:03:21 +01:00
rdbtCVSandCodex gpt-6.1-sol 25574a2d21 Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:02:49 +00:00
rdbtCVSandCodex gpt-6.1-sol 661e038c08 Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:02:48 +00:00
rdbtCVSandCodex gpt-6.1-sol d938f6a1dd Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:02:46 +00:00
rdbtCVSandCodex gpt-6.1-sol c37a6c6543 Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:02:44 +00:00
rdbtCVSandCodex gpt-6.1-sol 241155c490 Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:02:36 +00:00
Ludovic Marie 26a79956fd Merge pull request #9049 from senjinthedragon/portal-shadow-casters
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 13:02:11 +01:00
Ludovic Marie e2cf10bbd0 Merge pull request #9048 from senjinthedragon/glow-frame-entity-id
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 13:02:07 +01:00
Ludovic Marie c0df7b1959 Merge pull request #9047 from senjinthedragon/pack-format-decimal
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 13:02:02 +01:00
Ludovic MarieandClaude Opus 5.5 dab0067925 Merge #9005's branch: both trailing test modules stay
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:01:47 +01:00
Ludovic Marie 40c4441550 Merge remote-tracking branch 'origin/main' into cg28/9007
# Conflicts:
#	crates/horizon-client-render/src/gfx/pack_post.rs
2026-11-04 13:01:32 +01:00
Ludovic MarieandClaude Opus 5.5 09022c09b9 Give #8999's shadow filtering fixture the fields main added
#8042 added GbuffersInputs.item_id_declared and #8510 World/Geometry.material_maps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:01:26 +01:00
Ludovic Marie e221e9d786 Merge remote-tracking branch 'origin/main' into cg28/9005
# Conflicts:
#	crates/horizon-client-render/src/gfx/pack_post.rs
2026-11-04 13:00:50 +01:00
Ludovic Marie f5243832bb Merge remote-tracking branch 'origin/main' into cg28/8999 2026-11-04 13:00:50 +01:00
rdbtCVSandCodex gpt-6.1-sol 5d6b0be54b Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:41 +00:00
rdbtCVSandCodex gpt-6.1-sol 9b6841e04e Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:39 +00:00
rdbtCVSandCodex gpt-6.1-sol 1161302d27 Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:38 +00:00
rdbtCVSandCodex gpt-6.1-sol ad47d9b3fd Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:37 +00:00
rdbtCVSandCodex gpt-6.1-sol 55fa82033f Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:35 +00:00
rdbtCVSandCodex gpt-6.1-sol 81d02ec5d4 Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:33 +00:00
rdbtCVSandCodex gpt-6.1-sol ea92f9ea77 Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:32 +00:00
rdbtCVSandCodex gpt-6.1-sol 7ec86a7aec Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:32 +00:00
rdbtCVSandCodex gpt-6.1-sol 62e8a49947 Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:31 +00:00
rdbtCVSandCodex gpt-6.1-sol 1ef6bdd171 Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:30 +00:00
Ludovic Marie b3ec6f2d35 Merge pull request #9023 from arslanarm/shaderpacks/clear-false-ping-pong
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:25 +01:00
Ludovic Marie 73d3ee3d20 Merge pull request #9022 from arslanarm/shaderpacks/forced-flip-written
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:20 +01:00
Ludovic Marie 04b8b27a3b Merge pull request #9020 from arslanarm/shaderpacks/nether-pack-shade
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:16 +01:00
Ludovic Marie 638c0044f0 Merge pull request #9019 from arslanarm/shaderpacks/clouds-textured-fallback
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:12 +01:00
Ludovic Marie 797ca1fab9 Merge pull request #9015 from arslanarm/shaderpacks/custom-uniform-unknown-names
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:08 +01:00
Ludovic Marie 4b1157a697 Merge pull request #9014 from arslanarm/shaderpacks/feature-flag-case
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:03 +01:00
Ludovic Marie c60bb5a341 Merge pull request #9004 from arslanarm/shaderpacks/length-array-size
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:58 +01:00
Ludovic Marie e927a62b23 Merge pull request #9002 from arslanarm/shaderpacks/trinary-minmax
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:54 +01:00
Ludovic Marie 440d486f39 Merge pull request #8994 from arslanarm/shaderpacks/ssbo-vector-atomics
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:50 +01:00
Ludovic Marie 3c9dd1fb50 Merge pull request #8993 from arslanarm/shaderpacks/custom-uniform-option-macros
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:47 +01:00
Ludovic Marie ee5ef77630 Merge pull request #8975 from arslanarm/shaderpacks/properties-feature-macros
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:42 +01:00
Ludovic Marie 9dc66afd4d Merge pull request #8974 from arslanarm/shaderpacks/custom-smooth-fade-time
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:39 +01:00
Ludovic Marie 37aba3abe5 Merge pull request #8510 from gigamans442/fix/independent-terrain-materials-20261103
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:34 +01:00
Ludovic Marie 7d8f45fedd Merge pull request #8474 from gigamans442/fix/independent-uniform-defaults-20261103
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:29 +01:00
Ludovic Marie f897927efa Merge pull request #8042 from arslanarm/shaderpacks/rendered-item-id
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:24 +01:00
rdbtCVSandCodex gpt-6.1-sol 606a58c04c Fix formatting of warden pushing tests
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 11:58:09 +00:00
voidsipandCodex b011ec1e66 Find keytool independently of the parity Java shim
Cached oracle runs replace java on PATH, so looking beside that executable points into the cache shim directory instead of the JDK. Resolve keytool directly and support JAVA_HOME when it is not on PATH.

Fixes #9079

Co-Authored-By: Codex <[email protected]>
2026-11-04 05:54:54 -06:00
Ludovic MarieandClaude Opus 5.5 0efb67fc12 launcher: the Language list keeps a solid backing under its glass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:51:41 +01:00
JoansitohandGrok <grok-4.7> d230a5dee8 Drop the unused return at the end of the raider test.
The same needless return would fail clippy on the celebration test.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 12:50:24 +01:00
JoansitohandGrok <grok-4.7> 7d5f66a915 Drop the unused return at the end of the trader test.
Clippy rejects a return that is the last statement and does nothing.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 12:50:03 +01:00
270cd628bb Drop the extra borrow on the witch throw sound.
Clippy rejects &mut on a events field that is already a mutable reference.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 12:47:56 +01:00
Ludovic Marie c92fcbb17e Merge remote-tracking branch 'origin/main' into w8200 2026-11-04 12:47:18 +01:00
Ludovic Marie 9443236b20 Merge branch 'refs/heads/w8128' into w8200 2026-11-04 12:47:12 +01:00
Ludovic Marie 2d3d6a0c6e Merge commit 'cg29/8128' into cg29/8200
# Conflicts:
#	crates/horizon-client/src/game/dedicated/management.rs
#	crates/horizon-client/src/lan/mod.rs
#	crates/horizon-server/src/management_methods.rs
#	tools/port_map/rest-017.tsv
#	tools/test_management_protocol_parity.py
2026-11-04 12:47:12 +01:00
Ludovic MarieandClaude Opus 5.5 435b2837cd Keep the input ledger in the PR, not in the repo
The repo holds no per-PR research records; the ledger stays linked from the PR at its commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:46:44 +01:00
Ludovic MarieandClaude Opus 5.5 b12f0ad465 Keep the input ledger in the PR, not in the repo
The repo holds no per-PR research records; the ledger stays linked from the PR at its commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:46:44 +01:00
Ludovic MarieandClaude Opus 5.5 c38354c637 Give the captured frame no hand items
Main's capture.rs builds a Frame, which now has hand_items.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:46:38 +01:00
Ludovic Marie 6ea3787844 Merge remote-tracking branch 'origin/main' into cg28/8042 2026-11-04 12:46:26 +01:00
Ludovic MarieandClaude Opus 5.5 9d5e59e762 Management: log the entity broadcast range asked for, as vanilla does
MinecraftServerSettingsServiceImpl.setEntityBroadcastRangePercentage logs the
requested percentage before DedicatedServerProperties clamps it to 10..1000.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:46:19 +01:00
Ludovic MarieandClaude Opus 5.5 247d9941f2 launcher: fmt the Language list's anchor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:43:55 +01:00
Ludovic Marie acd184c1d9 Merge pull request #9094 from horizon-rust/dezer/modrinth
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head; Modrinth's Java mods stay listed for Horizon instances with the existing Works/Partly/Doesn't run mark, and installs still check SHA-512 before writing.
2026-11-04 12:43:20 +01:00
Arslan ArmanulyandClaude Opus 5.5 f0214c19b5 Shader packs: a custom uniform without its declared shape reads 0
In Iris 1.11.7 a custom uniform or variable whose expression doesn't give
its declared shape (a float given skyColor, a vec2 given a vec3), or that
joins a vector and a scalar with an operator (vec3 * 1.0), reads 0 as a
whole. Horizon evaluated it anyway.

custom.rs now checks each item's shape once, on the first eval, after the
unknown-name check of #9015: Shape and shape() follow rules fitted to our
probe packs' values in Iris (operators, vecN constructors, which functions
take vectors, matrices, the declared type). A failing item reads 0 with a
note, and #9015's pass marks everything using it.

The load-time dry run (pack_post.rs) gave every built-in as a scalar, so
skyColor looked like a float there; it now gives each one at its size
(pack_uniforms::zero).

Based on #9015 (shaderpacks/custom-uniform-unknown-names); its test's
`w = vec3(...) * 0.0 + 2.0` and main's `e = vec3(...) * 2` now read 0,
as Iris reads them.

Fixes #9086

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:42:50 +05:00
Ludovic MarieandClaude Opus 5.5 4ceaf4a8c2 Merge origin/main into dezer/logos
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:42:25 +01:00
Ludovic MarieandClaude Opus 5.5 7d41c97f80 Merge origin/main into dezer/settings-spy
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:42:01 +01:00
Ludovic MarieandClaude Opus 5.5 78f8d86785 Merge origin/main into dezer/lists
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:41:46 +01:00
Ludovic Marie a04888e69e Merge pull request #9096 from horizon-rust/dezer/avatar
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head (474+83); one session-server lookup per account per 24 h, the empty answer (default skin) cached too, so an offline launcher doesn't retry each frame.
2026-11-04 12:41:18 +01:00
Florian ERNSTandCodex 57d465a5a9 Evaluate inline slot sources in item commands
Co-Authored-By: Codex <[email protected]>
2026-11-04 12:40:05 +01:00
JoansitohandGrok <grok-4.7> 929aa267e0 Villager conversion keeps Silent, NoAI and the other shared flags.
Infection and the cure were dropping the flags convertCommon copies onto the new mob.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 12:39:25 +01:00
JoansitohandGrok <grok-4.7> b2ebcee304 A rolled-up armadillo no longer accepts food on the client.
The client predicted Animal.mobInteract before the shell's FAIL, so the click did not stop.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 12:39:25 +01:00
JoansitohandGrok <grok-4.7> 25a0cf0396 A celebrating raider no longer jumps while it is riding.
The celebration tick rolled the jump even when the raider was a passenger, which vanilla skips.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 12:39:25 +01:00
JoansitohandGrok <grok-4.7> ab167cadb9 A wandering trader no longer drinks in the End.
The End has a fixed time, so it is neither dark nor bright outside and neither potion is used.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 12:39:25 +01:00
JoansitohandGrok <grok-4.7> 88bd95bb32 A silent witch no longer plays its drink and throw sounds.
Vanilla skips both playSound calls, including the pitch draw, when the witch is silent.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 12:39:25 +01:00
Ludovic MarieandClaude Opus 5.5 88ee593627 launcher: menus open at once; the Language list stays in the window
The pop animation inside a deferred menu kept its first frame under GPUI Fast: the
version menu never showed on the desktop run, the Language list lost its plate. Menus
open at once again (pages and toasts keep the pop); the Language list is anchored and
opens upwards when there is no room below.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:38:44 +01:00
Ludovic Marie 056d097a9a Merge pull request #8687 from degepie/fix/looking-at-vehicles
🤖 Agent: vanilla: PlayerPredicate lookingAt over every non-spectator entity; tick/location gated by may_look. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:30 +01:00
Ludovic Marie d234d6daaa Merge pull request #8683 from degepie/fix/damage-sources
🤖 Agent: vanilla: indirectMagic(potion|cloud, owner) instead of main's Damage::player; overlaps #9053 (TheMrZZ), second to land resolves. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:25 +01:00
Ludovic Marie 1294fca5e5 Merge pull request #8662 from degepie/fix/bare-ids
🤖 Agent: vanilla: 26.3 EntityPredicate is a dispatchedMap by sub-predicate id; TagPredicate.id is a HolderSet. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:21 +01:00
Ludovic Marie 23261d97b3 Merge pull request #8654 from degepie/fix/throw-bucket-triggers
🤖 Agent: vanilla: hurtServer fires PLAYER_HURT_ENTITY at 0 damage outside i-frames; interact trigger after filled_bucket. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:17 +01:00
Ludovic Marie 422d9b6bbb Merge pull request #9051 from glitchsnake/fix/8785-wither-block-slowdown
🤖 Agent: vanilla: WitherBoss.makeStuckInBlock is empty; berry damage kept. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:13 +01:00
Ludovic Marie 6e9a91f185 Merge pull request #8964 from glitchsnake/fix/8957-spectator-container-vibrations
🤖 Agent: vanilla: ContainerOpenersCounter edges for CONTAINER_OPEN/CLOSE; tautological test removed. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:09 +01:00
Ludovic Marie 8737bbfe7e Merge pull request #8952 from glitchsnake/fix/8934-elder-guardian-home
🤖 Agent: vanilla: ElderGuardian.customServerAiStep setHomeTo(blockPosition, 16); tests trimmed; carries #8966's commits (guardian.rs conflict). Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:05 +01:00
Ludovic Marie 7332139da4 Merge pull request #8966 from glitchsnake/fix/8933-silent-guardian-flop
🤖 Agent: vanilla: Guardian.aiStep flop under !isSilent; tests folded into one. Lands before #8952 (stacked on it). Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:00 +01:00
Ludovic Marie 7e7336db35 Merge pull request #8501 from glitchsnake/fix/8419-summon-autocomplete
🤖 Agent: vanilla: SUMMONABLE_ENTITIES = canSummon types; only player and fishing_bobber are noSummon in 26.3. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:56 +01:00
Ludovic Marie 641cba9b30 Merge pull request #8887 from HrafnaValkyrja/fix/warden-vanilla-rules
🤖 Agent: vanilla: AbstractBoat.tick startRiding branch, a refused rider is neither seated nor pushed (#8916 closed as its duplicate). Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:52 +01:00
Ludovic Marie 26ae1d2991 Merge pull request #8864 from contactjayclatty/fix/8724-glow-squid-environmental-damage
🤖 Agent: vanilla: GlowSquid.hurtServer dims only if Squid.hurtServer is true, which needs getLastHurtByMob. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:48 +01:00
Ludovic Marie be63efabcc Merge pull request #8857 from contactjayclatty/fix/8776-wither-takes-no-effect
🤖 Agent: fixed on the branch: the refusal is WitherBoss.addEffect's (Mob::add_effect); canBeAffected stays vanilla so AreaEffectCloud keeps the wither as a victim. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:44 +01:00
Ludovic Marie 1b3e7c0f7f Merge pull request #8853 from contactjayclatty/fix/8721-zero-duration-effects
🤖 Agent: vanilla: MobEffectInstance.tickServer returns false at 0 (hasRemainingDuration), tickEffects removes it. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:40 +01:00
Ludovic Marie 9dcad939f7 Merge pull request #8843 from contactjayclatty/fix/8790-wither-cannot-ride-a-minecart
🤖 Agent: vanilla: OldMinecartBehavior/NewMinecartBehavior seat through startRiding, WitherBoss.canRide false. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:36 +01:00
Arslan ArmanulyandClaude Opus 5.5 f106b832ef Shader packs: atomic functions on a shared vector in a declaration list or a shared struct
An atomic function on a component of a shared integer vector (`atomicMin(mn.x, i)`) was
rewritten only when the vector had a declaration of its own. Two other forms reached naga
unsplit and failed with "Atomic operation is invalid: Pointer [..] to atomic is invalid":

- a declaration naming several variables, `shared ivec3 mn, mx;`: each name is now declared
  on its own, and the ones atomic functions use by component are split as before;
- a member of a shared struct whose type is defined in place,
  `shared struct { ivec3 mn; uint other; } sh;`: the member becomes `int mn_hz[3];` in the
  struct, with `hz_get_sh_mn()` / `hz_set_sh_mn(v)` for whole-vector reads and writes, through
  the member paths #8994 added for buffer blocks. A struct type whose name is used anywhere
  else stays as written.

Based on #8994 (shaderpacks/ssbo-vector-atomics), whose member-path rewrite this reuses.

Fixes #9085

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:31:59 +05:00
JanandClaude Sonnet 5.5 5198fae465 Native mods: a trade's live offer can be changed by mods (merchant_offer_traded)
After each trade made with a villager or a wandering trader, the mods are
asked with the live MerchantOffer in the trading session (uses counted):
uses, max_uses, demand, special_price_diff, price_multiplier and xp may be
changed and the change stands for the next click and the offers sent to the
player. Bukkit's PlayerTradeEvent#getTrade is mutable the same way. The
existing trade_with_villager stays listen-only and hears the offer as the
trade left it. A collect-contract hook with an API fragment.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-04 12:31:11 +01:00
Ludovic Marie f74b20d999 Merge the branch's fmt fix and main
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/guardian.rs
2026-11-04 12:28:20 +01:00
Ludovic Marie 725a5dcfa4 Merge #8966 into #8952: both append to guardian.rs
The two branches each added a test module at the end of guardian.rs, so
whichever lands second conflicts. Carry #8966 here; land #8966 first.

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

# Conflicts:
#	crates/horizon-client/src/mobs/guardian.rs
2026-11-04 12:27:13 +01:00
DenisHumenandClaude Opus 5.5 269718fac1 Draw sign text glyphs over their own box, not a fixed 8x8 cell
Sign text drew every glyph as an 8x8 quad at the pen. Vanilla's
BakedSheetGlyph draws x+left..x+right and y+up..y+down, which the HUD
already does. With Force Unicode Font a Latin letter is an 8x16 Unifont
glyph at half scale (4 units wide, advance 5), so stretching it to 8
units overlapped its neighbours.

Fixes #8699

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:26:18 +02:00
Ludovic Marie 23ae9e7f82 Merge pull request #9091 from horizon-rust/dezer/home
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head (473+84 passed); below 1280 px the mods button folds into ⋯ so the invite field keeps a 150 px minimum to the right of Play (screenshots at 1000×700).
2026-11-04 12:24:28 +01:00
TheMrZZandCodex gpt-6.1-sol 4baa918620 Omit ignored age fields from zombie nautilus saves
Follow AgeableMob save gating for zombie nautiluses while retaining modeled
age values for normal nautiluses. Cover stale raw data and the command path.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:22:21 +01:00
Ludovic Marie 3319108619 Merge pull request #9095 from horizon-rust/dezer/partner
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head; the GitHub mark is our own SVG in horizon-theme's glass icons, the Sculk card keeps its 2 px hover lift without the border.
2026-11-04 12:15:47 +01:00
Ludovic Marie 7a969a6122 Merge pull request #9099 from horizon-rust/dezer/mods
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head; the card re-renders only when its offset changes (on_scroll + next frame), so a long list costs nothing extra while scrolling; a scrollbar-thumb drag catches up on the next event (noted in the body).
2026-11-04 12:11:24 +01:00
Ludovic MarieandClaude Opus 5.5 9dfb3149fb Launcher: the Shop's countdown rounds whole days to the nearest
From three days up the span showed whole days rounded down, so a seven-day
window read "6 days left" a minute after it opened.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:08:53 +01:00
DenisHumenandClaude Opus 5.5 c6efd15f0c Mobs see the player's off-hand item (tempting food in the off hand)
Game::player_view left PlayerView.off_hand as None, so TemptGoal, the
animal brains' TemptingSensor, piglins' loved items, goats, happy
ghasts and taming never saw what the player holds in the off hand.
Vanilla's TemptGoal.shouldFollow and LivingEntity.isHolding check both
hands. Fill it from the off-hand slot, as main_hand is filled.

Fixes #8858

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:04:58 +02:00
Ludovic MarieandClaude Opus 5.5 99725aa079 launcher: the scroll spy takes the scroll off the sections' layout tops
A section's bounds from the ScrollHandle are its layout position before the scroll, so
the list stayed on the first section until the pane reached its end (seen on the desktop
run: Account still marked over the Launcher card).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:03:43 +01:00
DenisHumenandClaude Opus 5.5 344a829699 Opaque debug lines write depth, so water and clouds stay behind them
DrawableGizmoPrimitives.getGroup puts a line whose alpha is 255 in the
opaque group, which GizmoFeatureRenderer.buildLines draws with
RenderTypes.lines (pipeline/lines: DepthStencilState.DEFAULT, depth
written); only lines with alpha below 255 use
linesTranslucentNoDepthWrite. Horizon drew every depth-tested gizmo line
without writing depth, so translucent terrain and clouds drawn later
covered F3+B hitboxes and F3+G chunk borders in front of them.

The opaque lines now upload first and draw with a depth-writing
pipeline; the translucent ones (the chunk border's outer columns) keep
the old one.

Fixes #8465

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:02:25 +02:00
repositorykreml1nandCodex f0634ed6f2 fix: honor the player's mining efficiency attribute
Read the computed attribute when calculating destroy speed, including active
hand enchantments in the map without activating them in the wrong slot.
Add nine differential real-game probes against vanilla Player.getDestroySpeed.

Fixes #9021

Co-Authored-By: Codex <[email protected]>
2026-11-04 14:00:06 +03:00
masa-masa-0314andZCode 73715cdab4 Launcher: add data-location strings to the new language tables
Upstream added 52 launcher language tables on main; they predate this branch's
data-location strings, and the launcher's i18n completeness test requires every
table to carry every key. Add the missing entries with the English text, and
the Chinese translation for the new startup message.

Verified with fmt --check, clippy --all-targets -D warnings and the launcher
lib tests on the merged tree (482 here, 486 on Linux in WSL).

Co-Authored-By: ZCode <[email protected]>
2026-11-04 18:56:59 +08:00
LokiandAntigravity 0b831b1dca mobs: cargo fmt guardian.rs
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 19:53:23 +09:00
masa-masa-0314 f83a9c1ae9 Merge remote-tracking branch 'upstream/main' into feat/launcher-data-location 2026-11-04 18:52:36 +08:00
Loki 81e9973c62 Merge remote-tracking branch 'origin/main' into fix/8934-elder-guardian-home 2026-11-04 19:51:29 +09:00
Arslan ArmanulyandClaude Opus 5.5 2e4a5a13a6 Shader packs: textureMatrix is the texture matrix in gbuffers vertex stages
A gbuffers vertex stage that declared the core-profile `uniform mat4
textureMatrix` got a frame uniform the game has no value for, so it read
zeros, and packs computing their UVs as `textureMatrix * vaUV0` sampled
one texel per face. In the vertex stage it is now gl_TextureMatrix[0], as
projectionMatrix and modelViewMatrix already are their compatibility
matrices. Our probes read the identity there in Iris 1.11.7, and zero in
the fragment stage, which keeps its own frame slot.

Tests: the vertex stage's textureMatrix takes no frame slot while the
fragment stage's keeps one; on a host GPU, the vertex stage reads the
identity, a UV through it is the UV, and the fragment stage reads zero.

Fixes #9080

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:46:37 +05:00
Arslan ArmanulyandClaude Opus 5.5 b873ea2dd6 Shader packs: read struct and buffer-block members that atomic functions use
naga's GLSL frontend read `s.member` on the right-hand side out of a loaded
copy of the whole struct. Once an atomic function marks that member atomic,
the copy's member is an atomic value no operator, conversion, call or store
takes ("Incompatible operands: Atomic(...) as Uint"). The member is now
loaded through a pointer to it, as array elements already were; constant
expressions keep the value form.

Tests: every read the #9081 probe makes (assignment, operators,
constructors, min, subgroupBroadcastFirst) on a shared struct member, a
buffer-block member, a member of a shared struct array and a nested member;
constants' members in constant expressions; a member beside a runtime-sized
array; and, on a host GPU, the values those reads return.

Fixes #9081

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:44:19 +05:00
masa-masa-0314andZCode f1f7847050 CI: rerun launcher checks
The launcher job stopped with exit code 101 while verify succeeded and the same
fmt, clippy and lib tests pass on Linux locally (486 passed).

Co-Authored-By: ZCode <[email protected]>
2026-11-04 18:42:43 +08:00
Ludovic MarieandClaude Opus 5.5 e584b02ce2 launcher: the knob's slide owns its ends; fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:42:02 +01:00
Ludovic MarieandClaude Opus 5.5 9cab156727 launcher: fmt the instance dialog's logos
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:40:27 +01:00
Ludovic MarieandClaude Opus 5.5 a61d9f473d launcher: menus in the cards' glass, the website's motion, a whiter light look
Menus and popovers take the cards' glass with a shadow instead of the darker float, and
arrive as the website's hzl-pop (fade and 6 px rise, 200 ms); Settings' Language list is
one of them now (it was the old square box and ran off the window). Hovers ease on the
design's quick curve, pages and toasts pop, the glass switch's knob slides; reduced
motion draws each at its end. In light, the Play group's icons take the accent's ink and
the dawn sky is washed with white.

Addresses dezer-x's launcher feedback (points 7, 8 and 11).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:40:21 +01:00
Ludovic MarieandClaude Opus 5.5 a6ab89c89b Launcher: the Shop in the glass look, the new founder set, First Light in Settings
The Shop and Wardrobe tabs on glass plates in both appearances (Geist type, the
glass ink, light wells a soft violet grey so cream and violet items keep their
edges); the founder panel stacks under a 1280 px window. The Wardrobe gets a Shirt
row; its launcher theme row is gone: launcher themes are free for everyone in
Settings > Appearance (Launcher theme: Horizon or First Light), never claimed.
The top bar's Wardrobe follows the cosmetics service only. Shop previews use the
7-day window. The Shop strings in the 61 languages main added since, and the new
ones everywhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:39:59 +01:00
Ludovic MarieandClaude Opus 5.5 47222e152f Cosmetics: a torso slot, top-level parts on their own bone, on by default
A shirt's sleeves ride the arms: a top-level part may name its own bone (Item::limbs,
one root per bone under the item's attachment, Item::roots). The game draws each root
on its bone, the launcher's preview too. Slot::Torso, hidden under a chestplate.

Cosmetics run by default (Ludovic, 2026-11-04: on when the shop opens); other
players' and your own already default to shown. HORIZON_COSMETICS=0 turns them off;
horizon_cosmetics::forced() is HORIZON_COSMETICS=1 for the launcher's Wardrobe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:39:59 +01:00
Ludovic Marie 3fb4c3138e Merge pull request #8815 from itarqos5/perf/reduce-a-far-chunk-when-read
🤖 Agent: reviewed by literal-review (agent-state/literal-review.md): a far chunk is reduced only to the cell width that reads it (405 → 103 µs per chunk in its bench), same output; verify + launcher green at this head.
2026-11-04 11:37:02 +01:00
Arslan ArmanulyandClaude Opus 5.5 c94eb80064 Shader packs: read colortex formats only where Iris 1.11.7 does
Probe packs of ours, run in Iris 1.11.7, set colortexNFormat in many places. Horizon now
follows what Iris did with them:
- a format in a block comment counts only where `const` starts its line (our probes' Iris
  values fit this; it isn't a rule Iris states): not on the `/*` line, not after ` * `, not
  after another declaration on the line;
- a format in a vertex shader doesn't count;
- the colortex formats of the shadow programs' fragment shaders are read when the shadow pass
  doesn't run (observed in our probes for shadow.fsh and shadowcomp.fsh); nothing else of
  those files is read then (Horizon's choice: nothing else was probed);
- declaring `gdepth` no longer makes colortex1 RGBA32F.

Fixes #9063

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:30:59 +05:00
rdbtCVSandOpenAI Codex 5bdbffb5a5 Merge latest main after RAM limit removal
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-04 10:28:35 +00:00
masa-masa-0314andZCode f46a9c83a0 Launcher: open Settings when the data folder cannot be opened
A player whose drive is unplugged, whose location.json is damaged, or whose
migration lock is held used to get a silent exit: eprintln goes nowhere in the
Windows release build or from a desktop launcher. Startup now falls back to the
default folders and records the error, so the window opens on Settings with the
message, Retry and Use the default folder. Retry restarts into the configured
folders when they are readable again; Use the default folder keeps the old
pointer as location.json.old and restarts. Nothing in the moved folders is
deleted, and no launcher path reaches its expect() before the window exists.

Merge upstream/main (017c84f1f) while doing so: keep the launcher render env
strip and account-session removal alongside HORIZON_HOME, keep both the server
location_busy and stopped_line methods, keep one WorldLock, and keep the
explicit unlock in the world-lock test. The data-location strings the newer
language tables did not have yet are added (English fallback), with a Chinese
translation for the new message.

Tested: launcher lib 482 passed, bin 84 passed, integration suites passed;
cargo fmt --check and clippy --all-targets -D warnings pass.

Co-Authored-By: ZCode <[email protected]>
2026-11-04 18:23:55 +08:00
Ludovic Marie 17d4944fa9 Merge branch 'dezer/mods' into dezer/modrinth 2026-11-04 11:23:48 +01:00
Ludovic MarieandClaude Opus 5.5 e4b7c5723f launcher: fmt the Mods page's sticky card and sort
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:23:47 +01:00
Ludovic MarieandClaude Opus 5.5 57dba96d66 launcher: our own grass block for Vanilla, the real Horizon icon for Horizon
The Vanilla mark was baked from Mojang's grass textures in the prepared files, so the
Java Edition tab and the New instance card showed a letter until the game was prepared.
It is now an original drawing (tools/launcher_grass_block.py), always there. Horizon's
mark was a redrawn tile with the bar in the accent colour; it is now the brand icon.

Addresses dezer-x's launcher feedback (points 5 and 6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:23:40 +01:00
rdbtCVSandOpenAI Codex f198e226ee Keep field focus helper used by the theme editor
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-04 10:21:29 +00:00
Ludovic MarieandClaude Opus 5.5 83d38f5735 launcher: fmt the scroll spy's tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:21:14 +01:00
rdbtCVSandOpenAI Codex c25f708490 Merge main and remove remaining RAM limit features
Remove the hard allocation ceiling and glass launcher slider added since the original revert. Keep read-only memory diagnostics and unrelated upstream changes.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-04 10:18:52 +00:00
Ludovic Marie 201026e91e Merge pull request #9077 from horizon-rust/fix/mgmt-stop-exits
🤖 Agent: management server/stop now ends horizon-server as vanilla's does. Desktop: management_protocol FAIL on main 017c84f1fe (supervisor TimeoutExpired), PASS on e132eeba6c (--fresh, 167 s); dedicated_server PASS both; horizon-server fmt/clippy -D warnings/134 tests pass; the new supervisor test fails with the fix reverted.
2026-11-04 11:17:59 +01:00
Ludovic MarieandClaude Opus 5.5 ad7e2b3594 launcher: the scroll spy's test reads the section at 1000 px right
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:14:09 +01:00
Ludovic MarieandClaude Opus 5.5 6c597549a9 launcher: the Mods page's Modrinth placeholder follows its tab
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:13:59 +01:00
Ludovic MarieandClaude Opus 5.5 0b15d4a0d5 launcher: the top bar wears the signed-in player's face even when the account kept no skin
dezer-x's launcher feedback, point 14. The chip draws the account's head from its saved skin URL;
an account saved without one (older sign-ins, or a profile answered without its skin) fell back to
the default skin from a prepared version's files, and with none prepared to the user icon.
Such an account now asks Mojang's public session server (no token) for its skin, keeps the URL a
day beside the cached skins, and the face comes from the skin as before (textures.minecraft.net
only). Signed out or offline with nothing cached, the user icon stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:10:11 +01:00
Ludovic MarieandClaude Opus 5.5 b7b3c2cedf launcher: GitHub's mark on Sponsor on GitHub, Sculk's card on every page, no border
dezer-x's launcher feedback, points 12 and 13:
- Sponsor on GitHub carries GitHub's mark (github.svg in the glass icons, drawn for Horizon:
  filled, its cat cut out by a mask).
- Sculk's card stays at the sidebar's foot on every page, Servers too; its border is gone (it
  still lifts under the pointer).
- On Servers, Sculk's banner sits right under the server, where it shows without scrolling past
  the server's tabs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:09:00 +01:00
Ludovic MarieandClaude Opus 5.5 e2639c66d6 launcher: Home drops the Resources rows, keeps the invite code beside Play, edition tabs switch
dezer-x's launcher feedback, points 1, 2 and 15:
- Overview (glass look) ends with the screenshots and the patch log: the Resources and
  Minecraft folder rows (and their Sign in with Microsoft) are gone; Prepare stays in Play's
  menu, the folder in the folder menu, signing in in the top bar.
- The invite code stays on the actions' row at every width; under 1280 px Play and the version
  are narrower, the puzzle gives way (the Mods tab does the same) and the field takes the rest,
  with the shorter "Paste an invite code".
- The Java Edition / Rust Edition tabs switch: to that edition's instance played last, or to the
  New instance dialog on Vanilla or Horizon when there is none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:06:47 +01:00
Ludovic MarieandClaude Opus 5.5 aba52d49b6 launcher: Modrinth is back on the Mods page, every kind has its tab
Ludovic's point 16 on dezer-x's launcher feedback: mod translators are coming and the launcher
plays Java instances, so the Mods page searches Modrinth again (undoes what 0416fe600c hid, in
the 0.2.38 design).
- Tabs: Mods, Modpacks, Resource packs, Shader packs, Data packs.
- Mods, Modpacks and Resource packs pick their source under the search: Horizon's registry or
  Modrinth, CurseForge beside them as coming soon, inert. Shader packs stay on the registry
  (Horizon's .hshader), data packs on Modrinth, into the world picked there (its worlds read again
  when the tab opens; source_for gave data packs Horizon's registry, which has none).
- A Horizon instance's Modrinth mods are listed, each marked Works, Partly or Doesn't run in
  Horizon once its jar is checked; a Java instance's tabs still search Modrinth for its version and
  loader. Installs keep the SHA-512 check before writing.
- docs/mod-browser.md, launcher/README.md and the Mods page's module comments say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:06:29 +01:00
TheMrZZandCodex gpt-6.1-sol 17bc3161d5 Preserve saved Sitting flags for both nautilus species
Restore the modeled flag independently of Owner and serialize its current value. Cover defaults, owner removal, raw-None writes and model mutation against stale raw NBT, with a frozen Game command regression.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 11:04:48 +01:00
Ludovic MarieandClaude Opus 5.5 02f481fe07 launcher: the Mods page's card stays in view, Sort by sits beside the search
dezer-x's launcher feedback, points 3 and 10:
- The project card beside the results stays in view as the page scrolls (the mockup's
  lg:sticky lg:top-0): sticky.rs measures the row while it paints and moves the card down by what
  the page scrolled past it, never past the row's end. A scrolled page is drawn from its layer,
  so the page's scroll asks on the next frame whether the card must follow, and redraws only then.
- Sort by is on the search's right, as tall and as round as the search, its choices on a glass
  popover with the chosen one ticked; Modrinth's filter bar no longer repeats it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:02:39 +01:00
Arslan ArmanulyandClaude Opus 5.5 4cc3fc51d9 Shader packs: raw custom textures default to linear + clamp, and a PNG under a name of the pack's own isn't bound
A raw (`.dat`) custom texture is sampled linear and clamped unless its
`.mcmeta` says otherwise, each key overriding its own default only; PNGs
stay nearest and repeating. A PNG (the pack's or a game resource) under
`texture.<stage>.<name>` whose name is no sampler of the game's is not
bound. Iris 1.11.7 read these values with a probe pack of ours; the
binding rule is a fit to its cases.

Fixes #9064

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:59:00 +05:00
Ludovic MarieandClaude Opus 5.5 9f7e16a7b6 launcher: Settings' list follows the scroll, each last section in turn
dezer-x's launcher feedback, point 9. GPUI Fast draws a scrolled pane from its cached layer
without rendering the view, so the list only moved when something else redrew the page; at
the end the mockup's rule then jumped straight to Data collection.
- The pane's scroll asks, on the next frame, which section is read, and redraws only when it
  changed.
- The reading line sits at 30 % of the pane, then slides to its bottom over the last 70 % of a
  pane of scroll, so About and Data collection, too short to reach 30 %, each come in turn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:58:53 +01:00
Ludovic MarieandClaude Opus 5.5 ac67a24940 fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:57:47 +01:00
Ludovic MarieandClaude Opus 5.5 57c08c432b launcher: versions and patch notes get their gap, the pages stop short of the top bar
dezer-x's launcher feedback, point 4:
- Each Versions and Patch notes card carries the 10 px bottom margin cull::column already
  counted as their gap (the mockup's gap-2.5): the cards were fused.
- The glass look's scroll area starts 14 px under the top bar, so cards scrolled up stop short of
  it; the pages' heads hold 14 px less above their titles, so nothing moves at rest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:57:43 +01:00
Ludovic MarieandClaude Opus 5.5 fd381c7ad7 Merge origin/main into feat/launcher-shop-ui
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:57:12 +01:00
Arslan ArmanulyandClaude Opus 5.5 e05ce228d6 Shader packs: RGB5_A1, RGB565 and R3_G3_B2 buffers keep their bits
These formats live in 8-bit wgpu textures, so a pack read back 8-bit values where Iris 1.11.7
reads fewer bits. What a pass writes into such a buffer, and its clear colour, now keep the bits
Iris kept in our probe pack p8106c: the 8-bit byte's top 5, 5, 5 and 1 bits for RGB5_A1, 5, 6
and 5 for RGB565 and R3_G3_B2, repeated to fill 8. This is a fit to the probe's Iris values on
one driver (all 600 values match), not a rule Iris states.

Fixes #8106

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:54:55 +05:00
Ludovic Marie 4601a3ba9f Merge pull request #8802 from dontstopgorkingit/vec_it-game
🤖 Agent: union B6 (fur li lmo oc sah se szl vec: 16 PRs) stacked on #8610, ALL = 87 languages: tables complete at main's 1093 strings, controller labels written as commands fixed; desktop launcher fmt/clippy/test + horizon-client-core green (619 passed); verify + launcher green.
2026-11-04 10:54:54 +01:00
Ludovic Marie e82799f414 Merge pull request #8610 from dontstopgorkingit/fo_fo-game
🤖 Agent: union B5 (tt yi yo zu ba br cv fo: 15 PRs + #8600 closed) stacked on #8579: zu_za game table dropped (vanilla 26.3 ships no zu_za), Bashkir's two broken {done}/{total} placeholders fixed; desktop launcher fmt/clippy/test + horizon-client-core green (619 passed).
2026-11-04 10:54:42 +01:00
Ludovic Marie 90a4324c48 Merge pull request #8579 from dontstopgorkingit/la_la-game
🤖 Agent: union B4 (gd lb ky kn mt eo haw ig la: 18 PRs) stacked on #8496: Esperanto now has a table, so the two launcher tests that used "eo" as a table-less language (i18n pick, settings parse) use "tlh"; desktop launcher fmt/clippy/test + horizon-client-core green (619 passed).
2026-11-04 10:54:21 +01:00
Ludovic Marie b87821c99d Merge pull request #8496 from dontstopgorkingit/fy_nl-game
🤖 Agent: union B3 (ga eu gl fil ta mn lo so fy: 18 PRs) stacked on #8450: Lao's 323 U+200B removed, 56 Tamil entries with Horizon/Minecraft in Tamil script restored to Latin, missing strings filled; desktop launcher fmt/clippy/test + horizon-client-core green (619 passed); verify + launcher green.
2026-11-04 10:54:08 +01:00
Ludovic MarieandClaude Opus 5.5 a53ae76d3c Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:53:34 +01:00
Ludovic MarieandClaude Opus 5.5 3faaa3f304 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:53:34 +01:00
Ludovic MarieandClaude Opus 5.5 e81cf7e35c Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:53:34 +01:00
Ludovic MarieandClaude Opus 5.5 09d7b3625e Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:53:33 +01:00
Ludovic MarieandClaude Opus 5.5 f6e559c186 mobs: one flop test over both guardians, loud and silent
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:51:41 +01:00
Ludovic MarieandClaude Opus 5.5 5bea3820c1 game: drop the opener edge test that computed its own answer
It derived the open and close edges in the test from recheck_opener_count's
return, which the existing opener tests already pin; it never reached the
game events this PR gates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:51:32 +01:00
Ludovic MarieandClaude Opus 5.5 efb950a10d Merge main into fix/8933-silent-guardian-flop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:51:32 +01:00
Ludovic MarieandClaude Opus 5.5 8bd408af30 Merge main into fix/8957-spectator-container-vibrations
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:51:24 +01:00
Ludovic MarieandClaude Opus 5.5 425145eb50 mobs: one elder guardian home test, beside the code it pins
Keep the home and keep-existing-home cases and the restriction they give
in one test right after elder_server_ai_step (so it does not collide with
the flop tests #8966 appends to guardian.rs), drop the test that pinned
extra.home staying empty, and keep Mob::toward private.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:51:17 +01:00
Ludovic MarieandClaude Opus 5.5 1fea8858dd Merge main into fix/8934-elder-guardian-home
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:50:53 +01:00
Ludovic Marie b1f5b238b8 Merge pull request #8450 from dontstopgorkingit/cy_gb-game
🤖 Agent: union B2 (az uz kk ka hy bs mk sq cy: 18 PRs) stacked on #8425: missing launcher strings filled, Instance written as "example" in hy/mk fixed, product names restored; desktop launcher fmt/clippy/test + horizon-client-core green (619 passed); verify + launcher green.
2026-11-04 10:50:49 +01:00
Ludovic MarieandClaude Opus 5.5 b500705d23 mobs: the wither's addEffect refuses, its canBeAffected does not
WitherBoss overrides addEffect to return false and canBeAffected to refuse
the wither effect only. AreaEffectCloud.serverTick picks its victims with
canBeAffected, so a cloud of strength or healing still takes the boss as a
victim: its instant effects land and the cloud shrinks. Keep
mob_can_be_affected and Mob::can_be_affected vanilla's canBeAffected and
put the blanket refusal in Mob::add_effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:50:46 +01:00
Ludovic Marie f0fd35b635 Merge pull request #8425 from dontstopgorkingit/be_by-game
🤖 Agent: union B1 (ca et lv sl sr is af ms be: 18 PRs, octopus of their heads + fixes): 66/58 missing launcher strings filled per table, 152 Serbian entries had Horizon/Minecraft transliterated (Latin again), game sr table now serves vanilla's Cyrillic sr_sp and a Latin transliteration for sr_cs; desktop: launcher fmt, clippy -D warnings, cargo test + horizon-client-core fmt/clippy/test green (619 passed); verify + launcher green.
2026-11-04 10:50:41 +01:00
Ludovic MarieandClaude Opus 5.5 6581318519 Merge main into fix/8776-wither-takes-no-effect
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:50:14 +01:00
1013b2dc5e Put the cleared experience orbs back into their join list.
The respawn rule returns a Vec, and the client stores orbs in a JoinList, so verify could not assign one to the other.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 10:43:11 +01:00
6c4a43178f Keep the closed-shulker arrow test in the library that can build a mob.
The game crate's tests cannot see the library's cfg(test) mob fixture, so verify failed while compiling them.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 10:36:55 +01:00
Ludovic MarieandClaude Opus 5.5 e132eeba6c Server: name vanilla's stop path in the comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:36:38 +01:00
9515965bd5 Broadcast entity event 46 so a random teleport's portal particles reach the window.
Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 10:28:58 +01:00
785fd33992 The player who fills a composter was left out of level event 1500, so the fill particles and sound never reached them.
Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 10:27:56 +01:00
0ddc08b053 Bounce arrows off a closed shulker instead of removing them.
Shulker.hurtServer returns false for an AbstractArrow while the shell is closed, and AbstractArrow.onHitEntity then deflects REVERSE at power 0.2. The hit path was dropping that refusal.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 10:26:10 +01:00
80c9d8d5a7 Drop experience orbs when the client changes dimension.
A new ClientLevel starts empty, but the old orbs stayed and followed the player after the end fountain, so they were never picked up.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 10:25:59 +01:00
ca31bc0c02 Keep a reloaded potion amplifier of 255 instead of clamping it to the lowest level.
MobEffectInstance stores amplifier as an unsigned NBT byte. Sign-extending that byte turned 255 into -1, and the effect constructor then clamped it to 0.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 10:20:35 +01:00
Arslan ArmanulyandClaude Opus 5.5 2f24d605ce Shader packs: fill a stitched block atlas's padding with each sprite's edge
The stitched atlas now draws every sprite into each level with a small render
pass instead of copies. The padding around a sprite repeats its nearest edge
pixel, as vanilla's TextureAtlas.uploadInitialContents does: it blits with a
clamp-to-edge sampler, and animate_sprite.vsh stretches the UVs over the
padding. Packs that filter or sample just past a sprite's edge read its own
colour instead of an empty pixel.

Part of #9037

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:13:33 +05:00
Arslan ArmanulyandClaude Opus 5.5 51ea650d28 Shader packs: stitch the block atlas as the game does, each sprite at its own size
With a pack, flat terrain draws now read a block atlas laid out by vanilla's
Stitcher, with SpriteLoader's mip level and padding. Every sprite keeps its own
size, copied from the array level where the arrays hold it unscaled, and its
place goes to the sprite table. atlasSize and the texel distances packs derive
from it now match Iris 1.11.7: 2048 x 2048 on 26.3's sprites, where the grid
of 64 px cells gave 2304. Draws without the flat path keep the grid. The
padding stays empty for now.

Part of #9037

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:13:18 +05:00
d46ebbdaa5 Start the shulker teleport slide from the move packet.
Shulker.createInterpolationHandler is NO_OP, so setPos runs when the packet arrives, from the block the shulker occupied before it. client_tick only counts the slide down.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 10:05:56 +01:00
Florian ERNST 48b17308b2 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/special-recipe-command 2026-11-04 09:53:28 +01:00
Arslan ArmanulyandClaude Opus 5.5 e2e4df377c Shader packs: the shadow pass draws terrain faces turned away from the light
An Iris-format pack's shadow map held no faces turned away from the light,
for two reasons: the shadow and shadow_water pipelines got the game's
terrain geometry with back-face culling on, and the shadow lists kept only
the quad buckets that can face the light. Under a hill at low sun such
faces (cave ceilings) are often all that shadows a cave, so packs drew
sunlight and shafts inside it. Iris 1.11.7 keeps both sides in its shadow
map (probe in #9033).

The pack shadow pipelines now draw both sides, and pack shadow views ask
for every face of their sections; Horizon's own packs keep the facing
selection.

Fixes #9033

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:52:38 +05:00
TheMrZZandCodex gpt-6.1-sol 6cd68ac305 Load summoned potion clouds into the runtime model
Retain loaded cloud timing, radius and potion settings, and keep live cloud identity during memory pruning. Add a loader contract and preserve a strict diagnostic for separate dose-phase and damage-source gaps.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 09:47:37 +01:00
Florian ERNST fcb7d020cb Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/axolotl-memory-ttl 2026-11-04 09:46:17 +01:00
Florian ERNST 96afb04b19 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/mob-effect-command-result 2026-11-04 09:40:33 +01:00
Florian ERNST acd5d78e64 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/parrot-saved-age 2026-11-04 09:38:01 +01:00
Arslan ArmanulyandClaude Opus 5.5 0d1c7706fc Shader packs: flat terrain reads each sprite's place in the block atlas from a table
Flat terrain draws now look a sprite layer's corner and side up in a table
(set 3, binding 2: x | y << 16, side) instead of working out a grid cell. The
atlas is still the grid, so packs see the same coordinates; the table lets the
atlas place sprites of different sizes next.

Part of #9037

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:34:26 +05:00
Ludovic Marie 33816de710 Merge pull request #8122 from TiggerTakkyTTT/fix/server-management-simulation-distance
🤖 Agent: Hub::set_simulation_distance broadcasts SetSimulationDistance and state.view.1 keeps the status reply in step; third of the management stack, after #8118.
2026-11-04 09:33:26 +01:00
Florian ERNST d4d03d6219 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/parrot-saved-age 2026-11-04 09:33:06 +01:00
Ludovic Marie 39c8cd6533 Merge pull request #8118 from TiggerTakkyTTT/fix/server-management-view-distance
🤖 Agent: max(2).min(view) replaces clamp(2, view), which panicked when a management value below 2 made the bounds cross; second of the management stack, after #8108.
2026-11-04 09:32:42 +01:00
Ludovic Marie a937e21cac Merge pull request #8108 from TiggerTakkyTTT/fix/server-management-spawn-protection
🤖 Agent: hub.spawn_protection is the radius the block checks read, so the live set applies at once; base of the management stack (#8118, #8122, #8128 carry it in that order).
2026-11-04 09:32:00 +01:00
Ludovic Marie 377db4c754 Merge pull request #8103 from CocofireHD/fix/avoid-entity-targeting
🤖 Agent: merged main; since #8046 can_target_player already refuses a player in Peaceful (spares_players), so I dropped the goal's own difficulty check and the Peaceful test still covers it.
2026-11-04 09:31:25 +01:00
Florian ERNST e50732827a Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/turtle-summon-home 2026-11-04 09:31:03 +01:00
Ludovic Marie bedf814d2f Merge pull request #9046 from horizon-rust/perf/collision-scan-caches
🤖 Agent: only may_reach_out answers are cached (8 sections); the chunk queries keep vanilla's one-column cache, and the block_collision_scan harness checks that query trace against the oracle. Collision samples −21 % in a profile; A/B in the noise.
2026-11-04 09:28:45 +01:00
Ludovic Marie cb2b398f6e Merge pull request #9041 from horizon-rust/perf/sculk-listeners-by-cell
🤖 Agent: small win (tick_sculk_entities −0.2 to −0.3 ms, 3/3 pairs). Risk point: listener order. The cell lookup sorts back by insertion rank, and a randomized test checks it against the old full-list filter through inserts, removals and retains. sculk, server_player_sculk and mob_ai pass.
2026-11-04 09:28:42 +01:00
Ludovic Marie 9813851777 Merge pull request #9040 from horizon-rust/perf/lib-inline-accessors
🤖 Agent: random_ticks −3.9 to −4.4 ms in 3/3 A/B pairs. Six #[inline] attributes, no body changed; random_tick, random_tick_sections, mob_ai, server_entities, natural_spawn and villager_farming match vanilla.
2026-11-04 09:28:38 +01:00
Florian ERNST 621f9496db Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/turtle-summon-home 2026-11-04 09:28:11 +01:00
Ludovic MarieandClaude Opus 5.5 1afc192c4d Merge the B4 settings-test fix into the B6 union
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:27:48 +01:00
Ludovic MarieandClaude Opus 5.5 2ba6a92b70 Merge the B4 settings-test fix into the B5 union
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:27:48 +01:00
Ludovic MarieandClaude Opus 5.5 b1c0adffdc Launcher settings test: use a language without a table
Esperanto has a table now, so the settings test that reads an unknown saved language uses "tlh".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:27:48 +01:00
TiggerTakkyTTTandCodex 9c92abadf9 Merge resolved simulation distance branch into entity range management
Carry the resolved management stack into the entity broadcast range PR while preserving all routes, port-ledger entries, and parity cases from current main.

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 15:25:24 +07:00
Ludovic MarieandClaude Opus 5.5 eb03a89c7c Server: the management protocol's server/stop ends the supervisor
Since #8245 a stop that doesn't come from the supervisor's stdin or the
control API leaves the supervisor in its stopped phase. The management
`server/stop` only set `stop`, so horizon-server kept running after it,
where vanilla's process exits (the management_protocol harness timed out).
It now shuts down as `POST /api/v1/stop` does, through one helper.

Fixes #9027

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:23:49 +01:00
Florian ERNST 3382af51d8 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/command-cart-loaded-success 2026-11-04 09:23:15 +01:00
Florian ERNSTandCodex 35af5c3a6c Merge remote-tracking branch 'fork/fix/command-cart-loaded-success' into fix/command-cart-loaded-success
Co-Authored-By: Codex <[email protected]>
2026-11-04 09:23:09 +01:00
LokiandAntigravity d237664ed1 mobs: exclude wither from sweet berry bush and powder snow slowdown (fixes #8785)
In Vanilla Java Edition 26.3, WitherBoss.makeStuckInBlock overrides Entity.makeStuckInBlock as an empty NO-OP.
While Cobweb (WebBlock) already ignored Wither alongside Spiders, Wither was still getting stuck in and having its fall distance reset by Sweet Berry Bushes and Powder Snow.

This aligns effects_from_blocks with vanilla Java bytecode:
- Wither is immune to slowdown and fall distance resets from sweet berry bushes and powder snow.
- Wither still takes sweet berry bush damage when moving in mature bushes.
- Powder snow snowflake particles continue to spawn when moving.

Co-Authored-By: Antigravity <[email protected]>
2026-11-04 17:19:46 +09:00
Florian ERNST 632746dbb7 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/noai-animal-love 2026-11-04 09:17:21 +01:00
Florian ERNSTandCodex 6588538fe8 Merge remote-tracking branch 'fork/fix/noai-animal-love' into fix/noai-animal-love
Co-Authored-By: Codex <[email protected]>
2026-11-04 09:17:15 +01:00
Florian ERNST 2354aeaf29 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/noai-sulfur-cube-fuse
Co-Authored-By: Codex <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-04 09:09:22 +01:00
SenjinandClaude Opus 5.5 c0d5806f45 Shader packs: End portal faces reach the shadow pass with their block entity
The shadow pass drew a block entity's plain faces but left out its End portal or gateway faces
(the portal run of #8361). A pack's shadow pass sees block entities whole, so the portal faces
cast too, with end_portal.png bound as for the gbuffers draw; beams still do not cast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:08:33 +01:00
SenjinandClaude Opus 5.5 04104943fb Shader packs: a glow item frame and what it holds draw as glow_item_frame
Every frame's span was `item_frame`, so a pack's entity.properties could not tell glow frames
(and the item or map in them) from plain ones. Each frame, and the item or map it holds, now
takes its own frame's type.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:06:11 +01:00
SenjinandClaude Opus 5.5 b711d2336d Packs: a decimal pack format reads as its whole part
`Codec.INT` reads a JSON number through `Number.intValue`, so a pack.mcmeta with
`"min_format": [97.1]` declares format 97 and is compatible. Horizon rejected the decimal,
so such packs (Patrix 26.3) were listed as incompatible.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:05:55 +01:00
Florian ERNST 018098927c Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/allay-pocket-inventory 2026-11-04 09:03:18 +01:00
Florian ERNST 25bf0ae04d Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/allay-invalid-brain-retention 2026-11-04 08:58:58 +01:00
Florian ERNST ba06ac2671 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/hurting-projectile-float-inertia 2026-11-04 08:56:33 +01:00
Ludovic Marie 017c84f1fe Merge pull request #8719 from xxaxio/fix/8628-sniffer-digging
🤖 Agent: vanilla Sniffer/ClientPacketListener event 63 and SnifferSoundInstance checked; with the head merged into main, ambient_sound, server_entities, sound_engine, sound_event and dedicated_server pass on playhorizon.
2026-11-04 08:54:21 +01:00
Florian ERNST 22488873a6 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/hurting-projectile-float-inertia 2026-11-04 08:53:35 +01:00
Ludovic Marie f187b05511 Merge pull request #9035 from horizon-rust/fix/skin-layers-own-file
🤖 Agent: options failed on main (69ab59ef0) with 17524 differing cases from the extra threeDSkinLayers line. With this head it passes on playhorizon. The switch moves to horizon-extras.txt, and the old options.txt line is read once, so a player's setting is kept (unit test). fmt, clippy -D warnings and 3506 tests pass on the desktop.
2026-11-04 08:51:48 +01:00
Ludovic Marie 2a7ea4750e Merge pull request #9034 from horizon-rust/fix/armor-stand-speed-timing
🤖 Agent: command_exec failed on main (69ab59ef0) with one mismatch: a stand's summon-tick /data get printed attributes [movement_speed] where vanilla prints []. With this head it passes, 4374/4374 commands, on playhorizon. Vanilla only creates that instance in aiStep's removeFrost (AttributeMap.getInstance computeIfAbsent). fmt, clippy -D warnings and 2489 tests pass on the desktop.
2026-11-04 08:51:44 +01:00
Florian ERNST e52df2a4e7 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/tnt-block-state-codec 2026-11-04 08:47:30 +01:00
Florian ERNST a9e26418ad Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/marker-data-mutation 2026-11-04 08:42:50 +01:00
Ludovic MarieandClaude Opus 5.5 3980c45b0f 3D skin layers: keep the switch in horizon-extras.txt, not options.txt
#8091 wrote threeDSkinLayers into options.txt, a key vanilla doesn't have,
so every options.txt Horizon saved was one line off vanilla's. It now lives
in horizon-extras.txt with Horizon's other switches. A threeDSkinLayers line
in options.txt is read once, while horizon-extras.txt has none of its own,
so a player who turned it on keeps it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 08:38:53 +01:00
Florian ERNST f7f0e04f7a Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/axolotl-hunting-memory 2026-11-04 08:38:25 +01:00
Florian ERNSTandCodex c152d0c7dd Keep the remaining sulfur contact scope visible in the ledger
Co-Authored-By: Codex <[email protected]>
2026-11-04 08:36:16 +01:00
Florian ERNST 4d79106840 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/sulfur-cube-mob-contact 2026-11-04 08:32:58 +01:00
Ludovic Marie c61c2cce36 Merge pull request #8908 from Joansitoh/t3/sync-gateway-cooldown-beam
🤖 Agent: both the portal-tick trigger and an entity entering now go through triggerCooldown's block event 1 to the window, so the beam starts there; gateway_available keeps reading the server copy's cooldown.
2026-11-04 08:31:16 +01:00
Ludovic Marie f3f56ed758 Merge pull request #8903 from Joansitoh/fix/water-bottle-splash-particles
🤖 Agent: the packet is count 1, no spread, speed 1, so each client draws a Gaussian velocity, matching PotionItem.useOn's sendParticles(SPLASH, …, 1, 0, 0, 0, 1.0); the positions still come from the server's random.
2026-11-04 08:31:12 +01:00
Ludovic Marie 0c58d10960 Merge pull request #8902 from Joansitoh/fix/end-portal-travel-sound
🤖 Agent: hub.land already sent level event 1032 at BlockPos.ZERO; playing it as local ambience (pitch nextFloat()*0.4+0.8, volume 0.25), as LevelEventHandler does, is what makes it audible. Nothing else in Horizon plays block.portal.travel, so there is no double sound.
2026-11-04 08:31:08 +01:00
Ludovic Marie 87391f1f0f Merge pull request #8900 from Joansitoh/fix/decorated-pot-insert-fx
🤖 Agent: wobble is a Level.blockEvent (event 1, the WobbleStyle ordinal) and the dust is sendParticles(DUST_PLUME, 7), so both now reach the window; the server's own block_entity_event stays for its copy.
2026-11-04 08:31:03 +01:00
Ludovic Marie de61a53fff Merge pull request #8898 from Joansitoh/fix/cube-client-squish
🤖 Agent: oSquish is copied before the step toward targetSquish, then decreaseSquish (0.9 for magma cubes, 0.6 otherwise), the same order as AbstractCubeMob.tick, so the client's squish animates between packets.
2026-11-04 08:30:58 +01:00
Ludovic Marie 630e7e8ee2 Merge pull request #8880 from Joansitoh/fix/8865-minecart-tnt-event-70
🤖 Agent: event 70 only sets the client's fuse to 80 (MinecartTNT.handleEntityEvent -> primeFuse on a client), and the window counts it down with the smoke; the explosion stays the server's.
2026-11-04 08:30:53 +01:00
Ludovic Marie f9db8444d6 Merge pull request #8877 from Joansitoh/fix/fox-defending-aggro
🤖 Agent: the nextFloat() < 0.05 draw happens for every defending fox, silent or not, then Entity.playSound skips a silent one: the same random order as Fox.aiStep after super.aiStep.
2026-11-04 08:30:49 +01:00
Ludovic Marie f4ebaaf6b0 Merge pull request #8874 from Joansitoh/fix/primed-tnt-window-smoke
🤖 Agent: the smoke is added after the fuse decrement and the discard, at the moved position, as PrimedTnt.tick does on a client; the server's addParticle never reached the window.
2026-11-04 08:30:45 +01:00
Ludovic Marie 6143cc90a1 Merge pull request #8712 from Joansitoh/fix/8448-arrow-sound-feet
🤖 Agent: BowItem.releaseUsing and CrossbowItem.performShooting play at the shooter's getX/Y/Z, so the feet are vanilla; the eye-height source a few cm from the listener was what panned hard.
2026-11-04 08:30:42 +01:00
Ludovic Marie d522c111f3 Merge pull request #8710 from Joansitoh/fix/8594-nautilus-ambient
🤖 Agent: only the nautilus's breath (ambient) skips the creative/spectator gate. Vanilla's Dolphin's Grace also reaches creative players (DolphinSwimWithPlayerGoal uses forNonCombat targeting), but main already blocked that before this PR, so it is a separate gap.
2026-11-04 08:30:38 +01:00
Ludovic Marie becb9bdec1 Merge pull request #8649 from Joansitoh/fix/8573-health-boost-clamp
🤖 Agent: the remove-all/re-add batches (effects, equipment, enchantments) use set_modifier_unclamped and clamp once afterwards, so an unrelated effect refresh keeps health_boost hearts; LivingEntity.onAttributeUpdated clamps only on a real max_health drop.
2026-11-04 08:30:34 +01:00
Ludovic Marie bd6000145c Merge pull request #8643 from Joansitoh/fix/8521-space-focus
🤖 Agent: Tab on Create World sets focus=2, so has_caret_field() is false while a tabbed button is focused and Space still presses it; only a click into a field drops the button focus, as Screen.setFocused does.
2026-11-04 08:30:29 +01:00
Florian ERNSTandCodex 3ab97d81fb Make sulfur contact fixtures independent of test order
Co-Authored-By: Codex <[email protected]>
2026-11-04 08:30:05 +01:00
Ludovic MarieandClaude Opus 5.5 6675ee77a8 Armor stands: movement_speed is saved from their first tick, not at summon
LivingEntity.aiStep's removeFrost makes the movement_speed instance through
getAttribute on the stand's first server tick; AttributeMap.load makes it for
a save that lists it. A /data get in the summon tick showed it since #8420.
A stand now records when that instance exists and saves it from then on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 08:28:36 +01:00
Ludovic Marie 9f0089dd8f Merge pull request #9026 from horizon-rust/perf/core-inline-small
🤖 Agent: −4.8 to −5.0 ms MSPT in 3/3 A/B pairs (fast profile, which native mod builds use). Only #[inline] attributes, no body changed; 10 harnesses incl. mob_ai, entity_move and collision pass.
2026-11-04 08:27:31 +01:00
Ludovic Marie a540150a5b Merge pull request #9025 from horizon-rust/perf/lan-speed-by-type
🤖 Agent: tick_lan −0.3 to −0.6 ms in 3/3 A/B pairs; the 9 attribute transcript harnesses (server_entities, mob_attributes, attribute_updates…) match vanilla. Risk point: the per-type cache holds only the type's default and synced id, never a mob's own modifiers, which still go through base_value_with per mob.
2026-11-04 08:27:28 +01:00
Florian ERNST a631a2c4fc Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/sulfur-cube-mob-contact
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-04 08:20:28 +01:00
Ludovic Marie 69ab59ef00 Merge pull request #8736 from TheMrZZ/fix/noai-ravager-counters
🤖 Agent: the NoAI branch now runs ravager::ai_step after ageable_step, as Ravager.aiStep does outside serverAiStep. The corpus group pins the stun-to-roar boundary at tick 2 for both AI modes.
2026-11-04 08:17:53 +01:00
Florian ERNST 71efcb3bd7 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/sulfur-cube-mob-contact 2026-11-04 08:17:51 +01:00
Florian ERNSTandCodex a43750547f Apply hot sulfur cube contact damage to mobs
Co-Authored-By: Codex <[email protected]>
2026-11-04 08:17:50 +01:00
Ludovic Marie 7b224efb3c Merge pull request #8927 from TheMrZZ/fix/trident-stack-durability
🤖 Agent: release now rechecks nextDamageWillBreak after the wet/passenger test, as TridentItem.releaseUsing does, and the stack's max_damage component is sent over the network, so both peers admit the same throws.
2026-11-04 08:17:49 +01:00
Ludovic Marie 2ab18c4c6a Merge pull request #8904 from Joansitoh/fix/remote-dragon-death-effects
🤖 Agent: I collapsed the growl's if-let into a let chain, which was the clippy failure. The dead path follows EnderDragon.aiStep after tickDeath: growl (pitch, then nextInt(200)) before the per-tick EXPLOSION, both at the risen position.
2026-11-04 08:17:45 +01:00
Ludovic Marie 846cf334a3 Merge pull request #8884 from Joansitoh/fix/water-entry-splash
🤖 Agent: I added the missing RandomSource import and made water_entry pub, since only gameplay calls it and horizon-client-lib flagged it as dead code. The item splash draws from a random seeded per item (id, ticks), not the entity's own; that only affects particle spread.
2026-11-04 08:17:41 +01:00
Ludovic Marie 1b6abae1ce Merge pull request #8876 from Joansitoh/fix/rabbit-hop-sprint-dust
🤖 Agent: I moved the block lookup, clamp and velocity into one sprint_particle helper shared with sprint_dust, so the hop's crumb differs only in drawing from the rabbit's own random (x then z), as Entity.spawnSprintParticle does.
2026-11-04 08:17:36 +01:00
Ludovic Marie f63ed5dfdb Merge pull request #8856 from TheMrZZ/fix/h186-noai-allay-cooldown
🤖 Agent: Allay.updateDuplicationCooldown runs in aiStep, outside serverAiStep, so NoAI allays count down too; the normal path calls the same helper once, from allay::ai_step.
2026-11-04 08:17:31 +01:00
Ludovic Marie 61be71c323 Merge pull request #8796 from TheMrZZ/fix/h184-fish-color-bytes
🤖 Agent: DyeColor.byId uses ByIdMap's ZERO strategy, so any color byte above 15 reads as white; masking with & 15 had turned 17 into 1. The new dedicated-server harness builds its binary the way test_dedicated_server_parity does.
2026-11-04 08:17:27 +01:00
Ludovic MarieandClaude Opus 5.5 4d32656aa0 Collision scan: remember the last eight sections' reach
A box across a chunk border goes back and forth between two columns on
every row, so the single remembered may_reach_out answer was recomputed
(a palette walk) at each crossing. The scan now keeps the last eight
(chunk, section) answers; the cells read and the chunk queries are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 08:13:00 +01:00
TheMrZZandCodex gpt-6.1-sol d5ca800e8f Accept fresh mob hits with full Resistance
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 08:08:12 +01:00
Florian ERNSTandCodex 70bfeb0b45 Tick sulfur cube fuses independently of NoAI
Co-Authored-By: Codex <[email protected]>
2026-11-04 07:59:50 +01:00
Arslan ArmanulyandClaude Opus 5.5 0625049299 Shader packs: a Clear = false buffer starts each frame from its last write
Every frame now starts with texture A as each colortex buffer's "main". A
buffer that isn't cleared and ended the last frame on B has B copied to A
first; B keeps what was last written into it. So where a program leaves
pixels alone (discard) or blends, the texture it writes holds what Iris
1.11.7 shows there, measured with probe packs of our own: the previous
frame's last write in #9009's case 1, one accumulation per frame for a
buffer only blended onto, and 0 where a buffer flipped twice a frame was
never written.

Fixes #9009

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:58:15 +05:00
Arslan ArmanulyandClaude Opus 5.5 67191f028a Shader packs: light Nether faces at the Nether's up-face 0.9, as Iris does
With a shader pack lighting the faces (`oldLighting` off), the mesher gave every block and fluid
face 1.0. Iris 1.11.7 gives our probe pack 0.9 (229 of 255) on every Nether face, lava sides
included, and 1.0 in the overworld. These values fit the dimension's up-face value of vanilla's
CardinalLighting (NETHER up 0.9, DEFAULT up 1.0); that is a fit of our probe's Iris values, not an
established Iris rule. Fluid sides under a pack drop vanilla's side factor.

Fixes #9012

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:53:46 +05:00
Ludovic MarieandClaude Opus 5.5 bea9bad765 Drop blank lines left in the command corpus group list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:51:57 +01:00
Ludovic MarieandClaude Opus 5.5 b26016c7d0 Merge #8940's marker pig fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:51:43 +01:00
Ludovic MarieandClaude Opus 5.5 35122d81fb Merge #8940's marker pig fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:51:43 +01:00
Ludovic MarieandClaude Opus 5.5 02f5c497a9 Merge #8940's marker pig fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:51:42 +01:00
Ludovic MarieandClaude Opus 5.5 6bab8124e1 Merge #8940's marker pig fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:51:42 +01:00
Ludovic MarieandClaude Opus 5.5 b566311709 Merge #8940's marker pig fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:51:42 +01:00
Ludovic MarieandClaude Opus 5.5 0ab6729535 Merge #8940's marker pig fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:51:42 +01:00
Ludovic MarieandClaude Opus 5.5 dd1fc46290 Merge #8940's marker pig fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:51:42 +01:00
Ludovic MarieandClaude Opus 5.5 1ade779174 Merge #8940's marker pig fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:51:42 +01:00
Ludovic MarieandClaude Opus 5.5 765697c19b Clear leftover pigs before the marker group's pig count
The entities group leaves pigs loaded, so the single-pig check saw 3 when it
runs after it, as it does in the full corpus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:51:35 +01:00
Ludovic MarieandClaude Opus 5.5 e0f28aeaf0 Sculk: a game event finds its listeners by cell
Every game event (each mob's step is one) filtered the level's whole list
of sculk block entities by distance. The index now also keeps them by
32-block cell with their insertion rank, and an event reads the cells its
radius reaches (at most eight), sorted back by rank: the same listeners in
the same order as the filtered list. A test checks it against that filter
through inserts, removals and retains.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:44:45 +01:00
Arslan ArmanulyandClaude Opus 5.5 b9fdf62763 Shader packs: a custom uniform naming what Iris can't see reads 0 whole
In Iris 1.11.7 a `uniform.*`/`variable.*` expression that names
something its custom uniforms don't know fails as a whole and reads 0,
even when the name is in an `if` branch that isn't taken, and so does
every uniform or variable that uses it (probe packs of our own). Horizon
read only the name as 0 and kept the rest, and saw fogStart, fogColor
and eight more built-in names that Iris's custom uniforms don't.

Custom now works out, on its first evaluation, which items name an
unknown or hidden name, directly or through another item, and gives
them 0. Two names Iris sees that Horizon doesn't give yet
(currentRenderedItemId, lightningBoltPosition) still read 0 alone.

Fixes #8995

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:34:10 +05:00
Ludovic MarieandClaude Opus 5.5 95cd9455a0 Inline the hottest small accessors read across crates
Mob::species, alive and bounds (read by the game crate for every mob),
BlockFacts::get (an index into the state table), RandomTickMask::may_tick
(a bit test per drawn position) and World::block (a chunk lookup and a
palette read) ran as calls from the game crate in the fast profile that
native mod builds use. Marked #[inline]; bodies unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 07:23:01 +01:00
Florian ERNSTandCodex 9f5d8f7992 Respect numeric array conversions and edit acceptance
Co-Authored-By: Codex <[email protected]>
2026-11-04 07:22:44 +01:00
Arslan ArmanulyandClaude Opus 5.5 5b58cd736e Shader packs: without gbuffers_clouds, draw the clouds with gbuffers_textured, else gbuffers_basic
A pack without `gbuffers_clouds` left the clouds to the game, so they never reached the pack's
buffers. Iris 1.11.7 draws them with `gbuffers_textured`, else `gbuffers_basic` (not
`gbuffers_textured_lit`), blended, as its ShaderDoc's gbuffers table lists and as our probe packs
measure.

Fixes #9011

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:22:28 +05:00
Kacper 429b0d0f25 Merge pull request #8693 from Akirabane/fix/7484-save-gaps
A player's save keeps their motion, food timer, freezing and previous game mode
2026-11-04 07:21:42 +01:00
Arslan ArmanulyandClaude Opus 5.5 47b400625f Shader packs: a forced flip of a buffer the program writes cancels its flip
`flip.<program>.<buffer> = true` on a buffer the program also writes was
ORed with the write's own flip, so the buffer flipped once. Iris 1.11.7
leaves it unflipped (probe packs of our own): a later program samples the
side the program didn't write (0 for a cleared buffer), and a blended
write lands on what the program wrote (0.25 + 0.5 reads 0.749). The
forced flips are now XORed with the written ones.

Fixes #9010

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:21:01 +05:00
Ludovic MarieandClaude Opus 5.5 6c40453478 Core: inline the small vector, box and position helpers
Without LTO across crates (the fast profile, which native mod builds use
as performance), a non-generic function from horizon-core is only inlined
into another crate when it is marked #[inline] or is a tiny leaf. Aabb::new,
Aabb::max, max_position, deflate, Vec3's arithmetic, BlockPos::offset and
chunk and PalettedContainer::is_global ran as calls on the server's hottest
paths (the collision scan, the snapshot, mob movement), each returning its
box or position through memory. They are marked #[inline]; their bodies are
unchanged, so every value is the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 06:59:46 +01:00
Arslan ArmanulyandClaude Opus 5.5 e317145573 Shader packs: match iris.features names in any case, as Iris does
Iris 1.11.7 accepts the names of `iris.features.optional` and
`iris.features.required` in any case, and defines `IRIS_FEATURE_<name>`
with the name as the pack wrote it (probe packs of our own). Horizon
compared them with its uppercase list exactly, so `ssbo` defined nothing
and `compute_shaders` as required was noted as unsupported. The
HIGHER_SHADOWCOLOR check now matches in any case too.

Fixes #8996

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:58:28 +05:00
Arslan ArmanulyandClaude Opus 5.5 c4e0dd1600 Shader packs: a pack with an integer colortex0 loads
The world blit wrote its float colour into colortex0 whatever its
format, so a pack giving colortex0 an integer format (RGBA32UI, Arc
v0.15.2) failed wgpu's pipeline validation and was turned off. An
integer colortex0 now gets only what the pack writes: the blit leaves it
out (still copying depth and clearing the other buffers), it is cleared
in a pass of its own, and a pack without `final` gets a cleared screen
instead of a float copy of it.

Fixes #8459

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:58:00 +05:00
Arslan ArmanulyandClaude Opus 5.5 ab7164bba1 Shader packs: a const local may be a texel offset
A `const` local whose initializer is a constant expression is a
constant expression itself (GLSL 4.60, 4.3.3, as #8462 quotes it).
naga's GLSL frontend kept it as a local variable, so a texel offset
given by one failed with "Sample offset must be a const-expression". It
now becomes a module constant, as a global `const` does, also inside a
loop.

Fixes #8462

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:57:52 +05:00
TheMrZZandCodex gpt-6.1-sol 8a05c5c899 Default loaded sulfur cube health after applying size
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 06:55:25 +01:00
Florian ERNSTandCodex 0cff71db71 Use registered projectile UUIDs in command NBT views
Co-Authored-By: Codex <[email protected]>
2026-11-04 06:53:50 +01:00
Arslan ArmanulyandClaude Opus 5.5 72ca9db476 Shader packs: .length() of a sized buffer member as an array size
`.length()` of an explicitly sized array is a constant expression
whether or not the array is (GLSL 4.60, 4.3.3, as #8463 quotes it). In
a constant context (an array size) naga's GLSL frontend lowered the
array itself there and failed with "Unexpected runtime-expression"; the
array is now lowered outside it, for its type only.

Fixes #8463

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:52:03 +05:00
Arslan ArmanulyandClaude Opus 5.5 6d40db2be5 Shader packs: read shadowtex0/1 linearly through a plain sampler, as Iris does
Iris 1.11.7 filters shadowtex0 and shadowtex1 linearly when a program reads
them through a plain sampler2D (probe packs of our own: a quarter texel past a
texel's centre moves a quarter of the way to its neighbour, in gbuffers and in
composite), unless the pack sets shadowtex0Nearest, shadowtexNearest or
shadow0MinMagNearest (shadowtex0), or shadowtex1Nearest or
shadow1MinMagNearest (shadowtex1). Horizon read the nearest texel, so a lookup
next to a block's edge took the higher block's depth and shadowed faces Iris
lights, with stripes on far terrain.

wgpu filters a depth format only in a comparison, so a shadowtex read that way
now reads an R32Float copy of the final depth (copied through a buffer after
the shadow pass's terrain), with the linear sampler, on devices with
FLOAT32_FILTERABLE. Others keep the nearest texel.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:46:14 +05:00
Ludovic MarieandClaude Opus 5.5 f2033ffd0b Server: a snapshot looks a mob type's speed attribute up once
Every tick's entity snapshot looked movement speed up by name for every
mob: the catalog's id, the type's synced default (two string-keyed maps)
and the type's own default base (a SipHash of the attribute's name).
They depend only on the type, so a snapshot keeps them per type; each
mob's base still comes from its own finalized bases first. The synced
instances are the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 06:43:32 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 b723f5e5c5 Merge origin/main into fix/portal-parity
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-04 00:40:21 -05:00
Florian ERNSTandCodex 824af41138 Clear stale sleeping position from awake villager saves
Co-Authored-By: Codex <[email protected]>
2026-11-04 06:35:11 +01:00
Arslan ArmanulyandClaude Opus 5.5 c218bbe9a0 Shader packs: atomic functions on a component of a buffer block's vector
GLSL 4.60 8.11 lets an atomic memory function take a single component
of a vector in a buffer, as RenderPearl's hand light does with
atomicAdd(hand_light.right.x, rg); naga has no atomic vectors, so the
stage failed with "Pointer to atomic is invalid" and was skipped.

The rewrite that turns such shared vectors into arrays of components
now also covers members of std430 buffer blocks (named or not), in
graphics stages as in compute ones. The block keeps its std430 layout:
a padding member where the vector's alignment moved it, and four
elements per 3-component vector of an array. Other layouts, runtime-
sized arrays and blocks whose layout isn't known up to the vector stay
as written.

Fixes #8977

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:31:55 +05:00
Ludovic Marie d8edfbcd0f Merge pull request #8989 from horizon-rust/perf/shape-moved-allocs
🤖 Agent: verify green; profile: allocator samples under do_move halved (178 -> 88), ~-0.3 ms/tick, spans below noise; pr_ready --quick passes; 18/18 collision/shape harnesses PASS incl. geometry, entity_move, mob_ai.
2026-11-04 06:28:53 +01:00
Ludovic Marie c9d734aabc Merge pull request #8988 from horizon-rust/perf/random-tick-mask-rc
🤖 Agent: verify green; A/B 3 pairs random_ticks -0.62/-0.40/-0.28 ms/tick; pr_ready --quick passes (2,910 tests); 4/4 harnesses PASS (random_tick_sections, random_tick, villager_farming, dedicated_server).
2026-11-04 06:28:49 +01:00
Florian ERNSTandCodex 8cac78d7fa Turn waking villagers toward their bed and reset pitch
Co-Authored-By: Codex <[email protected]>
2026-11-04 06:23:24 +01:00
NerfPhaselockandClaude Opus 5.5 1a3125fcfd Renderer: GPU culling draws only the sides that face the eye
A GPU frame drew every solid quad of a section it kept and left the back
faces to the rasterizer, where a CPU frame draws only the runs of buckets
that can face the eye (Sides::visible_from, its MARGIN included). Each
record now carries where its buckets end and what visible_from compares
the eye with, and the tables the eye less each origin, both as u32 keys
that order as those floats do, so cull compares integers and writes a draw
per run, at most four per record.

A mesh sorted for two passes, which GPU culling never meets, keeps one run
of all its quads, and a frame whose eye less an origin is NaN is the CPU's.
Regions hold four draws per record, and cull counts the kept sections
itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:20:10 -05:00
Arslan ArmanulyandClaude Opus 5.5 a64ff0e803 Shader packs: min3, max3 and mid3 of GL_AMD_shader_trinary_minmax
A stage that enables GL_AMD_shader_trinary_minmax gets the extension's
min3, max3 and mid3 (genType, genIType and genUType overloads: the
per-component minimum, maximum and median of x, y and z) in its prelude,
for each one it calls and doesn't define itself. naga has none of them,
so such programs were skipped with "Unknown function 'max3'".

Fixes #8460

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:07:59 +05:00
Florian ERNSTandCodex e3e7502659 Omit supplied Age from slime and magma cube saves
Co-Authored-By: Codex <[email protected]>
2026-11-04 06:02:32 +01:00
martipopsandClaude Opus 5.5 84e3266fcc Controllers: one input path for pad and keyboard; focus menus, crosshair inventories
Input parity (pad and keyboard reach the game the same way):
- Held keys record which device holds them; splitscreen's keyboard
  handoff releases only the keyboard's, so the other player's controller
  keeps its holds.
- The pad sends Shift and Ctrl through the keyboard's modifier state, so
  the stack throw in inventories throws the whole stack again.
- Every bindable controller action has an explicit route, checked by a
  test; screenshot and smooth camera work from a controller binding, and
  pad look counts for the tutorial and zoom like the mouse.
- tools/pad_parity_e2e.py and headless Game tests compare pad and
  keyboard outcomes.

Menus and inventories:
- Menus use focus; inventories, containers and the screens whose
  clickable parts have no focus targets use a crosshair pointer centred on
  the point it clicks. The Menu Navigation option and the right stick
  click's swap are removed.
- A snapping engine settles the pointer: a glide onto the nearest slot,
  flicks commit to the neighbour they head for, the D-pad hops a slot.
- Pointer speed, scroll speed and navigation repeat settings per player.
- Focus starts in vanilla's tab order, returns to the widget a screen was
  left on, and only one widget is ever highlighted.
- Only a new press or real stick movement takes input from the mouse; a
  screen, focus or keyboard change releases held buttons until let go.



Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp81zX4YZ9N1mLLAwzKH
2026-11-03 23:56:48 -05:00
Ludovic MarieandClaude Opus 5.5 c8659d2757 Fix the splash's borrow of the remote state and the new item_areas test item
The window's item splashes ran while r.head_lerps was still borrowed; they
now run after it is released. Main's item_areas test builds an ItemEntity,
which needs was_touching_water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:55:53 +01:00
Ludovic MarieandClaude Opus 5.5 b5b62e3262 Merge main into the water-entry splash
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:55:32 +01:00
Florian ERNSTandCodex c54af83a72 Fix mob attribute reads for transient baby speed
Co-Authored-By: Codex <[email protected]>
2026-11-04 05:53:29 +01:00
Ludovic MarieandClaude Opus 5.5 b39fc62dc4 Make ItemEntity::water_entry public: only gameplay calls it
horizon-client-lib builds without gameplay, so a pub(crate) water_entry and
its ItemWater were dead code there (clippy -D warnings).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:48:59 +01:00
TheMrZZandCodex gpt-6.1-sol 6351cb3e1d Report function results for return fail
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 05:45:23 +01:00
Ludovic MarieandClaude Opus 5.5 933ed06f02 Merge the corpus stack below #8970 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:43:06 +01:00
Ludovic MarieandClaude Opus 5.5 1ef25329f9 Merge the corpus stack below #8968 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:43:05 +01:00
Ludovic MarieandClaude Opus 5.5 2f6f693c7b Merge the corpus stack below #8965 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:43:03 +01:00
Ludovic MarieandClaude Opus 5.5 815cada227 Merge the corpus stack below #8959 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:43:02 +01:00
Ludovic MarieandClaude Opus 5.5 ebcd2d2ba0 Merge the corpus stack below #8956 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:43:01 +01:00
Ludovic MarieandClaude Opus 5.5 81a99d60f0 Merge the corpus stack below #8953 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:43:01 +01:00
Ludovic MarieandClaude Opus 5.5 21dba43d9f Merge the corpus stack below #8949 (command_exec_corpus.py groups kept from both sides; axolotl memories: #8949's boolean hunting memory and #8860's integer TTLs both kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:43:00 +01:00
Ludovic MarieandClaude Opus 5.5 31167b1290 Merge the corpus stack below #8943 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:52 +01:00
Ludovic MarieandClaude Opus 5.5 306fdba861 Merge the corpus stack below #8940 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:52 +01:00
Ludovic MarieandClaude Opus 5.5 5ebc5279dc Merge the corpus stack below #8922 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:51 +01:00
Ludovic MarieandClaude Opus 5.5 db38db7622 Merge the corpus stack below #8909 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:51 +01:00
Ludovic MarieandClaude Opus 5.5 1a1887f52e Merge the corpus stack below #8888 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:50 +01:00
Ludovic MarieandClaude Opus 5.5 5101d519d2 Merge the corpus stack below #8860 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:50 +01:00
Ludovic MarieandClaude Opus 5.5 c1a8542360 Merge the corpus stack below #8878 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:50 +01:00
Ludovic MarieandClaude Opus 5.5 858c0a8de1 Merge the corpus stack below #8847 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:49 +01:00
Ludovic MarieandClaude Opus 5.5 9f666f514a Merge the corpus stack below #8841 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:49 +01:00
Ludovic MarieandClaude Opus 5.5 1301409191 Merge the corpus stack below #8821 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:48 +01:00
Ludovic MarieandClaude Opus 5.5 c785e69eda Merge the corpus stack below #8799 (command_exec_corpus.py groups kept from both sides)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:48 +01:00
Ludovic MarieandClaude Opus 5.5 94cb4d95f9 Share Entity.spawnSprintParticle between sprinting players and a rabbit's hop
The hop's crumb reused a copy of sprint_dust's block lookup, clamp and
velocity; both now call one sprint_particle helper and differ only in the
random that places the crumb.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:41:23 +01:00
Ludovic MarieandClaude Opus 5.5 370af2fa60 Import RandomSource for the item splash's random draws
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:40:19 +01:00
Florian ERNSTandCodex 1d4869bf83 Use native suffocation predicates and shapes in mob base ticks
Bind the existing registry to shared collision tables and resource/world tags, then test the thin eye box against the actual suffocating shape. Keep probe worlds and partial-block integration controls on the runtime binding.

Co-Authored-By: Codex <[email protected]>
2026-11-04 05:40:18 +01:00
Ludovic MarieandClaude Opus 5.5 d7fbec125d Collapse the dead dragon's growl check (clippy collapsible_if)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:39:15 +01:00
Arslan ArmanulyandClaude Opus 5.5 0127a87ccf Shader packs: expand macros in custom uniform and variable lines
Iris 1.11.7 reads a custom uniform's or variable's line in
shaders.properties with the macros expanded: the pack's options, the
standard macros and the file's own #defines (function-like ones too),
in the name as well as the expression (probe pack of our own). Horizon
read them raw, so an option such as RenderPearl's FOG read 0.

#define in .properties files now goes through the preprocessor's own
parsing (Macros::define_line), so function-like macros work there too.

Fixes #8976

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:39:10 +05:00
Ludovic Marie f40d4df781 Merge pull request #8962 from horizon-rust/perf/turtle-egg-search-palette
🤖 Agent: verify green; profile: AttackTurtleEgg 0.44 % -> 0.01 % of the tick (nextStartTick draw unchanged); pr_ready --quick passes (1,654 client-lib tests); 8/8 harnesses PASS on the wave-2 stack incl. mob_ai.
2026-11-04 05:17:11 +01:00
Ludovic Marie 97f6d296b6 Merge pull request #8961 from horizon-rust/perf/cart-tick-no-clone
🤖 Agent: verify green; A/B 3 pairs tick_carts -0.41 ms/tick; pr_ready --quick passes; 8/8 harnesses PASS on the wave-2 stack incl. vehicle_container.
2026-11-04 05:17:07 +01:00
Ludovic Marie baee70be80 Merge pull request #8960 from horizon-rust/perf/item-scans-cells
🤖 Agent: verify green; A/B 3 pairs creature -2.34 ms/tick at 6,200 mobs; pr_ready --quick passes; 8/8 harnesses PASS on the wave-2 stack (mob_ai, server_entities, dedicated_server, vehicle_container, dispenser, finalize_spawn, natural_spawn, entity_death_loot).
2026-11-04 05:17:03 +01:00
Ludovic MarieandClaude Opus 5.5 1191e6b721 Random ticks: a section's mask is shared without an atomic count
Every random tick handed out each ticking section's mask by cloning an
Arc twice (the cache, then the chunk's last answer) and dropping both
again. The mask is usually out of cache, and a locked increment on a
missed line stalls the loop: about 5 % of the server thread on a busy SMP
profile. The masks never leave the tick thread, so they are Rc now. The
draws, the masks and the ticks are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 05:14:49 +01:00
TheMrZZandCodex gpt-6.1-sol e6fa96799f Enable large-fireball explosion fire with mob griefing
Pass the rule's fire flag from the impact arm while preserving the shared
non-fire explosion default. Cover bounded enabled/disabled and TNT scenes,
with explicit fire-presence gates and unmodified raw count diagnostics.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 05:14:41 +01:00
LokiandAntigravity 08187d1377 mobs: suppress Guardian and Elder Guardian flop sounds when silent (fixes #8933)
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 13:14:18 +09:00
Florian ERNSTandCodex f65b7642e7 Pin remote fireball power test to widened float inertia
Use the exact Java value instead of the stale double-literal expectation; preserve the original motion behavior and strengthen the assertion.

Co-Authored-By: Codex <[email protected]>
2026-11-04 05:13:21 +01:00
Florian ERNST cfb2db51a7 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/hurting-projectile-float-inertia 2026-11-04 05:10:47 +01:00
Florian ERNSTandCodex caf3d88be6 Retain Allay liked-player memory after failed Brain loads
Preserve the live modeled liked-player fields when a whole-Brain data edit omits or invalidates the required memories map. Keep valid empty-map and individual-key clears working.

Co-Authored-By: Codex <[email protected]>
2026-11-04 05:09:20 +01:00
ElyTheCat c7d577e4c8 Optimize creature and projectile tick hot paths
Reuse the mob world snapshot without cloning per mob, skip unloaded work before nearest-player and difficulty queries, cache difficulty by chunk, and avoid guest-target allocations when no LAN guests exist. Empty projectile ticks now flush queued effects and return without scanning mobs or constructing projectile state.\n\nCo-Authored-By: OpenAI Codex <[email protected]>
2026-11-04 07:35:05 +03:30
Ludovic Marie db7134ac66 Merge pull request #8787 from dontstopgorkingit/perf/atlas-memory
🤖 Agent: measured on the desktop with pack_load_bench, 5 alternating A/B rounds on main eaa7da4fb, medians: block sprites one by one 100 → 80 ms, prefetched 44 → 35 ms, from the disk cache 41 → 30 ms, CTM prefetched 84 → 64 ms; stages that don't insert sprites are unchanged. The still-texture key no longer clones each frame's pixels. One risk: two different textures sharing a 64-bit SipHash would share a layer, about 1e-12 at a few thousand sprites.
2026-11-04 05:02:47 +01:00
Ludovic Marie a8dd3a4106 Merge pull request #8572 from dontstopgorkingit/ci/concurrency-backlog
🤖 Agent: I kept only the concurrency groups on mod-registry and telemetry-server: a newer push cancels a superseded PR run, and main's runs all finish. I dropped the pr-labels and triage-command groups, where a newer run cancels a pending one even when its own job is skipped (a title-only edit, a plain comment), so labels or a /triage would be lost. I also dropped the cron move, which had no stated reason.
2026-11-04 04:59:06 +01:00
Ludovic Marie 79e09456c4 Merge pull request #8806 from dontstopgorkingit/docs/readme-refresh
🤖 Agent: the gap block is exactly what `tools/parity_gaps.py readme` prints today (all ten lists #3512-#3521 at 0 open), and "all 281 harnesses" was out of date (there are 324+), so dropping the count is right.
2026-11-04 04:59:01 +01:00
Ludovic Marie 3f6f3a6dc1 Merge pull request #8478 from dontstopgorkingit/docs/contributing-shaders
🤖 Agent: CONTRIBUTING still said shader packs were removed on 24 October. They are back as a clean-room rewrite (horizon-shaders) plus Horizon's own API. The known-issues bullet was already removed on main (279a04a8eb), so I took main's file to resolve the conflict.
2026-11-04 04:58:58 +01:00
Ludovic Marie 2e1e541e02 Merge pull request #8812 from dontstopgorkingit/docs/shader-api
🤖 Agent: main does have the shader API (`manifest.rs`: API = "0.1", with a generated reference), so "none yet / not started" was wrong. I fixed the intro, which said the API was built "from scratch on top of" the removed one, listed the real stages (vertex was missing), and noted that F3 already shows pass costs.
2026-11-04 04:58:54 +01:00
Ludovic Marie 0ffc3ec0bb Merge pull request #8805 from dontstopgorkingit/docs/refresh-hosting-docs
🤖 Agent: every flag added here matches horizon-server's usage text and parsers on main (`restore --dimension`, `update --archive/--version/--timeout`, `rollback --timeout`, `[updates] keep = 1`). I put back the command descriptions the edit dropped and pointed import-resources at step 2, where it is explained.
2026-11-04 04:58:50 +01:00
Ludovic Marie b31a68ea8d Merge pull request #8791 from dontstopgorkingit/worktree-agent-afb089f7829283ca5
🤖 Agent: the audio callback allocated a fresh `voices` Vec on every buffer, on the real-time thread; it now reuses one in `Mixer`, as `cameras` already did. Mixing order and gains are unchanged. I rustfmt-ed it.
2026-11-04 04:58:46 +01:00
Ludovic Marie d2e82c7339 Merge pull request #8756 from dontstopgorkingit/perf/mob-pathfinding
🤖 Agent: `swim_neighbors` built an `FxHashMap` (a heap allocation plus 6 inserts and 8 lookups) for every node a water mob's pathfinder expands. A `[Option<u32>; 6]` indexed in `Direction.values()` order does the same: I checked each diagonal pair against the old `(dx,dz)` keys, and the call order of `swim_accepted` is unchanged. I rustfmt-ed it and replaced the index loop with a zip (clippy).
2026-11-04 04:58:42 +01:00
Florian ERNSTandCodex 0ec74c25b5 Widen hurting-projectile float inertia before scaling motion
Use Java float inertia values in the shared helper, retain its existing branch order, and pin exact motion with focused dry, submerged and custom-acceleration controls.

Co-Authored-By: Codex <[email protected]>
2026-11-04 04:54:51 +01:00
Ludovic MarieandClaude Opus 5.5 cefa8659ec Shapes: a moved shape allocates its coordinates once
A moved VoxelShape cloned its three coordinate vectors (three allocations
plus their Arc) and its boxes vector (two more). Every block an entity's
movement meets is moved to its position, several per mob per tick. The
three axes now share one slice (axis i is values[starts[i]..starts[i+1]])
and the boxes are an Arc<[Aabb]>, so a moved shape makes three allocations
instead of seven. Every boundary gets the same addition as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:54:01 +01:00
LokiandAntigravity f32999331f game: guard container open and close game events by opener count edge (fixes #8957)
In vanilla Java 26.3, `GameEvent.CONTAINER_OPEN` and `CONTAINER_CLOSE`
are fired exclusively when `ContainerOpenersCounter.openerCountChanged`
detects an opener count transition across zero (0 -> 1 on open, 1 -> 0
on close). Spectators never join `ContainerOpenersCounter`, and
subsequent openers on an already open container do not re-trigger edges.

Guarding `post_block_game_event` with `if first` and `if last` prevents
spectators from emitting vibrations that alert sculk sensors and wardens.

Co-Authored-By: Antigravity <[email protected]>
2026-11-04 12:48:30 +09:00
Ludovic MarieandClaude Opus 5.5 f7311a0ec6 Translation fixes for fur, li, lmo, oc, sah, se, szl, vec
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:45:07 +01:00
Ludovic MarieandClaude Opus 5.5 8d840de551 Union of 16 translation PRs: Furlan, Limburgs, Lombard, Occitan, Саха тыла, Davvisámegiella, Ślōnskŏ, Vèneto
Merges the heads of #8755, #8757, #8761, #8764, #8767, #8768, #8770, #8771, #8777, #8781, #8792, #8793, #8794, #8797, #8800, #8802 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:45:05 +01:00
Ludovic MarieandClaude Opus 5.5 ca2f38dbbf Translation fixes for tt, yi, yo, zu, ba, br, cv, fo
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.
- Zulu: vanilla 26.3 ships no zu_za language, so the game table could never be picked:
  removed (the launcher table stays).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:45:04 +01:00
Ludovic MarieandClaude Opus 5.5 36feb04d68 Union of 16 translation PRs: Татарча, ייִדיש, Yorùbá, isiZulu, Башҡортса, Brezhoneg, Чӑвашла, Føroyskt
Merges the heads of #8580, #8581, #8585, #8587, #8596, #8597, #8599, #8600, #8601, #8603, #8605, #8606, #8607, #8608, #8609, #8610 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:45:02 +01:00
Ludovic MarieandClaude Opus 5.5 d901cbf6b2 Translation fixes for gd, lb, ky, kn, mt, eo, haw, ig, la
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.
- The launcher test used "eo" as a language without a table: it now uses "tlh".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:45:01 +01:00
Ludovic MarieandClaude Opus 5.5 317df1503e Union of 18 translation PRs: Gàidhlig, Lëtzebuergesch, Кыргызча, ಕನ್ನಡ, Malti, Esperanto, ʻŌlelo Hawaiʻi, Igbo, Latina
Merges the heads of #8528, #8529, #8530, #8532, #8533, #8535, #8537, #8538, #8540, #8544, #8550, #8551, #8556, #8560, #8564, #8568, #8574, #8579 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:59 +01:00
Ludovic MarieandClaude Opus 5.5 c635912558 Translation fixes for ga, eu, gl, fil, ta, mn, lo, so, fy
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:58 +01:00
Ludovic MarieandClaude Opus 5.5 e3159f5bb8 Union of 18 translation PRs: Gaeilge, Euskara, Galego, Filipino, தமிழ், Монгол, ລາວ, Soomaali, Frysk
Merges the heads of #8451, #8452, #8453, #8454, #8455, #8456, #8458, #8467, #8468, #8469, #8472, #8473, #8475, #8477, #8483, #8484, #8495, #8496 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:57 +01:00
Ludovic MarieandClaude Opus 5.5 4b3d97f569 Translation fixes for az, uz, kk, ka, hy, bs, mk, sq, cy
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:56 +01:00
Ludovic MarieandClaude Opus 5.5 e3a960c2e8 Union of 18 translation PRs: Azərbaycanca, Oʻzbekcha, Қазақша, ქართული, Հայերեն, Bosanski, Македонски, Shqip, Cymraeg
Merges the heads of #8427, #8428, #8431, #8432, #8434, #8435, #8436, #8437, #8439, #8440, #8441, #8442, #8444, #8445, #8446, #8447, #8449, #8450 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:55 +01:00
Ludovic MarieandClaude Opus 5.5 3419da9b42 Translation fixes for ca, et, lv, sl, sr, is, af, ms, be
- Launcher tables get every string main has added since they were written, and lose strings the
  code no longer has.
- Product names translated or transliterated as words (Horizon, Minecraft, Java, Mojang…) are back
  in Latin script.
- Fixed the meanings machine translation got wrong: a crash written as a vehicle crash, Nightly
  as "every night", instance as "example", tick as a checkmark, the controller labels written
  as commands, and similar.
- Removed the U+200B pairs that came with the translation tool.
- Game tables drop entries identical to en_us.
- Also removed stray U+200B from main's bg, fi, he launcher tables and the da_dk game table.
- Serbian: the Cyrillic game table is vanilla's sr_sp (Српски); sr_cs is vanilla's Latin
  Serbian, so it now gets the same table transliterated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:54 +01:00
Ludovic MarieandClaude Opus 5.5 eac75f8b3d Union of 18 translation PRs: Català, Eesti, Latviešu, Slovenščina, Српски, Íslenska, Afrikaans, Bahasa Melayu, Беларуская
Merges the heads of #8394, #8395, #8396, #8398, #8401, #8402, #8405, #8408, #8410, #8412, #8413, #8414, #8415, #8416, #8417, #8418, #8424, #8425 as they are (each one's table, its Language entry in
launcher/src/i18n.rs and its Bundled arm in lang.rs), so each PR is marked merged when this lands.
Their edits to existing tables are left out: main's versions are newer and more accurate.
The next commit fixes the tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:44:52 +01:00
Ludovic MarieandClaude Opus 5.5 3fef07f080 ci: no concurrency group on triage-command
Every comment on an issue starts a run (most skip their job), and a newer run cancels a pending one in the
same group: a /triage waiting behind another run would be dropped by the next plain comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:41:39 +01:00
Ludovic MarieandClaude Opus 5.5 e5953f9e97 ci: keep pr-labels and the claim cron as on main
A title-only edit's run skips its job, but as a newer run in the group it would still cancel the opened run's
labelling. The claim cron move had no reason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:41:28 +01:00
Florian ERNSTandCodex b97c098c23 Decode primed TNT block state before saving
Keep the decoded state on the TNT model and encode it in command, chunk and portal save views. Invalid inputs fall back to the default TNT state.

Co-Authored-By: Codex <[email protected]>
2026-11-04 04:41:22 +01:00
Ludovic MarieandClaude Opus 5.5 48430a7647 shader docs: one consistent intro, the stages that exist, what is still missing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:41:21 +01:00
Ludovic MarieandClaude Opus 5.5 8a442b616b hosting: keep the command descriptions, point import-resources at step 2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:41:01 +01:00
Ludovic Marie 9048266788 Merge remote-tracking branch 'origin/main' into rv8-8478
# Conflicts:
#	docs/known-issues.md
2026-11-04 04:40:39 +01:00
Ludovic Marie eba1f44312 Merge remote-tracking branch 'origin/main' into rv8-8572 2026-11-04 04:40:19 +01:00
Ludovic Marie 847d64ca9f Merge remote-tracking branch 'origin/main' into rv8-8806 2026-11-04 04:40:18 +01:00
Ludovic Marie 02423ba72e Merge remote-tracking branch 'origin/main' into rv8-8812 2026-11-04 04:40:17 +01:00
Ludovic Marie a47ed5699e Merge remote-tracking branch 'origin/main' into rv8-8805 2026-11-04 04:40:16 +01:00
Ludovic MarieandClaude Opus 5.5 e124a775b1 path: fill the swim directions by zip, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:39:52 +01:00
Ludovic MarieandClaude Opus 5.5 bc29f62f9e audio: rustfmt the reused voices buffer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 04:39:32 +01:00
Ludovic Marie c351350bad Merge remote-tracking branch 'origin/main' into rv8-8756 2026-11-04 04:39:07 +01:00
Ludovic Marie 79fc2426f3 Merge remote-tracking branch 'origin/main' into rv8-8791 2026-11-04 04:39:06 +01:00
TheMrZZandCodex gpt-6.1-sol 9c63f70f62 Load validated mannequin pose for live eye anchoring
Set the mannequin's live avatar pose from its saved lowercase value,
using the five accepted poses and standing for absent or invalid inputs.
Keep existing dimension math and raw saved-field writing unchanged.
Cover exact native eye anchors, UUIDs, body positions and invalid controls.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 04:33:23 +01:00
Florian ERNSTandCodex a96240dcfb Keep Allay pocket stacks separate from held filters
Load and save the independent one-slot inventory, preserving stack components and item limits. Route existing inventory callers through that state.

Co-Authored-By: Codex <[email protected]>
2026-11-04 04:26:12 +01:00
Cheezer1656andClaude Opus 5.5 6ebcff17a2 Native mods: native_send_packet sends a play packet to the server
A client mod can now send any serverbound play packet, a plugin channel's
custom payload included, to any server, a vanilla one too. mod_api
re-exports the packet type as ServerboundPlayPacket. An error before the
player is in a server's play state, and for a custom payload past
ServerboundCustomPayloadPacket.MAX_PAYLOAD_SIZE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011R7zRSugxibySUu8PosW5U
2026-11-03 20:17:07 -07:00
Loki e8794c917c Merge remote-tracking branch 'origin/main' into fix/8934-elder-guardian-home 2026-11-04 12:14:42 +09:00
LokiandAntigravity f77fd4fbed mobs: set Elder Guardian wander restriction on ai.home (fixes #8934)
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 12:14:12 +09:00
Florian ERNSTandCodex eacb3e16a8 Preserve axolotl hunting memory values and lifetimes
Co-Authored-By: Codex <[email protected]>
2026-11-04 03:56:54 +01:00
Ludovic MarieandClaude Opus 5.5 775bdc3c3e Mobs: a zombie's turtle egg search asks the section palettes first
RemoveBlockGoal.canUse walks every cell within 24 blocks and a few
heights of the zombie (about 17,000 of them) for a turtle egg, once
every 10 to 20 seconds per zombie: a server's thousands of zombies
walked over a hundred thousand cells a tick, nearly always to find
none. The walk is passed over when no section of the cells it reaches
has a turtle egg in its palette, which is when it would find nothing;
anywhere else it walks as before. The walk and its bounds are split
out of find_nearest, and a test checks every walked cell is in them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 03:52:20 +01:00
Nikita Maximilian Grimm 0d4e0a1b1d Merge remote-tracking branch 'origin/main' into offscreen-hdr 2026-11-04 03:48:24 +01:00
Delete228andCodex 8c943fb1ac Refresh damage definitions when opening another world
Keep removed definitions frozen in the current session and rebuild the next catalog from current resources. Cover resource removal, reload and fresh-world behavior.

Co-Authored-By: Codex <[email protected]>
2026-11-04 05:48:23 +03:00
Nikita Maximilian Grimm e96694c5e8 Merge remote-tracking branch 'origin/main' into translucent-batching-checkbox
# Conflicts:
#	crates/horizon-client-render/src/graphics_ui.rs
2026-11-04 03:48:10 +01:00
Ludovic MarieandClaude Opus 5.5 47a372677c Mobs: a looting reach with no dropped item near it skips the item scan
Mob.aiStep's looting block, the foxes' item search and the dolphins'
tested each mob's reach against every dropped item of the level, so a
server's thousands of mobs times its eggs and drops made a quadratic
scan every tick. The items are now kept in 8-block cells for each pass:
a reach that meets no occupied cell meets no item, and its scan (which
would find nothing) is skipped. A reach near an item scans as before,
in the same order with the same tests, and the cells are rebuilt as
soon as an item is spawned or taken during the pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 03:43:46 +01:00
Ludovic Marie eaa7da4fb1 Merge pull request #8684 from NikitaMGrimm/hdr-16-bit-frame
🤖 Agent: HDR off or Smooth Gradients off keeps main's frame format at every site (checked each one); format selection now in tested functions; fmt, clippy and the touched crates' tests pass on the desktop at b750f4d7e0.
2026-11-04 03:40:38 +01:00
Delete228andCodex 4ad8604280 Load world damage definitions and reload their frozen registry tags
Preserve ordinary player damage, enchantment predicates and synchronized registry data for enabled resource/data packs. Validate definitions before publication and keep world definitions stable through reload.

Co-Authored-By: Codex <[email protected]>
2026-11-04 05:40:37 +03:00
Ludovic MarieandClaude Opus 5.5 a7e434b1ed Carts: a ticked cart takes its loot roll instead of copying it
tick_carts ticks a copy of each cart, and the copy carried the cart's
whole loot roll: a mineshaft chest cart holds it as an NBT compound,
cloned and dropped every tick (a tenth of cart ticking with a level's
mineshafts loaded). Only opening the cart, a command or a save reads
the roll, never a tick, so the copy takes it and the stored cart gets
it back with the rest of the tick's state. The stored cart keeps its
slots meanwhile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 03:40:27 +01:00
TheMrZZandCodex gpt-6.1-sol 16b49d2810 Dispatch damage commands to dropped items
Apply item Health damage with stack resistance and source eligibility,
retain its display name for lethal feedback, and reuse destroyed contents.
Cover real command results, Health, resistance, and selective removal.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 03:30:53 +01:00
Ludovic MarieandClaude Opus 5.5 b750f4d7e0 HDR frame format: select it in tested functions, fix a misplaced doc comment
draws_wide_frame and frame_format take the 16-bit frame's choice out of
Renderer::new, with tests for every input that keeps the 8-bit frame.
wide_frame had slid between paper_white_nits and its doc comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 03:30:02 +01:00
Ludovic Marie 297d91b07e Merge remote-tracking branch 'origin/main' into rv-8684 2026-11-04 03:27:07 +01:00
GabrielNichols f11f2f3736 Merge commit '367b20268dacb3c6fc2b73af18f5e5439ffd9045' into codex/port-pack-harness-verdict 2026-11-03 23:25:40 -03:00
GabrielNichols 699fde5c01 Merge upstream/main into codex/native-cache-cleanup-8028
Preserve canonical launcher translations and add cache messages to the newly supported locales.
2026-11-03 23:25:06 -03:00
Florian ERNSTandCodex 9f8dc4e9f2 Apply marker data mutations to existing entities
Co-Authored-By: Codex <[email protected]>
2026-11-04 03:23:15 +01:00
Cheezer1656andClaude Opus 5.5 9df8b23636 Native hooks: ClientChatReceivedEvent (client_chat_received)
A client mod is told of each chat line from the server just before it
shows, and may replace or cancel it: system messages (chat and action
bar), player chat and disguised chat. Chat Delay's queue now keeps each
message's sender and chat type, so the hook fires when the message
leaves the queue, as vanilla's showMessageToPlayer does, and a
cancelled message lets the next one through.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:20:30 -07:00
Ludovic Marie 6d3ed570fb Merge pull request #8923 from horizon-rust/perf/village-sections-villager-slots
🤖 Agent: village_sections reads the snapshot's villager slots, same order and tests (a slot keeps its species all tick); creature span −0.9 to −6.4 ms in 3 paired runs, median MSPT flat; mob_ai, villager_farming, natural_spawn, server_entities pass on the 4-PR stack.
2026-11-04 03:17:09 +01:00
Cheezer1656andClaude Opus 5.5 332f192412 Native mods: native_send_chat sends chat or a command to the server
A client mod sends what the player could type, through send_chat
(ClientPacketListener.sendChat / sendCommand): a leading / is a command,
anything else signed chat. No client chat event and no chat history. An
error when not in a server's play state, for an empty message, and when the
Chat option blocks the message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:15:51 -07:00
Ludovic Marie 16ca83f6f3 Merge pull request #8930 from horizon-rust/fix/dedicated-sigterm-save
🤖 Agent: queued: the dedicated engine now saves players and levels on SIGTERM/SIGINT/SIGHUP like vanilla's shutdown hook; the new dedicated_crash_save check fails on main and passes here (desktop).
2026-11-04 03:08:58 +01:00
Ludovic Marie 17b23db474 Merge pull request #8912 from horizon-rust/perf/cart-collisions-box-first
🤖 Agent: box test first (same entities, same order) + no entity boxes for a zero move (vanilla collide keeps zero at zero); paired A/B: tick_carts 34.1→3.9 and 51.4→6.3 ms; entity_collision/move, mob_ai, server_entities, dedicated_server pass on the 4-PR stack.
2026-11-04 03:07:19 +01:00
Ludovic Marie 3023ce1dc9 Merge pull request #8886 from horizon-rust/perf/spawn-costs-skip
🤖 Agent: spawn_cost is None for types no biome costs, so the skipped lookup cannot change a result; paired A/B: spawn_natural −2.0 to −2.2 ms per tick at 6,200 mobs; natural_spawn, spawn_rules, finalize_spawn, mob_ai, server_entities pass on the 4-PR stack.
2026-11-04 03:07:14 +01:00
Ludovic Marie 4916b004bf Merge pull request #8885 from horizon-rust/perf/mob-bounds-one-dims
🤖 Agent: same box, one dims() per box; paired A/B on the desktop: tick_carts −1.4 ms at ~5,800 mobs; mob_ai, entity_collision/move/obstruction, server_entities, dedicated_server pass on the 4-PR stack.
2026-11-04 03:07:10 +01:00
Florian ERNSTandCodex 4891301035 Use stack durability limits for tridents
Co-Authored-By: Codex <[email protected]>
2026-11-04 03:01:19 +01:00
TheMrZZandCodex gpt-6.1-sol 993d04b098 Resolve loaded projectile origin owners by UUID
Fall back to the retained Owner UUID for execute on origin when there is
no mob/player binding. Preserve existing player, guest and mob bindings.
Cover loaded stand/pig owners and unknown/absent owner controls.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 02:53:22 +01:00
Ludovic MarieandClaude Opus 5.5 02d36e14c4 Dedicated server: SIGTERM, SIGINT and SIGHUP save before it stops
The engine run as `horizon-client --dedicated` caught no stop signal: SIGTERM or Ctrl-C ended it at
once, losing every player inventory, ender chest and chunk changed since the last autosave (up to 5
minutes). Vanilla's shutdown hook halts the server and saves on these signals. Catch them for the
dedicated role too and end the tick loop on the first one, so the normal stop saves players and
levels; a second signal still ends a stuck save at once. A signal ignored from the start (nohup's
SIGHUP) stays ignored, as the JVM leaves it.

horizon-server already turned these signals into a console `stop`; this covers hosts that run the
engine directly (panels, systemd, docker without the supervisor).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 02:48:52 +01:00
JanandClaude Sonnet 5.5 275856c8bb Native mods: a dying player's inventory goes through native_loot_drops (#8919)
Player.dropEquipment: after vanishing-curse items are gone and before the
rest is thrown, the mods may add to, change or take out the stacks. The
context names the player (entity id, minecraft:player), no table. Not
heard with keep_inventory, as vanilla keeps the items then. No new hook:
an API fragment extends native_loot_drops.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-04 02:48:23 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 19942359aa HDR: screenshots from an --offscreen run, without an HDR display
`HORIZON_HDR_OUTPUT=pq` (or `scrgb`) now offers that output to an
`--offscreen` renderer, which has no display to ask. HDR is drawn as with
a window (the 16-bit frame included) and a screenshot writes its `-hdr.png`
beside the SDR one; nothing is presented, and Auto takes 203 and 1000 nits.
A 16-bit frame with HDR off reaches the offscreen target as it reaches a
swapchain. Nothing changes without the variable or with a window.

Fixes #8879

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 02:44:01 +01:00
MegaBytesMe bc97e77d55 Merge remote-tracking branch 'upstream/main' into feat/windows-arm64-support
# Conflicts:
#	launcher/Cargo.toml
2026-11-04 01:29:10 +00:00
Florian ERNSTandCodex 8786783616 Reject special recipes in recipe commands
Co-Authored-By: Codex <[email protected]>
2026-11-04 02:26:17 +01:00
eddcd2e4e8 Send the End gateway cooldown to the window so its beam draws.
EndGatewayBlock.entityInside starts that beam through triggerCooldown's block event. The server copy was the only one cooling down, so the window kept drawing nothing.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:25:27 +01:00
a9db1d3291 Shulker: ease a teleport with vanilla's 6-tick render offset
A client shulker that changed block only slid with ordinary position
interpolation. Shulker.getRenderPosition pulls the model back toward
the block it left for 6 ticks.

Fixes #8901

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:25:24 +01:00
81eef66d1e fix: dying dragon on the window keeps its explosion and growl
EnderDragon.aiStep still growls and adds one EXPLOSION each tick after tickDeath has started the rise. The window returned before both.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:23:46 +01:00
9617eaa350 Play the end portal travel sound when level event 1032 arrives.
The traveling player was sent that event and never heard block.portal.travel.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:23:38 +01:00
a581a170ac Send a poured water bottle's splash particles to the window.
PotionItem.useOn sends five SPLASH particles with ServerLevel.sendParticles. Adding them on the server Game never reaches the client window.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:23:34 +01:00
323fde62a9 Merge upstream/main into the water-entry splash.
Main split item ticking into tick_move and tick_settle. The splash now runs on that path, before the move and again after the second fluid push.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:23:19 +01:00
29e02cb71b fix: a decorated pot's insert wobble and dust reach the window
Inserting a sherd broadcast the sound, but the block event and the dust plume stayed on the server. Vanilla wobbles through Level.blockEvent and sends the dust with ServerLevel.sendParticles.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:23:02 +01:00
Ludovic Marie 757ce1bd14 Merge pull request #8838 from contactjayclatty/fix/8823-pr-ready-crlf-shebangs
🤖 Agent: test-only change (tests/test_pr_ready.py); verify and launcher green on this head.
2026-11-04 02:22:32 +01:00
8e3c1c68b8 Slimes and magma cubes squash on the client.
AbstractCubeMob.tick updates the squish scale on the client as well as the server. The window only ran the landing splash, so the model stayed rigid.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:22:14 +01:00
TheMrZZandCodex gpt-6.1-sol e46fdc8aba Emit landing game events for items and armor stands
Track item falls until ground contact and dispatch HIT_GROUND from both
item movement and the armor stand landing path. Cover real Game dispatch
with fresh sculk listeners and matched NoGravity controls.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 02:18:02 +01:00
9c5c94fb79 Show water-entry splashes for dropped items and mobs.
Entity.doWaterSplashEffect draws bubble and splash particles on the client. Dropped items never called it, and mob particles were only added on the server, so the window never drew them.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:17:07 +01:00
Ludovic MarieandClaude Opus 5.5 14b5c7632e Carts: a cart that does not move gathers no entity boxes
move_cart resolves a zero delta to zero whatever the boxes around it
(try_resolve_step_movement returns it before reading any shape, and the
step-up needs a blocked horizontal move), so an idle cart on its rails,
like every mineshaft's chest cart, no longer scans the level's mobs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 02:14:14 +01:00
janibert1andClaude Sonnet 5.5 98f679bd84 Native mods: NativeServerPlayer carries the player's IP address
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-04 02:11:31 +01:00
c60ce00f1e Broadcast entity event 70 when a TNT minecart is primed.
The window's cart kept its fuse at -1, so it never showed the smoke, the white flash or the swell.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:10:40 +01:00
Florian ERNSTandCodex e7db06bfca Count only changed mob effects in effect give
Co-Authored-By: Codex <[email protected]>
2026-11-04 02:09:25 +01:00
a12c22923f A defending fox plays entity.fox.aggro.
Fox.aiStep plays FOX_AGGRO through the server's playSound, so a server-thread player hears it at the fox.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:08:00 +01:00
505b6d2f12 A rabbit hop spawns the block-dust sprint particle
Vanilla Rabbit.handleEntityEvent(1) calls spawnSprintParticle and then
starts the hop clocks. The client played the hop and skipped the crumb.

Fixes #8867.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:07:56 +01:00
b5eee520e9 fix: primed TNT on the window shows its fuse smoke
PrimedTnt.tick adds one SMOKE particle each lit fuse tick on the client. The window ticks that fuse but never spawned the particle, and the server's Level.addParticle does not reach it.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 02:06:31 +01:00
Ludovic Marie 9abb7dc50f Merge pull request #7769 from martipops/split/fresh-launcher-accounts
🤖 Agent: reviewed the splitscreen account handoff; fixed sign-in-required accounts being handed over, the refresh lock, and leftover session files; desktop launcher + game crates green
2026-11-04 01:59:44 +01:00
contactjayclattyandOpenCode 5470844f14 mobs: environmental damage does not dim a glow squid
Squid.hurtServer reports a hit only when a living entity caused it, and GlowSquid.hurtServer dims the squid only when that returns true. Horizon dimmed on every hit, so a glow squid on a campfire, suffocating or drying out lost its glow.

Fixes #8724

Co-Authored-By: OpenCode (Jay's Unintelligence) <[email protected]>
2026-11-04 08:43:44 +08:00
Florian ERNSTandCodex 414e7a5c07 Preserve TTL on axolotl integer brain memories
Co-Authored-By: Codex <[email protected]>
2026-11-04 01:42:54 +01:00
deleteandCodex c57982648b Merge current Horizon main for native mod API review
Resolve adjacent, independent mob test modules while preserving both test suites.

Co-authored-by: Codex <[email protected]>
2026-11-04 03:37:34 +03:00
Ludovic MarieandClaude Opus 5.5 cbb81c5db4 Format and a clippy lint
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 01:34:12 +01:00
Ludovic MarieandClaude Opus 5.5 55dcb11813 Mobs: a zombie looking for a village reads the villagers' slots only
MoveThroughVillageGoal.canUse (and the villagers' own village checks)
gathered the village sections from every slot of the tick's snapshot,
so each zombie at night read every loaded mob. The mob ticks now keep
the slots holding a villager (a slot keeps its mob and species all
tick) and village_sections reads those, in the same order, with the
same alive and POI tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 01:33:43 +01:00
Ludovic MarieandClaude Opus 5.5 c8f7583036 Natural spawning: no biome lookup for a type no biome gives a spawn cost
NaturalSpawner.createState asks every loaded mob's biome for its type's
spawn cost each tick, and spawning asks again at each tested position.
Only a few nether types have one in any biome, so for the others the
answer is none wherever they stand: the zoomed biome lookup (the
fiddled BiomeManager zoom, then the chunk) is skipped for them. The
types are read from the level's biomes each pass, mods' included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 01:33:43 +01:00
Ludovic MarieandClaude Opus 5.5 4f35e0d6f8 Carts: test an entity's box before the rest of its collision tests
Each cart, each tick, collected the box of every cart, boat and mob of
the level (with a mob's pushable state) and kept only those within 2
blocks of it, so a server's thousands of mobs cost every cart a full
scan of allocations and pushable tests. The box test now comes first,
in cart_entity_collisions and in pickup_from_cart's candidates: the
same entities pass, in the same order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 01:33:43 +01:00
TiggerTakkyTTTandCodex 6b8bd75de7 Merge resolved view distance branch into simulation distance management
Carry current main, spawn protection, and view distance into the stacked simulation-distance PR while retaining every management route and harness case.

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 07:31:29 +07:00
Ludovic MarieandClaude Opus 5.5 9b05e6d523 Splitscreen accounts: never hand over a sign-in-required account, leave no session file behind
- The launcher's other-account handoff skipped the services' check for a token with time left,
  so an account marked sign-in required (a refused refresh, a token for another profile) could
  still be handed to a splitscreen player. Such accounts are now always skipped (Offline).
- Renewing the other accounts takes the account-operation lock, as every renewal does, so a
  rotated refresh token is not lost to another launcher renewing the same account.
- Session files written for a launch are deleted once the game would refuse them (2 min), when
  the launch fails before the game starts, and when the game exits; the sessions folder is
  0700 on Unix. The game consumes every listed file, even for an entry it then ignores.
- The splitscreen server rule (#6399) is a pure function with a test: offline profiles join only
  home-network offline-mode servers; a Microsoft session lifts that while it holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 01:31:09 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 9ad64d72c3 Boats never take a Warden or Wither aboard, like Entity.canRide
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 19:31:05 -05:00
contactjayclattyandOpenCode 726cd34fb2 mobs: the wither takes no potion effect
WitherBoss.addEffect returns false, so nothing lands on the boss. Horizon refused only the wither effect itself (the WitherSkeleton rule), so splash potions, area clouds, /effect and tipped arrows all worked on it.

Both paths that add an effect now refuse every effect for the wither; the wither skeleton keeps its own rule.

Fixes #8776

Co-Authored-By: OpenCode (Jay's Unintelligence) <[email protected]>
2026-11-04 08:29:31 +08:00
TiggerTakkyTTTandCodex afc9344898 Merge resolved spawn protection branch into view distance management
Carry current main and the spawn-protection management route into the stacked view-distance PR while preserving both parity harness ranges.

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 07:28:52 +07:00
TheMrZZandCodex gpt-6.1-sol 1ae3ca6b1c Tick Allay duplication cooldowns with NoAI
Share only the positive cooldown decrement with the NoAI path and cover
both AI modes and signed loader controls on real dedicated servers.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 01:28:07 +01:00
Cheezer1656andClaude Opus 5.5 437f078e47 Dispatch tests: stub ClientboundPlayPacket for client_packet
The dispatch fixture compiles every generated hook against its own stubs;
client_packet names ClientboundPlayPacket, which it lacked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011R7zRWRvULWvkt2DSEw3VN
2026-11-03 17:27:37 -07:00
TiggerTakkyTTTandCodex 4a2713d4e5 Merge upstream/main into spawn protection management
Preserve the spawn-protection management path alongside the operator, status reply, and online-player visibility management changes now on main.

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 07:26:15 +07:00
Ludovic Marie 14cb95e5dd Merge remote-tracking branch 'origin/main' into rv/7769 2026-11-04 01:25:35 +01:00
deleteandCodex a5f384f89c Discard native controlled particles on resource reload
Keep owned sprite-set indices from referring to a different declared particle after the resource sets are rebuilt.

Co-authored-by: Codex <[email protected]>
2026-11-04 03:25:14 +03:00
Florian ERNSTandCodex ea85770a6f Omit supplied parrot Age from saved NBT
Co-Authored-By: Codex <[email protected]>
2026-11-04 01:19:27 +01:00
Delete228andCodex 66c3284171 Expose controlled particle visibility without removing its state
Keep hidden controller ticks and population accounting while emitting no geometry, matching visibility-gated source particles.

Co-Authored-By: Codex <[email protected]>
2026-11-04 03:16:14 +03:00
contactjayclattyandOpenCode 4a4ec9a5ff mobs: a zero-duration effect is over, not infinite
LivingEntity.tickEffects drops an effect whose duration is 0, because MobEffectInstance.hasRemainingDuration is false there. Horizon counted it down from 0 to -1, the infinite-duration sentinel, so an effect loaded with duration 0 stayed forever and even took its first tick.

Fixes #8721

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 08:14:47 +08:00
Lyvt-DevandCodex 5db48acf25 Fix Warden collision pushability while emerging or digging
Fixes #8832

Co-Authored-By: Codex <[email protected]>
2026-11-04 01:11:15 +01:00
TiggerTakkyTTTandCodex 5657fabce7 Server API: pin backup replies
Add JSON golden cases for backup listing, creation, download, deletion, whole-world restore, dimension restore, and representative errors. Exercise streamed download bytes and normalize only the generated backup name.

Fixes #8839

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 07:07:49 +07:00
contactjayclattyandOpenCode 46387fb392 game: a wither never rides a minecart
WitherBoss.canRide returns false, so OldMinecartBehavior.pushAndPickupEntities cannot seat the boss in a moving cart. can_ride_cart excluded only the warden.

Fixes #8790

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 08:07:20 +08:00
HrafnaValkyrjaandClaude Sonnet 5.5 1c57d808d8 Shadows: only blocks with a full collision shape take a mob shadow
A hopper, cauldron or composter reaches y=1 with its rim, so its collision
bounds looked like a full block and a mob shadow was drawn across the top.
EntityRenderer.extractShadowPiece draws a shadow piece only when the block
below isCollisionShapeFullBlock; the same gate now applies here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 19:07:17 -05:00
HrafnaValkyrja ae59c5dde7 Merge remote-tracking branch 'origin/main' into fix/portal-parity 2026-11-03 19:06:51 -05:00
HrafnaValkyrja f4f901f2b1 Merge remote-tracking branch 'origin/main' into fix/carpet-dupe 2026-11-03 19:06:26 -05:00
Florian ERNST 7b47de35c3 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/noai-animal-love 2026-11-04 01:04:38 +01:00
Delete228andCodex d6700da628 Allow owned native particle tick controllers in the ordinary engine
Retain sprite sets, particle setting and distance gates, interpolation, lifetime helpers, blending and normal population limits. Verify owned clone state, movement, expiry, sprite frames and rendered alpha/layer.

Co-Authored-By: Codex <[email protected]>
2026-11-04 03:04:19 +03:00
HrafnaValkyrja c9ecd5d900 Merge remote-tracking branch 'origin/main' into fix/portal-parity
# Conflicts:
#	crates/horizon-client/src/game/dedicated.rs
2026-11-03 19:03:30 -05:00
HrafnaValkyrja 47b64a3288 Merge remote-tracking branch 'origin/main' into fix/carpet-dupe
# Conflicts:
#	crates/horizon-world/src/level/tests.rs
2026-11-03 19:03:30 -05:00
JoansitohandGrok f4196f750c Merge upstream/main into the Space-focus branch.
The overlap-note check timed out and a rerun needs admin
rights, so this push starts it again on current main.

Co-Authored-By: Grok <[email protected]>
2026-11-04 01:03:11 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 6fabf6d69a Server API: list and read crash reports, and a panel section for them
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 19:02:42 -05:00
Florian ERNSTandCodex c600fea060 Tick animal love counters when AI is disabled
Co-Authored-By: Codex <[email protected]>
2026-11-04 01:02:24 +01:00
JoansitohandGrok bb152dd336 Merge upstream/main into the health-boost clamp branch.
CI compiled this branch against a main that still built the
composite shader test's Resources without its rgb field.

Co-Authored-By: Grok <[email protected]>
2026-11-04 01:02:19 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 ff02139e7f Panel: Ban and Pardon for players, and a list of banned players
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 19:01:29 -05:00
Ludovic MarieandClaude Opus 5.5 3f98dd1ca6 Mobs: a box reads the mob's size once
Mob::bounds_at called width() and height(), and each ran the whole
dims() (the sleeping check, the species' baby, pose and scale rules).
Every box asked for (collisions, pushes, cart and spawn tests) paid it
twice. Same f32 rounding as before, so the same boxes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:59:07 +01:00
contactjayclattyandOpenCode 9870e9faac test_pr_ready: run the launcher snippet's fakes on Git Bash
On Windows the launcher fallback test fails: the fake uname, xcrun, pkg-config and cargo scripts are written in text mode, so their shebang is #!/bin/sh\r, and Git Bash puts /usr/bin ahead of every Windows PATH entry, so the real uname answers MINGW64_NT-... and the macOS fallback never adds --features gpui_platform/runtime_shaders.

Write the scripts with LF, and on Windows prepend their folder to PATH inside the shell, where Git Bash cannot reorder it.

Fixes #8823

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 07:57:16 +08:00
Cheezer1656andClaude Opus 5.5 2e669e614d Native mods: a client hook point that reads the server's play packets
native_client_packet hears every play packet the window's game handles,
decoded and in order, just before it is handled: handle_packet, plus the
chunk and ForgetLevelChunk paths in poll_remote that bypass it. It only
reads. mod_api re-exports the packet enum as ClientboundPlayPacket so
trait mods can name it.

Closes #8820

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:55:03 -07:00
TiggerTakkyTTTandCodex 50888d15bc Server API: pin lifecycle replies
Add reusable JSON golden cases for the stable status, metrics, logs, start, stop, and restart response contracts. Normalize only release, process, and elapsed-time values while checking their types.

Fixes #8816

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 06:51:24 +07:00
Delete228andCodex 31ce9b1787 Support named native mob damage types and combat classifications
Preserve custom source IDs through ordinary mob damage, protection, immunity, knockback and loot paths. Skip enchantment protection for bypasses_effects damage.

Co-Authored-By: Codex <[email protected]>
2026-11-04 02:48:49 +03:00
Florian ERNSTandCodex 154d703413 Resolve turtle summon home before applying command position
Co-Authored-By: Codex <[email protected]>
2026-11-04 00:44:43 +01:00
AfrobamaYTandClaude Opus 5.5 4f6a3fc20c Native mods: a camera to draw the world from (native_camera_pose)
A client mod can move and turn the camera a frame is drawn from: after
vanilla placed it, native_camera_pose gets its position, yaw and pitch and
may set them (a cinematic, replay or film camera). The picture, culling, the
sound's listener and what faces the camera follow it; the player, their aim,
the hand and chunk loading stay the player's. Values that aren't finite keep
vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:38:35 +01:00
gorkandClaude Code d179172286 docs: refresh stale shader API documentation
Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:38:07 +01:00
gorkandClaude Code 62e5ef76fe docs: refresh README project status numbers
Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:36:28 +01:00
gorkandClaude Code b49b1f8f15 docs: refresh hosting command and option reference
Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:35:24 +01:00
Florian ERNST 312885da94 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/command-cart-loaded-success 2026-11-04 00:34:47 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free f4d073f8d0 Add Vèneto (vec_it) game translation
Bundles Horizon's own Vèneto strings so vec_it players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:34:13 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3cfd9dac2d Add Vèneto (vec) launcher translation support
Registers Vèneto as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:33:23 +01:00
Florian ERNSTandCodex 7ff407670a Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/command-cart-loaded-success
Co-Authored-By: Codex <[email protected]>
2026-11-04 00:32:57 +01:00
rdbtCVSandCodex gpt-6.1-sol b5635af3b1 perf(server): share item defaults while resource tables load
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 23:32:50 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free baf34b068e Add Ślōnskŏ (szl) game translation
Bundles Horizon's own Ślōnskŏ strings so szl players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:31:25 +01:00
Florian ERNSTandCodex 478bdb0d27 Reset loaded command minecart successes through command sync
Co-Authored-By: Codex <[email protected]>
2026-11-04 00:30:37 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8783d772ed Add Ślōnskŏ (szl) launcher translation support
Registers Ślōnskŏ as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:30:05 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 79358b516e Add Davvisámegiella (se_no) game translation
Bundles Horizon's own Davvisámegiella strings so se_no players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:28:06 +01:00
TheMrZZandCodex gpt-6.1-sol 7800d0e905 Normalize invalid tropical fish NBT color bytes
Decode full packed color bytes with the DyeColor zero fallback and cover
valid, invalid and unknown-pattern controls on real dedicated servers.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 00:27:51 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8209baacd7 Add Davvisámegiella (se) launcher translation support
Registers Davvisámegiella as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:27:16 +01:00
gorkandClaude Code 7edc16e18d perf: reduce overhead in audio mixing
Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:26:08 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 822fc1ae0e Add Саха тыла (sah_sah) game translation
Bundles Horizon's own Саха тыла strings so sah_sah players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:24:42 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 92043991e5 Add Саха тыла (sah) launcher translation support
Registers Саха тыла as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:23:49 +01:00
gorkandClaude Code d874278161 perf: reduce texture atlas memory
Dedupe still textures by a hashed key instead of storing their full
frame bytes, and avoid retaining intermediate paletted atlas frames.

Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:22:27 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free fe72ef9ce5 Add Occitan (oc_fr) game translation
Bundles Horizon's own Occitan strings so oc_fr players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:21:37 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 9623d41ffd Add Occitan (oc) launcher translation support
Registers Occitan as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:20:49 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 460f3c86db Add Lombard (lmo) game translation
Bundles Horizon's own Lombard strings so lmo players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:18:43 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 1cb4e91b76 Add Lombard (lmo) launcher translation support
Registers Lombard as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:17:49 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 87d61a948e Add Limburgs (li_li) game translation
Bundles Horizon's own Limburgs strings so li_li players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:15:34 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2b3854f081 Add Limburgs (li) launcher translation support
Registers Limburgs as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:14:00 +01:00
rdbtCVSandCodex gpt-6.1-sol 6fd33b70f7 perf(server): measure fonts without building a texture atlas
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 23:13:50 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 c726e412a1 Translucent Batching: the box reads Off where the GPU can't batch, as its label does
Fixes #8759.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:13:16 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d7fee590de Add Furlan (fur_it) game translation
Bundles Horizon's own Furlan strings so fur_it players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:11:49 +01:00
gorkandClaude Code 134096b5cf perf: reduce allocations in mob pathfinding
Replace per-node FxHashMap allocation in swim pathfinding with a fixed
six-element array, preserving traversal behaviour.

Co-Authored-By: Claude Code <[email protected]>
2026-11-04 00:10:39 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 7148c0771a Add Furlan (fur) launcher translation support
Registers Furlan as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-04 00:10:12 +01:00
rdbtCVSandCodex gpt-6.1-sol 09dd6322b4 perf(server): skip custom item textures during item loading
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 23:06:14 +00:00
itarqos5andClaude Opus 5 d934726850 test(lod bench): report how many spans a far column holds, not just the mean
`lod_grid_bench` printed a mean of 5.3 to 5.9 spans a column. A mean
alone says nothing about the shape of the distribution, which is what
decides whether a column's spans fit a fixed inline capacity or take a
heap block; sizing `Spans` off the mean would be a guess.

Print the distribution beside it, with a running total so the share that
fits any given capacity can be read straight off.

Measured at seed 0, 40 tiles a level, Full far detail: four spans or
fewer covers 38% of columns at level 0, 28% at level 2, 26% at level 4
and 32% at level 6, with a tail reaching 14 to 16.

These are the spans worldgen returns, before the client's `shade_sky`
splits fluid and leaf runs a light level at a time.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-03 23:01:49 +00:00
itarqos5andClaude Opus 5 ec5b8b4c31 perf(far terrain): reduce a far chunk only to the cell width that reads it
A chunk's detail arrived as `ReducedColumns`, which reduced the 16x16
columns to cells 2, 4, 8 and 16 blocks wide up front: 85 columns beside
the chunk's 256, and four passes of `Column::reduced` over every span of
every footprint.

One chunk is covered by one far tile, so one of the four widths is what
gets read, and a chunk whose detail arrives while full-detail terrain
still draws it reads none. Building all four cost 405 us of a far
terrain thread for every chunk the world sent (measured over 200 chunks
of 5.7 spans a column, the mean `lod_grid_bench` reports); reducing on
the first read of a width costs 103 us, and nothing at all for a chunk
no far tile draws reduced.

`OnceLock` a level each, so a second mesh job reading the same width
takes the reduction already built rather than racing to repeat it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-03 22:59:03 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 485c2f56ea HDR: Smooth Gradients' box unticked where the GPU can't, and the visibility buffer in the frame's format
The checkbox read the saved value; it now reads Off there, as its label does. HORIZON_VISBUFFER's resolve
pass draws into the frame, so it takes the frame's format. The float-frame readback test goes until a float
frame does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:58:43 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 33f9fbfda0 HDR: grey Smooth Gradients out where the GPU can't draw the 16-bit frame
Like Translucent Batching: the saved On reads Off there and waits for a device that can.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:52:29 +01:00
Florian ERNST 2e50b8ed7d Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/noai-ravager-counters 2026-11-03 23:47:27 +01:00
Florian ERNST beb1c5e150 Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/noai-ravager-counters 2026-11-03 23:42:52 +01:00
Florian ERNSTandCodex 3fed8554f1 Merge latest main projectile updates before validation
Co-Authored-By: Codex <[email protected]>
2026-11-03 23:42:51 +01:00
Florian ERNSTandCodex ecbfc14d52 Merge latest main before H179 validation
Co-Authored-By: Codex <[email protected]>
2026-11-03 23:40:59 +01:00
Florian ERNSTandCodex 526aadb20a Tick ravager attack stun and roar counters without AI
Co-Authored-By: Codex <[email protected]>
2026-11-03 23:40:42 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 c1ab6def52 LAN test: fill the socket until the outbox waits, so the disconnect waits on Windows too
Windows takes a write of any size whole while its buffer has room, so the 64 MiB the test sent all went
through and the disconnect with it. Fixes #8734.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:37:05 +01:00
HrafnaValkyrja 2082bd559c Merge remote-tracking branch 'origin/main' into fix/entity-save-rest 2026-11-03 17:32:05 -05:00
Torin SappandClaude Opus 5.5 0954041bd5 Content: read every declaration through one reader
Content::check read each declared block, item, mob effect, creative tab,
entity type and menu with Content::file, which opens the mod's archive
again and parses its whole central directory, unbuffered, every time.
Loading a packaged mod was quadratic in its size. Checking chipped's
.hmod (6,981 blocks, 58,563 entries) took over 20 minutes; with one
reader it takes 2 seconds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 15:27:22 -07:00
AxxioandAntigravity 49f43c7569 Sniffer digging sound event 63 and silent particles (fixes #8628)
Sniffer.onDiggingStart broadcasts entity event 63 so clients play
SnifferSoundInstance. Sniffer.emitDiggingParticles emits block dust
regardless of silent state.

Co-Authored-By: Antigravity <[email protected]>
2026-11-03 23:20:13 +01:00
degepie f23ab6c91e Merge remote-tracking branch 'origin/main' into fix/bare-ids 2026-11-04 03:16:59 +05:00
Nikita Maximilian GrimmandClaude Opus 5.5 3a801dcc57 HDR: Smooth Gradients under the HDR switch, said by what it does
Calibrate's "the three below" again means the brightness settings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:16:22 +01:00
Stink-O 458199f845 Merge branch 'main' of https://github.com/ludovic111/horizon into native/far-terrain-edits 2026-11-03 22:13:16 +00:00
degepie 0daeda2021 Merge remote-tracking branch 'origin/main' into fix/throw-bucket-triggers 2026-11-04 03:12:26 +05:00
HrafnaValkyrjaandClaude Sonnet 5.5 4a30280e22 Let a finished moving piston support what is attached to it
A moving block at full progress now supports like the block it carries, as vanilla's
moving block entity does (getExtendedProgress is 0 at progress 1.0). Wall coral fans
and the carpets on them survived in vanilla when a slime column landed; here the fan
popped because the slime block behind it was still a moving piston, so the carpet
duplication machine had nothing to duplicate.

Fixes #8490

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 17:00:54 -05:00
Nikita Maximilian GrimmandClaude Opus 5.5 ee8c5b7b13 install.sh signature test: skip on Windows, where install.sh doesn't install
Fixes #8708.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:59:16 +01:00
CocofireHDandHermes 0a5b6efe10 Commands: /attribute resolves its target in the target's level
`cmd_attribute` looked the entity up in the command source's level only, while
a selector with no spatial constraint searches every loaded level
(`EntitySelector.findEntities`). A target in another dimension therefore came
back as an id the level did not hold: the generic path answered "Entity
entity. has no attribute <name>" and max_health, movement_speed and
attack_damage silently did nothing.

Run the attribute work with the target's level in place through the existing
`in_target_level` helper, the one /data, /kill and /tp already use.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 22:57:04 +01:00
JoansitohandClaude Sonnet 5.5 9dc679d49e Play the bow and crossbow shot sounds at the shooter's feet, as vanilla does (#8448)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 22:50:45 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 31ee745f7e Minecarts: chest and hopper carts use the container slowdown
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 16:47:47 -05:00
Nikita Maximilian GrimmandClaude Opus 5.5 f437985e02 HDR: a Smooth Gradients switch for the 16-bit frame, on by default
On the HDR page, after Calibrate. Off, the frame stays 8-bit with HDR on, as
before. Like Translucent Batching it applies at the next start or resource
reload, and is no part of a preset. Saved as hdrSmoothGradients; the two new
strings are in en_us only, for the translators.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:45:21 +01:00
martipopsandClaude Opus 5.5 ef242dfe34 Merge upstream main into launcher accounts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:44:02 -05:00
JoansitohandClaude Sonnet 5.5 f4b991cc51 Nautilus Breath applies ambient in every game mode (#8594)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 22:43:08 +01:00
NerfPhaselock 6ea9e9608f Merge remote-tracking branch 'upstream/main' into perf/gpu-culling-g1b
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-11-03 16:39:17 -05:00
Teiwazik 1c88130124 Merge remote-tracking branch 'origin/main' into fix/leave-server-sounds 2026-11-03 22:39:08 +01:00
ErikKalikasandClaude Opus 5.5 9841d6bffd Native mods: a mod menu type opened with extra data (openMenu with an extra-data writer)
A mod block opening its content mod's menu type asks native_menu_extra_data for bytes
of the mod's own; a horizon:menu_extra payload carries them with the window and the
block's position to Horizon clients right before OpenScreen. The client takes the
block from it instead of the last clicked one, and native_screen_pos /
native_screen_extra_data read both while the mod's screen is open.

Fixes #8675

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:28:50 +02:00
itarqos5andClaude Opus 5 e602ed84ac fix(heap census): read thread names off Windows, and name the loaders
`horizon-ctl memory` falls back to a thread's name for allocations made
outside any step, span or job. On Windows `thread_name` returned nothing
at all -- the stub for every platform without `pthread_getname_np` -- so
every untagged allocation on every thread fell into one `unnamed thread`
bucket. That bucket was 580 MiB of a playing game's 1219 MiB resident
set: the largest of all of them, and useless, because it named no thread.

Rust keeps the name `Builder::name` set in its own thread handle, so the
fallback reads it from there. No new dependency, and nothing new in a
release binary: `GetThreadDescription` would have wanted Windows 10 1607
and an import in every build, where this is the name Rust already holds.

The resource loaders had no name to read either: they are spawned through
`Step::spawn` and bare `scope.spawn`, which leave a thread unnamed. They
go through a new `named_scope`, which covers the eight core steps, the
five world-rest loaders and the panorama. The job pool already named its
workers, and so did far terrain.

What the same run reports now, where it reported one 580 MiB bucket:

| MiB | tag |
|---:|---|
| 328.2 | Far sampling |
| 76.4 | unnamed thread |
| 52.0 | Far decoration |
| 35.4 | Far terrain |
| 13.9 | Far meshing |
| 12.4 | World data preload |
| 8.2 | Worldgen loader |
| 8.0 | main |

Far terrain holds ~430 MiB of the heap across its four threads, beside
the 661 MiB of far-tile vertices already measured in video memory. None
of that was visible before.

The startup trace gains the same names: 13 of its 17 lanes read
`unnamed`, which a trace viewer cannot lay out usefully.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-03 21:28:16 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 ba9dcb41e4 Move riders with a teleported vehicle, rotate without teleporting, dismount at source, add the player boarding cooldown
Entity.teleportPassengers: teleporting a boat, minecart or mob takes its mob,
player and guest riders along at once. /rotate is Entity.forceSetRotation:
rotation fields only, pitch clamped before the setter. Ender pearl and bed
entry stop riding at their source instead of at guest-turn teardown. Players
and guests get Entity.boardingCooldown (60 ticks) checked before boarding.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 16:27:07 -05:00
CocofireHDandHermes a1c4fa382e Mobs: a digging turtle sends the level event it is supposed to
`Turtle.aiStep` broadcasts LevelEvent 2001 while it digs; the handler drew the
particles into the local buffer instead, which a serving client never renders
and no client hears. Route it through `level_event_at(2001, ...)` like every
broken block does, so the sand crumbs and the break sound both follow.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 22:23:53 +01:00
CocofireHDandHermes c0cd050fac Mobs: a remote warden's digging particles reach the client
`Warden.clientDiggingParticles` is client work, but `client_tick` discarded the
`DigParticles` events it had already computed (`animate(m, &mut Vec::new())`),
and `sync::client_tick` had no sink to hand them to. Thread the game's event
vector through; it lands in `warden_dig_particles`, which draws the 30 block
particles from the block under the warden.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 22:21:42 +01:00
TeiwazikandClaude Opus 5.5 ca17074fd9 Older servers: nested items and the song flag in item components
- jukebox_playable: EitherHolder.streamCodec is either(holder, key), so
  its flag is set for a holder; it was read the other way round, and an
  item with a song by key broke the rest of its packet (on a 1.21.11
  server a whole container_set_content at login).
- container and use_remainder (and sulfur_cube_content) hold item
  stacks: their bytes were kept as the server sent them, in its item and
  component numbering, and before 26.1 as ItemStacks (a count first). Read
  from an older server, they are now read whole and written again as
  pinned templates.
- pot_decorations writes its items' pinned ids, and a value rewritten
  this way is read back in the pinned numbering.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 22:20:51 +01:00
AkirabaneandClaude Opus 5.5 479918f6a3 A guest keeps and saves the game mode they left last
The owner already had previousPlayerGameType; a guest had no such field,
and the save removed the key, so the game mode switcher of a guest who
rejoined had nothing to go back to. A guest now keeps it as
ServerPlayerGameMode.changeGameModeForPlayer does (the mode left becomes
the previous one; the mode in use changes nothing), and its file writes it
when there is one and reads it back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:13:46 +01:00
AkirabaneandClaude Opus 5.5 10d1e900b9 A guest's save keeps the time spent frozen
PlayerMotion.ticks_frozen never reached the file: a guest who left inside
powder snow came back thawed, and one who thawed kept whatever TicksFrozen
the file it joined from had. The saved body now carries it, written only
above 0 as Entity.saveWithoutId does (the key is removed otherwise), and read
back with Entity.load's default of 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:13:46 +01:00
AkirabaneandClaude Opus 5.5 2f80af1466 A guest's save keeps the food exhaustion and the food timer
store set the food level and saturation but left foodExhaustionLevel and
foodTickTimer at 0, so a guest who left mid-sprint came back with no
exhaustion and the regeneration timer restarted. The saved body now carries
both from the live FoodData (FoodData.addAdditionalSaveData) and puts them
back on the next body.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:13:46 +01:00
AkirabaneandClaude Opus 5.5 54acf9e4be A guest's save keeps the player's motion
store never set Motion, so a guest's file always said the player stood
still. The saved body now carries the live delta movement, written as
Motion (Entity.saveWithoutId) and read back for the next body with
Entity.load's rule: a component over 10 blocks a tick reads as 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:13:46 +01:00
degepieandClaude Opus 5.5 dd3b316fc9 Advancements: looking_at sees every entity, in every trigger
PlayerPredicate.matches looks for the entity the player looks at
(ProjectileUtil.getEntityHitResult over every non-spectator entity but
the player, then hasLineOfSight) whatever the trigger. Horizon searched
only the mobs, and only for using_item, so a looking_at boat, minecart,
armor stand, frame, painting or crystal never matched, and nothing
matched in tick, location or any other trigger.

looking_at_entity now walks every entity list plus frames, paintings
and crystals, and player_entity sets looking_at for every trigger. The
per-tick tick and location triggers cast the ray only when one of their
criteria may ask (names looking_at or a reference predicate), worked
out once per tree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 02:10:03 +05:00
OpenAI Codex 11121fc59c Merge public main with visibility audit and span test isolation fixes
Co-Authored-By: Codex <[email protected]>
2026-11-03 16:08:24 -05:00
Nikita Maximilian GrimmandClaude Opus 5.5 2ff9047fff HDR: draw the frame in 16 bits when HDR is on, so dark gradients stop banding
With HDR output on at the start, the frame every pass draws into is
Rgba16Unorm instead of the swapchain's 8-bit format. It clips at white after
every pass as the 8-bit frame does, so the picture stays SDR's, but keeps
the steps 8 bits band in dark gradients, which HDR shows. Where the device
can't draw, blend and filter it, and with HDR switched on later, the 8-bit
frame as before. SDR is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:05:05 +01:00
HrafnaValkyrja 28c50a1f0b Merge remote-tracking branch 'origin/main' into fix/entity-save-rest 2026-11-03 15:42:21 -05:00
huiandClaude Opus 5.5 d9692d81b1 pr_ready: run the Python checks beside cargo
The Python tests and the public-tree check only read files, yet cargo
waited for them: about two minutes of every run. They now run in a
thread beside rustfmt, clippy and the tests; the harnesses, which build
with cargo, still come after it, and --verbose keeps everything in
order so the output doesn't interleave.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:31:36 +01:00
OpenAI Codex e4b9dfa9a4 Format the split material census adapter
Co-Authored-By: Codex <[email protected]>
2026-11-03 15:23:32 -05:00
TeiwazikandClaude Opus 5.5 f725a92d3d Older servers: read item components whose layout changed
Three components kept their names but changed their stream codecs, so an
older server's item holding one failed to decode (with a vanilla 1.21.11
server: "1 extra bytes" and "NBT truncated" on container_set_slot):

- pot_decorations (26.3): a list of up to four item ids, a brick for a
  plain side, became four optional item stack templates;
- jukebox_playable (26.1): an EitherHolder, a flag then the song's key or
  holder, became a holder; a key has no pinned holder and is dropped;
- damage_resistant (26.1): a bare damage type tag became a HolderSet.

Two quirks say which a version has, and one reader (read_added) now
serves the slot, template and wire patch readers alike: it drops the
components the pinned version lacks, rewrites these three into the
pinned layout and reads the rest as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 21:20:06 +01:00
degepieandClaude Opus 5.5 5d6752f7bd Potion and cloud kills and falling-block hits fire their advancement triggers
A harming splash potion or lingering cloud hurt mobs through Mob::hurt, a
player_attack from the player whoever threw it, and never recorded the thrower
in last_hurt_by_player, so on the server thread the kill reached nobody's
player_killed_entity. They now deal indirectMagic(potion or cloud, owner)
(HealOrHarmMobEffect.applyInstantaneousEffect), the thrower's kill when a
player threw it, and the mob keeps the potion or cloud as the killing blow's
direct entity (Mob::last_direct).

A falling block hitting the player now fires entity_hurt_player with the
falling block as causing and direct entity (FallingBlockEntity.causeFallDamage,
LivingEntity.hurtServer).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 01:18:33 +05:00
CocofireHDandHermes be258bc235 Mobs: a mob's particles reach the clients, not a headless buffer
`MobEvent::ParticleAt` was drawn with `self.particles.add` only, and a serving
client has no window: the squid's 30 ink particles never left the server. Send
them as packets like every other server-thread particle site; with count 0 the
packet's deltas are the particle's motion.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 21:12:25 +01:00
OpenAI Codex 797d6d0ae9 Count all split material textures in the new GPU census
Co-Authored-By: Codex <[email protected]>
2026-11-03 15:11:37 -05:00
TeiwazikandClaude Opus 5.5 7140f3cbd2 Older servers: read past the item components 26.3 dropped
26.3 removed the map_color and swing_animation data components. A 1.21.x,
26.1 or 26.2 server still sends them in item patches (an explorer map's
color, a 1.21.11 weapon's swing), and the item readers, which renumber
the component id to -1 there, could not read the value: the whole packet
was skipped, so a container holding such an item showed empty (seen on a
Folia 1.21.11 server: container_set_content, set_entity_data and
update_advancements undecodable).

The registry id generator now lists the components an older version has
and the pinned one lacks, and the item readers read their values past in
that version's layout (an int; two varints) and drop them, from added and
removed components alike. tooltip_display's hidden component ids are
renumbered as well, both ways.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 21:10:39 +01:00
CocofireHDandHermes 61791d3e20 Mobs: a nautilus answers its own food, and a tame one is mounted
Neither nautilus had an arm in `remote_mob_takes`, so a tame adult's mount or
screen click fell through, and the generic `Animal.mobInteract` arm claimed any
`kind.food` item for an untamed one although `AbstractNautilus.mobInteract`
only takes `#minecraft:nautilus_taming_items` until it is tamed.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 21:10:34 +01:00
GabrielNichols 06f5846948 Merge remote-tracking branch 'upstream/main' into codex/port-pack-harness-verdict 2026-11-03 17:08:33 -03:00
CocofireHDandHermes 7d59356e42 Mobs: the golems answer a right click
`IronGolem.mobInteract` takes an iron ingot on a damaged golem and
`CopperGolem.mobInteract` takes an empty hand while it holds an item or shears
on its antenna; both were missing from the client's interaction check, so the
click fell through to the item and to the off hand.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 21:07:56 +01:00
GabrielNichols 012eaf84f0 Merge upstream/main into codex/native-cache-cleanup-8028
Keep upstream palette and locale fixes, preserving the native cache messages and safety behavior.
2026-11-03 17:00:43 -03:00
JoansitohandClaude Sonnet 5.5 299b9a9ba4 Keep boosted hearts across remove/re-add modifier batches (#8573)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 21:00:32 +01:00
JoansitohandClaude Sonnet 5.5 a3f4d04bfa Clamp health in set_modifier for every max_health change (#8573)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 21:00:32 +01:00
JoansitohandClaude Sonnet 5.5 c0407fa8b7 Clamp health to max_health when effect modifiers change (#8573)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 21:00:32 +01:00
CocofireHDandHermes 13698219ce Mobs: an allay answers an empty main hand only
`Allay.mobInteract` returns SUCCESS for a holding allay only when the click
came from an empty main hand; everything else is PASS so the held item is
still used. The client answered a flat true whenever the allay held anything,
which swallowed the click, and the server's take-back path ignored the hand.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 20:57:02 +01:00
degepieandClaude Opus 5.5 02205a31e0 Advancement predicates read a bare id as a minecraft: one
A data pack that writes an id, tag or registry key without a namespace
("panda", "#planks", "the_nether", "inventory_changed") never matched:
the predicates compared the JSON string literally. Vanilla reads them
through Identifier codecs, where a missing namespace is minecraft.

`full` qualifies the id wherever a predicate compares one: id_matches
(plain id and #tag), the location dimension, entity sub-predicate keys,
effect keys, the loot condition type, the trigger name, the inventory and
recipe indexes, and recipe_unlocked / changed_dimension. damage_source's
tags[].id is a holder set (TagPredicate) and goes through id_matches.
The entity predicate's short key aliases follow from the qualification;
the non-vanilla "type" alias is gone (vanilla 26.3 refuses that key).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 00:56:01 +05:00
OpenAI Codex 6833688d8a Merge public main preserving material and RGB test modules
Co-Authored-By: Codex <[email protected]>
2026-11-03 14:53:12 -05:00
CocofireHDandHermes 710a2ad3a9 Mobs: an age-locked baby draws no happy villager particle
`AbstractHorse.handleEating` (and the llama and camel ones) gates both the age
up and the particle on `isBaby() && !isAgeLocked()`. The client's copy only
asked for `alive() && is_baby()`, so feeding an age-locked baby drew a particle
the server never agreed to.

The decision moves into `ages_on_food`, where the lock belongs.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 20:52:26 +01:00
valkdzxandClaude Opus 4.8 9ae15a3eac Elytra rocket boost: take the look vector from the float sine table
The firework glide boost in follow_own_player read the look direction from
super::view_vector (Rust f64 sin/cos), while the server derives the player's
rocket boost from getLookAngle()'s float sine table. travelFallFlying already
uses the table; this boost was the one path left on f64 trig, so boosting
under a rocket while gliding drifted the client off the server's position and
tripped the movement check. Use horizon_world::view_direction::view_vector
(the float table), matching the server and the rest of the glide. Covers the
remote path too, which now routes through follow_own_player.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-11-03 21:49:28 +02:00
TeiwazikandClaude Opus 5.5 faa8667704 Stop every sound when leaving a server
Leaving a multiplayer server goes from close_world straight to
leave_server, which never stopped the sounds: the biome's ambient loop
played on over the title screen until the next world. leave_server now
does what finish_close_world does for single player, as vanilla's
updateLevelInEngines(null) on Minecraft.disconnect: it clears the
ambience, lets the music manager go of its track and stops every sound.
A kick takes the same path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 20:49:25 +01:00
CocofireHDandHermes 2ef3c9d5f2 Mobs: a zombie horse eats a red mushroom, other horses do not
`AbstractHorse.isFood` is the `#minecraft:horse_food` tag and `ZombieHorse`
overrides it with `#minecraft:zombie_horse_food`; the food table was shared by
both, so a zombie horse refused its mushroom and a red mushroom in hand fell
through to a horse's `handleEating` and healed it.

The table moves into `eating(species, item)`, which applies the per-subclass
tag rule and the eat sound gains ZombieHorse's own. A tamed skeleton horse now
eats horse food too, as it inherits that tag (an untamed one still passes).

Co-Authored-By: Hermes <[email protected]>
2026-11-03 20:46:30 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 2a2d59e505 Keep /tp rotation on every entity kind (rotation on the Entity trait)
/tp <entity> x y z <yaw> <pitch> set the rotation only on mobs and players.
Items, orbs, TNT, falling blocks, rockets, armor stands, boats, minecarts,
projectiles and tridents dropped the pitch (and several the yaw), so
/data get Rotation read [0, 0] where vanilla reads [90, 10].

Entity gets rotation() and set_rotation() (Entity.teleportSetPosition:
setYRot, setXRot clamped to -90..90, setOldRot). The kinds with no rotation
field gain one (pitch for armor stands and boats), /tp calls the trait
method for every kind, and the entity saves write the field.

Part of #8024

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 14:42:19 -05:00
hildlabs 85e7b3bfb5 Merge remote-tracking branch 'origin/main' into shader-option-sliders
# Conflicts:
#	docs/api/shaders.md
2026-11-03 20:40:47 +01:00
CocofireHDandHermes 425c842a51 Mobs: a turtle carrying an egg refuses its food
`Turtle.canFallInLove` is `super.canFallInLove() && !this.hasEgg()`, and
`Animal.mobInteract` only eats when `getAge() == 0 && canFallInLove()`. The
food gate only looked at age and the love timer, so an egg-carrying turtle ate
seagrass, entered love mode and played the heart particles.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 20:38:21 +01:00
GabrielNichols 60bbc4f278 launcher: complete missing palette and browser translations 2026-11-03 16:37:26 -03:00
GabrielNichols a283fd939b launcher: use wardrobe visibility when listing palette pages 2026-11-03 16:37:25 -03:00
HrafnaValkyrjaandClaude Sonnet 5.5 2282df048d Console commands run in the overworld, not the level the last tick left in place
A dedicated server's console source is bound to the overworld. Horizon ran a
console line in whichever level was in place, which after a tick with forced
chunks in the nether is the nether: summons, fills and selectors landed there.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 14:34:46 -05:00
GabrielNichols 2fb4d8f7a3 fix(port-pack): reject empty and failed harness verdicts 2026-11-03 16:34:24 -03:00
itarqos5andClaude Opus 5 703ab70218 feat(import): a game build dropped on the window is imported
The launcher could already install a `horizon-client` of your own beside
the releases (`build_import`), to try a merged change before the next
Nightly, but only through the Versions page's file picker or the
`import-build` command. Dropping the program on the window answered
"horizon-client.exe can't be imported: drop a .mrpack, a .zip or a
.hshader", which is the one file the window would not take.

It now takes it. `Dropped::Build` covers the program itself, a release
archive and a GitHub Actions artifact -- a zip around the release's own
archive, which `read_program` already looks into. An `.exe`, `.gz` or
`.tgz` is taken on its extension unread, so `build_import` is the one to
say why a file by that name is not a game, which is a better answer than
"nothing in it that Horizon reads". A file with no extension (a Linux or
macOS build) and a zip nothing else claims are read and checked through
the new `build_import::holds_game`, last, because that reads the whole
program.

The drop starts the same job the picker does: `start_build_import` is
split out of `import_build` so there is one path, one `Msg::Imported`
and one guard against a second import or a removal running at the same
time.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-03 19:34:08 +00:00
JoansitohandClaude Sonnet 5.5 80aece6be0 Fix Space re-triggering a focused button after clicking a text field (#8521)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 20:32:30 +01:00
GabrielNichols c217b00548 launcher: cover cache messages in all current launcher locales 2026-11-03 16:28:22 -03:00
GabrielNichols 78dd8d2032 launcher: translate cache cleanup messages for added Hebrew locale 2026-11-03 16:26:48 -03:00
HrafnaValkyrjaandClaude Sonnet 5.5 55d3014458 Add the player's dimension to GET /api/v1/players
The status of each online player (server-status, and /api/v1/players built on it) already
carries the position; it now also carries the dimension the player is in, for example
minecraft:the_nether. The ROUTES text and the generated reference name it, and
tools/server_admin_e2e.py checks the dimension and the three coordinates on a real server.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 14:22:41 -05:00
GabrielNichols 325f338203 Merge upstream/main into codex/native-cache-cleanup-8028
Preserve current launcher changes and translations alongside safe native cache cleanup.
2026-11-03 16:19:41 -03:00
Ludovic MarieandClaude Opus 5.5 0e98e286f7 Launcher: the Shop tab, the founder set to claim for free while its window is open
The Wardrobe page gets Wardrobe | Shop tabs when the cosmetics service shows the
Shop. The Shop has the founder set: when it can be claimed until, the time left, one
Claim button, the four items on cards (Free, Owned, Equipped, Soon), and every state
the mockups list: signed out, loading, offline, service down, claiming, claimed,
owned, not open yet, a preview for one account, closed (the items are gone; owned
ones stay), the claim failing. Equip stays in the Wardrobe. No price anywhere.

HORIZON_SHOP_STATE=<state> previews each one in development builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 20:14:58 +01:00
itarqos5andClaude Opus 5 49886f7154 feat(lod): far terrain a zoom magnifies is planned finer
`Plan::visit`'s falloff keeps a far tile's cells roughly the same size on
screen, which silently assumes the field of view never changes. A
spyglass takes a tenth of it, and the zoom mod as much again: the same
tile then covers many times more pixels, so its cells look as coarse as
a tile ten times nearer would. Looking through a spyglass at the horizon
showed exactly the blocky cells it was given for an unzoomed screen.

`ZoomAim` carries what the plan needs to answer that: the view
direction, the cone the narrowed view covers, and how much finer to go.
Tiles inside the cone split further; every other tile keeps the plan it
would have had, tile for tile.

The cone is the point. Lifting the whole circle to a tenth of the cell
size asks for about a hundred times the tiles, which at the 4078 tiles
and ~1500 quads a tile measured at `lodDistance` 256 is gigabytes of
vertices. `MAX_ZOOM_BOOST` caps it at three times finer as well, so the
worst case is roughly 45% more far tiles while the zoom is held, given
back when it is released.

Reads the field of view actually in use rather than any one zoom, so it
covers all three: the spyglass (`getFieldOfViewModifier` while
`isScoping`), the zoom mod (`native_zoom`) and any mod that narrows the
view through `native_compute_fov`. The yaw is rounded to five degrees
before it reaches the plan, and the cone widened by as much, so turning
the mouse does not rebuild the quadtree every frame; the aim is part of
`Lod::update`'s area, so a zoom starting or ending replans exactly once.

The `lodZoomDetail` extra switches it off. It does nothing unless far
terrain is on and the view is actually zoomed, so an unzoomed frame
plans as it always did.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-03 19:13:40 +00:00
CorbonatandCodex b87aee2af6 Adapt glint test quad to the merged model fields
Use upstream's neutral light, color, AO, render type and shade defaults in the generated glint fixture. Preserve its geometry, UVs and assertions.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-03 22:13:07 +03:00
AkirabaneandClaude Sonnet 5.5 dd762d48ff Commands: /execute on owner no longer finds the guest in place when the owner is not in the world
`Game::target_with_uuid` answered `Target::Player` for the level's own player
UUID. During a guest's turn the player in place is that guest, so a wolf
whose owner is the world's player (and not among the players online, for
example a single-player world moved to a dedicated server) made
`execute as <wolf> on owner run give @s ...` act on whoever ran the command.

Vanilla's `on owner` is `OwnableEntity.getOwner`, which resolves the stored
UUID through the level (`EntityReference.getLivingEntity`, then the level's
UUID lookup): an owner who is not in the level gives no entity. The player in
place is now the acting guest's UUID during a guest's turn and the level's
player otherwise. An owner who is online as another guest was already found
first by `target_by_uuid`.

Fixes #8488

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 20:11:23 +01:00
degepieandClaude Opus 5.5 36b6b3e935 Thrown snowballs, eggs and pearls and bucketed mobs fire their advancement triggers
A player's snowball, egg or ender pearl that hurts a mob (0 damage
included, as LivingEntity.hurtServer reports it) now credits the hit to
the thrower and fires player_hurt_entity with the projectile as the
direct entity, as the content-mod throwable arm already did. Scooping a
fish, axolotl or tadpole into a water bucket now fires
player_interacted_with_entity with the water bucket and the mob, as
ServerGamePacketListenerImpl.handleInteract does after
Bucketable.bucketMobPickup succeeds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 00:06:36 +05:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 933a5ef900 Add Føroyskt (fo_fo) game translation
Bundles Horizon's own Føroyskt strings so fo_fo players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 20:03:07 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 7f02471f16 Add Føroyskt (fo) launcher translation support
Registers Føroyskt as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 20:02:18 +01:00
MegaBytesMe 4f1704263f feat(windows): add native ARM64 build support (#8247) 2026-11-03 19:02:08 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 9489b8ea55 Sync launcher tables with the newest strings
These tables were generated before the latest strings landed, so the
launcher completeness test fails on main and on every PR. Add the
missing translations.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 20:02:00 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 39410d1e7e Add Чӑвашла (cv_cu) game translation
Bundles Horizon's own Чӑвашла strings so cv_cu players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:58:42 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 0985acf94d Add Чӑвашла (cv) launcher translation support
Registers Чӑвашла as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:57:51 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free fc2cf9fe0c Add Brezhoneg (br_fr) game translation
Bundles Horizon's own Brezhoneg strings so br_fr players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:55:51 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 6790d55665 Add Brezhoneg (br) launcher translation support
Registers Brezhoneg as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:55:00 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free b71bbb2e02 Add Башҡортса (ba_ru) game translation
Bundles Horizon's own Башҡортса strings so ba_ru players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:53:01 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 644a335044 Add Башҡортса (ba) launcher translation support
Registers Башҡортса as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:52:11 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 4ea8c48b36 Add isiZulu (zu_za) game translation
Bundles Horizon's own isiZulu strings so zu_za players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:49:38 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 7b24627544 Add isiZulu (zu) launcher translation support
Registers isiZulu as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:48:53 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free aa7b959b0d Add Yorùbá (yo_ng) game translation
Bundles Horizon's own Yorùbá strings so yo_ng players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:47:03 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 70d475eefc Add Yorùbá (yo) launcher translation support
Registers Yorùbá as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:46:19 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 0e0ae0bf03 Sync launcher tables with the newest strings
These ten tables were generated before the latest settings, native-mod
and instance strings landed, so the launcher completeness test fails on
main and on every PR. Add the missing translations.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:45:54 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d9aa85b6a9 Add ייִדיש (yi_de) game translation
Bundles Horizon's own ייִדיש strings so yi_de players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:42:54 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 7066159406 Add ייִדיש (yi) launcher translation support
Registers ייִדיש as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:42:10 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free ae089e970f Add Татарча (tt_ru) game translation
Bundles Horizon's own Татарча strings so tt_ru players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:39:59 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2c01b2f5f5 Add Татарча (tt) launcher translation support
Registers Татарча as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:39:04 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 5d3593c1b3 Add Latina (la_la) game translation
Bundles Horizon's own Latina strings so la_la players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:35:50 +01:00
MSedgeMC e4db5de23c port_map: ExperienceOrbRenderer.getBlockLightLevel names the orb's rule
The submit rows said the billboard was inline; the light the billboard is
drawn with had no row at all.
2026-11-03 18:33:34 +00:00
MSedgeMC 8e935b200d test: pin the orb's light, its probe and its quad
A dark cell packs block light 7 where the old code packed 10 in both nibbles,
torchlight 9 clamps to 15, and daylight keeps the sky byte; the quad's four
corners resolve to a quarter of its height below the orb.
2026-11-03 18:33:34 +00:00
MSedgeMC 26261d9643 fix: an experience orb is lit seven levels above its block, and its sprite hangs right
ExperienceOrbRenderer.getBlockLightLevel is the level's block light plus
seven, clamped, with the sky light left alone; the pass raised both nibbles
to a floor of 10 instead. submit's quad runs from (-0.5, -0.25) to
(0.5, 0.75) under a 0.3 scale after translate(0, 0.1, 0), so the sprite is a
whole block tall about the orb and hangs a quarter of it below; Horizon drew
a symmetric 0.3 square 0.075 too low. The probe is the orb's eye, half its
box above its feet.
2026-11-03 18:33:34 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free f9b6a2a820 Add Latina (la) launcher translation support
Registers Latina as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:33:19 +01:00
gorkandClaude Code 23a2db75b0 ci: add concurrency guards and stagger scheduled claim run
Add PR-scoped cancellation to mod-registry and telemetry-server, per-event
concurrency to pr-labels and triage-command, and move claim-command off minute 23.

Co-Authored-By: Claude Code <[email protected]>
2026-11-03 19:30:06 +01:00
SenjinandClaude Opus 5.5 68f626d9f6 HDR: an HDR Lighting switch, on by default; off, HDR shows SDR's picture
HDR does two jobs: an HDR canvas, and Horizon's look on it. Off, nothing in the world is
lifted, light sources, the sun and sunlight included, and the lighting passes don't run, so
HDR output matches SDR: the vanilla look on an HDR desktop, and a neutral base for packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:24:21 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 35c0142f89 Add Igbo (ig_ng) game translation
Bundles Horizon's own Igbo strings so ig_ng players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:22:29 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free f68b448ef1 Add Igbo (ig) launcher translation support
Registers Igbo as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:21:47 +01:00
martipopsandCodex GPT-6 cc66b6f8b6 Merge upstream main into launcher accounts
Preserve the local-server restriction for offline profiles while allowing authenticated Microsoft accounts to join external servers. Keep both translation additions and account regression tests.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-03 13:21:25 -05:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 79836dfb8c Add ʻŌlelo Hawaiʻi (haw_us) game translation
Bundles Horizon's own ʻŌlelo Hawaiʻi strings so haw_us players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:19:11 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 201bca5d8a Add ʻŌlelo Hawaiʻi (haw) launcher translation support
Registers ʻŌlelo Hawaiʻi as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:18:23 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3deec8af4e Add Esperanto (eo_uy) game translation
Bundles Horizon's own Esperanto strings so eo_uy players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:16:33 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free cc57fd06c8 Add Esperanto (eo) launcher translation support
Registers Esperanto as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:15:19 +01:00
Federico TaglianiandCopilot App 85556f651b Accept Java-compatible streamed server packs
Co-authored-by: Copilot App <[email protected]>
2026-11-03 19:14:16 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free c6e8d9fa43 Add Malti (mt_mt) game translation
Bundles Horizon's own Malti strings so mt_mt players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:13:46 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 26207e4532 Add Malti (mt) launcher translation support
Registers Malti as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:12:59 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8a6a766e8a Add ಕನ್ನಡ (kn_in) game translation
Bundles Horizon's own ಕನ್ನಡ strings so kn_in players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:12:34 +01:00
hildlabsandClaude Opus 5.5 b5d039487b Shader API: a pack's options turn its [shadow] map off and pick its size
`[shadow] option` names the option the map is drawn while it is not 0: off,
there is no map, no shadow pass and `has_shadow` reads 0, as `[reflection]
option` does for the water mirror. `resolution_option` and `distance_option`
name an int option, or a choice whose values are numbers, whose value replaces
`resolution` or `distance`, checked against the same ranges; changing one
rebuilds the map, as any option change reloads the pack. The options sizing
the map need no `override`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:12:24 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 0f95bf2bd2 Add ಕನ್ನಡ (kn) launcher translation support
Registers ಕನ್ನಡ as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:12:05 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2c767855fa Add Кыргызча (ky_kg) game translation
Bundles Horizon's own Кыргызча strings so ky_kg players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:11:38 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 567bdff46b Add Кыргызча (ky) launcher translation support
Registers Кыргызча as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:10:52 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2fb0ab01e8 Add Lëtzebuergesch (lb_lu) game translation
Bundles Horizon's own Lëtzebuergesch strings so lb_lu players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:10:24 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 4fd3ea1b41 Add Lëtzebuergesch (lb) launcher translation support
Registers Lëtzebuergesch as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:09:41 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free f08f9e62e8 Add Gàidhlig (gd) launcher translation support
Registers Gàidhlig as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:09:07 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 53f77529f5 Add Gàidhlig (gd_gb) game translation
Bundles Horizon's own Gàidhlig strings so gd_gb players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 19:08:43 +01:00
Federico TaglianiandCopilot App fa62beefdf Reject corrupt server resource packs
Co-authored-by: Copilot App <[email protected]>
2026-11-03 19:02:42 +01:00
AkirabaneandClaude Opus 5.5 c4bc3a29d1 Worker pool: release idle generators while the client keeps meshing
After a world loads, the client keeps queueing a few meshes a second for a
minute or more (vanilla's leaf distance pass reshapes the trees). Two things
in the shared pool then kept the server's generator buffers and caches,
which nothing reads until the player moves into new ground:

- Every wake restarted every sleeping worker's idle wait, even a worker
  that got no job, so with jobs queued every few seconds no worker ever
  reached IDLE_RELEASE. A worker now counts from its own last job.
- `all_idle` (#7519 empties the generator's shared caches there) needs
  every worker idle at once, which never happens while one source stays
  busy. A source with nothing queued or running for IDLE_RELEASE is now
  told on its own: `idle` for each worker's state and `all_idle`, once
  until its next job. The release frees nothing a later job needs: the
  generator's caches are emptied whole, as at their limit (#7519).

Each freed release hands the pages back to the OS (`release_free_memory`).

Measured with the alloc-census build, seed 1234, RD 12, offscreen, 150 s
after opening a new world (two runs): stream.generate 171.8 -> 45.3 MB,
the biome cache 1366 chunks -> 0, the live heap 645 -> 527 MB, the resident
set 1063 -> 1001-1014 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:02:00 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3a17d0ae4d Fill in new launcher strings for bundled languages
Nine tables were generated before the remaining settings and native-mod
strings landed, so the launcher completeness test fails on main. Add the
missing translations.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:51:08 +01:00
Federico TaglianiandCopilot App b8740c5d9d Support minified server pack shaders
Co-authored-by: Copilot App <[email protected]>
2026-11-03 18:50:49 +01:00
LokiandAntigravity 9cda105a19 chat: include entity_types and wind charges in summon autocomplete (fixes #8419)
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 02:50:13 +09:00
ErikKalikas 9a99293888 Merge remote-tracking branch 'upstream/main' into mod-reload-listeners
# Conflicts:
#	crates/horizon-client/src/mod_api.rs
#	crates/horizon-mod-api-gen/dispatch-tests/src/lib.rs
2026-11-03 19:49:27 +02:00
OpenAI Codex 3a0132d80e Make encoded material GPU controls detect filtering and mip regressions
Strengthen only the original synthetic oracle: fractional contrasting data, hostile unused mip contents and deliberately invalid filtering sampler negative control. Production/API code is byte-identical to the already completed full-check head ec6054c.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:48:18 -05:00
AkirabaneandClaude Sonnet 5.5 ce0d76008a fix(boats): a boat in powder snow is slowed and throws snowflakes
`PowderSnowBlock.entityInside` makes any entity that is not a living one stuck
(`makeStuckInBlock` 0.9, 1.5, 0.9) and, on the server, sends a snowflake when
the entity moved horizontally. Boats did neither: no particle showed when a
boat entered powder snow, and it was not slowed (#8156).

A boat whose box, or the box it had before its move, reaches into powder snow
now keeps a stuck multiplier at the end of its tick, as `AbstractBoat.tick`'s
`applyEffectsFromBlocks` does, and its next move goes its velocity times the
multiplier and spends the velocity (`Entity.move`). The server also sends the
snowflakes, twice a tick as `AbstractBoat.tick` applies the block effects twice,
for boats a player drives as for the others. A boat that is not in powder snow
moves exactly as before. Only the boat's own box is scanned.

Refs #8156

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 18:46:07 +01:00
AkirabaneandClaude Sonnet 5.5 d056f6d157 horizon-server: Prometheus metrics by dimension (loaded chunks and entities)
The game's status now lists each level's loaded chunks and entities
(`levels`), the level in place and the parked ones counted the same way
(players and block entities apart). prometheus() turns them into
horizon_loaded_chunks{dimension} and horizon_entities{dimension}; a level
with no count has no line rather than a 0. The docs drop the item from
"Not there yet".

Fixes #8191

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 18:43:31 +01:00
Ludovic MarieandClaude Opus 5.5 f145601875 Merge main into the AvoidEntityGoal targeting fix
goals.rs: main's peaceful_targets_tests (#8046's fix) and this branch's
avoid_entity_targeting_tests sit at the file's end; both kept. Since that
fix, can_target_player already refuses a player in Peaceful through
spares_players, so the goal's own difficulty check is dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:40:50 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free b43cc79a53 Add Frysk (fy_nl) game translation
Bundles Horizon's own Frysk strings so fy_nl players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:40:05 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 56b9d1d31c Add Frysk (fy) launcher translation support
Registers Frysk as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:39:24 +01:00
Arslan ArmanulyandClaude Opus 5.5 db726a1c49 Shader packs: read shaders.properties with the feature macros, as Iris does
Iris 1.11.7 reads shaders.properties with IRIS_FEATURE_<name> defined for
the features it has, whether the pack lists them as optional, as required
or not at all (probe pack); its programs still see only the optional ones.
Horizon defined them only after reading the file, so a custom uniform or
a screen behind `#ifdef IRIS_FEATURE_*` was left out. The Shader Options
page reads the file with the same macros.

Fixes #8493

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:37:01 +05:00
Arslan ArmanulyandClaude Opus 5.5 29d8afbf25 Shader packs: smooth() fade times count tenths of a second, as in Iris
Iris 1.11.7's values in our probe fit a half-life of a tenth of the fade
time's seconds (probe pack across the scene suite's weather changes):
smooth(rainStrength, 20, 20) reads 0.87 four seconds after the rain is
full, where Horizon's fade of 20 seconds gave 0.2. Horizon's smooth()
lagged ten times behind those values.

Fixes #8492

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:37:01 +05:00
Ludovic MarieandClaude Opus 5.5 d7bf2ad63c Merge main (main's rustfmt fix for client_lib.rs, #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:34:43 +01:00
utophii fefde42033 feat: add musl linux release build 2026-11-03 20:32:47 +03:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 00e2549a20 Add Soomaali (so_so) game translation
Bundles Horizon's own Soomaali strings so so_so players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:31:03 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free a71fecab82 Add Soomaali (so) launcher translation support
Registers Soomaali as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:30:21 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free b14c46b59c Add ລາວ (lo_la) game translation
Bundles Horizon's own ລາວ strings so lo_la players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:28:43 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 090c6fdcd3 Add ລາວ (lo) launcher translation support
Registers ລາວ as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:27:58 +01:00
gorkandClaude Code cb7c5c8967 docs: reorganize CONTRIBUTING and remove stale shader packs known issue
Shader packs are back as a clean-room rewrite, so the stale removal passage and
known-issue bullet no longer apply.

Co-Authored-By: Claude Code <[email protected]>
2026-11-03 18:27:38 +01:00
Stink-OandClaude Opus 5.5 bb55cab336 Far terrain: columns a single-player server changes in bulk out of the window's view are summarized again
A column changed by World::edit_runs (a mod's native_edit_blocks) goes whole only to the players it was sent to.
The single-player window's far terrain kept drawing an unsent column as the window last saw it (Lod::saved),
so a strike edited while the player was away left the old land in far terrain until the player came within
view distance again. The hub now hands such columns to its memory-connected window (handoff::changed), which
gives them to far terrain to summarize (Lod::observe), as it does columns it unloads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:26:51 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3b45f7c91c Add Монгол (mn_mn) game translation
Bundles Horizon's own Монгол strings so mn_mn players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:26:14 +01:00
hildlabsandClaude Opus 5.5 01d6c3d88b Shader packs: a Horizon pack's float and int options are sliders
On a Horizon pack's options page a float or int option is a slider, as FOV
is, where it was a button stepping through every value. A float takes the
nearest step from its minimum (the last slot its maximum), an int whole
numbers, rounded to the f32 the pack's override gets so a slider back in the
middle is the default exactly. The value is saved, and the pack rebuilt, once
when the slider is let go, and only if it changed. Shift-click puts it back
to its default; Tab, Enter and the arrows work it as they do vanilla's
sliders, a step at a time and stopping at the ends. Bool and choice options
stay buttons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:25:16 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free e0967311bd Add Монгол (mn) launcher translation support
Registers Монгол as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:24:59 +01:00
Federico TaglianiandCopilot App a2d36b2f31 Fix Metal sprite texture array splitting
Co-authored-by: Copilot App <[email protected]>
2026-11-03 18:24:43 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free ac26100d0f Add தமிழ் (ta_in) game translation
Bundles Horizon's own தமிழ் strings so ta_in players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:23:13 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free df4c3895dd Add தமிழ் (ta) launcher translation support
Registers தமிழ் as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:22:44 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free b66e55041e Add Filipino (fil_ph) game translation
Bundles Horizon's own Filipino strings so fil_ph players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:21:43 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8e0614ddf0 Add Filipino (fil) launcher translation support
Registers Filipino as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:20:28 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2d86492394 Add Galego (gl_es) game translation
Bundles Horizon's own Galego strings so gl_es players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:18:49 +01:00
AkirabaneandClaude Sonnet 5.5 e3b0f06133 feat(server): live status-heartbeat-interval in the management protocol
`serversettings/status_heartbeat_interval` and `/set` were not answered, and the
supervisor started its heartbeat only when the interval was above 0 at start,
with that first value for good.

The heartbeat thread is now always there and counts its seconds itself. It
reads the interval each second and starts counting again at every set, as
vanilla cancels the heartbeat and schedules a new one
(`ManagementServer.scheduleHeartbeat`: the first after as many seconds), so a
server that began with none can have one and a server that had one can stop it,
with no restart. 0 or less means none, and the interval is kept as it was set,
as vanilla keeps any int.

The set writes `status-heartbeat-interval` to server.properties and to
server.toml's `[properties]`, logs vanilla's "Updated status heartbeat
interval from {} to {}" and answers with the interval now in force. A value
that is not an integer is refused as the other settings' are.

The port map moves `DedicatedServer.setStatusHeartbeatInterval` out of
`skip:server` and the management protocol parity harness compares the get, the
set, an invalid value, the saved property and the value after a restart with
vanilla's. That harness runs a real vanilla server and was not run here; the
unit tests cover the live heartbeat and the persistence.

Fixes #8190

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 18:18:29 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free dbc8d5e52d Add Galego (gl) launcher translation support
Registers Galego as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:18:11 +01:00
AkirabaneandClaude Sonnet 5.5 52e29c1ac7 test(lan): accepting transfers changes live
Log in by transfer on a server whose listener is not bound (a local connection):
turned away with `multiplayer.disconnect.transfers_disabled` while transfers are
off, taken once `Shared::set_accepts_transfers(true)` has run, and turned away
again after it is set back. With the handshake reading the fixed
`config.accepts_transfers` again, the middle login fails.

Refs #8190

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 18:18:03 +01:00
AkirabaneandClaude Sonnet 5.5 59140732d6 feat(server): live accepts-transfers in the management protocol
`serversettings/accept_transfers` and `/set` (`ServerSettingsService`,
`DedicatedServer.setAcceptsTransfers`) were not answered. The listener read
`accepts-transfers` once at start, from a fixed field of its configuration.

The listener's shared state now holds it as an atomic, as it holds the live
login limit, and the transfer handshake of `lan/conn.rs` reads that. The
management method changes it at once, writes `accepts-transfers` to
server.properties and, through the supervisor, to server.toml's `[properties]`
so a restart keeps it, and logs vanilla's "Update accepts transfers from {} to
{}". A value that is not a boolean is refused as the other settings' are.

The port map moves `DedicatedServer.setAcceptsTransfers` out of `skip:server`
and the management protocol parity harness compares the get, the set, an
invalid value, the saved property and the value after a restart with vanilla's.
That harness runs a real vanilla server and was not run here.

Refs #8190

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 18:18:03 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 24982d2ce4 Add Euskara (eu_es) game translation
Bundles Horizon's own Euskara strings so eu_es players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:16:22 +01:00
CorbonatandCodex ab9032a188 Merge pinned main 49a6e02 into GUI glint atlas fix
Preserve upstream line shader setup alongside GUI glint atlas preparation.
Keep local Windows parity helper repairs outside this contribution.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-03 20:16:12 +03:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 07e377015c Add Euskara (eu) launcher translation support
Registers Euskara as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:15:37 +01:00
OpenAI Codex ec6054c5c0 Record baseline native material API sanity-check input
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:14:28 -05:00
dontstopgorkingitandDeepSeek V4.1 Flash Free c696f5a17f Add Gaeilge (ga_ie) game translation
Bundles Horizon's own Gaeilge strings so ga_ie players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:13:53 +01:00
OpenAI Codex 881a8b8be6 Record pinned public-main API integration inputs
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:13:25 -05:00
OpenAI Codex b227d63fe8 Record pinned public-main material API integration inputs
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:13:25 -05:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d385fa042c Add Gaeilge (ga) launcher translation support
Registers Gaeilge as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:13:06 +01:00
OpenAI Codex a900d9ad88 Initialize authored-content API in a new public-main test literal
Actual final-main Clippy/test compile identified the newly added Interface construction; retain its previous neutral48-byte contents. Independent production behavior unchanged.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:11:38 -05:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 882d679119 Add Cymraeg (cy_gb) game translation
Bundles Horizon's own Cymraeg strings so cy_gb players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:11:20 +01:00
OpenAI Codex 49d6e26bc2 Initialize material API fields in new public-main test literals
Preserve existing fixture behavior with absent companion maps and no block-surface scope. Consulted only the necessary current-main struct construction sites for this integration.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:11:13 -05:00
OpenAI Codex 5ca8699133 Initialize material scope for the new main line geometry API
The independent candidate reserves block companions for terrain; the newly added main line draw has no colour surface identity and explicitly uses defaults.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:10:46 -05:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 4af6cd6f0a Add Cymraeg (cy) launcher translation support
Registers Cymraeg as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:10:32 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 344b67e56d Add Shqip (sq_al) game translation
Bundles Horizon's own Shqip strings so sq_al players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:08:47 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 7b031bc412 Add Shqip (sq) launcher translation support
Registers Shqip as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:08:14 +01:00
OpenAI Codex 2d0f159464 Record nonbehavioral material integration comment updates
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:08:06 -05:00
OpenAI Codex ef9d8b67d5 Transport terrain resource-pack material maps to shader samplers
Proposed independently developed replacement for closed #7800, addressing #7747. Preserve colour-layer identity across split arrays, nearest encoded data transport, neutral defaults, resource ownership and colour-selected companion frames. Add original CPU and actual-GPU controls with a pinned input/exposure ledger. Frozen candidate production hunks preserved; integration adjusts a test-module append and clarifies comments.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:07:54 -05:00
dontstopgorkingitandDeepSeek V4.1 Flash Free e47e463341 Add Македонски (mk_mk) game translation
Bundles Horizon's own Македонски strings so mk_mk players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:06:47 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3a412f7dd1 Add Македонски (mk) launcher translation support
Registers Македонски as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:06:07 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free d234aec4fe Add Bosanski (bs_ba) game translation
Bundles Horizon's own Bosanski strings so bs_ba players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:04:28 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 1443d76064 Add Bosanski (bs) launcher translation support
Registers Bosanski as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:03:59 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free ba51566766 Add Հայերեն (hy_am) game translation
Bundles Horizon's own Հայերեն strings so hy_am players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:02:30 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 09f41157c2 Add Հայերեն (hy) launcher translation support
Registers Հայերեն as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:01:57 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 0029029841 Add ქართული (ka_ge) game translation
Bundles Horizon's own ქართული strings so ka_ge players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:00:37 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free a98449dc6a Add ქართული (ka) launcher translation support
Registers ქართული as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 18:00:04 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free fb613892f9 Add Қазақша (kk_kz) game translation
Bundles Horizon's own Қазақша strings so kk_kz players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:58:50 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 8b7040df82 Add Қазақша (kk) launcher translation support
Registers Қазақша as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:58:07 +01:00
OpenAI Codex 9315b5a43f Preserve authored ordinary uniform initial values
Proposed independently developed replacement for closed #7842, addressing #7811. Preserve compiler-evaluated constants in actual UBO layout, retain runtime-write precedence, and add original typed and real-GPU controls. Public research input ledger records pinned permissive shader-pack inputs, frozen candidate lineage and exposure limits.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 11:57:59 -05:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 1c23b3eb70 Add Oʻzbekcha (uz_uz) game translation
Bundles Horizon's own Oʻzbekcha strings so uz_uz players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:56:23 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 31301bf5a6 Add Oʻzbekcha (uz) launcher translation support
Registers Oʻzbekcha as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:55:10 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3f771f701d Add Azərbaycanca (az_az) game translation
Bundles Horizon's own Azərbaycanca strings so az_az players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:53:27 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 2d04ef0198 Add Azərbaycanca (az) launcher translation support
Registers Azərbaycanca as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:52:36 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 26f51528c1 Add Беларуская (be_by) game translation
Bundles Horizon's own Беларуская strings so be_by players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:50:13 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 5121d46ea7 Add Беларуская (be) launcher translation support
Registers Беларуская as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:49:22 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 ab212945d7 Entity saves: ghast ExplosionPower, trident LeftOwner, ticked fireballs burn
Ghast.addAdditionalSaveData writes ExplosionPower (default 1). An ownerless
projectile has left its owner after its first tick, so a trident saves
LeftOwner. AbstractHurtingProjectile.tick ignites a fireball for a second
every tick (wind charges do not burn).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 11:46:06 -05:00
HrafnaValkyrja 765cec5be7 Merge remote-tracking branch 'origin/main' into fix/item-tnt-block-speed 2026-11-03 11:45:42 -05:00
Ludovic MarieandClaude Opus 5.5 76ae2acc14 Merge #8122, #8118 and #8108 (management series) into entity broadcast range management
Stacks the management series so the queue lands them in order; every
handler, setter, port-map row and harness check of the four is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:00 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 79634c271f Add Bahasa Melayu (ms_my) game translation
Bundles Horizon's own Bahasa Melayu strings so ms_my players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:39:44 +01:00
Ludovic MarieandClaude Opus 5.5 62f3edb377 Merge #8118 and #8108 (view distance, spawn protection management) into simulation distance management
Stacks the management series so the queue lands them in order; every
handler, setter, port-map row and harness check of the three is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:39:19 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 57a40ae32a Add Bahasa Melayu (ms) launcher translation support
Registers Bahasa Melayu as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:39:01 +01:00
Ludovic MarieandClaude Opus 5.5 560e63c0ea Merge #8108 (spawn protection management) into view distance management
Stacks the management series so the queue lands them in order: both add
handlers, a lan.rs setter, a port-map row and harness checks at the same
places; all of both are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:37:50 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 31daade750 Add Afrikaans (af_za) game translation
Bundles Horizon's own Afrikaans strings so af_za players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:37:20 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free bb45e3e267 Add Afrikaans (af) launcher translation support
Registers Afrikaans as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:36:39 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 35be9810d1 Add Íslenska (is_is) game translation
Bundles Horizon's own Íslenska strings so is_is players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:34:43 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free cdbda60df6 Add Íslenska (is) launcher translation support
Registers Íslenska as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:34:04 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 3b46ba0360 Add Српски (sr_cs) game translation
Bundles Horizon's own Српски strings so sr_cs players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:32:39 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free a8030fc2bd Add Српски (sr) launcher translation support
Registers Српски as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:32:06 +01:00
ErikKalikasandClaude Opus 5.5 420ef24de8 Native mods: a mod's own data folders, read on each data load
native_add_server_reload_listeners lists a mod's listeners (NeoForge's
AddServerReloadListenersEvent): an id and a folder under
data/<namespace>/. On each data load of a server (a world opening,
/reload), before native_server_data_loaded,
native_server_reload_listener_apply hands over the folder's .json files
as vanilla's SimpleJsonResourceReloadListener reads them: <ns>:<path>
ids, the top pack's file for each id over the resource root and the
world's enabled data packs, a file that isn't JSON logged and left out.
Fixes #8400.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:30:59 +02:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 024a9dac9f Add Slovenščina (sl_si) game translation
Bundles Horizon's own Slovenščina strings so sl_si players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:30:51 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 302c8a16f4 Add Slovenščina (sl) launcher translation support
Registers Slovenščina as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:30:10 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free e6aac81666 Add Latviešu (lv_lv) game translation
Bundles Horizon's own Latviešu strings so lv_lv players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:28:35 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free e80459c495 Add Latviešu (lv) launcher translation support
Registers Latviešu as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:27:55 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free c04758dfec Add Eesti (et_ee) game translation
Bundles Horizon's own Eesti strings so et_ee players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:26:19 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 1a5a195fbb Add Eesti (et) launcher translation support
Registers Eesti as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:25:38 +01:00
itarqos5andClaude Opus 5 9267d457a0 perf(worldgen): Reduced far detail paints only the top of a wide column
Vanilla's surface rules reach eight blocks down, layering dirt under
grass and sand under a beach. A far tile 256 or 1024 blocks across draws
at most a block or two of each column's vertical face, so the six below
that are work no view resolves.

Reduced far detail now stops at depth 2 for tiles 16 blocks a column or
wider. Depth 1 is still painted, so a tile's surface -- its height, its
top state, its water and its biome -- is exactly what it was: dumping
every column of 25 tiles at levels 4 and 6 gives a file identical to the
one before this change, all 12800 columns. Only the interior of a span,
seen on a cliff face, loses its vanilla material. Full far detail, the
default, is untouched, and so are narrow tiles, whose faces are tall
enough in view to show the layering.

Measured with `lod_grid_bench` over 100 tiles a run, five runs, taking
the least-interfered run of each (this machine's spread is wide):

| level | stride | before | after |
|---|---|---|---|
| 2 | 4 | 10.6 ms | 10.8 ms (gated off) |
| 4 | 16 | 11.1 ms | 10.3 ms (-7.2%) |
| 6 | 64 | 8.7 ms | 8.2 ms (-5.7%) |

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-03 16:24:17 +00:00
dontstopgorkingitandDeepSeek V4.1 Flash Free efc18e8b0d Add Català (ca_es) game translation
Bundles Horizon's own Català strings so ca_es players see translated
menus and options instead of the English fallback.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:23:59 +01:00
dontstopgorkingitandDeepSeek V4.1 Flash Free 1184009b11 Add Català (ca) launcher translation support
Registers Català as a launcher language with a full table translated
from the English strings.

Co-Authored-By: DeepSeek V4.1 Flash Free <[email protected]>
2026-11-03 17:23:18 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 284fba6c68 Slow dropped items and primed TNT on soul sand and honey
Entity.move ends with getBlockSpeedFactor for every entity; items and
primed TNT now call the shared helper from #8096.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 10:49:25 -05:00
NerfPhaselockandClaude Opus 5.5 ce776dff98 Renderer: GPU culling leaves fading sections to the CPU
Since the pass culls the near solid sections itself, a section it draws
appears whole where a CPU frame fades it in or out (vanilla's chunk
fade-in, far terrain's fades). A recorded section now gets FADING in its
record, which cull skips, and its walk mark, so the CPU draws it with its
fade, exactly while the instance loop's own expression for it is not a
whole section's (section_visibility and the far-terrain fade, bit for bit).
The flag is decided at upload, when a section leaves or is held for far
terrain, and anew for every record when the fade-in time changes.

A heap of fade ends in GpuCulling says when to look again, so a frame with
no end due does no fade work; Records counts the flagged records, and
switching GPU culling off forgets the flags with the records. The debug
check also compares each flag and mark with what the fades say.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 09:16:16 -05:00
AkirabaneandClaude Opus 5.5 acc6d017c4 A server sends its world packs' tags for every registry, not only dialogs
TagNetworkSerialization.serializeToNetwork sends each synchronized registry's
tags as TagLoader bound them from the whole pack stack. Horizon's server read
the resource root's tag files only and merged the world's data packs into
the dialog tags alone, so no client (vanilla or Horizon) was told that a
pack put dirt in #minecraft:mineable/pickaxe or a block in #climbable. After
/reload, keep_registries read the root's tags again without any pack, the
packs' dialog tags included.

merge_pack_tags merges, for every synchronized registry the world's packs
have tag files for (tags/<path>, a mod's registry under tags/<ns>/<path> as
Registries.tagsDirPath has it), the root's files and then each pack's in
order ("replace": true, nested tags expanded), at load and on /reload.
Registries no pack touches keep load_tags' tags.

Refs #7843

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 14:03:41 +01:00
masa-masa-0314andCodex 34543f259f Merge upstream main and fix launcher relocation CI
Complete relocation strings in new language tables, explicitly release completed world-operation locks, and retain unsaved settings when a move cannot save them.

Co-Authored-By: Codex <[email protected]>
2026-11-03 20:55:24 +08:00
TiggerTakkyTTTandCodex 63554a33a7 Merge upstream main into entity range management
Preserve entity broadcast range parity alongside the merged allow-flight management changes and log clamped values.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 19:24:37 +07:00
TiggerTakkyTTTandCodex 76fdd95603 Merge upstream main into simulation distance management
Preserve simulation-distance parity alongside the merged allow-flight management changes.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 19:21:16 +07:00
TiggerTakkyTTTandCodex f823c66dae Merge upstream main into view distance management
Preserve view-distance parity alongside the merged allow-flight management changes.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 19:18:11 +07:00
AkirabaneandClaude Sonnet 5.5 685d0b1e8c Buckets: the fluid ray passes through the hollow of a torch, fence or flower
pick_fluid stopped at any non-air cell without a fluid instead of clipping
the block's outline, so a water bucket could not fill through the empty part
of a torch's cell. ClipContext.Block.OUTLINE lets the ray through; the ray
now stops only where it meets an outline box. This covers the buckets
(empty, fish), glass bottle, lily pad / frogspawn and spawn eggs, which
all use this ray.

Fixes #8180

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 13:15:02 +01:00
TiggerTakkyTTTandCodex 52c5740b56 Merge upstream main into spawn protection management
Preserve the spawn-protection parity path alongside the merged allow-flight management changes.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 19:08:09 +07:00
AkirabaneandClaude Sonnet 5.5 ee480fe577 fix(shaders): a bool member of a std430 buffer translates
GLSL gives a `bool` in a `layout(std430)` buffer a size and an alignment of
4. naga does not let a bool into a storage buffer at all, so the program
failed with "Global variable [6] '' is invalid: ... The type is not
host-shareable": no name, no member, and the line of the include site (#8175).

Such a member now becomes a `uint` of the same size and every use of it is
rewritten before the stage is scanned: a read is `(flag != 0u)` (also through
an instance, `state.flag`, and an array, `flags[i]`), a plain assignment is
`flag = uint(...)`. Nothing is added or removed from a line, so the pack's
line numbers stay right. It applies to render and compute programs.

What the rewrite cannot make sure of is refused with the member's name and its
line, instead of naga's anonymous error: a compound assignment (`flag |= x`),
an assignment used as a value, a local or parameter of the same name, a `bool`
vector member and an instance array of such a buffer.

Tests: the five shapes of the issue translate; the refusals name the member and
the line; the rewritten text is checked exactly; a compute program reads and
writes a bool member. Before the change the first test fails with the exact
message of the issue for every shape.

Fixes #8175

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 13:05:45 +01:00
AkirabaneandClaude Opus 5.5 54a8a4e47c Superflat worlds without structures place their features and lakes too
A superflat world that decorates (`features` or `lakes`) but generates no
structure set, because `generate_structures` is off or its
`structure_overrides` is `[]`, still went through FlatTerrain, which lays the
layers and the Void platform only. FlatLevelSource decorates with
adjustGenerationSettings whatever its structures do.

FlatTerrain::structure_settings becomes pipeline_settings: the settings of a
superflat world that lets a structure set generate or that decorates. World
loading streams such a world on the flat pipeline when its structures generate
or it decorates; the pipeline then has no structure starts when
generate_structures is off, and no set when the overrides are empty.
FlatTerrain keeps the worlds with neither, and the Void.

Refs #2912

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 12:29:30 +01:00
AkirabaneandClaude Sonnet 5.5 209f2fd34d fix(motion): a passenger freezes in powder snow, and the frostbite overlay shows it
A player carried by a boat only ran `passenger_pose_tick`, never the part of
`LivingEntity.aiStep` that applies the effects of the blocks it is in. Powder
snow's FREEZE never added a tick of frost to the window's own player, and
nothing thawed it, so `ticks_frozen`, which the frostbite overlay reads, stayed
where it was: the server froze and hurt the rider, who saw no frost (#8156).

`passenger_block_effects_tick` samples the blocks the passenger is in, as the
client does on foot, and thaws two ticks a tick out of powder snow as the
server does; the riding branch of the client tick calls it after the pose.
The server's copy of a riding guest is untouched: it already freezes in
`server_block_damage`.

Refs #8156

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 12:10:24 +01:00
AkirabaneandClaude Opus 5.5 b40b532a67 Parity harness: superflat chunks against vanilla's FlatLevelSource
tools/test_flat_generation_parity.py runs VanillaChunkGenerationOracle with
HORIZON_FLAT=<flat_level_generator_preset>, which builds a real FlatLevelSource
from the preset registry (with ChunkMap's random state for a generator without
noise, and createState's structure state), and chunk_dump with the same
variable on OverworldGenerator::flat. Every block, quart biome and block entity
of the target chunks is compared after decoration, for the three presets that
decorate (Overworld, Desert, Tunnelers' Dream) and three that only place layers
and structures. A decorating preset must hold blocks beyond its layers in
vanilla, so the decoration is known to be covered.

Refs #2912

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 12:03:18 +01:00
AkirabaneandClaude Opus 5.5 679b9f53be Superflat worlds with structures place their features and lakes
A superflat world on the structure pipeline (Generator::flat, #5509) built its
decoration with no placed feature at all. FlatLevelSource decorates with
FlatLevelGeneratorSettings.adjustGenerationSettings of its biome (26.3
bytecode): the lava lakes of createLakesList in the lakes step when `lakes` is
on; the biome's own features when `features` is on and the layers are not all
air (or the biome is the_void), except those of the underground and surface
structure steps, and of the lakes step when `lakes` is on.

FlatSettings now carries `features` and `lakes`, and Decoration::new_with takes
that adjustment. The FillLayerFeatures stay last in TOP_LAYER_MODIFICATION, as
the builder adds them after the biome's features.

Refs #2912

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 12:03:17 +01:00
TiggerTakkyTTTandCodex f90f7aac52 Add live entity broadcast range management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 17:00:44 +07:00
TiggerTakkyTTTandCodex aa9341243e Add live simulation distance management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 16:46:28 +07:00
TiggerTakkyTTTandCodex 8aab0488dc Add live view distance management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 16:35:17 +07:00
TiggerTakkyTTTandCodex ccda2b69eb Add live spawn protection management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 15:57:20 +07:00
CocofireHDandHermes 072cd038c5 Mobs: an AvoidEntityGoal target needs sight, visibility and a non-Peaceful level
`AvoidEntity::can_use` picked what to flee with a distance and height test,
where `AvoidEntityGoal` targets through `TargetingConditions.forCombat()`:
`canAttack` (no player is a target at all in Peaceful), the range scaled by
`getVisibilityPercent` with a floor of 2, and line of sight. Fish, ocelots,
foxes, rabbits, pandas and illusioners fled a player standing behind a wall or
on the other side of a hill, a sneaking or invisible one from the full 8 or 16
blocks, and still fled in Peaceful; creepers fled cats and skeletons fled
wolves through walls.

The player arm now runs `Mob::can_target_player` (the existing port of
`TargetingConditions.test`) after the goal's own predicate, and the species arm
needs `Mob::sees`. The target lookup moved into `AvoidEntity::avoid_target`, so
the conditions are testable without the random retreat position and the path.

Co-Authored-By: Hermes <[email protected]>
2026-11-03 09:45:29 +01:00
masa-masa-0314andCodex 45b1e501de Launcher: use octal permissions in relocation regression test
Fix Linux clippy::non_octal_unix_permissions without changing the unreadable-source fixture or its assertions.

Co-Authored-By: Codex <[email protected]>
2026-11-03 15:27:26 +08:00
masa-masa-0314andCodex ed7f1f4428 Launcher: retain data relocation safety regression tests
Cover source-preserving migration, real unreadable-file failures, unsafe destinations and links, stale workers, leases, atomic pointer failure and structured path remapping. Fix canonical Windows drive prefix validation exposed by the permanent tests.

Refs #7218

Co-Authored-By: Codex <[email protected]>
2026-11-03 15:08:13 +08:00
GabrielNichols 5bd3afa1ad launcher: bound native build caches and support safe cleanup (#8028)
Limit fast/release compiler outputs after builds and expose cache inspection and cleanup. Coordinate builds and cleanup with OS/Cargo locks; preserve installed versions and player data.
2026-11-03 04:00:03 -03:00
Arslan ArmanulyandClaude Opus 5.5 514bada684 Shader packs: currentRenderedItemId per draw, and the hand per held item
`currentRenderedItemId` was a frame uniform reading 0 in every program, and
a hand program that read it without declaring it failed to translate, so
the hand fell back to gbuffers_textured_lit.

- `HzDraw`'s fourth int, a pad until now, is `currentRenderedItemId`: -1 for
  terrain, water and the sky, 0 for entity runs (items drawn in the level
  are not done).
- The hand draws one run per arm and held item, each with its own `HzDraw`
  slot. A block item reads its block's block.properties id, or 0 when that
  file lacks it, whatever item.properties says; any other item reads its
  item.properties id, 65535 when the file lacks it, and 0 without the file.
- The hand's programs may read `currentRenderedItemId` undeclared; terrain's
  and entities' may not.

This is what Iris 1.11.7 does, observed with probe packs of our own.

Part of #6812

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 09:23:27 +05:00
Ludovic MarieandClaude Opus 5.5 f545530d3e launcher: pick the loader's version in New instance
Modded now lists the chosen loader's versions for the chosen Minecraft
version, asked of its service once per loader and version: Newest stable
first, then every version newest first, betas marked. The choice goes to
loaders::choose when the instance is made, and goes back to the newest
stable one when the loader or the game version changes. The loader card
shows the version chosen. Its four strings are in every language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:44:18 +01:00
Minantiii 71566a3268 Armadillos: rolling up and out posts GameEvent.ENTITY_ACTION
Armadillo.rollUp and Armadillo.rollOut call gameEvent(ENTITY_ACTION)
before their sound, silent or not; sculk sensors, shriekers and wardens
now hear it.

Fixes #8714
2026-11-02 23:38:29 -03:00
Ludovic MarieandClaude Opus 5.5 0b47accde1 launcher: import a modded .mrpack as a Java instance
instance import <file> --java makes a Minecraft: Java Edition instance from a
Modrinth pack, as Prism Launcher imports one: the Minecraft version and the
mod loader its index names (minecraft, fabric-loader, quilt-loader, forge,
neoforge), every override (overrides/, client-overrides/) and file it
carries, files checked against their hash; a server-only file and
server-overrides/ are left out, and a path that leaves the folder is
refused. A pack that fails half way leaves no instance behind. Without
--java a modded pack still goes to the Modpacks import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 02:58:28 +01:00
Ludovic MarieandClaude Opus 5.5 349deacbb3 launcher: test Forge's and NeoForge's real install profiles and a dry-run launch
The processors are planned (steps) before they run, so a test checks the
client steps three real install profiles ask for (NeoForge 26.3.0.101,
Forge 26.3-66.1.0 and 1.20.1-47.4.0, recorded without icon and library
lists) against values read off the profiles by hand. The ignored real
install now also builds Play's command and runs it with the JVM's
--dry-run: module path, classpath and main class resolve, nothing of the
game runs, no account is involved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 02:43:41 +01:00
Ludovic Marie d0bdbc9eb0 Merge branch 'launcher-forge-versions' into launcher-forge-install 2026-11-03 02:37:45 +01:00
reservedbytes 03f2b226ff fix(items): player drops use vanilla's exact throw motion 2026-11-03 00:01:39 -01:00
CorbonatandCodex 8a529538f2 Merge pinned upstream main for GUI glint validation
Integrate e2385d0ddb while retaining logical item-atlas glint coordinates, metadata rejection and GUI/CIT separation.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-03 01:51:00 +03:00
deloskiyt eb0a4cf524 feat(realms): implement in-game Configure Realm and Add Realm screens, widen join button 2026-11-02 22:50:34 +01:00
deloskiyt fae8efb8fd fix(realms): restore button icons, wire up all click actions, and add join code & pending invites screens 2026-11-02 22:35:21 +01:00
deloskiyt 2b427b6ccc feat(realms): exact 1:1 port of vanilla RealmsMainScreen and RealmsHeader layout 2026-11-02 22:07:23 +01:00
deloskiyt bef44dd626 fix(client): match authentic vanilla Realms UI layout and correct cookie authentication format 2026-11-02 21:47:53 +01:00
0a499bde42 Splitscreen: use the launcher's other Microsoft accounts
Hand off private per-account sessions when Splitscreen is enabled, retain unavailable accounts in the picker, and connect guests with their own authentication, skins and secure-chat keys. Preserve per-launch account selection and validate the metadata UUID before accepting a session.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-02 15:33:02 -05:00
deloskiyt a5391f2e9c feat(client): implement Minecraft Realms support and title screen browser
- Enable Minecraft Realms button on main menu and handle navigation
- Implement dedicated Realms browser screen supporting world list, status badges, selection and refreshing
- Implement Realms HTTPS client querying pc.realms.minecraft.net using AuthSession access token
- Add join endpoint resolution and direct server connection via Action::Connect
- Update agent mode serialization and port map ledger
2026-11-02 21:02:53 +01:00
Ludovic MarieandClaude Opus 5.5 d9e8401558 launcher: install and launch Forge and NeoForge instances
forge_install downloads the installer (checked against the .sha1 its
repository publishes), takes the libraries from its maven/ folder or the
repository (each sha1 checked), runs the client's processors on the
instance's Java as java -cp <jar+classpath> <Main-Class> <args> with the
install profile's data, skips a processor whose outputs already match and
refuses one that leaves another sha1. The profile is kept as
versions/<id>/<id>.json with a copy of the game's jar named after it, as
Forge's ignoreList (${version_name}.jar) expects; java_launch::resolve now
lets a child name its own jar.

java_game picks the Java before installing the loader, so the installer
runs on the Java the game runs on. The new instance's loader picker shows
every loaders::Kind with its logo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:57:45 +01:00
Ludovic MarieandClaude Opus 5.5 2c9dac6bbc Block sprites: keep the CPU copy compressed once uploaded
The block texture array's pixels (21 MB with the default resources) stayed in
RenderAssets for the whole session, though the GPU holds them and the copy
only serves a renderer made again. Once a renderer exists they are
lz4-compressed on a thread of their own, as the GUI atlas is (#6843); a
renderer built again uncompresses them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:26:53 +01:00
masa-masa-0314andCodex 7df27f1cf9 Launcher: move managed data to another drive
Add a two-step data-folder move that copies and verifies launcher, game and build data offline, preserves source recovery copies, and atomically commits an independent location pointer before reopening. Protect migration with process leases, reject links and overlapping destinations, preserve Windows access permissions and remap managed structured paths.

Fixes #7218

Co-Authored-By: Codex <[email protected]>
2026-11-03 00:03:28 +08:00
CorbonatandCodex c5adff2477 Merge main and reject invalid GUI glint atlas texture metadata
Preserve main's face-normal and sprite-rectangle caches while keeping GUI
vanilla atlas coordinates separate from CIT sprite-local glint UVs.
Reject malformed texture sections before stitching and read metadata from
the same prepared snapshot as rendered textures. Add rejection, control,
prepared-resource and real-oracle emitted-UV regression coverage.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-02 14:55:28 +03:00
CorbonatandCodex 709fa73ee3 Fix GUI item glint UV scale using the vanilla item atlas
Reconstruct the pinned item atlas layout from the active sprite sources so
standard GUI glint receives the same atlas UVs as vanilla. Use the device
limit and selected filtering when rebuilding the renderer; keep the HUD
texture coordinates and CIT glint coordinates separate.

Add independent vanilla UV and packing regressions, including animation,
generated sources, padding, invalid metadata and atlas-limit boundaries.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-02 13:16:52 +03:00
rdbtCVSandClaude Sonnet 5.5 e7bdd16474 Remove the RAM limit
A soft limit that lowers render distance and then crashes the game is not
a cap, and only Windows could hold the game under a number at all. Takes out
the launcher's Maximum memory row, HORIZON_MEMORY_LIMIT_MB, /ramlimit, the
governor and the watcher thread (reverts #5800 and #5801). A Java instance's
-Xmx stays settable from the launcher CLI.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:48:32 +00:00
1121 changed files with 178937 additions and 10424 deletions

No files matched your search

+2 -1
View File
@@ -1,3 +1,4 @@
self-hosted-runner:
# The Mac mini's runner (ludovics-mac-mini), which holds the maintainer's own Minecraft files.
labels: [horizon-game, ludovics-mac-mini]
# windows-11-vs2026-arm is GitHub's Windows ARM64 image (2026-08), newer than actionlint's label list.
labels: [horizon-game, ludovics-mac-mini, windows-11-vs2026-arm]
+5 -5
View File
@@ -22,7 +22,7 @@ jobs:
outputs:
platforms: ${{ steps.platforms.outputs.platforms }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- id: platforms
run: echo "platforms=$(python3 -c 'import json, tools.horizon_mod_binstall as m; print(json.dumps(m.PLATFORMS))')" >> "$GITHUB_OUTPUT"
build:
@@ -35,7 +35,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -67,7 +67,7 @@ jobs:
[ -f "$binary.exe" ] && binary="$binary.exe"
mkdir -p dist
tar -czf "dist/$(basename "$binary" | sed 's/\.exe$//')-${{ matrix.target }}.tar.gz" -C "$(dirname "$binary")" "$(basename "$binary")"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: cargo-horizon-mod-${{ matrix.target }}
path: dist/*.tar.gz
@@ -81,8 +81,8 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
pattern: cargo-horizon-mod-*
path: dist
+94 -11
View File
@@ -41,10 +41,10 @@ jobs:
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
if: runner.environment == 'github-hosted'
with:
python-version: '3.12'
@@ -126,7 +126,7 @@ jobs:
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Does this pull request change the launcher or a crate it builds?
@@ -158,7 +158,90 @@ jobs:
run: |
cargo +stable fmt --check
cargo +stable clippy --all-targets --locked -- -D warnings
cargo +stable test --locked --lib
cargo +stable test --locked --lib --bins
# Compile and test Windows ARM64 on an actual ARM64 host, and inspect the resulting PE headers. By hand only
# (`gh workflow run checks --ref <branch>`): GitHub's runner queue is the bottleneck, and main's runs gate the
# releases (tools/release_train.py waits for them), so neither pull requests nor main's pushes start this
# 90-minute job. The Nightly and launcher releases build and check these binaries on every run.
windows-arm64:
name: Windows ARM64 game and launcher
if: github.event_name == 'workflow_dispatch'
runs-on: windows-11-vs2026-arm
timeout-minutes: 90
env:
RUSTFLAGS: -C target-feature=+crt-static
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Use every runner core for Cargo
shell: pwsh
run: |
$cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
"CARGO_BUILD_JOBS=$cores" >> $env:GITHUB_ENV
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
with:
shared-key: windows-arm64-game
- uses: Swatinem/rust-cache@v2
with:
workspaces: launcher
shared-key: windows-arm64-launcher
- name: Check LLVM assembler used by ring
shell: pwsh
run: |
$llvmBin = Join-Path $env:ProgramFiles 'LLVM\bin'
if (-not (Get-Command clang -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $llvmBin 'clang.exe'))) {
$env:PATH = "$llvmBin;$env:PATH"
"PATH=$env:PATH" >> $env:GITHUB_ENV
}
$clang = Get-Command clang -ErrorAction Stop
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64
run: cargo build --release --locked --target aarch64-pc-windows-msvc -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: Check the game's PE machine
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Check game DLL imports
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Start every game executable
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.exe'
$output = & $client --help 2>&1
if ($LASTEXITCODE -ne 0 -or ($output -join "`n") -notmatch '--resources') { throw 'horizon-client --help failed' }
foreach ($binary in @('horizon-ctl.exe', 'horizon-prepare.exe', 'horizon-server.exe')) {
$path = "target/aarch64-pc-windows-msvc/release/$binary"
$output = & $path --help 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --help failed" }
}
foreach ($binary in @('horizon-prepare.exe', 'horizon-server.exe')) {
$path = "target/aarch64-pc-windows-msvc/release/$binary"
$output = & $path --version 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --version failed" }
Write-Output ($output -join "`n")
}
- name: Test and build the native Windows ARM64 launcher
working-directory: launcher
run: |
cargo test --release --locked --target aarch64-pc-windows-msvc --lib
cargo build --release --locked --target aarch64-pc-windows-msvc
- name: Check the launcher's PE machine
working-directory: launcher
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
- name: Check launcher DLL imports and command-line startup
working-directory: launcher
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
python ../tools/check_imports.py $launcher
$output = & $launcher --version 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
Write-Output ($output -join "`n")
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
@@ -169,7 +252,7 @@ jobs:
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
@@ -204,13 +287,13 @@ jobs:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- uses: actions/setup-java@v4
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '25'
@@ -552,11 +635,11 @@ jobs:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- uses: actions/setup-java@v4
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '25'
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
@@ -63,7 +63,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
+49 -12
View File
@@ -1,6 +1,6 @@
name: game build
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server, Windows x64
# and ARM64 on GitHub, and macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
@@ -45,7 +45,7 @@ jobs:
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
persist-credentials: false
@@ -73,7 +73,7 @@ jobs:
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: original-exporters
path: exporters/
@@ -83,6 +83,10 @@ jobs:
build:
needs: exporters
name: build ${{ matrix.name }}
# Windows ARM64 and musl are new: a failure never holds the other platforms' release (the signing job
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
# optional archives out with a warning.
continue-on-error: ${{ matrix.name == 'windows-arm64' || matrix.name == 'linux-musl' }}
strategy:
fail-fast: false
matrix:
@@ -94,13 +98,28 @@ jobs:
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: linux-musl
os: ubuntu-22.04
musl: true
dir: horizon-linux-musl-x86_64
asset: horizon-linux-musl-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
pe_arch: x64
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: windows-arm64
os: windows-11-vs2026-arm
target: aarch64-pc-windows-msvc
dir: horizon-windows-arm64
asset: horizon-windows-arm64.zip
exe: .exe
pe_arch: arm64
# The ARM64 runner builds and runs native ARM64 executables; keep the C runtime in the archive.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
@@ -128,7 +147,7 @@ jobs:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
persist-credentials: false
@@ -141,21 +160,29 @@ jobs:
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
if: runner.environment == 'github-hosted' && !matrix.musl
with:
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
save-if: ${{ inputs.trusted }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
if: runner.os == 'Linux' && !matrix.musl
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
if: ${{ !matrix.musl }}
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
if: ${{ !matrix.musl }}
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: Verify Windows executable architecture
if: runner.os == 'Windows'
run: |
python tools/check_pe_arch.py "${{ matrix.pe_arch }}" \
"$BIN/horizon-client${{ matrix.exe }}" "$BIN/horizon-ctl${{ matrix.exe }}" \
"$BIN/horizon-prepare${{ matrix.exe }}" "$BIN/horizon-server${{ matrix.exe }}"
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
env:
@@ -173,6 +200,7 @@ jobs:
codesign --verify --strict --verbose=2 "$BIN/$binary"
done
- name: Only system libraries
if: ${{ !matrix.musl }}
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
@@ -188,13 +216,14 @@ jobs:
- name: Baseline x86-64 CPUs only
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
if: runner.os != 'macOS'
if: runner.os != 'macOS' && matrix.pe_arch != 'arm64' && !matrix.musl
run: |
set -euo pipefail
rustup component add llvm-tools
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_cpu_baseline.py \
$(for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do echo "$BIN/$binary${{ matrix.exe }}"; done)
- name: Every program starts
if: ${{ !matrix.musl }}
run: |
set -euo pipefail
bin=$BIN
@@ -239,18 +268,26 @@ jobs:
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: runner.environment == 'self-hosted' && !matrix.target
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
name: original-exporters
path: exporters
- name: Build, check and package for musl in Alpine
if: matrix.musl
# Run Docker on the Ubuntu host: JavaScript actions themselves require glibc.
run: |
docker run --rm -v "$PWD:/work" -w /work \
-e HORIZON_VERSION -e HORIZON_NIGHTLY \
rust:alpine3.22 sh tools/ci/build-musl.sh
- name: Package
if: ${{ !matrix.musl }}
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
@@ -277,7 +314,7 @@ jobs:
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
@@ -299,7 +336,7 @@ jobs:
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
+47 -15
View File
@@ -1,5 +1,5 @@
name: launcher
# Builds the launcher on Linux, macOS and Windows, and releases it by itself (docs/release-flow.md): after a
# Builds the launcher on Linux, macOS, Windows x64 and ARM64, and releases it by itself (docs/release-flow.md): after a
# push to main that changes the launcher or a crate it builds in (the paths below),
# every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as
# for the game.
@@ -15,8 +15,9 @@ name: launcher
# 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);
# unset (forks), they are signed ad hoc.
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
# Windows ships each architecture's launcher in its ZIP (what a launcher updates itself from) and as
# horizon-launcher-setup.exe or horizon-launcher-setup-arm64.exe, which opens the per-user installer instead of Home
# (launcher/src/installer.rs).
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
# gh workflow run launcher --ref main -f dry_run=false -f force=true
@@ -73,7 +74,7 @@ jobs:
version: ${{ steps.decide.outputs.version }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: mode
@@ -102,6 +103,10 @@ jobs:
needs: decide
if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
# ARM64 files out with a warning.
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
strategy:
fail-fast: false
matrix:
@@ -125,9 +130,20 @@ jobs:
os: windows-latest
asset: horizon-launcher-windows-x64.zip
binary: horizon-launcher.exe
pe_arch: x64
setup_asset: horizon-launcher-setup.exe
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
# which only the Visual C++ redistributable installs.
rustflags: -C target-feature=+crt-static
- name: windows-arm64
os: windows-11-vs2026-arm
target: aarch64-pc-windows-msvc
asset: horizon-launcher-windows-arm64.zip
binary: horizon-launcher.exe
pe_arch: arm64
setup_asset: horizon-launcher-setup-arm64.exe
# The C runtime linked in, so the standalone ARM64 launcher needs no redistributable.
rustflags: -C target-feature=+crt-static
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
# Notarizing the app and then the .dmg can take a few minutes each.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
@@ -150,7 +166,7 @@ jobs:
working-directory: launcher
shell: bash
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
@@ -182,6 +198,9 @@ jobs:
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- name: Verify Windows executable architecture
if: runner.os == 'Windows'
run: python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$BIN/${{ matrix.binary }}"
- name: The version is the release's
run: |
out=$("$BIN/${{ matrix.binary }}" --version)
@@ -212,7 +231,8 @@ jobs:
"$launcher" links --json
# The Intel Mac launcher can come out before any game build has its asset (#1161): then there is nothing
# to install yet. Only that entry skips, and only on a version list that parsed and had no build here
# (exit 3); a launcher or a script that fails still fails the step.
# (exit 3); a launcher or a script that fails still fails the step. The Windows ARM64 launcher always has
# one: it installs the x64 build where a release has no ARM64 one (backend.rs, GAME_FALLBACK).
if [[ "${{ matrix.name }}" == macos-x64 ]]; then
versions=$(retry "$launcher" --json versions)
no_build='import json, sys; sys.exit(3 if not any(r["available_here"] for r in json.load(sys.stdin)) else 0)'
@@ -236,6 +256,11 @@ jobs:
# release linked to a Nix store is a warning here, not a broken launcher.
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
if [[ "$RUNNER_OS" == "Windows" ]]; then
if ! python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$client"; then
# A Nightly without an ARM64 build: the ARM64 launcher installs the x64 one (backend.rs, GAME_FALLBACK).
[[ "${{ matrix.pe_arch }}" == arm64 ]] && python ../tools/check_pe_arch.py x64 "$client" || exit 1
echo "::warning::the latest game has no ARM64 build: the launcher installed the x64 one"
fi
python ../tools/check_imports.py "$client"
elif [[ "$RUNNER_OS" == "Linux" ]]; then
soft python3 ../tools/check_imports.py "$client"
@@ -295,8 +320,8 @@ jobs:
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
if: runner.os == 'Windows'
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
- uses: actions/upload-artifact@v4
run: cp "$BIN/${{ matrix.binary }}" "out/${{ matrix.setup_asset }}"
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.asset }}
path: launcher/out/*
@@ -314,12 +339,12 @@ jobs:
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: dist
pattern: horizon-launcher-*
@@ -331,13 +356,13 @@ jobs:
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
TAG: ${{ needs.decide.outputs.tag }}
run: bash tools/ci/sign_release.sh "$TAG" dist/horizon-launcher-*
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: needs.decide.outputs.dry_run == 'true'
with:
name: ephemeral.pub
@@ -351,7 +376,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Notes
@@ -361,7 +386,7 @@ jobs:
python3 tools/release_train.py notes --kind launcher --tag "${{ needs.decide.outputs.tag }}" \
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
@@ -373,6 +398,13 @@ jobs:
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
# Never marked latest: Horizon's own releases stay the repository's latest.
- name: The Windows ARM64 launcher, if it built
# Its build may fail without holding the release (continue-on-error): the release then has no ARM64
# files, and ARM64 launchers stay on their version until one has (backend::launcher_update_from).
run: |
for file in horizon-launcher-windows-arm64.zip horizon-launcher-setup-arm64.exe; do
[[ -f "dist/$file" ]] || echo "::warning::$file is missing: this release goes out without it"
done
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
@@ -387,7 +419,7 @@ jobs:
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: launcher-release-notes
path: notes/
+4 -1
View File
@@ -15,6 +15,9 @@ on:
- '.github/workflows/mod-registry.yml'
permissions:
contents: read
concurrency:
group: mod-registry-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
@@ -24,7 +27,7 @@ jobs:
run:
working-directory: crates/horizon-mod-registry
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Check the manifest
env:
GH_TOKEN: ${{ github.token }}
+6 -2
View File
@@ -24,7 +24,7 @@ jobs:
pull-requests: write
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
@@ -52,4 +52,8 @@ jobs:
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.'
# The repo doesn't let GitHub Actions open pull requests (that setting would also let it approve them):
# the branch is pushed, and a maintainer opens the PR from it.
if ! 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.'; then
echo "::warning::Stamped fragments are on $branch; open a pull request from it (GitHub Actions may not open one here)."
fi
+30 -9
View File
@@ -92,7 +92,7 @@ jobs:
source_tag: ${{ steps.pick.outputs.source_tag }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
@@ -124,14 +124,14 @@ jobs:
env:
SHA: ${{ steps.pick.outputs.sha }}
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: steps.pick.outputs.build == 'true'
with:
name: horizon-source
path: horizon-source.tar.gz
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: nightly-notes
path: nightly-notes.md
@@ -155,12 +155,12 @@ jobs:
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: dist
pattern: horizon-*
@@ -171,16 +171,22 @@ jobs:
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
# Windows ARM64's build may fail without holding the Nightly (game-build.yml): then it is left out.
arm64=(); if [[ -f dist/horizon-windows-arm64.zip ]]; then arm64=(dist/horizon-windows-arm64.zip)
else echo "::warning::horizon-windows-arm64.zip is missing: SHA256SUMS goes out without it"; fi
musl=(); if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then musl=(dist/horizon-linux-musl-x86_64.tar.gz)
else echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: SHA256SUMS goes out without it"; fi
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
"${arm64[@]}" "${musl[@]}" \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: needs.pick.outputs.dry_run == 'true'
with:
name: ephemeral.pub
@@ -195,10 +201,10 @@ jobs:
contents: write
issues: read # the open release-blocker issues (gate)
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
@@ -230,12 +236,27 @@ jobs:
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# The ARM64 archive only when its build succeeded (the sign job left it out of SHA256SUMS otherwise).
arm64=()
if [[ -f dist/horizon-windows-arm64.zip ]]; then
arm64=("dist/horizon-windows-arm64.zip#Game files the launcher installs (Nightly): Windows ARM64 (horizon-windows-arm64.zip)")
else
echo "::warning::horizon-windows-arm64.zip is missing: the Nightly goes out without it (ARM64 launchers install the x64 build)"
fi
musl=()
if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then
musl=("dist/horizon-linux-musl-x86_64.tar.gz#Game files for musl Linux (horizon-linux-musl-x86_64.tar.gz)")
else
echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: the Nightly goes out without it"
fi
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
# before the Nightly-only switch keep (they don't read Nightlies).
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"${musl[@]}" \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"${arm64[@]}" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
issues: write # answers in the thread
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
+2 -2
View File
@@ -48,7 +48,7 @@ jobs:
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
- uses: actions/checkout@v7
if: steps.when.outputs.run == 'true'
with:
fetch-depth: 0
@@ -82,7 +82,7 @@ jobs:
run: python3 tools/ci/parity_issues.py --since "${{ steps.audit.outputs.since }}" ${{ inputs.dry_run && '--dry-run' || '' }} | tee -a "$GITHUB_STEP_SUMMARY"
- name: Logs of the failed harnesses
if: steps.when.outputs.run == 'true' && steps.audit.outputs.exit != '0'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: nightly-parity-logs
path: .local/parity-runner/logs/
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install Rust and the ALSA and udev headers
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: [self-hosted, macOS, horizon-parity-main]
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
@@ -69,7 +69,7 @@ jobs:
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
- name: Logs of the failed harnesses
if: steps.harnesses.outcome == 'failure'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: parity-main-logs
path: .local/parity-main-logs/
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
# harnesses at a time) up to a few hours, and any pull request's job may land there.
timeout-minutes: ${{ inputs.full && 360 || 180 }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
# The commit the event names: a fork's later pushes need another run-parity label. The next step merges
# main into it.
@@ -89,7 +89,7 @@ jobs:
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: parity-logs
path: .local/parity-runner/logs/
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
urgent: ${{ steps.decide.outputs.urgent }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: mode
+4 -1
View File
@@ -15,6 +15,9 @@ on:
- '.github/workflows/telemetry-server.yml'
permissions:
contents: read
concurrency:
group: telemetry-server-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
@@ -24,7 +27,7 @@ jobs:
run:
working-directory: crates/horizon-telemetry-server
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
+34 -33
View File
@@ -5,7 +5,8 @@ everything is that **it plays exactly like vanilla**. This page is the short ver
welcome on [Discord](https://discord.gg/playhorizon).
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything
else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
@@ -14,23 +15,25 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
## Build it
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
Horizon never downloads the game: it prepares its resources from your own copy of Minecraft 26.3, as described in
[Build from source](README.md#build-from-source).
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
(`libasound2-dev libudev-dev`).
- **Resources:** Horizon never downloads the game; it prepares its resources from your own copy of Minecraft 26.3, as
described in [Build from source](README.md#build-from-source).
- **Commands:**
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
The launcher is its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
## Vanilla first
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here
means Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a
**harness**: a `tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust
probe against Horizon, and compares their outputs. There are about 290 of them.
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
`tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon,
and compares their outputs. There are about 290 of them.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
@@ -41,11 +44,10 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
off by default wherever they would change the vanilla look.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
`tools/check_public_tree.py` rejects them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code, or code whose license allows it
@@ -54,24 +56,23 @@ another project's source may also be read and used, **only when that project's l
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
public-domain dedication. That code may be read, copied or adapted.
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
- **Off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative Commons licenses
(CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them), custom or
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
to download a mod or a pack does not give you the right to read its files.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
at your commit counts. In doubt, don't: ask in #contributors first.
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
your commit counts. In doubt, don't: ask in #contributors first.
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
pull request that used another project's source without saying so is closed.
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
was changed.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
their license. Shader packs were removed on 24 October 2026 (#1561) because their code had been written from
another project's source; they come back only as a clean-room rewrite from a written description of the pack
format.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
## Before asking for review
@@ -87,8 +88,8 @@ your change can affect. Without the vanilla data on your machine it lists those
`--quick` skips the harnesses. It exits non-zero when something fails.
For the game workspace, Clippy checks all targets of the touched crates and their transitive workspace dependents,
including examples. The dependency scope includes optional, platform, build and development dependencies.
Tests run library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
including examples. The dependency scope includes optional, platform, build and development dependencies. Tests run
library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
## Claim before you start
@@ -123,8 +124,8 @@ it (edited on every push, never repeated): talk to its author in the thread befo
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
`label:parity` is the list of real progress toward vanilla.
- **Release labels** (we set them if you can't): `feature` also puts it under "New" in the release notes,
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix. `release-now` is for crash
fixes that shouldn't wait. pr_ready suggests one.
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
+4 -4
View File
@@ -7,7 +7,7 @@
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
</p>
@@ -30,7 +30,7 @@ Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same t
## Download
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
@@ -122,13 +122,13 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
## Project status
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
### Java parity: 100%
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
+2 -2
View File
@@ -868,7 +868,7 @@ Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526). The templates' block entities now keep their patterns ([#1928](https://github.com/horizon-rust/horizon/issues/1928)).
</details>
@@ -878,7 +878,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660). The loot's enchantment functions now reach every table that rolls ([#1911](https://github.com/horizon-rust/horizon/issues/1911), [#2037](https://github.com/horizon-rust/horizon/issues/2037)).
</details>
+2
View File
@@ -16,6 +16,8 @@ are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
The GLSL frontend also resets the common type for each comma-separated declarator so one
variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
## libmimalloc-sys and mimalloc
+870
View File
@@ -0,0 +1,870 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:00:15Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "nvidia",
"driver": "NVIDIA 595.71.05",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.408,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 66658.183,
"stdev": 61839.502,
"min": 1827.42,
"max": 124993.694,
"ci95": 153630.237
},
"startup.world_ms": {
"n": 3,
"mean": 74754.411,
"stdev": 59189.587,
"min": 13600.651,
"max": 131761.773,
"ci95": 147046.952
},
"chunks.village.load_ms": {
"n": 3,
"mean": 5571.499,
"stdev": 1900.32,
"min": 3378.38,
"max": 6730.394,
"ci95": 4721.037
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 15287.0,
"stdev": 527.501,
"min": 14788.0,
"max": 15839.0,
"ci95": 1310.49
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2958.557,
"stdev": 376.543,
"min": 2526.453,
"max": 3216.434,
"ci95": 935.46
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 368.457,
"stdev": 90.668,
"min": 300.631,
"max": 471.438,
"ci95": 225.25
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 91.04,
"stdev": 40.506,
"min": 46.124,
"max": 124.796,
"ci95": 100.63
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 581.0,
"stdev": 48.0,
"min": 533.0,
"max": 629.0,
"ci95": 119.248
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1277.531,
"stdev": 25.546,
"min": 1253.879,
"max": 1304.623,
"ci95": 63.466
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.529,
"stdev": 0.127,
"min": 0.396,
"max": 0.649,
"ci95": 0.315
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.185,
"stdev": 0.034,
"min": 0.162,
"max": 0.224,
"ci95": 0.085
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3318.3,
"stdev": 911.608,
"min": 2266.627,
"max": 3883.075,
"ci95": 2264.743
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 16565.333,
"stdev": 172.755,
"min": 16367.0,
"max": 16683.0,
"ci95": 429.182
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 2333.626,
"stdev": 569.124,
"min": 1745.365,
"max": 2881.457,
"ci95": 1413.897
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 336.615,
"stdev": 185.666,
"min": 208.133,
"max": 549.487,
"ci95": 461.257
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 160.734,
"stdev": 127.586,
"min": 39.777,
"max": 294.05,
"ci95": 316.967
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 666.333,
"stdev": 24.44,
"min": 645.0,
"max": 693.0,
"ci95": 60.718
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1346.816,
"stdev": 42.279,
"min": 1305.547,
"max": 1390.037,
"ci95": 105.035
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.669,
"stdev": 0.084,
"min": 0.598,
"max": 0.761,
"ci95": 0.208
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.222,
"stdev": 0.032,
"min": 0.197,
"max": 0.258,
"ci95": 0.079
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 2463.434,
"stdev": 1056.0,
"min": 1527.516,
"max": 3608.283,
"ci95": 2623.462
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 14226.667,
"stdev": 307.077,
"min": 14038.0,
"max": 14581.0,
"ci95": 762.883
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 2192.874,
"stdev": 1320.904,
"min": 673.829,
"max": 3071.415,
"ci95": 3281.573
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 345.406,
"stdev": 294.904,
"min": 8.977,
"max": 559.232,
"ci95": 732.642
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 172.573,
"stdev": 149.386,
"min": 1.038,
"max": 274.089,
"ci95": 371.125
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 629.0,
"stdev": 32.0,
"min": 597.0,
"max": 661.0,
"ci95": 79.499
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1352.223,
"stdev": 51.951,
"min": 1301.492,
"max": 1405.314,
"ci95": 129.065
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.512,
"stdev": 0.099,
"min": 0.406,
"max": 0.603,
"ci95": 0.246
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.178,
"stdev": 0.038,
"min": 0.135,
"max": 0.206,
"ci95": 0.094
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 6260.675,
"stdev": 3077.348,
"min": 2976.342,
"max": 9077.55,
"ci95": 7645.173
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 16015.333,
"stdev": 209.872,
"min": 15826.0,
"max": 16241.0,
"ci95": 521.393
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1412.095,
"stdev": 962.145,
"min": 714.773,
"max": 2509.775,
"ci95": 2390.293
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 182.479,
"stdev": 177.563,
"min": 13.364,
"max": 367.429,
"ci95": 441.126
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 88.451,
"stdev": 87.578,
"min": 1.526,
"max": 176.667,
"ci95": 217.572
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 799.667,
"stdev": 18.475,
"min": 789.0,
"max": 821.0,
"ci95": 45.899
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1491.572,
"stdev": 36.719,
"min": 1459.775,
"max": 1531.76,
"ci95": 91.221
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.697,
"stdev": 0.155,
"min": 0.602,
"max": 0.876,
"ci95": 0.386
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 0.325,
"stdev": 0.115,
"min": 0.239,
"max": 0.456,
"ci95": 0.286
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1545.896,
"stdev": 30.591,
"min": 1524.965,
"max": 1581.004,
"ci95": 75.999
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1348.389,
"stdev": 52.5,
"min": 1296.172,
"max": 1401.168,
"ci95": 130.428
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 37594,
"seconds": 12.0,
"avg_fps": 3132.783,
"low_1pct_fps": 471.438,
"low_01pct_fps": 124.796,
"p50_ms": 0.271,
"p95_ms": 0.494,
"p99_ms": 1.127,
"p999_ms": 2.15,
"max_ms": 155.204,
"cpu_p50_ms": 0.168,
"gpu_p50_ms": 0.104,
"histogram": [
37151,
394,
30,
8,
5,
2,
2,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 38598,
"seconds": 12.0,
"avg_fps": 3216.434,
"low_1pct_fps": 300.631,
"low_01pct_fps": 46.124,
"p50_ms": 0.267,
"p95_ms": 0.446,
"p99_ms": 1.034,
"p999_ms": 1.941,
"max_ms": 493.993,
"cpu_p50_ms": 0.162,
"gpu_p50_ms": 0.104,
"histogram": [
38177,
387,
16,
8,
4,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
1,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 30318,
"seconds": 12.0,
"avg_fps": 2526.453,
"low_1pct_fps": 333.302,
"low_01pct_fps": 102.199,
"p50_ms": 0.305,
"p95_ms": 0.757,
"p99_ms": 1.69,
"p999_ms": 3.539,
"max_ms": 170.856,
"cpu_p50_ms": 0.224,
"gpu_p50_ms": 0.106,
"histogram": [
29368,
759,
139,
35,
11,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 34578,
"seconds": 12.0,
"avg_fps": 2881.457,
"low_1pct_fps": 549.487,
"low_01pct_fps": 294.05,
"p50_ms": 0.32,
"p95_ms": 0.483,
"p99_ms": 1.213,
"p999_ms": 2.5,
"max_ms": 5.358,
"cpu_p50_ms": 0.197,
"gpu_p50_ms": 0.154,
"histogram": [
34119,
379,
64,
9,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 28489,
"seconds": 12.0,
"avg_fps": 2374.055,
"low_1pct_fps": 208.133,
"low_01pct_fps": 39.777,
"p50_ms": 0.313,
"p95_ms": 0.937,
"p99_ms": 1.863,
"p999_ms": 4.318,
"max_ms": 571.48,
"cpu_p50_ms": 0.211,
"gpu_p50_ms": 0.151,
"histogram": [
27193,
1071,
140,
51,
19,
9,
1,
2,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 20945,
"seconds": 12.0,
"avg_fps": 1745.365,
"low_1pct_fps": 252.226,
"low_01pct_fps": 148.375,
"p50_ms": 0.343,
"p95_ms": 1.505,
"p99_ms": 2.758,
"p999_ms": 5.341,
"max_ms": 11.642,
"cpu_p50_ms": 0.258,
"gpu_p50_ms": 0.162,
"histogram": [
18145,
2293,
337,
106,
38,
13,
6,
5,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 36856,
"seconds": 12.0,
"avg_fps": 3071.415,
"low_1pct_fps": 559.232,
"low_01pct_fps": 274.089,
"p50_ms": 0.286,
"p95_ms": 0.492,
"p99_ms": 1.179,
"p999_ms": 2.711,
"max_ms": 6.718,
"cpu_p50_ms": 0.135,
"gpu_p50_ms": 0.127,
"histogram": [
36366,
405,
60,
13,
10,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 34001,
"seconds": 12.0,
"avg_fps": 2833.377,
"low_1pct_fps": 468.008,
"low_01pct_fps": 242.591,
"p50_ms": 0.295,
"p95_ms": 0.589,
"p99_ms": 1.41,
"p999_ms": 3.048,
"max_ms": 7.7,
"cpu_p50_ms": 0.206,
"gpu_p50_ms": 0.126,
"histogram": [
33314,
544,
106,
23,
10,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 8086,
"seconds": 12.0,
"avg_fps": 673.829,
"low_1pct_fps": 8.977,
"low_01pct_fps": 1.038,
"p50_ms": 0.302,
"p95_ms": 0.665,
"p99_ms": 2.495,
"p999_ms": 15.716,
"max_ms": 4849.364,
"cpu_p50_ms": 0.194,
"gpu_p50_ms": 0.128,
"histogram": [
7808,
166,
50,
26,
8,
2,
3,
5,
2,
5,
1,
2,
2,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
2
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 12141,
"seconds": 12.0,
"avg_fps": 1011.738,
"low_1pct_fps": 166.643,
"low_01pct_fps": 87.16,
"p50_ms": 0.641,
"p95_ms": 2.642,
"p99_ms": 4.468,
"p999_ms": 7.821,
"max_ms": 22.536,
"cpu_p50_ms": 0.456,
"gpu_p50_ms": 0.138,
"histogram": [
7840,
2996,
866,
278,
87,
38,
16,
11,
2,
5,
0,
0,
0,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 30118,
"seconds": 12.0,
"avg_fps": 2509.775,
"low_1pct_fps": 367.429,
"low_01pct_fps": 176.667,
"p50_ms": 0.316,
"p95_ms": 0.771,
"p99_ms": 1.931,
"p999_ms": 4.2,
"max_ms": 14.633,
"cpu_p50_ms": 0.239,
"gpu_p50_ms": 0.127,
"histogram": [
29101,
775,
173,
38,
16,
8,
4,
1,
0,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 8577,
"seconds": 12.0,
"avg_fps": 714.773,
"low_1pct_fps": 13.364,
"low_01pct_fps": 1.526,
"p50_ms": 0.416,
"p95_ms": 2.257,
"p99_ms": 4.36,
"p999_ms": 212.217,
"max_ms": 1051.428,
"cpu_p50_ms": 0.28,
"gpu_p50_ms": 0.132,
"histogram": [
7017,
978,
377,
111,
39,
24,
10,
6,
1,
0,
2,
2,
0,
0,
0,
0,
0,
0,
1,
0,
1,
2,
4,
2
]
}
],
"label": "horizon-desktop"
}
+870
View File
@@ -0,0 +1,870 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T14:47:19Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "nvidia",
"driver": "NVIDIA 595.71.05",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 29.665,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 45386.16,
"stdev": 38970.921,
"min": 1077.276,
"max": 74343.156,
"ci95": 96816.948
},
"startup.world_ms": {
"n": 3,
"mean": 86585.608,
"stdev": 71331.593,
"min": 11201.581,
"max": 153020.143,
"ci95": 177211.803
},
"chunks.village.load_ms": {
"n": 3,
"mean": 11082.587,
"stdev": 5351.291,
"min": 7620.454,
"max": 17246.096,
"ci95": 13294.417
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 48440.333,
"stdev": 656.16,
"min": 48060.0,
"max": 49198.0,
"ci95": 1630.124
},
"fps.village.avg_fps": {
"n": 3,
"mean": 853.775,
"stdev": 345.646,
"min": 524.913,
"max": 1214.059,
"ci95": 858.701
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 130.235,
"stdev": 117.39,
"min": 9.024,
"max": 243.388,
"ci95": 291.635
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 70.65,
"stdev": 68.628,
"min": 1.125,
"max": 138.345,
"ci95": 170.496
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 2202.333,
"stdev": 33.307,
"min": 2165.0,
"max": 2229.0,
"ci95": 82.745
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1652.647,
"stdev": 73.452,
"min": 1568.063,
"max": 1700.344,
"ci95": 182.478
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.493,
"stdev": 0.169,
"min": 0.319,
"max": 0.657,
"ci95": 0.42
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.692,
"stdev": 0.207,
"min": 0.531,
"max": 0.925,
"ci95": 0.513
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 15019.921,
"stdev": 12855.4,
"min": 5613.942,
"max": 29668.11,
"ci95": 31937.162
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 80224.667,
"stdev": 678.709,
"min": 79626.0,
"max": 80962.0,
"ci95": 1686.142
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 898.208,
"stdev": 446.3,
"min": 382.944,
"max": 1163.61,
"ci95": 1108.759
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 178.513,
"stdev": 107.096,
"min": 54.933,
"max": 244.255,
"ci95": 266.064
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 103.369,
"stdev": 78.403,
"min": 12.974,
"max": 152.87,
"ci95": 194.779
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 2201.667,
"stdev": 34.429,
"min": 2163.0,
"max": 2229.0,
"ci95": 85.532
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1809.063,
"stdev": 93.103,
"min": 1707.98,
"max": 1891.305,
"ci95": 231.3
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.585,
"stdev": 0.147,
"min": 0.493,
"max": 0.754,
"ci95": 0.365
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.989,
"stdev": 0.725,
"min": 0.57,
"max": 1.826,
"ci95": 1.802
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 12399.047,
"stdev": 15052.073,
"min": 3174.115,
"max": 29768.486,
"ci95": 37394.441
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 64737.0,
"stdev": 16149.666,
"min": 46089.0,
"max": 74089.0,
"ci95": 40121.232
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 735.22,
"stdev": 601.75,
"min": 108.108,
"max": 1307.912,
"ci95": 1494.952
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 134.654,
"stdev": 147.391,
"min": 1.252,
"max": 292.881,
"ci95": 366.169
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 62.062,
"stdev": 66.294,
"min": 0.202,
"max": 132.042,
"ci95": 164.697
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 1942.333,
"stdev": 251.725,
"min": 1657.0,
"max": 2133.0,
"ci95": 625.369
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1812.661,
"stdev": 102.065,
"min": 1697.912,
"max": 1893.313,
"ci95": 253.564
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.421,
"stdev": 0.227,
"min": 0.193,
"max": 0.646,
"ci95": 0.563
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.513,
"stdev": 0.194,
"min": 0.303,
"max": 0.686,
"ci95": 0.483
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 31815.539,
"stdev": 36758.245,
"min": 9582.166,
"max": 74243.988,
"ci95": 91319.913
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 90876.333,
"stdev": 1525.588,
"min": 89793.0,
"max": 92621.0,
"ci95": 3790.075
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 712.657,
"stdev": 252.815,
"min": 450.565,
"max": 955.041,
"ci95": 628.077
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 117.93,
"stdev": 85.922,
"min": 24.389,
"max": 193.34,
"ci95": 213.46
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 72.407,
"stdev": 65.468,
"min": 3.577,
"max": 133.895,
"ci95": 162.646
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 2783.667,
"stdev": 48.881,
"min": 2741.0,
"max": 2837.0,
"ci95": 121.436
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1931.359,
"stdev": 106.681,
"min": 1808.184,
"max": 1994.242,
"ci95": 265.031
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.03,
"stdev": 0.685,
"min": 0.564,
"max": 1.817,
"ci95": 1.702
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 1.025,
"stdev": 0.315,
"min": 0.765,
"max": 1.376,
"ci95": 0.783
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2051.333,
"stdev": 147.151,
"min": 1884.176,
"max": 2161.316,
"ci95": 365.573
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1810.92,
"stdev": 94.623,
"min": 1707.342,
"max": 1892.828,
"ci95": 235.075
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 6301,
"seconds": 12.004,
"avg_fps": 524.913,
"low_1pct_fps": 9.024,
"low_01pct_fps": 1.125,
"p50_ms": 0.555,
"p95_ms": 2.072,
"p99_ms": 4.067,
"p999_ms": 282.455,
"max_ms": 3525.74,
"cpu_p50_ms": 0.531,
"gpu_p50_ms": 0.218,
"histogram": [
4878,
1060,
217,
86,
19,
15,
6,
2,
5,
0,
1,
2,
0,
0,
0,
0,
0,
0,
0,
2,
0,
4,
3,
1
]
},
{
"phase": "village",
"repeat": 1,
"frames": 9869,
"seconds": 12.001,
"avg_fps": 822.353,
"low_1pct_fps": 138.293,
"low_01pct_fps": 72.479,
"p50_ms": 0.976,
"p95_ms": 2.871,
"p99_ms": 5.065,
"p999_ms": 9.942,
"max_ms": 23.337,
"cpu_p50_ms": 0.925,
"gpu_p50_ms": 0.245,
"histogram": [
5262,
3404,
771,
248,
81,
49,
23,
11,
12,
3,
0,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 14570,
"seconds": 12.001,
"avg_fps": 1214.059,
"low_1pct_fps": 243.388,
"low_01pct_fps": 138.345,
"p50_ms": 0.655,
"p95_ms": 1.486,
"p99_ms": 2.883,
"p999_ms": 5.366,
"max_ms": 26.145,
"cpu_p50_ms": 0.62,
"gpu_p50_ms": 0.239,
"histogram": [
11502,
2683,
257,
77,
25,
23,
1,
1,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 4595,
"seconds": 11.999,
"avg_fps": 382.944,
"low_1pct_fps": 54.933,
"low_01pct_fps": 12.974,
"p50_ms": 2.269,
"p95_ms": 5.193,
"p99_ms": 8.719,
"p999_ms": 21.933,
"max_ms": 243.247,
"cpu_p50_ms": 1.826,
"gpu_p50_ms": 0.502,
"histogram": [
273,
1654,
1395,
836,
172,
122,
52,
38,
26,
10,
7,
5,
0,
2,
1,
0,
0,
0,
1,
0,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 13964,
"seconds": 12.001,
"avg_fps": 1163.61,
"low_1pct_fps": 244.255,
"low_01pct_fps": 152.87,
"p50_ms": 0.682,
"p95_ms": 1.524,
"p99_ms": 2.877,
"p999_ms": 5.801,
"max_ms": 7.977,
"cpu_p50_ms": 0.57,
"gpu_p50_ms": 0.467,
"histogram": [
11067,
2532,
235,
82,
23,
16,
6,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 13777,
"seconds": 12.0,
"avg_fps": 1148.071,
"low_1pct_fps": 236.351,
"low_01pct_fps": 144.264,
"p50_ms": 0.691,
"p95_ms": 1.637,
"p99_ms": 3.131,
"p999_ms": 5.794,
"max_ms": 9.301,
"cpu_p50_ms": 0.57,
"gpu_p50_ms": 0.465,
"histogram": [
10826,
2511,
284,
106,
23,
16,
5,
5,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 1224,
"seconds": 11.322,
"avg_fps": 108.108,
"low_1pct_fps": 1.252,
"low_01pct_fps": 0.202,
"p50_ms": 0.439,
"p95_ms": 3.015,
"p99_ms": 7.359,
"p999_ms": 2880.23,
"max_ms": 7005.436,
"cpu_p50_ms": 0.303,
"gpu_p50_ms": 0.258,
"histogram": [
1081,
31,
49,
10,
3,
7,
23,
12,
4,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
2
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 15695,
"seconds": 12.0,
"avg_fps": 1307.912,
"low_1pct_fps": 292.881,
"low_01pct_fps": 132.042,
"p50_ms": 0.624,
"p95_ms": 1.219,
"p99_ms": 2.173,
"p999_ms": 4.841,
"max_ms": 16.764,
"cpu_p50_ms": 0.551,
"gpu_p50_ms": 0.401,
"histogram": [
13558,
1932,
140,
37,
14,
6,
3,
1,
1,
1,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 9476,
"seconds": 12.0,
"avg_fps": 789.641,
"low_1pct_fps": 109.83,
"low_01pct_fps": 53.941,
"p50_ms": 0.802,
"p95_ms": 3.406,
"p99_ms": 5.844,
"p999_ms": 14.977,
"max_ms": 22.481,
"cpu_p50_ms": 0.686,
"gpu_p50_ms": 0.403,
"histogram": [
5819,
2151,
787,
455,
112,
65,
36,
18,
7,
7,
7,
5,
4,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 5352,
"seconds": 11.878,
"avg_fps": 450.565,
"low_1pct_fps": 24.389,
"low_01pct_fps": 3.577,
"p50_ms": 1.431,
"p95_ms": 4.548,
"p99_ms": 7.601,
"p999_ms": 96.086,
"max_ms": 714.027,
"cpu_p50_ms": 1.376,
"gpu_p50_ms": 0.398,
"histogram": [
1412,
2323,
811,
460,
138,
84,
49,
34,
13,
9,
8,
2,
1,
1,
0,
1,
0,
0,
1,
2,
1,
1,
1,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8789,
"seconds": 12.001,
"avg_fps": 732.365,
"low_1pct_fps": 136.06,
"low_01pct_fps": 79.749,
"p50_ms": 0.972,
"p95_ms": 3.329,
"p99_ms": 5.68,
"p999_ms": 9.7,
"max_ms": 18.289,
"cpu_p50_ms": 0.935,
"gpu_p50_ms": 0.383,
"histogram": [
4529,
3007,
715,
287,
120,
61,
37,
16,
12,
1,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 11461,
"seconds": 12.001,
"avg_fps": 955.041,
"low_1pct_fps": 193.34,
"low_01pct_fps": 133.895,
"p50_ms": 0.787,
"p95_ms": 2.026,
"p99_ms": 3.953,
"p999_ms": 6.511,
"max_ms": 9.761,
"cpu_p50_ms": 0.765,
"gpu_p50_ms": 0.379,
"histogram": [
7754,
3114,
328,
165,
46,
33,
14,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-desktop"
}
+854
View File
@@ -0,0 +1,854 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T12:44:40Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.39,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 1516.155,
"stdev": 34.227,
"min": 1476.782,
"max": 1538.813,
"ci95": 85.032
},
"startup.world_ms": {
"n": 3,
"mean": 4080.9,
"stdev": 748.712,
"min": 3273.516,
"max": 4752.301,
"ci95": 1860.054
},
"chunks.village.load_ms": {
"n": 3,
"mean": 3363.749,
"stdev": 224.552,
"min": 3105.622,
"max": 3514.059,
"ci95": 557.864
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 15473.667,
"stdev": 339.602,
"min": 15123.0,
"max": 15801.0,
"ci95": 843.686
},
"fps.village.avg_fps": {
"n": 3,
"mean": 999.753,
"stdev": 41.901,
"min": 970.527,
"max": 1047.758,
"ci95": 104.095
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 336.821,
"stdev": 17.834,
"min": 318.068,
"max": 353.566,
"ci95": 44.306
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 263.782,
"stdev": 31.556,
"min": 227.767,
"max": 286.582,
"ci95": 78.395
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1147.654,
"stdev": 31.82,
"min": 1116.875,
"max": 1180.422,
"ci95": 79.052
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.414,
"stdev": 0.036,
"min": 0.373,
"max": 0.44,
"ci95": 0.089
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.52,
"stdev": 0.071,
"min": 0.44,
"max": 0.577,
"ci95": 0.177
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3685.368,
"stdev": 844.43,
"min": 2932.117,
"max": 4598.204,
"ci95": 2097.85
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 16795.333,
"stdev": 437.802,
"min": 16325.0,
"max": 17191.0,
"ci95": 1087.648
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 592.615,
"stdev": 20.339,
"min": 580.401,
"max": 616.094,
"ci95": 50.529
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 250.069,
"stdev": 31.227,
"min": 217.065,
"max": 279.149,
"ci95": 77.58
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 151.684,
"stdev": 77.064,
"min": 64.033,
"max": 208.811,
"ci95": 191.454
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1064.344,
"stdev": 148.185,
"min": 901.203,
"max": 1190.609,
"ci95": 368.142
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.424,
"stdev": 0.003,
"min": 0.422,
"max": 0.427,
"ci95": 0.007
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.617,
"stdev": 0.016,
"min": 0.599,
"max": 0.629,
"ci95": 0.039
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 3594.311,
"stdev": 1381.026,
"min": 2011.153,
"max": 4551.541,
"ci95": 3430.936
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 14527.0,
"stdev": 319.629,
"min": 14166.0,
"max": 14774.0,
"ci95": 794.068
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 789.003,
"stdev": 25.076,
"min": 766.83,
"max": 816.216,
"ci95": 62.297
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 276.887,
"stdev": 51.785,
"min": 217.373,
"max": 311.665,
"ci95": 128.651
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 169.278,
"stdev": 29.119,
"min": 137.156,
"max": 193.944,
"ci95": 72.341
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 982.995,
"stdev": 287.819,
"min": 651.109,
"max": 1164.063,
"ci95": 715.039
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.397,
"stdev": 0.028,
"min": 0.38,
"max": 0.429,
"ci95": 0.069
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.569,
"stdev": 0.045,
"min": 0.533,
"max": 0.62,
"ci95": 0.112
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 5682.993,
"stdev": 1272.063,
"min": 4476.873,
"max": 7012.069,
"ci95": 3160.234
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 15640.0,
"stdev": 204.105,
"min": 15507.0,
"max": 15875.0,
"ci95": 507.067
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 716.475,
"stdev": 8.139,
"min": 707.613,
"max": 723.617,
"ci95": 20.221
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 313.736,
"stdev": 3.759,
"min": 311.007,
"max": 318.023,
"ci95": 9.338
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 270.042,
"stdev": 19.572,
"min": 247.802,
"max": 284.642,
"ci95": 48.624
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 997.474,
"stdev": 231.733,
"min": 790.266,
"max": 1247.703,
"ci95": 575.704
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.385,
"stdev": 0.023,
"min": 0.359,
"max": 0.401,
"ci95": 0.057
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 0.609,
"stdev": 0.044,
"min": 0.56,
"max": 0.645,
"ci95": 0.109
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1213.255,
"stdev": 81.897,
"min": 1136.078,
"max": 1299.172,
"ci95": 203.461
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1034.279,
"stdev": 188.802,
"min": 816.453,
"max": 1150.961,
"ci95": 469.049
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2167.747,
"stdev": 14.039,
"min": 2153.721,
"max": 2181.799,
"ci95": 34.878
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 1889.468,
"stdev": 24.444,
"min": 1862.799,
"max": 1910.807,
"ci95": 60.727
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 12573,
"seconds": 12.0,
"avg_fps": 1047.758,
"low_1pct_fps": 353.566,
"low_01pct_fps": 286.582,
"p50_ms": 0.921,
"p95_ms": 1.077,
"p99_ms": 2.675,
"p999_ms": 2.946,
"max_ms": 5.36,
"cpu_p50_ms": 0.44,
"gpu_p50_ms": 0.914,
"histogram": [
11056,
1291,
218,
6,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 11646,
"seconds": 12.0,
"avg_fps": 970.527,
"low_1pct_fps": 318.068,
"low_01pct_fps": 227.767,
"p50_ms": 0.973,
"p95_ms": 1.264,
"p99_ms": 2.805,
"p999_ms": 3.808,
"max_ms": 5.305,
"cpu_p50_ms": 0.577,
"gpu_p50_ms": 0.969,
"histogram": [
7035,
4334,
230,
39,
6,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 11771,
"seconds": 11.999,
"avg_fps": 980.973,
"low_1pct_fps": 338.828,
"low_01pct_fps": 276.996,
"p50_ms": 0.985,
"p95_ms": 1.157,
"p99_ms": 2.779,
"p999_ms": 3.068,
"max_ms": 4.308,
"cpu_p50_ms": 0.542,
"gpu_p50_ms": 0.98,
"histogram": [
6880,
4663,
208,
18,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 7394,
"seconds": 12.001,
"avg_fps": 616.094,
"low_1pct_fps": 253.993,
"low_01pct_fps": 182.207,
"p50_ms": 1.565,
"p95_ms": 1.915,
"p99_ms": 3.329,
"p999_ms": 4.661,
"max_ms": 7.32,
"cpu_p50_ms": 0.599,
"gpu_p50_ms": 1.551,
"histogram": [
82,
6988,
144,
162,
14,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 6965,
"seconds": 12.0,
"avg_fps": 580.401,
"low_1pct_fps": 217.065,
"low_01pct_fps": 64.033,
"p50_ms": 1.654,
"p95_ms": 1.983,
"p99_ms": 3.233,
"p999_ms": 3.893,
"max_ms": 81.863,
"cpu_p50_ms": 0.629,
"gpu_p50_ms": 1.645,
"histogram": [
11,
6622,
153,
174,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 6977,
"seconds": 12.001,
"avg_fps": 581.35,
"low_1pct_fps": 279.149,
"low_01pct_fps": 208.811,
"p50_ms": 1.659,
"p95_ms": 2.006,
"p99_ms": 3.273,
"p999_ms": 3.982,
"max_ms": 5.889,
"cpu_p50_ms": 0.623,
"gpu_p50_ms": 1.653,
"histogram": [
17,
6606,
164,
186,
1,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 9793,
"seconds": 11.998,
"avg_fps": 816.216,
"low_1pct_fps": 301.624,
"low_01pct_fps": 176.734,
"p50_ms": 1.171,
"p95_ms": 1.499,
"p99_ms": 2.928,
"p999_ms": 3.333,
"max_ms": 23.472,
"cpu_p50_ms": 0.533,
"gpu_p50_ms": 1.163,
"histogram": [
259,
9293,
182,
56,
2,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 9202,
"seconds": 12.0,
"avg_fps": 766.83,
"low_1pct_fps": 217.373,
"low_01pct_fps": 137.156,
"p50_ms": 1.226,
"p95_ms": 1.912,
"p99_ms": 3.396,
"p999_ms": 6.003,
"max_ms": 13.562,
"cpu_p50_ms": 0.62,
"gpu_p50_ms": 1.219,
"histogram": [
635,
8130,
261,
124,
27,
15,
7,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 9408,
"seconds": 12.001,
"avg_fps": 783.964,
"low_1pct_fps": 311.665,
"low_01pct_fps": 193.944,
"p50_ms": 1.22,
"p95_ms": 1.541,
"p99_ms": 2.865,
"p999_ms": 3.298,
"max_ms": 13.861,
"cpu_p50_ms": 0.555,
"gpu_p50_ms": 1.214,
"histogram": [
100,
9066,
204,
34,
2,
0,
1,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 8618,
"seconds": 12.0,
"avg_fps": 718.194,
"low_1pct_fps": 312.177,
"low_01pct_fps": 277.681,
"p50_ms": 1.338,
"p95_ms": 1.664,
"p99_ms": 3.068,
"p999_ms": 3.364,
"max_ms": 4.008,
"cpu_p50_ms": 0.622,
"gpu_p50_ms": 1.329,
"histogram": [
62,
8301,
130,
125,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8492,
"seconds": 12.001,
"avg_fps": 707.613,
"low_1pct_fps": 311.007,
"low_01pct_fps": 284.642,
"p50_ms": 1.35,
"p95_ms": 1.707,
"p99_ms": 3.08,
"p999_ms": 3.329,
"max_ms": 3.796,
"cpu_p50_ms": 0.645,
"gpu_p50_ms": 1.342,
"histogram": [
32,
8216,
111,
133,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 8684,
"seconds": 12.001,
"avg_fps": 723.617,
"low_1pct_fps": 318.023,
"low_01pct_fps": 247.802,
"p50_ms": 1.318,
"p95_ms": 1.699,
"p99_ms": 2.875,
"p999_ms": 3.573,
"max_ms": 5.186,
"cpu_p50_ms": 0.56,
"gpu_p50_ms": 1.306,
"histogram": [
46,
8389,
199,
49,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-macmini"
}
+854
View File
@@ -0,0 +1,854 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:17:26Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.712,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 1528.444,
"stdev": 181.21,
"min": 1319.58,
"max": 1643.783,
"ci95": 450.188
},
"startup.world_ms": {
"n": 3,
"mean": 12368.716,
"stdev": 6475.37,
"min": 7168.429,
"max": 19621.62,
"ci95": 16087.01
},
"chunks.village.load_ms": {
"n": 3,
"mean": 6926.208,
"stdev": 3712.691,
"min": 4208.555,
"max": 11156.419,
"ci95": 9223.581
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 46790.333,
"stdev": 1381.89,
"min": 45236.0,
"max": 47880.0,
"ci95": 3433.082
},
"fps.village.avg_fps": {
"n": 3,
"mean": 453.505,
"stdev": 14.384,
"min": 441.517,
"max": 469.455,
"ci95": 35.735
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 222.897,
"stdev": 10.449,
"min": 212.173,
"max": 233.048,
"ci95": 25.96
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 173.55,
"stdev": 31.798,
"min": 142.967,
"max": 206.437,
"ci95": 78.996
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1544.565,
"stdev": 26.422,
"min": 1516.75,
"max": 1569.328,
"ci95": 65.64
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.528,
"stdev": 0.018,
"min": 0.511,
"max": 0.548,
"ci95": 0.045
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 1.496,
"stdev": 0.049,
"min": 1.453,
"max": 1.549,
"ci95": 0.121
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8297.116,
"stdev": 689.909,
"min": 7716.447,
"max": 9059.776,
"ci95": 1713.966
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 78617.667,
"stdev": 494.532,
"min": 78228.0,
"max": 79174.0,
"ci95": 1228.586
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 216.265,
"stdev": 6.372,
"min": 209.501,
"max": 222.156,
"ci95": 15.831
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 137.027,
"stdev": 3.787,
"min": 133.07,
"max": 140.617,
"ci95": 9.408
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 114.683,
"stdev": 7.985,
"min": 106.585,
"max": 122.551,
"ci95": 19.839
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1420.638,
"stdev": 247.812,
"min": 1266.047,
"max": 1706.469,
"ci95": 615.649
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.423,
"stdev": 0.059,
"min": 0.355,
"max": 0.458,
"ci95": 0.148
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 1.605,
"stdev": 0.269,
"min": 1.295,
"max": 1.779,
"ci95": 0.668
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 8354.659,
"stdev": 2877.749,
"min": 5454.947,
"max": 11209.93,
"ci95": 7149.301
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 74315.667,
"stdev": 63.106,
"min": 74246.0,
"max": 74369.0,
"ci95": 156.776
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 274.828,
"stdev": 4.993,
"min": 270.041,
"max": 280.004,
"ci95": 12.404
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 165.765,
"stdev": 7.21,
"min": 157.538,
"max": 170.985,
"ci95": 17.912
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 140.185,
"stdev": 9.74,
"min": 129.976,
"max": 149.377,
"ci95": 24.198
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1306.578,
"stdev": 97.011,
"min": 1199.094,
"max": 1387.641,
"ci95": 241.007
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.416,
"stdev": 0.029,
"min": 0.392,
"max": 0.448,
"ci95": 0.071
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 1.636,
"stdev": 0.121,
"min": 1.496,
"max": 1.708,
"ci95": 0.301
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 17124.873,
"stdev": 3461.08,
"min": 13599.217,
"max": 20517.544,
"ci95": 8598.494
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 91909.0,
"stdev": 815.603,
"min": 91157.0,
"max": 92776.0,
"ci95": 2026.235
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 242.657,
"stdev": 3.405,
"min": 240.66,
"max": 246.588,
"ci95": 8.459
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 110.95,
"stdev": 14.622,
"min": 99.907,
"max": 127.532,
"ci95": 36.325
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 51.329,
"stdev": 28.253,
"min": 30.391,
"max": 83.465,
"ci95": 70.191
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1451.688,
"stdev": 127.471,
"min": 1310.969,
"max": 1559.43,
"ci95": 316.681
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.614,
"stdev": 0.017,
"min": 0.594,
"max": 0.627,
"ci95": 0.043
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 2.233,
"stdev": 0.05,
"min": 2.194,
"max": 2.289,
"ci95": 0.124
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1697.984,
"stdev": 29.735,
"min": 1667.406,
"max": 1726.797,
"ci95": 73.871
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1428.19,
"stdev": 120.658,
"min": 1333.0,
"max": 1563.891,
"ci95": 299.756
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 4921.753,
"stdev": 30.436,
"min": 4890.971,
"max": 4951.831,
"ci95": 75.613
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4079.43,
"stdev": 10.978,
"min": 4068.049,
"max": 4089.956,
"ci95": 27.273
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 5634,
"seconds": 12.001,
"avg_fps": 469.455,
"low_1pct_fps": 223.469,
"low_01pct_fps": 171.246,
"p50_ms": 2.054,
"p95_ms": 2.492,
"p99_ms": 3.993,
"p999_ms": 5.425,
"max_ms": 6.645,
"cpu_p50_ms": 1.453,
"gpu_p50_ms": 1.965,
"histogram": [
0,
2082,
3324,
193,
24,
9,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 5395,
"seconds": 12.001,
"avg_fps": 449.543,
"low_1pct_fps": 233.048,
"low_01pct_fps": 206.437,
"p50_ms": 2.155,
"p95_ms": 2.611,
"p99_ms": 4.068,
"p999_ms": 4.456,
"max_ms": 5.305,
"cpu_p50_ms": 1.549,
"gpu_p50_ms": 2.043,
"histogram": [
0,
902,
4255,
206,
29,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 5298,
"seconds": 12.0,
"avg_fps": 441.517,
"low_1pct_fps": 212.173,
"low_01pct_fps": 142.967,
"p50_ms": 2.171,
"p95_ms": 3.249,
"p99_ms": 4.138,
"p999_ms": 5.591,
"max_ms": 9.676,
"cpu_p50_ms": 1.485,
"gpu_p50_ms": 2.092,
"histogram": [
0,
709,
4314,
229,
36,
6,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 2606,
"seconds": 12.002,
"avg_fps": 217.137,
"low_1pct_fps": 140.617,
"low_01pct_fps": 106.585,
"p50_ms": 4.471,
"p95_ms": 5.926,
"p99_ms": 6.575,
"p999_ms": 7.735,
"max_ms": 12.187,
"cpu_p50_ms": 1.74,
"gpu_p50_ms": 4.238,
"histogram": [
0,
0,
0,
221,
2094,
172,
110,
8,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 2514,
"seconds": 12.0,
"avg_fps": 209.501,
"low_1pct_fps": 137.393,
"low_01pct_fps": 122.551,
"p50_ms": 4.627,
"p95_ms": 6.304,
"p99_ms": 6.93,
"p999_ms": 7.971,
"max_ms": 8.506,
"cpu_p50_ms": 1.779,
"gpu_p50_ms": 4.409,
"histogram": [
0,
0,
2,
35,
2097,
200,
162,
17,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 2667,
"seconds": 12.005,
"avg_fps": 222.156,
"low_1pct_fps": 133.07,
"low_01pct_fps": 114.913,
"p50_ms": 4.37,
"p95_ms": 5.756,
"p99_ms": 6.742,
"p999_ms": 8.444,
"max_ms": 8.984,
"cpu_p50_ms": 1.295,
"gpu_p50_ms": 4.152,
"histogram": [
0,
0,
0,
440,
1978,
144,
85,
16,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 3294,
"seconds": 12.003,
"avg_fps": 274.44,
"low_1pct_fps": 157.538,
"low_01pct_fps": 129.976,
"p50_ms": 3.521,
"p95_ms": 4.814,
"p99_ms": 5.592,
"p999_ms": 7.491,
"max_ms": 7.868,
"cpu_p50_ms": 1.703,
"gpu_p50_ms": 3.277,
"histogram": [
0,
4,
55,
2967,
130,
122,
10,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 3241,
"seconds": 12.002,
"avg_fps": 270.041,
"low_1pct_fps": 168.771,
"low_01pct_fps": 141.201,
"p50_ms": 3.578,
"p95_ms": 4.942,
"p99_ms": 5.631,
"p999_ms": 6.498,
"max_ms": 7.601,
"cpu_p50_ms": 1.708,
"gpu_p50_ms": 3.323,
"histogram": [
0,
2,
8,
2970,
110,
144,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 3360,
"seconds": 12.0,
"avg_fps": 280.004,
"low_1pct_fps": 170.985,
"low_01pct_fps": 149.377,
"p50_ms": 3.477,
"p95_ms": 4.678,
"p99_ms": 5.462,
"p999_ms": 6.626,
"max_ms": 6.788,
"cpu_p50_ms": 1.496,
"gpu_p50_ms": 3.23,
"histogram": [
0,
1,
5,
3125,
129,
90,
10,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 2888,
"seconds": 12.0,
"avg_fps": 240.66,
"low_1pct_fps": 127.532,
"low_01pct_fps": 83.465,
"p50_ms": 3.984,
"p95_ms": 5.171,
"p99_ms": 6.823,
"p999_ms": 9.168,
"max_ms": 15.341,
"cpu_p50_ms": 2.194,
"gpu_p50_ms": 3.786,
"histogram": [
0,
0,
4,
2048,
638,
144,
33,
16,
3,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 2889,
"seconds": 12.001,
"avg_fps": 240.722,
"low_1pct_fps": 99.907,
"low_01pct_fps": 40.132,
"p50_ms": 3.961,
"p95_ms": 5.706,
"p99_ms": 7.364,
"p999_ms": 12.597,
"max_ms": 49.156,
"cpu_p50_ms": 2.289,
"gpu_p50_ms": 3.802,
"histogram": [
0,
5,
126,
1859,
599,
194,
72,
22,
8,
1,
2,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 2959,
"seconds": 12.0,
"avg_fps": 246.588,
"low_1pct_fps": 105.411,
"low_01pct_fps": 30.391,
"p50_ms": 3.851,
"p95_ms": 5.353,
"p99_ms": 6.248,
"p999_ms": 9.05,
"max_ms": 54.378,
"cpu_p50_ms": 2.216,
"gpu_p50_ms": 3.667,
"histogram": [
0,
2,
58,
2196,
462,
196,
32,
10,
1,
0,
0,
0,
0,
0,
0,
1,
0,
1,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-macmini"
}
+42
View File
@@ -0,0 +1,42 @@
# Horizon Nightly 2026-11-04 vs vanilla 26.3 on the Mac mini and the desktop (2026-11-04)
> **The Mac mini reports (`*-macmini-rd*.json`) are offscreen, not comparable.** Horizon rendered offscreen (no presenting)
> while vanilla drew into a window, so the Mac rows below are not a Horizon-vs-vanilla comparison, and the website does not
> publish them: it keeps the Mac mini's 2026-10-22 runs. They are kept here only as a record of that day's offscreen run.
The desktop runs are the ones behind playhorizon.dev/benchmarks as of 2026-11-04 (horizon-rust/horizon-site#58): `tools/perf_bench.py`
reports, render distance 12 and 32, three games each, every game from a fresh world (seed 1234), 1280x720, VSync off. Files:
`{horizon,vanilla}-macmini-rd{12,32}.json` and `{horizon,vanilla}-desktop-rd{12,32}.json` next to this page.
## Builds
- **Horizon:** the Nightly 2026-11-04 release bundle (67dad0d), as the launcher installs it: macOS arm64 on the Mac,
Linux x86_64 on the desktop. Its resources come from the bundle's own `horizon-prepare`. perf_bench ran from main
a55b9a2e52 and wrote that checkout's crate version and commit; `app.version` and `app.commit` were set to the bundle's by hand.
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25, the official launcher's JVM flags.
## Machines and conditions
- **Mac mini, Apple M4 Pro (14 cores, 20-core GPU), macOS: offscreen, not comparable.** The session was locked, so Horizon ran `--offscreen`: a locked
session gives a window no frames, and windowed Horizon measured 0 FPS. Vanilla drew into its window. Offscreen skips
presenting, which favours Horizon, so its averages aren't held at the 144 Hz display as they were on 2026-10-22. Each game
started at a 1-minute load of 17-18 (the run waited for < 20); during the runs the load rose to 30-50.
- **Desktop, RTX 4080 + i7-13700KF, NixOS, NVIDIA 595.** Both games drew into a headless sway rendering on the RTX 4080 (never
on its screen), both through Wayland on Vulkan. Vanilla went through SDL's Wayland driver: through Xwayland each of its
frames took about 2 s on this day. The machine also runs CI and agents' builds: each game started at a load of 28-30 on
24 threads (the run waited for < 30) and the load rose to 40-70 during the runs. Vanilla at RD 32 crashed twice under that
load (`GPU timeout attempting to acquire next frame`) before a third series finished. Start-up times are inflated by the
load and by a cold disk on each series' first start (start → world up to 130-235 s).
- **Order:** RD 12 ran Horizon then vanilla; RD 32 ran vanilla then Horizon.
## Results (average over the flights of the three games; FPS average / 1% low)
| Machine | RD | Horizon | Vanilla | Horizon ÷ vanilla (avg, 1% low) |
|---|---|---|---|---|
| Mac mini (**offscreen, not comparable**) | 12 | 774 / 294 | 132 / 27 | — |
| Mac mini (**offscreen, not comparable**) | 32 | 297 / 159 | 68 / 16 | — |
| Desktop | 12 | 2,224 / 308 | 1,824 / 201 | 1.2x, 1.5x |
| Desktop | 32 | 800 / 140 | 696 / 64 | 1.15x, 2.2x |
Neither machine was idle, so these say how both games behave on busy machines. They are not clean ratios; for those, see
`windows-9800x3d-5080-2026-10-29.md`. The scripts and logs are in horizon-parity `shots/site-bench-2026-11-04/`.
+787
View File
@@ -0,0 +1,787 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:28:55Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "NVIDIA",
"driver": "1.4.329 NVIDIA 595.71.05",
"backend": "vulkan",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.788,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 121075.467,
"stdev": 30646.429,
"min": 93272.034,
"max": 153935.834,
"ci95": 76136.094
},
"startup.world_ms": {
"n": 3,
"mean": 191063.8,
"stdev": 34368.16,
"min": 153895.533,
"max": 221692.034,
"ci95": 85382.134
},
"chunks.village.load_ms": {
"n": 3,
"mean": 18724.322,
"stdev": 9639.048,
"min": 13107.549,
"max": 29854.376,
"ci95": 23946.655
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2161.532,
"stdev": 287.109,
"min": 1830.069,
"max": 2332.81,
"ci95": 713.276
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 162.575,
"stdev": 29.799,
"min": 136.023,
"max": 194.804,
"ci95": 74.03
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 27.752,
"stdev": 3.186,
"min": 24.403,
"max": 30.746,
"ci95": 7.916
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.village.rss_mb": {
"n": 3,
"mean": 2902.054,
"stdev": 158.708,
"min": 2747.244,
"max": 3064.393,
"ci95": 394.285
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.691,
"stdev": 0.097,
"min": 0.601,
"max": 0.794,
"ci95": 0.241
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8852.332,
"stdev": 530.809,
"min": 8350.811,
"max": 9408.241,
"ci95": 1318.709
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1841.581,
"stdev": 1186.781,
"min": 471.209,
"max": 2529.847,
"ci95": 2948.365
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 255.087,
"stdev": 217.128,
"min": 5.798,
"max": 402.877,
"ci95": 539.419
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 92.882,
"stdev": 81.722,
"min": 0.61,
"max": 156.13,
"ci95": 203.024
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 3008.178,
"stdev": 149.07,
"min": 2875.082,
"max": 3169.256,
"ci95": 370.34
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.687,
"stdev": 0.132,
"min": 0.562,
"max": 0.825,
"ci95": 0.327
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 21215.745,
"stdev": 22821.547,
"min": 8034.077,
"max": 47567.797,
"ci95": 56696.441
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1525.483,
"stdev": 1472.651,
"min": 633.984,
"max": 3225.275,
"ci95": 3658.563
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 191.398,
"stdev": 272.942,
"min": 10.346,
"max": 505.335,
"ci95": 678.08
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 49.506,
"stdev": 66.145,
"min": 1.104,
"max": 124.874,
"ci95": 164.326
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 3062.136,
"stdev": 128.594,
"min": 2962.541,
"max": 3207.313,
"ci95": 319.472
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 1.029,
"stdev": 0.573,
"min": 0.595,
"max": 1.678,
"ci95": 1.423
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 33255.15,
"stdev": 20451.99,
"min": 16655.144,
"max": 56102.104,
"ci95": 50809.661
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1769.12,
"stdev": 996.863,
"min": 818.512,
"max": 2806.557,
"ci95": 2476.544
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 195.916,
"stdev": 195.285,
"min": 11.586,
"max": 400.567,
"ci95": 485.154
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 52.202,
"stdev": 51.208,
"min": 1.461,
"max": 103.864,
"ci95": 127.217
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 2957.948,
"stdev": 340.935,
"min": 2586.504,
"max": 3256.625,
"ci95": 846.998
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.436,
"stdev": 1.406,
"min": 0.462,
"max": 3.048,
"ci95": 3.494
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 3107.953,
"stdev": 141.888,
"min": 3020.105,
"max": 3271.645,
"ci95": 352.498
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2985.317,
"stdev": 210.327,
"min": 2761.164,
"max": 3178.35,
"ci95": 522.523
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 21963,
"seconds": 12.001,
"avg_fps": 1830.069,
"low_1pct_fps": 136.023,
"low_01pct_fps": 30.746,
"p50_ms": 0.331,
"p95_ms": 1.153,
"p99_ms": 3.001,
"p999_ms": 9.997,
"max_ms": 326.36,
"histogram": [
20336,
1238,
167,
108,
40,
23,
12,
10,
8,
7,
5,
1,
2,
1,
2,
0,
1,
1,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 27994,
"seconds": 12.0,
"avg_fps": 2332.81,
"low_1pct_fps": 194.804,
"low_01pct_fps": 28.107,
"p50_ms": 0.321,
"p95_ms": 0.771,
"p99_ms": 1.356,
"p999_ms": 3.836,
"max_ms": 331.931,
"histogram": [
27270,
652,
34,
13,
5,
2,
3,
4,
3,
2,
0,
1,
0,
0,
0,
0,
1,
0,
1,
1,
0,
2,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 27861,
"seconds": 12.0,
"avg_fps": 2321.717,
"low_1pct_fps": 156.898,
"low_01pct_fps": 24.403,
"p50_ms": 0.281,
"p95_ms": 0.852,
"p99_ms": 1.73,
"p999_ms": 6.331,
"max_ms": 301.991,
"histogram": [
26932,
726,
124,
42,
4,
4,
3,
2,
9,
2,
3,
1,
1,
1,
0,
1,
1,
0,
2,
0,
2,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 30359,
"seconds": 12.0,
"avg_fps": 2529.847,
"low_1pct_fps": 356.586,
"low_01pct_fps": 121.906,
"p50_ms": 0.353,
"p95_ms": 0.563,
"p99_ms": 1.405,
"p999_ms": 4.444,
"max_ms": 20.302,
"histogram": [
29756,
460,
57,
52,
11,
5,
3,
5,
2,
4,
1,
0,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 5781,
"seconds": 12.268,
"avg_fps": 471.209,
"low_1pct_fps": 5.798,
"low_01pct_fps": 0.61,
"p50_ms": 0.359,
"p95_ms": 0.77,
"p99_ms": 1.78,
"p999_ms": 20.826,
"max_ms": 5834.37,
"histogram": [
5586,
158,
12,
11,
0,
1,
3,
2,
0,
0,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
2,
3
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 30285,
"seconds": 12.0,
"avg_fps": 2523.687,
"low_1pct_fps": 402.877,
"low_01pct_fps": 156.13,
"p50_ms": 0.351,
"p95_ms": 0.539,
"p99_ms": 1.574,
"p999_ms": 3.683,
"max_ms": 16.022,
"histogram": [
29666,
490,
74,
35,
7,
2,
2,
1,
1,
5,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 11685,
"seconds": 16.293,
"avg_fps": 717.19,
"low_1pct_fps": 10.346,
"low_01pct_fps": 1.104,
"p50_ms": 0.299,
"p95_ms": 1.119,
"p99_ms": 2.568,
"p999_ms": 12.054,
"max_ms": 5250.537,
"histogram": [
10993,
502,
102,
39,
14,
8,
6,
4,
4,
1,
1,
2,
0,
2,
0,
0,
0,
0,
1,
0,
0,
2,
0,
4
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 38704,
"seconds": 12.0,
"avg_fps": 3225.275,
"low_1pct_fps": 505.335,
"low_01pct_fps": 124.874,
"p50_ms": 0.283,
"p95_ms": 0.36,
"p99_ms": 0.909,
"p999_ms": 2.015,
"max_ms": 132.801,
"histogram": [
38385,
279,
25,
3,
2,
0,
1,
0,
2,
4,
1,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 7609,
"seconds": 12.002,
"avg_fps": 633.984,
"low_1pct_fps": 58.513,
"low_01pct_fps": 22.539,
"p50_ms": 1.141,
"p95_ms": 4.438,
"p99_ms": 9.113,
"p999_ms": 27.496,
"max_ms": 64.424,
"histogram": [
3452,
2398,
956,
368,
143,
96,
61,
46,
20,
29,
6,
10,
6,
7,
7,
0,
1,
3,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 10168,
"seconds": 12.423,
"avg_fps": 818.512,
"low_1pct_fps": 11.586,
"low_01pct_fps": 1.461,
"p50_ms": 0.267,
"p95_ms": 0.892,
"p99_ms": 3.176,
"p999_ms": 198.757,
"max_ms": 1882.64,
"histogram": [
9733,
205,
121,
49,
12,
9,
8,
2,
2,
0,
1,
2,
1,
3,
0,
3,
2,
0,
0,
2,
4,
5,
1,
3
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 33679,
"seconds": 12.0,
"avg_fps": 2806.557,
"low_1pct_fps": 400.567,
"low_01pct_fps": 103.864,
"p50_ms": 0.324,
"p95_ms": 0.411,
"p99_ms": 1.167,
"p999_ms": 3.508,
"max_ms": 83.572,
"histogram": [
33272,
315,
46,
22,
2,
1,
4,
2,
9,
1,
2,
2,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 20188,
"seconds": 12.0,
"avg_fps": 1682.292,
"low_1pct_fps": 175.594,
"low_01pct_fps": 51.28,
"p50_ms": 0.374,
"p95_ms": 1.37,
"p99_ms": 2.821,
"p999_ms": 8.947,
"max_ms": 53.494,
"histogram": [
18109,
1592,
314,
83,
36,
19,
7,
6,
6,
1,
3,
1,
4,
2,
3,
0,
1,
1,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-desktop"
}
+787
View File
@@ -0,0 +1,787 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T15:46:25Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "NVIDIA",
"driver": "1.4.329 NVIDIA 595.71.05",
"backend": "vulkan",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.799,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 112742.179,
"stdev": 27885.804,
"min": 81110.692,
"max": 133773.748,
"ci95": 69277.769
},
"startup.world_ms": {
"n": 3,
"mean": 235049.179,
"stdev": 31461.958,
"min": 203486.748,
"max": 266409.692,
"ci95": 78162.144
},
"chunks.village.load_ms": {
"n": 3,
"mean": 79828.05,
"stdev": 109107.365,
"min": 16685.972,
"max": 205814.266,
"ci95": 271059.597
},
"fps.village.avg_fps": {
"n": 3,
"mean": 525.667,
"stdev": 390.505,
"min": 101.073,
"max": 869.433,
"ci95": 970.146
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 44.209,
"stdev": 39.491,
"min": 1.644,
"max": 79.658,
"ci95": 98.109
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 10.635,
"stdev": 9.759,
"min": 0.686,
"max": 20.192,
"ci95": 24.244
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.village.rss_mb": {
"n": 3,
"mean": 3296.229,
"stdev": 296.464,
"min": 2956.234,
"max": 3500.773,
"ci95": 736.517
},
"tick.village.client_ms": {
"n": 3,
"mean": 1.23,
"stdev": 0.449,
"min": 0.897,
"max": 1.74,
"ci95": 1.114
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 61172.527,
"stdev": 17434.762,
"min": 46779.931,
"max": 80559.429,
"ci95": 43313.846
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 599.408,
"stdev": 492.96,
"min": 64.173,
"max": 1034.813,
"ci95": 1224.68
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 61.923,
"stdev": 56.534,
"min": 0.737,
"max": 112.221,
"ci95": 140.449
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 15.771,
"stdev": 14.11,
"min": 0.098,
"max": 27.463,
"ci95": 35.054
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 3278.964,
"stdev": 301.465,
"min": 2947.268,
"max": 3536.27,
"ci95": 748.941
},
"tick.forest.client_ms": {
"n": 3,
"mean": 5.908,
"stdev": 8.813,
"min": 0.693,
"max": 16.084,
"ci95": 21.895
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 61762.098,
"stdev": 3569.17,
"min": 57919.097,
"max": 64972.973,
"ci95": 8867.026
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1175.044,
"stdev": 131.94,
"min": 1093.801,
"max": 1327.281,
"ci95": 327.785
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 119.758,
"stdev": 68.419,
"min": 77.252,
"max": 198.683,
"ci95": 169.976
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 24.224,
"stdev": 18.982,
"min": 12.308,
"max": 46.114,
"ci95": 47.159
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 3275.349,
"stdev": 317.864,
"min": 2924.113,
"max": 3543.23,
"ci95": 789.681
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.654,
"stdev": 0.066,
"min": 0.579,
"max": 0.699,
"ci95": 0.163
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 113439.529,
"stdev": 64746.162,
"min": 55046.887,
"max": 183068.386,
"ci95": 160851.363
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 485.832,
"stdev": 317.254,
"min": 121.312,
"max": 699.617,
"ci95": 788.165
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 28.612,
"stdev": 23.334,
"min": 2.229,
"max": 46.542,
"ci95": 57.97
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 4.56,
"stdev": 3.408,
"min": 0.75,
"max": 7.318,
"ci95": 8.467
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 3248.708,
"stdev": 480.962,
"min": 2700.645,
"max": 3600.484,
"ci95": 1194.873
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.832,
"stdev": 2.076,
"min": 0.618,
"max": 4.229,
"ci95": 5.157
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 3371.958,
"stdev": 280.347,
"min": 3059.926,
"max": 3602.613,
"ci95": 696.476
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 3291.249,
"stdev": 316.441,
"min": 2935.975,
"max": 3542.848,
"ci95": 786.147
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 1248,
"seconds": 12.348,
"avg_fps": 101.073,
"low_1pct_fps": 1.644,
"low_01pct_fps": 0.686,
"p50_ms": 1.225,
"p95_ms": 11.161,
"p99_ms": 226.597,
"p999_ms": 1457.605,
"max_ms": 1457.874,
"histogram": [
548,
232,
97,
118,
51,
49,
24,
39,
16,
17,
13,
6,
3,
2,
5,
3,
2,
2,
4,
2,
3,
8,
1,
3
]
},
{
"phase": "village",
"repeat": 1,
"frames": 10435,
"seconds": 12.002,
"avg_fps": 869.433,
"low_1pct_fps": 79.658,
"low_01pct_fps": 20.192,
"p50_ms": 0.813,
"p95_ms": 2.537,
"p99_ms": 5.685,
"p999_ms": 13.292,
"max_ms": 351.251,
"histogram": [
7598,
2071,
393,
171,
55,
53,
22,
24,
20,
10,
9,
2,
2,
1,
3,
0,
0,
0,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 7278,
"seconds": 12.0,
"avg_fps": 606.495,
"low_1pct_fps": 51.326,
"low_01pct_fps": 11.026,
"p50_ms": 1.105,
"p95_ms": 3.854,
"p99_ms": 7.067,
"p999_ms": 24.484,
"max_ms": 443.95,
"histogram": [
3206,
2621,
800,
360,
112,
71,
34,
26,
12,
13,
3,
5,
2,
7,
2,
1,
1,
0,
1,
0,
0,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 975,
"seconds": 15.193,
"avg_fps": 64.173,
"low_1pct_fps": 0.737,
"low_01pct_fps": 0.098,
"p50_ms": 0.864,
"p95_ms": 5.083,
"p99_ms": 77.689,
"p999_ms": 10225.237,
"max_ms": 10225.237,
"histogram": [
680,
99,
104,
12,
30,
13,
8,
4,
2,
3,
2,
1,
1,
3,
0,
1,
0,
1,
2,
3,
1,
2,
1,
2
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 8391,
"seconds": 12.0,
"avg_fps": 699.237,
"low_1pct_fps": 72.811,
"low_01pct_fps": 19.752,
"p50_ms": 1.087,
"p95_ms": 2.927,
"p99_ms": 5.909,
"p999_ms": 15.268,
"max_ms": 252.557,
"histogram": [
3342,
3998,
656,
231,
56,
26,
15,
19,
12,
12,
11,
6,
1,
3,
1,
0,
0,
1,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 12418,
"seconds": 12.0,
"avg_fps": 1034.813,
"low_1pct_fps": 112.221,
"low_01pct_fps": 27.463,
"p50_ms": 0.837,
"p95_ms": 1.394,
"p99_ms": 3.491,
"p999_ms": 13.217,
"max_ms": 245.59,
"histogram": [
11155,
1004,
100,
64,
29,
16,
16,
10,
2,
6,
5,
3,
4,
2,
0,
1,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 13126,
"seconds": 12.0,
"avg_fps": 1093.801,
"low_1pct_fps": 77.252,
"low_01pct_fps": 12.308,
"p50_ms": 0.714,
"p95_ms": 1.402,
"p99_ms": 2.875,
"p999_ms": 12.067,
"max_ms": 238.158,
"histogram": [
11605,
1207,
191,
54,
17,
11,
7,
12,
4,
4,
2,
3,
2,
1,
0,
0,
0,
0,
1,
2,
3,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 15928,
"seconds": 12.0,
"avg_fps": 1327.281,
"low_1pct_fps": 198.683,
"low_01pct_fps": 46.114,
"p50_ms": 0.67,
"p95_ms": 1.172,
"p99_ms": 2.111,
"p999_ms": 7.067,
"max_ms": 193.743,
"histogram": [
14891,
856,
102,
42,
7,
6,
8,
6,
0,
5,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 13249,
"seconds": 12.0,
"avg_fps": 1104.051,
"low_1pct_fps": 83.338,
"low_01pct_fps": 14.249,
"p50_ms": 0.711,
"p95_ms": 1.403,
"p99_ms": 3.046,
"p999_ms": 10.625,
"max_ms": 451.869,
"histogram": [
11824,
1165,
124,
58,
13,
16,
11,
14,
7,
7,
3,
2,
1,
0,
0,
0,
1,
0,
0,
1,
1,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 1469,
"seconds": 12.109,
"avg_fps": 121.312,
"low_1pct_fps": 2.229,
"low_01pct_fps": 0.75,
"p50_ms": 2.12,
"p95_ms": 13.173,
"p99_ms": 125.189,
"p999_ms": 727.045,
"max_ms": 1939.723,
"histogram": [
196,
440,
438,
136,
50,
36,
24,
25,
22,
17,
17,
13,
6,
11,
8,
4,
1,
4,
4,
4,
3,
7,
2,
1
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8396,
"seconds": 12.001,
"avg_fps": 699.617,
"low_1pct_fps": 46.542,
"low_01pct_fps": 7.318,
"p50_ms": 1.022,
"p95_ms": 2.433,
"p99_ms": 5.528,
"p999_ms": 13.993,
"max_ms": 305.464,
"histogram": [
3248,
4509,
361,
113,
52,
37,
21,
26,
14,
4,
3,
2,
0,
0,
0,
0,
0,
0,
1,
1,
2,
2,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 7639,
"seconds": 12.0,
"avg_fps": 636.566,
"low_1pct_fps": 37.064,
"low_01pct_fps": 5.612,
"p50_ms": 1.023,
"p95_ms": 3.0,
"p99_ms": 7.11,
"p999_ms": 17.521,
"max_ms": 268.877,
"histogram": [
3051,
3794,
413,
158,
66,
43,
35,
21,
31,
12,
3,
4,
1,
1,
0,
0,
0,
0,
0,
0,
4,
2,
0,
0
]
}
],
"label": "vanilla-desktop"
}
+771
View File
@@ -0,0 +1,771 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T12:54:29Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.628,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 12652.182,
"stdev": 3231.342,
"min": 9526.004,
"max": 15979.309,
"ci95": 8027.746
},
"startup.world_ms": {
"n": 3,
"mean": 31329.516,
"stdev": 10016.129,
"min": 20777.004,
"max": 40705.309,
"ci95": 24883.451
},
"chunks.village.load_ms": {
"n": 3,
"mean": 12623.084,
"stdev": 4958.724,
"min": 7251.986,
"max": 17026.894,
"ci95": 12319.147
},
"fps.village.avg_fps": {
"n": 3,
"mean": 92.729,
"stdev": 58.527,
"min": 28.713,
"max": 143.496,
"ci95": 145.402
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 16.52,
"stdev": 10.949,
"min": 4.001,
"max": 24.308,
"ci95": 27.2
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 11.515,
"stdev": 7.155,
"min": 3.549,
"max": 17.394,
"ci95": 17.774
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1834.469,
"stdev": 329.198,
"min": 1455.688,
"max": 2051.516,
"ci95": 817.839
},
"tick.village.client_ms": {
"n": 3,
"mean": 1.903,
"stdev": 1.114,
"min": 1.131,
"max": 3.18,
"ci95": 2.767
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 14590.694,
"stdev": 3260.615,
"min": 10942.613,
"max": 17221.108,
"ci95": 8100.47
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 106.817,
"stdev": 104.558,
"min": 43.857,
"max": 227.513,
"ci95": 259.759
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 23.0,
"stdev": 26.394,
"min": 7.338,
"max": 53.473,
"ci95": 65.57
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 14.841,
"stdev": 15.773,
"min": 5.157,
"max": 33.042,
"ci95": 39.186
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1814.245,
"stdev": 154.974,
"min": 1635.453,
"max": 1910.125,
"ci95": 385.007
},
"tick.forest.client_ms": {
"n": 3,
"mean": 1.452,
"stdev": 0.542,
"min": 0.826,
"max": 1.777,
"ci95": 1.346
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 13309.049,
"stdev": 8088.157,
"min": 6394.403,
"max": 22203.194,
"ci95": 20093.716
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 164.405,
"stdev": 141.4,
"min": 62.162,
"max": 325.77,
"ci95": 351.285
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 36.242,
"stdev": 37.051,
"min": 14.736,
"max": 79.024,
"ci95": 92.047
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 20.898,
"stdev": 17.899,
"min": 8.855,
"max": 41.466,
"ci95": 44.468
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1724.453,
"stdev": 347.692,
"min": 1323.5,
"max": 1942.734,
"ci95": 863.784
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.866,
"stdev": 0.248,
"min": 0.58,
"max": 1.03,
"ci95": 0.617
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 17989.43,
"stdev": 3969.295,
"min": 13444.423,
"max": 20774.261,
"ci95": 9861.07
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 162.084,
"stdev": 160.057,
"min": 68.44,
"max": 346.896,
"ci95": 397.635
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 33.74,
"stdev": 36.146,
"min": 11.158,
"max": 75.429,
"ci95": 89.798
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 17.194,
"stdev": 14.232,
"min": 7.903,
"max": 33.579,
"ci95": 35.357
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1977.602,
"stdev": 108.627,
"min": 1872.516,
"max": 2089.453,
"ci95": 269.866
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.772,
"stdev": 0.332,
"min": 0.463,
"max": 1.123,
"ci95": 0.825
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2135.313,
"stdev": 54.617,
"min": 2072.344,
"max": 2169.828,
"ci95": 135.686
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1836.44,
"stdev": 223.694,
"min": 1578.141,
"max": 1965.781,
"ci95": 555.732
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3253.456,
"stdev": 68.323,
"min": 3178.409,
"max": 3312.05,
"ci95": 169.737
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 3144.363,
"stdev": 69.868,
"min": 3069.394,
"max": 3207.66,
"ci95": 173.576
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 1272,
"seconds": 12.002,
"avg_fps": 105.979,
"low_1pct_fps": 21.25,
"low_01pct_fps": 17.394,
"p50_ms": 7.527,
"p95_ms": 22.34,
"p99_ms": 40.461,
"p999_ms": 54.28,
"max_ms": 60.7,
"histogram": [
0,
0,
0,
56,
161,
170,
180,
167,
184,
118,
71,
61,
26,
25,
28,
12,
10,
3,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1723,
"seconds": 12.007,
"avg_fps": 143.496,
"low_1pct_fps": 24.308,
"low_01pct_fps": 13.602,
"p50_ms": 5.317,
"p95_ms": 16.181,
"p99_ms": 29.771,
"p999_ms": 56.832,
"max_ms": 90.209,
"histogram": [
0,
0,
63,
413,
271,
273,
185,
148,
112,
89,
47,
43,
28,
22,
18,
4,
5,
1,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 346,
"seconds": 12.05,
"avg_fps": 28.713,
"low_1pct_fps": 4.001,
"low_01pct_fps": 3.549,
"p50_ms": 23.684,
"p95_ms": 110.057,
"p99_ms": 225.236,
"p999_ms": 281.776,
"max_ms": 281.776,
"histogram": [
0,
0,
0,
4,
8,
6,
6,
12,
21,
20,
23,
25,
24,
32,
50,
28,
24,
27,
17,
11,
6,
2,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 589,
"seconds": 12.001,
"avg_fps": 49.081,
"low_1pct_fps": 8.19,
"low_01pct_fps": 6.325,
"p50_ms": 15.443,
"p95_ms": 53.004,
"p99_ms": 94.69,
"p999_ms": 158.114,
"max_ms": 158.114,
"histogram": [
0,
0,
0,
2,
7,
20,
30,
46,
53,
48,
56,
62,
61,
66,
63,
18,
22,
20,
10,
4,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 2731,
"seconds": 12.004,
"avg_fps": 227.513,
"low_1pct_fps": 53.473,
"low_01pct_fps": 33.042,
"p50_ms": 3.801,
"p95_ms": 7.955,
"p99_ms": 14.177,
"p999_ms": 24.479,
"max_ms": 38.218,
"histogram": [
0,
0,
526,
1157,
432,
257,
151,
88,
46,
32,
13,
15,
6,
6,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 527,
"seconds": 12.016,
"avg_fps": 43.857,
"low_1pct_fps": 7.338,
"low_01pct_fps": 5.157,
"p50_ms": 16.784,
"p95_ms": 57.353,
"p99_ms": 111.984,
"p999_ms": 193.905,
"max_ms": 193.905,
"histogram": [
0,
0,
0,
1,
2,
9,
16,
29,
40,
61,
50,
54,
52,
62,
57,
35,
28,
11,
13,
5,
2,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 746,
"seconds": 12.001,
"avg_fps": 62.162,
"low_1pct_fps": 14.965,
"low_01pct_fps": 12.372,
"p50_ms": 13.76,
"p95_ms": 35.144,
"p99_ms": 57.63,
"p999_ms": 80.827,
"max_ms": 80.827,
"histogram": [
0,
0,
2,
13,
12,
41,
37,
53,
67,
82,
80,
99,
82,
76,
58,
17,
11,
12,
4,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 3910,
"seconds": 12.002,
"avg_fps": 325.77,
"low_1pct_fps": 79.024,
"low_01pct_fps": 41.466,
"p50_ms": 2.685,
"p95_ms": 5.125,
"p99_ms": 8.342,
"p999_ms": 20.782,
"max_ms": 30.571,
"histogram": [
0,
78,
2526,
892,
203,
95,
49,
27,
13,
11,
7,
3,
2,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 1268,
"seconds": 12.044,
"avg_fps": 105.282,
"low_1pct_fps": 14.736,
"low_01pct_fps": 8.855,
"p50_ms": 6.542,
"p95_ms": 25.245,
"p99_ms": 50.465,
"p999_ms": 79.065,
"max_ms": 146.809,
"histogram": [
0,
0,
4,
171,
190,
169,
167,
133,
105,
93,
53,
43,
35,
41,
26,
13,
11,
10,
3,
1,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 822,
"seconds": 12.011,
"avg_fps": 68.44,
"low_1pct_fps": 14.632,
"low_01pct_fps": 10.101,
"p50_ms": 11.396,
"p95_ms": 35.387,
"p99_ms": 55.24,
"p999_ms": 99.005,
"max_ms": 99.005,
"histogram": [
0,
0,
0,
10,
42,
62,
55,
86,
89,
97,
64,
73,
74,
72,
48,
21,
17,
8,
4,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 4165,
"seconds": 12.006,
"avg_fps": 346.896,
"low_1pct_fps": 75.429,
"low_01pct_fps": 33.579,
"p50_ms": 2.541,
"p95_ms": 4.838,
"p99_ms": 7.998,
"p999_ms": 20.209,
"max_ms": 53.577,
"histogram": [
0,
640,
2307,
855,
178,
93,
34,
22,
15,
3,
3,
8,
2,
2,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 851,
"seconds": 12.0,
"avg_fps": 70.916,
"low_1pct_fps": 11.158,
"low_01pct_fps": 7.903,
"p50_ms": 10.128,
"p95_ms": 36.498,
"p99_ms": 68.434,
"p999_ms": 126.541,
"max_ms": 126.541,
"histogram": [
0,
0,
0,
38,
61,
87,
76,
69,
84,
100,
63,
53,
68,
53,
48,
15,
9,
16,
9,
2,
0,
0,
0,
0
]
}
],
"label": "vanilla-macmini"
}
+771
View File
@@ -0,0 +1,771 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:08:37Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 18.159,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 8607.88,
"stdev": 949.596,
"min": 8010.323,
"max": 9702.854,
"ci95": 2359.119
},
"startup.world_ms": {
"n": 3,
"mean": 44182.88,
"stdev": 4031.933,
"min": 40172.323,
"max": 48235.854,
"ci95": 10016.685
},
"chunks.village.load_ms": {
"n": 3,
"mean": 17227.171,
"stdev": 5351.234,
"min": 13892.279,
"max": 23399.566,
"ci95": 13294.274
},
"fps.village.avg_fps": {
"n": 3,
"mean": 76.237,
"stdev": 45.661,
"min": 28.922,
"max": 120.041,
"ci95": 113.437
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 17.319,
"stdev": 12.99,
"min": 5.593,
"max": 31.283,
"ci95": 32.272
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 12.642,
"stdev": 9.463,
"min": 4.308,
"max": 22.93,
"ci95": 23.51
},
"memory.village.rss_mb": {
"n": 3,
"mean": 2674.987,
"stdev": 30.71,
"min": 2641.352,
"max": 2701.531,
"ci95": 76.294
},
"tick.village.client_ms": {
"n": 3,
"mean": 1.341,
"stdev": 0.407,
"min": 0.924,
"max": 1.737,
"ci95": 1.012
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 34112.389,
"stdev": 19305.487,
"min": 22633.094,
"max": 56401.09,
"ci95": 47961.36
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 72.848,
"stdev": 51.859,
"min": 18.307,
"max": 121.528,
"ci95": 128.837
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 22.096,
"stdev": 20.299,
"min": 3.973,
"max": 44.03,
"ci95": 50.429
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 16.093,
"stdev": 14.608,
"min": 3.688,
"max": 32.194,
"ci95": 36.291
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 2519.729,
"stdev": 82.097,
"min": 2426.297,
"max": 2580.328,
"ci95": 203.957
},
"tick.forest.client_ms": {
"n": 3,
"mean": 1.115,
"stdev": 0.411,
"min": 0.726,
"max": 1.545,
"ci95": 1.02
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 19066.516,
"stdev": 9372.33,
"min": 13163.781,
"max": 29873.383,
"ci95": 23284.039
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 69.735,
"stdev": 46.656,
"min": 16.524,
"max": 103.635,
"ci95": 115.909
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 17.636,
"stdev": 13.616,
"min": 3.179,
"max": 30.217,
"ci95": 33.827
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 12.115,
"stdev": 10.798,
"min": 2.584,
"max": 23.842,
"ci95": 26.825
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 2557.143,
"stdev": 84.375,
"min": 2463.781,
"max": 2627.945,
"ci95": 209.616
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 1.056,
"stdev": 0.233,
"min": 0.815,
"max": 1.281,
"ci95": 0.58
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 62214.712,
"stdev": 4884.13,
"min": 58937.045,
"max": 67828.14,
"ci95": 12133.83
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 51.809,
"stdev": 7.118,
"min": 45.235,
"max": 59.369,
"ci95": 17.685
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 6.336,
"stdev": 1.097,
"min": 5.545,
"max": 7.588,
"ci95": 2.725
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.458,
"stdev": 0.756,
"min": 2.827,
"max": 4.296,
"ci95": 1.878
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 2604.227,
"stdev": 15.043,
"min": 2588.234,
"max": 2618.094,
"ci95": 37.371
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.957,
"stdev": 0.03,
"min": 0.926,
"max": 0.985,
"ci95": 0.074
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2731.016,
"stdev": 56.767,
"min": 2688.516,
"max": 2795.484,
"ci95": 141.03
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2577.339,
"stdev": 77.295,
"min": 2488.203,
"max": 2625.875,
"ci95": 192.028
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 4612.571,
"stdev": 20.245,
"min": 4595.363,
"max": 4634.879,
"ci95": 50.296
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4271.925,
"stdev": 26.308,
"min": 4246.207,
"max": 4298.785,
"ci95": 65.357
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 349,
"seconds": 12.067,
"avg_fps": 28.922,
"low_1pct_fps": 5.593,
"low_01pct_fps": 4.308,
"p50_ms": 24.684,
"p95_ms": 92.764,
"p99_ms": 150.338,
"p999_ms": 232.136,
"max_ms": 232.136,
"histogram": [
0,
0,
0,
0,
0,
0,
2,
3,
13,
17,
26,
31,
40,
46,
50,
32,
25,
20,
30,
10,
4,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1441,
"seconds": 12.004,
"avg_fps": 120.041,
"low_1pct_fps": 31.283,
"low_01pct_fps": 22.93,
"p50_ms": 7.267,
"p95_ms": 13.622,
"p99_ms": 23.449,
"p999_ms": 42.564,
"max_ms": 44.657,
"histogram": [
0,
0,
0,
0,
0,
64,
543,
397,
218,
100,
54,
24,
20,
7,
7,
5,
2,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 958,
"seconds": 12.013,
"avg_fps": 79.749,
"low_1pct_fps": 15.082,
"low_01pct_fps": 10.689,
"p50_ms": 9.138,
"p95_ms": 28.848,
"p99_ms": 52.247,
"p999_ms": 93.55,
"max_ms": 93.55,
"histogram": [
0,
0,
0,
0,
0,
19,
144,
232,
148,
111,
64,
56,
59,
57,
32,
17,
9,
6,
4,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 220,
"seconds": 12.017,
"avg_fps": 18.307,
"low_1pct_fps": 3.973,
"low_01pct_fps": 3.688,
"p50_ms": 40.635,
"p95_ms": 157.363,
"p99_ms": 235.253,
"p999_ms": 271.153,
"max_ms": 271.153,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
3,
6,
14,
17,
25,
25,
17,
27,
33,
24,
16,
11,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1459,
"seconds": 12.005,
"avg_fps": 121.528,
"low_1pct_fps": 44.03,
"low_01pct_fps": 32.194,
"p50_ms": 7.381,
"p95_ms": 12.386,
"p99_ms": 18.354,
"p999_ms": 29.428,
"max_ms": 32.695,
"histogram": [
0,
0,
0,
0,
0,
0,
357,
642,
300,
80,
28,
28,
14,
7,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 945,
"seconds": 12.006,
"avg_fps": 78.709,
"low_1pct_fps": 18.284,
"low_01pct_fps": 12.397,
"p50_ms": 9.554,
"p95_ms": 27.657,
"p99_ms": 43.253,
"p999_ms": 80.663,
"max_ms": 80.663,
"histogram": [
0,
0,
0,
0,
0,
0,
18,
304,
191,
119,
67,
59,
53,
70,
34,
15,
10,
3,
2,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 201,
"seconds": 12.164,
"avg_fps": 16.524,
"low_1pct_fps": 3.179,
"low_01pct_fps": 2.584,
"p50_ms": 44.933,
"p95_ms": 155.019,
"p99_ms": 263.919,
"p999_ms": 387.044,
"max_ms": 387.044,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
2,
4,
7,
20,
8,
29,
18,
25,
29,
29,
17,
8,
4,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 1244,
"seconds": 12.004,
"avg_fps": 103.635,
"low_1pct_fps": 30.217,
"low_01pct_fps": 23.842,
"p50_ms": 8.349,
"p95_ms": 17.445,
"p99_ms": 28.276,
"p999_ms": 38.037,
"max_ms": 45.85,
"histogram": [
0,
0,
0,
0,
0,
0,
162,
453,
312,
129,
71,
47,
32,
18,
16,
3,
1,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 1069,
"seconds": 12.005,
"avg_fps": 89.047,
"low_1pct_fps": 19.512,
"low_01pct_fps": 9.92,
"p50_ms": 9.541,
"p95_ms": 20.819,
"p99_ms": 31.409,
"p999_ms": 99.668,
"max_ms": 101.942,
"histogram": [
0,
0,
0,
0,
0,
0,
64,
266,
283,
160,
112,
74,
51,
33,
17,
4,
1,
2,
1,
1,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 551,
"seconds": 12.181,
"avg_fps": 45.235,
"low_1pct_fps": 5.545,
"low_01pct_fps": 3.252,
"p50_ms": 14.921,
"p95_ms": 50.78,
"p99_ms": 105.154,
"p999_ms": 307.476,
"max_ms": 307.476,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
42,
137,
72,
58,
50,
47,
57,
39,
20,
15,
8,
3,
2,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 718,
"seconds": 12.094,
"avg_fps": 59.369,
"low_1pct_fps": 7.588,
"low_01pct_fps": 4.296,
"p50_ms": 12.178,
"p95_ms": 38.273,
"p99_ms": 65.694,
"p999_ms": 232.789,
"max_ms": 232.789,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
140,
202,
115,
72,
48,
47,
43,
19,
19,
6,
4,
0,
3,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 610,
"seconds": 12.003,
"avg_fps": 50.822,
"low_1pct_fps": 5.875,
"low_01pct_fps": 2.827,
"p50_ms": 13.894,
"p95_ms": 44.208,
"p99_ms": 75.023,
"p999_ms": 353.686,
"max_ms": 353.686,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
67,
115,
125,
88,
55,
54,
43,
25,
13,
15,
7,
0,
1,
2,
0,
0
]
}
],
"label": "vanilla-macmini"
}
+28 -7
View File
@@ -489,15 +489,36 @@ pub fn parse_duration(text: &str) -> Option<i64> {
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let up = |unit: i64| (millis + unit - 1) / unit;
let (count, unit) = if millis <= 60 * MINUTE {
(up(MINUTE), "minute")
/// The unit used when presenting a moderation duration to a player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DurationUnit {
Minute,
Hour,
Day,
}
/// A duration's rounded count and unit, shared by logs and translated replies.
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
let (unit, kind) = if millis <= 60 * MINUTE {
(MINUTE, DurationUnit::Minute)
} else if millis <= 48 * 60 * MINUTE {
(up(60 * MINUTE), "hour")
(60 * MINUTE, DurationUnit::Hour)
} else {
(up(24 * 60 * MINUTE), "day")
(24 * 60 * MINUTE, DurationUnit::Day)
};
(
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
kind,
)
}
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let (count, unit) = duration_parts(millis);
let unit = match unit {
DurationUnit::Minute => "minute",
DurationUnit::Hour => "hour",
DurationUnit::Day => "day",
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
+16
View File
@@ -327,3 +327,19 @@ fn an_allow_line_without_letters_allows_nothing() {
assert!(f.matches("nigger"), "{line}");
}
}
#[test]
fn duration_parts_preserve_rounding_without_overflow() {
for (millis, expected) in [
(1, (1, DurationUnit::Minute)),
(60_000, (1, DurationUnit::Minute)),
(61_000, (2, DurationUnit::Minute)),
(3_600_000, (60, DurationUnit::Minute)),
(3_600_001, (2, DurationUnit::Hour)),
(172_800_000, (48, DurationUnit::Hour)),
(172_800_001, (3, DurationUnit::Day)),
(i64::MAX, (106_751_991_168, DurationUnit::Day)),
] {
assert_eq!(duration_parts(millis), expected);
}
}
@@ -143,7 +143,9 @@ impl<'a> Fields<'a> {
/// `RandomizableContainer.tryLoadLootTable` / `trySaveLootTable`; true when the
/// block entity has a loot table (and so saves no items).
fn loot_table(&mut self) -> bool {
let Some(table) = text(self.input.get("LootTable")) else {
let Some(table) =
text(self.input.get("LootTable")).and_then(|id| crate::identifier::normalize_id(&id))
else {
return false;
};
self.put("LootTable", string(&table));
@@ -905,6 +907,16 @@ mod tests {
assert_eq!(out.get("LootTable"), Some(&string("minecraft:chests/x")));
}
#[test]
fn invalid_loot_table_leaves_items_intact() {
let out = canon(
"{id:\"minecraft:barrel\",x:1,y:2,z:3,LootTable:\"INVALID TABLE\",\
Items:[{Slot:0b,id:\"minecraft:stone\",count:1}]}",
);
assert!(out.get("Items").is_some());
assert!(out.get("LootTable").is_none());
}
#[test]
fn items_are_validated_and_sorted_by_slot() {
let out = canon(
+5 -3
View File
@@ -207,9 +207,11 @@ fn style_flag(v: Option<&Value>) -> Option<bool> {
}
}
fn untrusted_http_url(url: &str) -> bool {
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
// https only, scheme case-insensitive.
/// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
/// https only, scheme case-insensitive. Every `open_url` a server sends must
/// pass it: anything else (`file:`, a UNC path, a custom protocol handler)
/// could start a program through the system's URL handler.
pub fn untrusted_http_url(url: &str) -> bool {
url.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
+348 -3
View File
@@ -160,6 +160,162 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/el_gr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/el_gr.json")
}
"assets/horizon/lang/ca_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ca_es.json")
}
"assets/horizon/lang/et_ee.json" => {
include_str!("../../horizon-client/assets/horizon/lang/et_ee.json")
}
"assets/horizon/lang/lv_lv.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lv_lv.json")
}
"assets/horizon/lang/sl_si.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sl_si.json")
}
"assets/horizon/lang/sr_cs.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sr_cs.json")
}
"assets/horizon/lang/is_is.json" => {
include_str!("../../horizon-client/assets/horizon/lang/is_is.json")
}
"assets/horizon/lang/af_za.json" => {
include_str!("../../horizon-client/assets/horizon/lang/af_za.json")
}
"assets/horizon/lang/ms_my.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ms_my.json")
}
"assets/horizon/lang/be_by.json" => {
include_str!("../../horizon-client/assets/horizon/lang/be_by.json")
}
"assets/horizon/lang/sr_sp.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sr_sp.json")
}
"assets/horizon/lang/az_az.json" => {
include_str!("../../horizon-client/assets/horizon/lang/az_az.json")
}
"assets/horizon/lang/uz_uz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uz_uz.json")
}
"assets/horizon/lang/kk_kz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/kk_kz.json")
}
"assets/horizon/lang/ka_ge.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ka_ge.json")
}
"assets/horizon/lang/hy_am.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hy_am.json")
}
"assets/horizon/lang/bs_ba.json" => {
include_str!("../../horizon-client/assets/horizon/lang/bs_ba.json")
}
"assets/horizon/lang/mk_mk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mk_mk.json")
}
"assets/horizon/lang/sq_al.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sq_al.json")
}
"assets/horizon/lang/cy_gb.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cy_gb.json")
}
"assets/horizon/lang/ga_ie.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ga_ie.json")
}
"assets/horizon/lang/eu_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/eu_es.json")
}
"assets/horizon/lang/gl_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/gl_es.json")
}
"assets/horizon/lang/fil_ph.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fil_ph.json")
}
"assets/horizon/lang/ta_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ta_in.json")
}
"assets/horizon/lang/mn_mn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mn_mn.json")
}
"assets/horizon/lang/lo_la.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lo_la.json")
}
"assets/horizon/lang/so_so.json" => {
include_str!("../../horizon-client/assets/horizon/lang/so_so.json")
}
"assets/horizon/lang/fy_nl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fy_nl.json")
}
"assets/horizon/lang/gd_gb.json" => {
include_str!("../../horizon-client/assets/horizon/lang/gd_gb.json")
}
"assets/horizon/lang/lb_lu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lb_lu.json")
}
"assets/horizon/lang/ky_kg.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ky_kg.json")
}
"assets/horizon/lang/kn_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/kn_in.json")
}
"assets/horizon/lang/mt_mt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mt_mt.json")
}
"assets/horizon/lang/eo_uy.json" => {
include_str!("../../horizon-client/assets/horizon/lang/eo_uy.json")
}
"assets/horizon/lang/haw_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/haw_us.json")
}
"assets/horizon/lang/ig_ng.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ig_ng.json")
}
"assets/horizon/lang/la_la.json" => {
include_str!("../../horizon-client/assets/horizon/lang/la_la.json")
}
"assets/horizon/lang/tt_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tt_ru.json")
}
"assets/horizon/lang/yi_de.json" => {
include_str!("../../horizon-client/assets/horizon/lang/yi_de.json")
}
"assets/horizon/lang/yo_ng.json" => {
include_str!("../../horizon-client/assets/horizon/lang/yo_ng.json")
}
"assets/horizon/lang/ba_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ba_ru.json")
}
"assets/horizon/lang/br_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/br_fr.json")
}
"assets/horizon/lang/cv_cu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cv_cu.json")
}
"assets/horizon/lang/fo_fo.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fo_fo.json")
}
"assets/horizon/lang/fur_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fur_it.json")
}
"assets/horizon/lang/li_li.json" => {
include_str!("../../horizon-client/assets/horizon/lang/li_li.json")
}
"assets/horizon/lang/lmo.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lmo.json")
}
"assets/horizon/lang/oc_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/oc_fr.json")
}
"assets/horizon/lang/sah_sah.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sah_sah.json")
}
"assets/horizon/lang/se_no.json" => {
include_str!("../../horizon-client/assets/horizon/lang/se_no.json")
}
"assets/horizon/lang/szl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/szl.json")
}
"assets/horizon/lang/vec_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
@@ -232,6 +388,36 @@ pub fn apply_deprecated(map: &mut HashMap<String, String>, json: &[u8]) {
}
}
/// `Language.loadFromJson` for one `lang/<code>.json`: its entries go into `map` in file order.
/// A file that is plain JSON reads as before; any other goes through Gson's lenient reader
/// (`#`, `//` and `/* */` comments, unquoted or single-quoted names and values, raw control
/// characters in strings, `=` or `=>` and `;`), which vanilla reads lang files with (expected
/// from `Language.loadFromJson`, Gson). A file that is not JSON even so adds nothing; a value
/// that is not a string, number or boolean (`GsonHelper.convertToString`) ends the file there.
fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
use horizon_world::datafix::json::{Json, parse_lenient};
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(bytes) {
map.extend(entries);
return;
}
let text = String::from_utf8_lossy(bytes);
// Gson's JsonReader skips a leading byte order mark.
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
let utf16: Vec<u16> = text.encode_utf16().collect();
let Some(Json::Obj(entries)) = parse_lenient(&utf16) else {
return;
};
for (key, value) in entries {
let value = match value {
Json::Str(s) => String::from_utf16_lossy(&s),
Json::Num(n) => n,
Json::Bool(b) => b.to_string(),
Json::Null | Json::Arr(_) | Json::Obj(_) => break,
};
map.insert(String::from_utf16_lossy(&key), value);
}
}
impl Language {
/// ClientLanguage.loadFrom: en_us first, then the selected language, each
/// from every namespace and every source in priority order (later sources
@@ -257,9 +443,7 @@ impl Language {
else {
continue;
};
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(&bytes) {
map.extend(entries);
}
read_lang_file(&bytes, &mut map);
}
}
}
@@ -719,4 +903,165 @@ mod tests {
["en_us", "fr_fr"]
);
}
fn read(body: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
read_lang_file(body.as_bytes(), &mut map);
map
}
fn entries(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
#[test]
fn a_plain_json_lang_file_reads_as_before() {
let body = "{\"a\": \"Apple \\\"quoted\\\" \\u00e9\", \"b\": \"\", \"a\": \"Again\"}";
let strict: HashMap<String, String> = serde_json::from_str(body).unwrap();
assert_eq!(read(body), strict);
assert_eq!(read(body), entries(&[("a", "Again"), ("b", "")]));
}
#[test]
fn lang_files_read_with_comments() {
let want = entries(&[("a", "Apple"), ("b", "Banana # not a comment // either")]);
for body in [
"# header\n{\n# a comment\n\"a\": \"Apple\",\n\"b\": \"Banana # not a comment // either\"\n}\n",
"// header\n{\"a\": \"Apple\", // after\n\"b\": \"Banana # not a comment // either\"}",
"/* header\n over lines */{\"a\": /* inline */ \"Apple\", \"b\": \"Banana # not a comment // either\"}",
] {
assert_eq!(read(body), want, "{body}");
}
}
#[test]
fn lang_files_read_unquoted_and_single_quoted_names_and_values() {
assert_eq!(
read("{a: 'Apple', 'b': \"Banana\", c: Cherry}"),
entries(&[("a", "Apple"), ("b", "Banana"), ("c", "Cherry")])
);
// A single-quoted string may hold a double quote, and the reverse.
assert_eq!(
read("{'a': 'say \"hi\"', \"b\": \"it's\"}"),
entries(&[("a", "say \"hi\""), ("b", "it's")])
);
}
#[test]
fn lang_files_read_raw_control_characters_in_strings() {
assert_eq!(
read("{\"a\": \"tab\there\", \"b\": \"two\nlines\"}"),
entries(&[("a", "tab\there"), ("b", "two\nlines")])
);
}
#[test]
fn lang_files_read_gson_separators_and_a_byte_order_mark() {
assert_eq!(
read("\u{feff}{\"a\" = \"Apple\"; \"b\" => \"Banana\"}"),
entries(&[("a", "Apple"), ("b", "Banana")])
);
// A trailing comma before `}` is not JSON even to Gson's lenient reader.
assert_eq!(read("{# c\n\"a\": \"Apple\",}"), HashMap::new());
}
#[test]
fn numbers_and_booleans_read_as_their_text() {
// GsonHelper.convertToString: a primitive reads as its text.
assert_eq!(
read("{\"a\": 1.50, \"b\": true, \"c\": -2e3}"),
entries(&[("a", "1.50"), ("b", "true"), ("c", "-2e3")])
);
}
#[test]
fn a_value_that_is_no_text_ends_the_file_there() {
assert_eq!(
read("# c\n{\"a\": \"Apple\", \"b\": [\"x\"], \"c\": \"Cherry\"}"),
entries(&[("a", "Apple")])
);
assert_eq!(read("{\"a\": null, \"b\": \"Banana\"}"), HashMap::new());
}
#[test]
fn a_lang_file_that_is_not_json_adds_nothing() {
for body in [
"# c\n{\"a\": \"Apple\"",
"[\"a\"]",
"\"a\"",
"",
"{\"a\": \"Apple\"} {",
] {
assert_eq!(read(body), HashMap::new(), "{body:?}");
}
}
#[test]
fn a_pack_lang_file_with_comments_layers_over_english() {
let base = Mem(HashMap::from([(
"assets/minecraft/lang/en_us.json".into(),
br#"{"a":"Apple","b":"Banana"}"#.to_vec(),
)]));
let pack = Mem(HashMap::from([(
"assets/minecraft/lang/fr_fr.json".into(),
b"{\n# Fruits\n\"a\": \"Pomme\"\n}\n".to_vec(),
)]));
let sources: [&dyn ResourceSource; 2] = [&base, &pack];
let fr = Language::load(&sources, "fr_fr");
assert_eq!(fr.get("a"), Some("Pomme"));
assert_eq!(fr.get("b"), Some("Banana"));
}
/// Every lang file of a downloaded pack, e.g. BACAP Language Pack:
/// `HORIZON_LANG_PACK_DIR=<pack>/assets/minecraft/lang cargo test -p horizon-client-core
/// lang::tests::every_lang_file_of_a_pack_reads -- --ignored --nocapture`.
#[test]
#[ignore = "needs a downloaded pack in HORIZON_LANG_PACK_DIR"]
fn every_lang_file_of_a_pack_reads() {
let Ok(dir) = std::env::var("HORIZON_LANG_PACK_DIR") else {
return;
};
let mut failed = Vec::new();
let mut paths: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
paths.sort();
for path in paths {
let bytes = std::fs::read(&path).unwrap();
let strict = serde_json::from_slice::<HashMap<String, String>>(&bytes).is_ok();
let mut map = HashMap::new();
read_lang_file(&bytes, &mut map);
// An independent count: the distinct `"name":` that start a line.
let text = String::from_utf8_lossy(&bytes);
let names: std::collections::HashSet<&str> = text
.lines()
.filter_map(|line| {
let rest = line.trim_start().strip_prefix('"')?;
let mut escaped = false;
let end = rest.char_indices().find_map(|(i, c)| {
let close = c == '"' && !escaped;
escaped = c == '\\' && !escaped;
close.then_some(i)
})?;
rest[end + 1..]
.trim_start()
.starts_with(':')
.then(|| &rest[..end])
})
.collect();
println!(
"{}: strict {strict}, {} keys, {} names",
path.display(),
map.len(),
names.len()
);
if map.is_empty() || map.len() != names.len() {
failed.push(path);
}
}
assert!(failed.is_empty(), "{failed:?}");
}
}
+215 -34
View File
@@ -79,28 +79,65 @@ fn elements(tag: &Tag) -> Option<Vec<Tag>> {
_ => return None,
})
}
fn number(t: &Tag) -> Option<i64> {
match t {
Tag::Byte(v) => Some(i64::from(*v)),
Tag::Short(v) => Some(i64::from(*v)),
Tag::Int(v) => Some(i64::from(*v)),
Tag::Long(v) => Some(*v),
_ => None,
/// `DoubleTag.intValue` / `FloatTag.intValue` floor before the i32 conversion;
/// integral tags use their intValue narrowing. Byte arrays then keep the low byte.
fn int_value(t: &Tag) -> Option<i32> {
Some(match t {
Tag::Byte(v) => i32::from(*v),
Tag::Short(v) => i32::from(*v),
Tag::Int(v) => *v,
Tag::Long(v) => *v as i32,
Tag::Float(v) => f32::from_bits(*v).floor() as i32,
Tag::Double(v) => f64::from_bits(*v).floor() as i32,
_ => return None,
})
}
/// `FloatTag.longValue` truncates; `DoubleTag.longValue` floors before narrowing.
fn long_value(t: &Tag) -> Option<i64> {
Some(match t {
Tag::Byte(v) => i64::from(*v),
Tag::Short(v) => i64::from(*v),
Tag::Int(v) => i64::from(*v),
Tag::Long(v) => *v,
// FloatTag.longValue truncates; DoubleTag.longValue floors first.
Tag::Float(v) => f32::from_bits(*v) as i64,
Tag::Double(v) => f64::from_bits(*v).floor() as i64,
_ => return None,
})
}
#[derive(Clone, Copy)]
enum ElementKind {
List,
NumericArray,
}
impl ElementKind {
// Admission only: arrays require NumericTag; lists accept every tag type.
fn accepts(self, value: &Tag) -> bool {
matches!(self, Self::List)
|| matches!(
value,
Tag::Byte(_)
| Tag::Short(_)
| Tag::Int(_)
| Tag::Long(_)
| Tag::Float(_)
| Tag::Double(_)
)
}
}
/// Run `f` over the elements of a list/array, writing array results back.
/// Rust view adapter: run `f` over the elements, then write arrays using their numeric accessor.
/// None when `tag` is not list-like.
fn with_elements<R>(tag: &mut Tag, f: impl FnOnce(&mut Vec<Tag>) -> R) -> Option<R> {
fn with_elements<R>(tag: &mut Tag, f: impl FnOnce(&mut Vec<Tag>, ElementKind) -> R) -> Option<R> {
match tag {
Tag::List(l) => Some(f(l)),
Tag::List(l) => Some(f(l, ElementKind::List)),
Tag::ByteArray(_) | Tag::IntArray(_) | Tag::LongArray(_) => {
let mut v = elements(tag)?;
let r = f(&mut v);
let nums: Vec<i64> = v.iter().filter_map(number).collect();
let r = f(&mut v, ElementKind::NumericArray);
match tag {
Tag::ByteArray(a) => *a = nums.iter().map(|n| *n as u8).collect(),
Tag::IntArray(a) => *a = nums.iter().map(|n| *n as i32).collect(),
Tag::LongArray(a) => *a = nums,
// Float/double byteValue uses intValue's floor, then narrows.
Tag::ByteArray(a) => *a = v.iter().filter_map(int_value).map(|n| n as u8).collect(),
Tag::IntArray(a) => *a = v.iter().filter_map(int_value).collect(),
Tag::LongArray(a) => *a = v.iter().filter_map(long_value).collect(),
_ => {}
}
Some(r)
@@ -157,6 +194,20 @@ impl NbtPath {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
/// Whether this selects a compound-field chain, including filtered or quoted fields.
/// Matching a root or child does not change that subtree's writer provenance.
pub fn is_field_path(&self, fields: &[&str]) -> bool {
let mut fields = fields.iter();
for node in &self.nodes {
match node {
Node::MatchRoot(_) => {}
Node::Child(name) | Node::MatchChild(name, _)
if fields.next().copied() == Some(name.as_str()) => {}
_ => return false,
}
}
fields.next().is_none()
}
fn not_found(&self, node: usize) -> PathError {
let end = self.ends[node];
PathError::new(
@@ -349,7 +400,7 @@ impl NbtPath {
});
removed
}
/// `insert` (also append and prepend): into every list the path reaches.
/// `NbtPathArgument.NbtPath.insert`: insert accepted tags into every reached collection.
pub fn insert(
&self,
index: i32,
@@ -372,19 +423,23 @@ impl NbtPath {
return;
}
let shown = crate::snbt::to_snbt(target);
let result = with_elements(target, |list| {
let result = with_elements(target, |list, kind| {
let start = if index < 0 {
list.len() as i64 + i64::from(index) + 1
} else {
i64::from(index)
};
let mut any = false;
for (n, v) in values.iter().enumerate() {
let at = start + n as i64;
let mut at = start;
for v in values {
if !kind.accepts(v) {
continue;
}
if at < 0 || at as usize > list.len() {
return Err(CollectionError::InvalidIndex(at as i32));
}
list.insert(at as usize, v.clone());
at += 1;
any = true;
}
Ok(any)
@@ -428,7 +483,8 @@ fn preferred_parent(node: &Node) -> Tag {
_ => Tag::Compound(Compound::new()),
}
}
/// `getOrCreateTag` (or `getTag` without `create`): the children of `parent`.
/// Children of `parent`; the array creation arm follows
/// `NbtPathArgument.AllElementsNode.getOrCreateTag` admission.
fn step_mut(
node: &Node,
parent: &mut Tag,
@@ -468,11 +524,14 @@ fn step_mut(
}
Node::AllElements => {
let make = create;
let _ = with_elements(parent, |list| {
let _ = with_elements(parent, |list, kind| {
if list.is_empty() {
if let Some(make) = make {
list.push(make());
out(&mut list[0]);
let value = make();
if kind.accepts(&value) {
list.push(value);
out(&mut list[0]);
}
}
} else {
for t in list.iter_mut() {
@@ -482,7 +541,7 @@ fn step_mut(
});
}
Node::Index(i) => {
let _ = with_elements(parent, |list| {
let _ = with_elements(parent, |list, _kind| {
let n = list.len() as i64;
let idx = if *i < 0 {
n + i64::from(*i)
@@ -518,7 +577,8 @@ fn step_mut(
}
}
}
/// `Node.setTag`: replace the child(ren) of `parent` with `value`.
/// Element setters follow `NbtPathArgument.AllElementsNode.setTag` and
/// `NbtPathArgument.IndexedElementNode.setTag`; object nodes replace their children.
fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
match node {
Node::Child(name) => {
@@ -538,29 +598,36 @@ fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
}
0
}
Node::AllElements => with_elements(parent, |list| {
if list.is_empty() {
list.push(value.clone());
Node::AllElements => with_elements(parent, |list, kind| {
let len = list.len();
if len == 0 {
if kind.accepts(value) {
list.push(value.clone());
}
// AllElementsNode returns 1 here even when addTag rejects.
return 1;
}
// Compare the original tags, before numeric conversion.
let changed = list.iter().filter(|t| *t != value).count();
if changed == 0 {
return 0;
}
for t in list.iter_mut() {
*t = value.clone();
list.clear();
if !kind.accepts(value) {
return 0;
}
list.resize(len, value.clone());
changed
})
.unwrap_or(0),
Node::Index(i) => with_elements(parent, |list| {
Node::Index(i) => with_elements(parent, |list, kind| {
let n = list.len() as i64;
let idx = if *i < 0 {
n + i64::from(*i)
} else {
i64::from(*i)
};
if (0..n).contains(&idx) && list[idx as usize] != *value {
if (0..n).contains(&idx) && list[idx as usize] != *value && kind.accepts(value) {
list[idx as usize] = value.clone();
1
} else {
@@ -586,6 +653,7 @@ fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
Node::MatchRoot(_) => 0,
}
}
/// Removal arms, including `NbtPathArgument.AllElementsNode.removeTag`.
fn remove_tag(node: &Node, parent: &mut Tag) -> usize {
match node {
Node::Child(name) => match parent {
@@ -603,13 +671,13 @@ fn remove_tag(node: &Node, parent: &mut Tag) -> usize {
}
_ => 0,
},
Node::AllElements => with_elements(parent, |l| {
Node::AllElements => with_elements(parent, |l, _kind| {
let n = l.len();
l.clear();
n
})
.unwrap_or(0),
Node::Index(i) => with_elements(parent, |l| {
Node::Index(i) => with_elements(parent, |l, _kind| {
let n = l.len() as i64;
let idx = if *i < 0 {
n + i64::from(*i)
@@ -772,6 +840,25 @@ fn parse_compound(text: &str, i: usize) -> Result<(Tag, usize), PathError> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_provenance_handles_quotes_and_filters_without_matching_lists() {
for text in [
"blockState.properties",
"\"blockState\".\"properties\"",
"{source:1b}.blockState.properties",
"blockState{id:\"minecraft:piston_head\"}.properties",
] {
let path = NbtPath::parse(text).unwrap();
assert!(path.is_field_path(&["blockState", "properties"]));
assert!(!path.is_field_path(&["blockState"]));
}
assert!(NbtPath::parse("{source:1b}").unwrap().is_field_path(&[]));
assert!(
!NbtPath::parse("blockState[].properties")
.unwrap()
.is_field_path(&["blockState", "properties"])
);
}
fn tag(s: &str) -> Tag {
parse_snbt(s).unwrap().0
@@ -887,6 +974,100 @@ mod tests {
));
}
#[test]
fn numeric_array_edits_use_the_destination_numeric_accessor() {
for (initial, value, inserted, indexed, all) in [
(
"{v:[B;1b,2b]}",
"-3.5f",
"{v:[B;1b,-4b,2b]}",
"{v:[B;-4b,2b]}",
"{v:[B;-4b,-4b]}",
),
(
"{v:[B;1b,2b]}",
"-3.5d",
"{v:[B;1b,-4b,2b]}",
"{v:[B;-4b,2b]}",
"{v:[B;-4b,-4b]}",
),
(
"{v:[I;1,2]}",
"-3.5f",
"{v:[I;1,-4,2]}",
"{v:[I;-4,2]}",
"{v:[I;-4,-4]}",
),
(
"{v:[I;1,2]}",
"-3.5d",
"{v:[I;1,-4,2]}",
"{v:[I;-4,2]}",
"{v:[I;-4,-4]}",
),
(
"{v:[L;1L,2L]}",
"-3.5f",
"{v:[L;1L,-3L,2L]}",
"{v:[L;-3L,2L]}",
"{v:[L;-3L,-3L]}",
),
(
"{v:[L;1L,2L]}",
"-3.5d",
"{v:[L;1L,-4L,2L]}",
"{v:[L;-4L,2L]}",
"{v:[L;-4L,-4L]}",
),
] {
let value = tag(value);
let mut root = tag(initial);
assert_eq!(
path("v")
.insert(1, &mut root, std::slice::from_ref(&value))
.unwrap(),
1
);
assert_eq!(root, tag(inserted));
let mut root = tag(initial);
assert_eq!(path("v[0]").set(&mut root, &value).unwrap(), 1);
assert_eq!(root, tag(indexed));
let mut root = tag(initial);
assert_eq!(path("v[]").set(&mut root, &value).unwrap(), 2);
assert_eq!(root, tag(all));
}
}
#[test]
fn numeric_arrays_reject_nonnumeric_insert_and_indexed_set() {
for initial in ["{v:[B;1b,2b]}", "{v:[I;1,2]}", "{v:[L;1L,2L]}"] {
let original = tag(initial);
let value = tag("\"bad\"");
for index in [0, 99, -99] {
let mut root = original.clone();
assert_eq!(
path("v")
.insert(index, &mut root, std::slice::from_ref(&value))
.unwrap(),
0
);
assert_eq!(root, original);
}
let mut root = original.clone();
assert_eq!(path("v[0]").set(&mut root, &value).unwrap(), 0);
assert_eq!(root, original);
}
}
#[test]
fn empty_array_wildcard_set_preserves_vanillas_count_on_rejection() {
for initial in ["{v:[B;]}", "{v:[I;]}", "{v:[L;]}"] {
let mut root = tag(initial);
assert_eq!(path("v[]").set(&mut root, &tag("\"bad\"")).unwrap(), 1);
assert_eq!(root, tag(initial));
}
}
#[test]
fn partial_comparison() {
let actual = tag("{a:1,b:{c:[1,2,3],d:\"x\"},e:2}");
@@ -173,6 +173,16 @@ impl Registries {
fill(&mut self.templates, "templates");
fill(&mut self.persistent_components, "persistent_components");
fill(&mut self.learnable_recipes, "learnable_recipes");
if self.summonable.is_empty() {
self.summonable = strings(&r["summonable"]);
self.summonable.sort();
self.summonable.dedup();
}
if self.entity_types.is_empty() {
self.entity_types = strings(&r["entity_types"]);
self.entity_types.sort();
self.entity_types.dedup();
}
const RELOADED: [&str; 5] = [
"minecraft:advancement",
"minecraft:recipe",
@@ -236,6 +246,16 @@ impl Registries {
fill(&mut self.templates, &e.templates);
fill(&mut self.persistent_components, &e.persistent_components);
fill(&mut self.learnable_recipes, &e.learnable_recipes);
if self.summonable.is_empty() {
self.summonable.clone_from(&e.summonable);
self.summonable.sort();
self.summonable.dedup();
}
if self.entity_types.is_empty() {
self.entity_types.clone_from(&e.entity_types);
self.entity_types.sort();
self.entity_types.dedup();
}
for (name, ids) in &e.by_registry {
if !self.by_registry.contains_key(name) {
self.by_registry.insert(name.clone(), ids.clone());
+115 -13
View File
@@ -8,11 +8,20 @@ pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to
// ---- the coloured form -----------------------------------------------------
/// Writer provenance for a live moving-piston command read, or its selected subtree.
#[derive(Clone, Copy)]
pub enum PistonPart {
Entity,
State,
Properties,
}
struct Pretty {
indentation: String,
result: Text,
indent_depth: usize,
depth: usize,
piston: Option<PistonPart>,
}
fn is_numeric(t: &Tag) -> bool {
matches!(
@@ -58,6 +67,10 @@ impl Pretty {
self.lit("]", None);
}
fn sub(&mut self, tag: &Tag, indent: bool) {
self.sub_with(tag, indent, None);
}
fn sub_with(&mut self, tag: &Tag, indent: bool, piston: Option<PistonPart>) {
let previous = std::mem::replace(&mut self.piston, piston);
if indent {
self.indent_depth += 1;
}
@@ -67,6 +80,7 @@ impl Pretty {
self.indent_depth -= 1;
}
self.depth -= 1;
self.piston = previous;
}
fn visit(&mut self, tag: &Tag) {
match tag {
@@ -157,7 +171,11 @@ impl Pretty {
"\n"
};
let n = c.len();
for (i, (k, v)) in java_hash_order(c).into_iter().enumerate() {
let entries = match self.piston {
Some(part) => piston_hash_order(c, part),
None => java_hash_order(c),
};
for (i, (k, v)) in entries.into_iter().enumerate() {
self.lit(&entry, None);
let key = string_of(k);
if !key.is_empty()
@@ -171,7 +189,12 @@ impl Pretty {
}
self.lit(":", None);
self.lit(" ", None);
self.sub(v, true);
let child = match (self.piston, key.as_str()) {
(Some(PistonPart::Entity), "blockState") => Some(PistonPart::State),
(Some(PistonPart::State), "properties") => Some(PistonPart::Properties),
_ => None,
};
self.sub_with(v, true, child);
if i + 1 < n {
self.lit(",", None);
self.lit(spacing, None);
@@ -186,22 +209,61 @@ impl Pretty {
}
}
}
fn hash_bucket(len: usize, key: &horizon_world::nbt::JavaString) -> usize {
let mut capacity = 16usize;
while len > capacity * 3 / 4 {
capacity *= 2;
}
let h = string_of(key)
.encode_utf16()
.fold(0u32, |h, ch| h.wrapping_mul(31).wrapping_add(u32::from(ch)));
((h ^ (h >> 16)) as usize) & (capacity - 1)
}
fn piston_hash_order(
c: &horizon_world::nbt::Compound,
part: PistonPart,
) -> Vec<(&horizon_world::nbt::JavaString, &Tag)> {
let mut entries: Vec<_> = c.iter().collect();
match part {
PistonPart::Entity => {
// saveAdditional, then the base's components and saveMetadata.
let fields = [
"blockState",
"facing",
"progress",
"extending",
"source",
"components",
"id",
"x",
"y",
"z",
];
entries.sort_by_key(|(k, _)| {
(
hash_bucket(c.len(), k),
fields
.iter()
.position(|s| *s == string_of(k))
.unwrap_or(fields.len()),
)
});
}
PistonPart::Properties => {
// StateDefinition builds property codecs in sorted-name order.
// DFU's nested map-pair encoder writes the second codec first.
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), std::cmp::Reverse(*k)));
}
PistonPart::State => return java_hash_order(c),
}
entries
}
/// The iteration order of the `HashMap<String, Tag>` a `CompoundTag` holds,
/// for a map that grew to its size by insertion: buckets by
/// `HashMap.hash(String.hashCode())`, keys sharing a bucket in key order.
pub fn java_hash_order(
c: &horizon_world::nbt::Compound,
) -> Vec<(&horizon_world::nbt::JavaString, &Tag)> {
let mut capacity = 16usize;
while c.len() > capacity * 3 / 4 {
capacity *= 2;
}
let bucket = |k: &horizon_world::nbt::JavaString| {
let h = string_of(k)
.encode_utf16()
.fold(0u32, |h, ch| h.wrapping_mul(31).wrapping_add(u32::from(ch)));
((h ^ (h >> 16)) as usize) & (capacity - 1)
};
// Keys sharing a bucket iterate in insertion order: the order vanilla's
// savers write them, as far as it is known here.
const WRITE_ORDER: &[&str] = &[
@@ -268,7 +330,7 @@ pub fn java_hash_order(
.unwrap_or(WRITE_ORDER.len())
};
let mut entries: Vec<_> = c.iter().collect();
entries.sort_by_key(|(k, _)| (bucket(k), rank(k)));
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), rank(k)));
entries
}
/// `NbtUtils.toPrettyComponent`: the coloured, unindented form.
@@ -281,6 +343,19 @@ pub fn pretty_indented(tag: &Tag, indentation: &str) -> Text {
result: Text::empty(),
indent_depth: 0,
depth: 0,
piston: None,
};
p.visit(tag);
p.result
}
/// Command-only writer order; ordinary pretty printing keeps its existing policy.
pub fn pretty_piston(tag: &Tag, part: PistonPart) -> Text {
let mut p = Pretty {
indentation: String::new(),
result: Text::empty(),
indent_depth: 0,
depth: 0,
piston: Some(part),
};
p.visit(tag);
p.result
@@ -295,6 +370,33 @@ mod tests {
to_snbt(&parse_snbt(s).unwrap().0)
}
#[test]
fn live_piston_writer_order_does_not_change_default_pretty() {
let tag = parse_snbt(
r#"{id:"minecraft:piston",blockState:{id:"minecraft:piston_head",properties:{facing:"east",short:"false",type:"normal"}},components:{},x:1,y:100,z:0,facing:5b,progress:0.0f,source:1b,extending:1b}"#,
).unwrap().0;
let ordinary = r#"{blockState: {id: "minecraft:piston_head", properties: {facing: "east", short: "false", type: "normal"}}, components: {}, x: 1, facing: 5b, progress: 0.0f, y: 100, z: 0, id: "minecraft:piston", source: 1b, extending: 1b}"#;
assert_eq!(pretty(&tag).plain(), ordinary);
assert_eq!(
pretty_piston(&tag, PistonPart::Entity).plain(),
r#"{blockState: {id: "minecraft:piston_head", properties: {short: "false", facing: "east", type: "normal"}}, components: {}, x: 1, facing: 5b, progress: 0.0f, y: 100, z: 0, source: 1b, id: "minecraft:piston", extending: 1b}"#,
);
let state = tag.get("blockState").unwrap();
assert_eq!(
pretty_piston(state, PistonPart::State).plain(),
r#"{id: "minecraft:piston_head", properties: {short: "false", facing: "east", type: "normal"}}"#,
);
let props = state.get("properties").unwrap();
assert_eq!(
pretty_piston(props, PistonPart::Properties).plain(),
r#"{short: "false", facing: "east", type: "normal"}"#,
);
assert_eq!(
pretty(props).plain(),
r#"{facing: "east", short: "false", type: "normal"}"#
);
assert_eq!(pretty(&tag).plain(), ordinary);
}
#[test]
fn compact_form_matches_vanilla() {
assert_eq!(
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
+288 -39
View File
@@ -119,6 +119,19 @@ impl Button {
][face],
}
}
/// The face button that plays this one's part on a Nintendo pad going by its letters
/// (`PadSettings::nintendo_letters`): the A it prints on the right is South, which confirms
/// and jumps as A does on a Switch; B (bottom) is East, X (top) West, Y (left) North. It is
/// its own inverse, so it turns a part into the button playing it and back.
pub fn by_letter(self) -> Button {
match self {
Button::South => Button::East,
Button::East => Button::South,
Button::West => Button::North,
Button::North => Button::West,
other => other,
}
}
}
/// A pad's inputs at one moment. Sticks are `[x, y]` with right and up
@@ -142,6 +155,14 @@ impl PadState {
pub fn held(&self, button: Button) -> bool {
self.buttons & button.bit() != 0
}
/// A Nintendo pad's buttons read by their letters (`Button::by_letter`).
pub fn by_letter(&self) -> PadState {
let mut state = *self;
for part in [Button::South, Button::East, Button::West, Button::North] {
state.set(part, self.held(part.by_letter()));
}
state
}
/// Two sources at once (a real pad and `horizon-ctl pad`): buttons held on
/// either, sticks and triggers added.
pub fn merge(&self, other: &PadState) -> PadState {
@@ -179,12 +200,13 @@ impl PadState {
#[derive(Clone, Debug, Default)]
pub struct Edges {
held: u32,
suppressed: u32,
}
impl Edges {
pub fn update(&mut self, state: &PadState, threshold: f32) -> Vec<(Button, bool)> {
let mut now = state.buttons;
for (trigger, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
let was = self.held & button.bit() != 0;
let was = (self.held | self.suppressed) & button.bit() != 0;
let value = state.triggers[trigger];
let on = if was {
value >= (threshold - 0.1).max(0.05)
@@ -197,6 +219,8 @@ impl Edges {
now &= !button.bit();
}
}
self.suppressed &= now;
now &= !self.suppressed;
let changed = now ^ self.held;
self.held = now;
Button::ALL
@@ -217,6 +241,50 @@ impl Edges {
pub fn is_held(&self, button: Button) -> bool {
self.held & button.bit() != 0
}
/// Cancel a context without turning its still-held buttons into new presses.
pub fn cancel(&mut self) -> Vec<(Button, bool)> {
self.suppressed |= self.held;
self.release_all()
}
}
/// Intentional activity may take input ownership; an unchanged hold may not.
#[derive(Default)]
pub struct Activity {
buttons: u32,
sticks: [[f32; 2]; 2],
}
impl Activity {
pub fn observe(&mut self, state: &PadState, settings: &PadSettings) -> bool {
let mut buttons = state.buttons;
for (i, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
let threshold = if self.buttons & button.bit() != 0 {
(settings.trigger_threshold - 0.1).max(0.05)
} else {
settings.trigger_threshold
};
if state.triggers[i] >= threshold {
buttons |= button.bit();
}
}
let mut activity = buttons & !self.buttons != 0;
self.buttons = buttons;
for (i, (stick, deadzone)) in [
(state.left, settings.move_deadzone),
(state.right, settings.look_deadzone),
]
.into_iter()
.enumerate()
{
if length(stick) <= deadzone {
self.sticks[i] = [0.0; 2];
} else if length([stick[0] - self.sticks[i][0], stick[1] - self.sticks[i][1]]) > 0.08 {
activity = true;
self.sticks[i] = stick;
}
}
activity
}
}
/// Horizon's pad-only actions (no vanilla key mapping).
@@ -248,6 +316,39 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.smoothCamera", None),
];
/// How a bindable action reaches the game: one of its own handlers, shared
/// with the keyboard where the keyboard has one, or else the vanilla default
/// key or button of its mapping, as a rebound keyboard key would.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Route {
HotbarStep {
next: bool,
},
/// Held, or flipped when the pad's toggle sneak is on.
Sneak,
Screenshot,
SmoothCamera,
Key(winit::keyboard::KeyCode),
Mouse(winit::event::MouseButton),
}
/// The route of a bindable action; `None` for one the pad can't reach.
pub fn route(action: &str) -> Option<Route> {
match action {
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
"key.sneak" => Some(Route::Sneak),
"key.screenshot" => Some(Route::Screenshot),
"key.smoothCamera" => Some(Route::SmoothCamera),
_ => {
let default = crate::keybinds::KeyBinds::default_of(action);
crate::keybinds::mouse_button(default)
.map(Route::Mouse)
.or_else(|| crate::keybinds::key_code(default).map(Route::Key))
}
}
}
/// An action's name in the binds list: vanilla's translation key, or
/// Horizon's own English for the pad-only actions.
pub fn action_caption(action: &str) -> &str {
@@ -313,31 +414,6 @@ impl GlyphStyle {
}
}
/// How a controller drives menus (docs/controllers.md, "Menus and
/// inventories").
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum MenuNav {
/// A console-style highlight moved between widgets and slots.
#[default]
Focus,
/// A virtual mouse pointer.
Pointer,
}
impl MenuNav {
pub fn name(self) -> &'static str {
match self {
MenuNav::Focus => "focus",
MenuNav::Pointer => "pointer",
}
}
pub fn parse(name: &str) -> Self {
match name {
"pointer" => MenuNav::Pointer,
_ => MenuNav::Focus,
}
}
}
/// The on-screen keyboard A opens on a text field in focus mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TextKeyboard {
@@ -399,9 +475,16 @@ pub struct PadSettings {
/// holding it, unless vanilla's toggle crouch is on already.
pub toggle_sneak: bool,
pub glyphs: GlyphStyle,
/// How the pad drives menus: a focus highlight or a virtual pointer.
pub menu_nav: MenuNav,
/// A Nintendo pad's face buttons go by their letters (A confirms and jumps, as on the
/// Switch's own menus and keyboard) instead of their places (B, at the bottom, does): on
/// by default on the Switch only.
pub nintendo_letters: bool,
pub keyboard: TextKeyboard,
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
pub pointer_speed: f32,
pub scroll_speed: f32,
pub repeat_delay: f32,
pub repeat_interval: f32,
/// Action name -> button, `None` for unbound; missing means the default.
bindings: BTreeMap<&'static str, Option<Button>>,
}
@@ -420,8 +503,12 @@ impl Default for PadSettings {
trigger_threshold: 0.4,
toggle_sneak: true,
glyphs: GlyphStyle::Auto,
menu_nav: MenuNav::Focus,
nintendo_letters: cfg!(target_os = "horizon"),
keyboard: TextKeyboard::Auto,
pointer_speed: 1.0,
scroll_speed: 12.0,
repeat_delay: 0.35,
repeat_interval: 0.09,
bindings: BTreeMap::new(),
}
}
@@ -462,6 +549,13 @@ impl PadSettings {
.filter(|v| v.is_finite())
.map_or(d, |v| v.clamp(0.0, 1.0))
};
let bounded = |default: f32, min: f32, max: f32| {
value
.parse::<f32>()
.ok()
.filter(|v| v.is_finite())
.map_or(default, |v| v.clamp(min, max))
};
match key.trim() {
"enabled" => s.enabled = flag(s.enabled),
"vibration" => s.vibration = flag(s.vibration),
@@ -477,8 +571,12 @@ impl PadSettings {
}
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
"glyphs" => s.glyphs = GlyphStyle::parse(value),
"menuNavigation" => s.menu_nav = MenuNav::parse(value),
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
"keyboard" => s.keyboard = TextKeyboard::parse(value),
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
other => {
if let Some(action) = other.strip_prefix("bind_") {
let button = if value == "none" {
@@ -497,7 +595,7 @@ impl PadSettings {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nmenuNavigation:{}\nkeyboard:{}\n",
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nnintendoLetters:{}\nkeyboard:{}\n",
bool_text(self.enabled),
bool_text(self.vibration),
bool_text(self.adaptive_triggers),
@@ -510,9 +608,13 @@ impl PadSettings {
self.trigger_threshold,
bool_text(self.toggle_sneak),
self.glyphs.name(),
self.menu_nav.name(),
bool_text(self.nintendo_letters),
self.keyboard.name(),
);
text.push_str(&format!(
"pointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
));
for (action, button) in &self.bindings {
text.push_str(&format!(
"bind_{action}:{}\n",
@@ -1163,6 +1265,131 @@ pub fn bow_power(ticks: u32) -> f32 {
mod tests {
use super::*;
#[test]
fn every_bindable_action_reaches_the_game() {
for (action, _) in ACTIONS {
assert!(route(action).is_some(), "{action} has nowhere to go");
}
}
#[test]
fn navigation_preferences_migrate_and_stay_with_each_player() {
let dir = std::env::temp_dir().join(format!(
"horizon-pad-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let p2 = dir.join("P2");
fs::create_dir_all(&p2).unwrap();
let fresh = PadSettings::load(&dir);
assert_eq!(fresh, PadSettings::default());
// An older file, with the removed menuNavigation key: bindings kept.
let legacy =
PadSettings::parse("menuNavigation:pointer\nbind_key.jump:east\nlookSensitivity:0.7\n");
assert_eq!(legacy.binding("key.jump"), Some(Button::East));
assert_eq!(legacy.look_sensitivity, 0.7);
fresh.save(&dir);
assert!(!dir.join(FILE).exists(), "defaults write no file");
let mut other = legacy;
other.pointer_speed = 2.5;
other.scroll_speed = 24.0;
other.repeat_delay = 0.6;
other.repeat_interval = 0.12;
other.save(&p2);
assert_eq!(PadSettings::load(&p2), other);
assert_eq!(PadSettings::load(&dir), fresh, "P2 did not write P1");
let invalid = PadSettings::parse(
"pointerSpeed:NaN\nscrollSpeed:inf\nrepeatDelay:-10\nrepeatInterval:20\n",
);
assert_eq!((invalid.pointer_speed, invalid.scroll_speed), (1.0, 12.0));
assert_eq!((invalid.repeat_delay, invalid.repeat_interval), (0.1, 0.3));
let bounded = PadSettings::parse(
"pointerSpeed:10\nscrollSpeed:-1\nrepeatDelay:NaN\nrepeatInterval:inf\n",
);
assert_eq!((bounded.pointer_speed, bounded.scroll_speed), (3.0, 1.0));
assert_eq!(
(bounded.repeat_delay, bounded.repeat_interval),
(0.35, 0.09)
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn ownership_requires_new_activity_and_ignores_drift() {
let settings = PadSettings::default();
let mut p1 = Activity::default();
let mut p2 = Activity::default();
let mut state = PadState {
left: [0.04, 0.03],
..PadState::default()
};
assert!(!p1.observe(&state, &settings));
state.set(Button::South, true);
assert!(p1.observe(&state, &settings));
assert!(
!p1.observe(&state, &settings),
"held pad cannot reclaim mouse ownership"
);
assert!(
p2.observe(&state, &settings),
"another player's tracker is independent"
);
state.set(Button::South, false);
assert!(!p1.observe(&state, &settings));
state.left = [0.5, 0.0];
assert!(p1.observe(&state, &settings));
state.left = [0.52, 0.0];
assert!(!p1.observe(&state, &settings));
for x in [0.54, 0.56] {
state.left = [x, 0.0];
assert!(!p1.observe(&state, &settings));
}
state.left = [0.6, 0.0];
assert!(
p1.observe(&state, &settings),
"slow intentional movement accumulates"
);
state.left = [0.0; 2];
state.triggers[0] = 0.5;
assert!(p1.observe(&state, &settings));
state.triggers[0] = 0.35;
assert!(!p1.observe(&state, &settings));
state.triggers[0] = 0.45;
assert!(
!p1.observe(&state, &settings),
"trigger threshold noise is not new activity"
);
}
#[test]
fn cancelled_holds_stay_released_until_neutral_then_press_again() {
let mut edges = Edges::default();
let mut state = PadState::default();
state.set(Button::South, true);
state.triggers[1] = 0.8;
assert_eq!(
edges.update(&state, 0.4),
vec![(Button::South, true), (Button::RightTrigger, true)]
);
assert_eq!(
edges.cancel(),
vec![(Button::South, false), (Button::RightTrigger, false)]
);
assert!(edges.update(&state, 0.4).is_empty());
state.triggers[1] = 0.35;
assert!(edges.update(&state, 0.4).is_empty());
assert!(edges.update(&PadState::default(), 0.4).is_empty());
assert_eq!(edges.update(&state, 0.4), vec![(Button::South, true)]);
state.triggers[1] = 0.7;
assert_eq!(
edges.update(&state, 0.4),
vec![(Button::RightTrigger, true)]
);
}
#[test]
fn defaults_follow_bedrock() {
let s = PadSettings::default();
@@ -1192,7 +1419,9 @@ mod tests {
s.invert_y = true;
s.look_sensitivity = 0.75;
s.glyphs = GlyphStyle::PlayStation;
s.nintendo_letters = !s.nintendo_letters;
let text = s.serialize();
assert!(text.contains(&format!("nintendoLetters:{}\n", s.nintendo_letters)));
assert!(text.contains("bind_key.jump:east\n"));
assert!(text.contains("bind_key.drop:none\n"));
assert!(!text.contains("key.sneak"));
@@ -1238,6 +1467,27 @@ mod tests {
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
}
#[test]
fn a_nintendo_pad_by_its_letters_confirms_with_a() {
// The pad's A sits on the right (East): going by letters it plays South, confirm and jump.
let mut pad = PadState::default();
pad.set(Button::East, true);
pad.set(Button::LeftBumper, true);
pad.left = [0.5, -0.25];
let parts = pad.by_letter();
assert!(parts.held(Button::South));
assert!(!parts.held(Button::East));
// Other buttons and the sticks stay; reading by letters again gives the pad back.
assert!(parts.held(Button::LeftBumper));
assert_eq!(parts.left, [0.5, -0.25]);
assert_eq!(parts.by_letter(), pad);
// A part's glyph is the letter of the button playing it.
assert_eq!(Button::South.by_letter().label(GlyphStyle::Nintendo), "A");
assert_eq!(Button::East.by_letter().label(GlyphStyle::Nintendo), "B");
assert_eq!(Button::West.by_letter().label(GlyphStyle::Nintendo), "X");
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
}
#[test]
fn radial_deadzone_rescales() {
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
@@ -1414,14 +1664,13 @@ mod tests {
assert_eq!(stick_direction([0.9, 0.2], 0.5), Some(Button::DPadRight));
assert_eq!(stick_direction([-0.2, -0.8], 0.5), Some(Button::DPadDown));
assert_eq!(stick_direction([0.3, 0.3], 0.5), None);
let mut s = PadSettings::default();
assert_eq!(s.menu_nav, MenuNav::Focus);
s.menu_nav = MenuNav::Pointer;
s.keyboard = TextKeyboard::Horizon;
let back = PadSettings::parse(&s.serialize());
let s = PadSettings {
keyboard: TextKeyboard::Horizon,
..PadSettings::default()
};
assert_eq!(
(back.menu_nav, back.keyboard),
(MenuNav::Pointer, TextKeyboard::Horizon)
PadSettings::parse(&s.serialize()).keyboard,
TextKeyboard::Horizon
);
assert!(steam_keyboard(TextKeyboard::Steam) && !steam_keyboard(TextKeyboard::Horizon));
}
+155
View File
@@ -0,0 +1,155 @@
//! The keys a player's mappings hold, as their vanilla default keys, and which
//! devices hold each one. A key stays held while any device holds it, so one
//! device letting go (splitscreen moving the keyboard to another player, say)
//! can't release what another still presses.
use std::collections::HashMap;
use winit::keyboard::KeyCode;
/// Where a press came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
/// The keyboard and mouse, and the scripting socket acting as them.
Keyboard,
Pad,
}
impl Source {
fn bit(self) -> u8 {
match self {
Source::Keyboard => 1,
Source::Pad => 2,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HeldKeys {
held: HashMap<KeyCode, u8>,
}
impl HeldKeys {
pub fn contains(&self, key: &KeyCode) -> bool {
self.held.contains_key(key)
}
/// `source` holds `key`. True when nothing held it before.
pub fn press(&mut self, key: KeyCode, source: Source) -> bool {
let sources = self.held.entry(key).or_insert(0);
let new = *sources == 0;
*sources |= source.bit();
new
}
/// `source` lets go of `key`. True when nothing holds it any more.
pub fn release(&mut self, key: &KeyCode, source: Source) -> bool {
let Some(sources) = self.held.get_mut(key) else {
return true;
};
*sources &= !source.bit();
if *sources == 0 {
self.held.remove(key);
true
} else {
false
}
}
pub fn held_by(&self, key: &KeyCode, source: Source) -> bool {
self.held
.get(key)
.is_some_and(|sources| sources & source.bit() != 0)
}
/// `source` lets go of everything: the keys nothing holds any more.
pub fn release_source(&mut self, source: Source) -> Vec<KeyCode> {
let keys: Vec<KeyCode> = self
.held
.iter()
.filter(|(_, sources)| *sources & source.bit() != 0)
.map(|(key, _)| *key)
.collect();
keys.into_iter()
.filter(|key| self.release(key, source))
.collect()
}
/// Held, by the keyboard unless a device already holds it: for callers
/// that don't say where the press came from.
pub fn insert(&mut self, key: KeyCode) -> bool {
if self.contains(&key) {
return false;
}
self.press(key, Source::Keyboard)
}
/// Let go of `key` whoever holds it.
pub fn remove(&mut self, key: &KeyCode) -> bool {
self.held.remove(key).is_some()
}
pub fn clear(&mut self) {
self.held.clear();
}
pub fn is_empty(&self) -> bool {
self.held.is_empty()
}
pub fn len(&self) -> usize {
self.held.len()
}
pub fn iter(&self) -> impl Iterator<Item = &KeyCode> {
self.held.keys()
}
}
impl IntoIterator for HeldKeys {
type Item = KeyCode;
type IntoIter = std::collections::hash_map::IntoKeys<KeyCode, u8>;
fn into_iter(self) -> Self::IntoIter {
self.held.into_keys()
}
}
impl<'a> IntoIterator for &'a HeldKeys {
type Item = &'a KeyCode;
type IntoIter = std::collections::hash_map::Keys<'a, KeyCode, u8>;
fn into_iter(self) -> Self::IntoIter {
self.held.keys()
}
}
impl FromIterator<KeyCode> for HeldKeys {
fn from_iter<I: IntoIterator<Item = KeyCode>>(keys: I) -> Self {
let mut held = HeldKeys::default();
for key in keys {
held.insert(key);
}
held
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_key_stays_held_while_any_device_holds_it() {
let mut keys = HeldKeys::default();
assert!(keys.press(KeyCode::Space, Source::Pad));
assert!(!keys.press(KeyCode::Space, Source::Keyboard));
assert!(keys.press(KeyCode::KeyW, Source::Keyboard));
assert_eq!(keys.release_source(Source::Keyboard), vec![KeyCode::KeyW]);
assert!(keys.contains(&KeyCode::Space) && !keys.contains(&KeyCode::KeyW));
assert!(keys.held_by(&KeyCode::Space, Source::Pad));
assert!(!keys.held_by(&KeyCode::Space, Source::Keyboard));
assert!(keys.release(&KeyCode::Space, Source::Pad));
assert!(keys.is_empty());
}
#[test]
fn untagged_inserts_and_removes_act_as_before() {
let mut keys = HeldKeys::default();
keys.press(KeyCode::ShiftLeft, Source::Pad);
assert!(!keys.insert(KeyCode::ShiftLeft), "already held");
assert!(!keys.held_by(&KeyCode::ShiftLeft, Source::Keyboard));
assert!(
keys.remove(&KeyCode::ShiftLeft),
"remove lets go for everyone"
);
assert!(!keys.contains(&KeyCode::ShiftLeft));
assert!(keys.release(&KeyCode::KeyA, Source::Keyboard), "never held");
}
}
+2
View File
@@ -5,6 +5,7 @@
#![forbid(unsafe_code)]
pub mod clipboard;
pub mod gamepad;
pub mod held;
pub mod input;
pub mod interaction_input;
pub mod key_names;
@@ -12,4 +13,5 @@ pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod snap;
pub mod window;
+152
View File
@@ -0,0 +1,152 @@
//! Where the controller's menu cursor settles once the stick is let go, and
//! how it gets there. Every rule's number lives in [`Tuning`], so the feel can
//! be adjusted in one place.
/// The snapping engine's knobs, in GUI pixels and seconds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tuning {
/// A let-go cursor settles on a target this close to it.
pub radius: f32,
/// How long the glide onto a target takes.
pub glide_seconds: f32,
/// Movement shorter than this is a wobble: the cursor stays where it was.
pub min_travel: f32,
/// A flick that ends still nearest its starting target commits to the
/// neighbour it was heading for once it covered this fraction of the way.
pub commit: f32,
/// How far off the flick's direction a neighbour may lie (cosine of the
/// angle: 0.7 is about 45 degrees).
pub cone: f32,
/// The pointer's speed over a slot or control, as a fraction of its
/// speed elsewhere (1 leaves it unchanged).
pub over_target_speed: f32,
}
pub const TUNING: Tuning = Tuning {
radius: 16.0,
glide_seconds: 0.025,
min_travel: 1.5,
commit: 0.15,
cone: 0.7,
over_target_speed: 1.0,
};
type Point = (f32, f32);
fn distance(a: Point, b: Point) -> f32 {
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
}
/// The target nearest `at`, if one lies within `radius`.
pub fn nearest(at: Point, targets: &[Point], radius: f32) -> Option<usize> {
targets
.iter()
.enumerate()
.map(|(i, t)| (i, distance(at, *t)))
.filter(|(_, d)| *d <= radius)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
}
/// Where a stick movement from `start` to `end` settles among `targets`
/// (their centres), or `None` to leave the cursor where it is.
pub fn settle(start: Point, end: Point, targets: &[Point], tuning: &Tuning) -> Option<usize> {
let near = nearest(end, targets, tuning.radius);
let travel = (end.0 - start.0, end.1 - start.1);
let length = distance(start, end);
if length < tuning.min_travel {
return near;
}
let origin = nearest(start, targets, tuning.radius);
if near != origin {
return near;
}
// Still nearest where it started: a quick flick toward a neighbour.
let Some(o) = origin else {
return near;
};
let from = targets[o];
let heading = (travel.0 / length, travel.1 / length);
let next = targets
.iter()
.enumerate()
.filter(|(i, _)| *i != o)
.filter_map(|(i, t)| {
let to = (t.0 - from.0, t.1 - from.1);
let gap = distance(from, *t);
let aligned = (to.0 * heading.0 + to.1 * heading.1) / gap;
(gap > 0.0 && aligned >= tuning.cone).then_some((i, gap, to))
})
.min_by(|a, b| a.1.total_cmp(&b.1));
match next {
Some((i, gap, to)) => {
let progress = ((end.0 - from.0) * to.0 + (end.1 - from.1) * to.1) / (gap * gap);
if progress >= tuning.commit {
Some(i)
} else {
near
}
}
None => near,
}
}
/// The cursor `seconds` into a glide from `from` to `to`: eased out, so it
/// slows as it lands, and exactly `to` once the glide is over.
pub fn glide(from: Point, to: Point, seconds: f32, tuning: &Tuning) -> Point {
let t = (seconds / tuning.glide_seconds).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - t).powi(3);
(
from.0 + (to.0 - from.0) * eased,
from.1 + (to.1 - from.1) * eased,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// A row of inventory slots, 18 pixels apart, and the slot below the first.
const SLOTS: [Point; 4] = [(8.0, 8.0), (26.0, 8.0), (44.0, 8.0), (8.0, 26.0)];
#[test]
fn a_short_flick_moves_to_the_neighbour_it_heads_for() {
// 4 pixels right of slot 0: still nearest slot 0, but heading for slot 1.
assert_eq!(settle((8.0, 8.0), (12.0, 8.0), &SLOTS, &TUNING), Some(1));
assert_eq!(settle((8.0, 8.0), (8.0, 12.0), &SLOTS, &TUNING), Some(3));
assert_eq!(settle((26.0, 8.0), (22.0, 8.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn a_wobble_stays_put() {
assert_eq!(settle((8.0, 8.0), (8.5, 8.4), &SLOTS, &TUNING), Some(0));
// Barely started toward the neighbour: not committed.
assert_eq!(settle((8.0, 8.0), (9.6, 8.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn a_long_move_settles_on_the_nearest_target() {
assert_eq!(settle((8.0, 8.0), (42.0, 9.0), &SLOTS, &TUNING), Some(2));
// Far from everything: left where it is.
assert_eq!(settle((8.0, 8.0), (200.0, 200.0), &SLOTS, &TUNING), None);
}
#[test]
fn a_flick_off_any_row_does_not_jump_sideways() {
// Up from slot 0: nothing lies that way, so it stays on slot 0.
assert_eq!(settle((8.0, 8.0), (8.0, 4.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn the_glide_eases_onto_its_target_and_stops_there() {
let (from, to) = ((0.0, 0.0), (20.0, -10.0));
assert_eq!(glide(from, to, 0.0, &TUNING), from);
let half = glide(from, to, TUNING.glide_seconds / 2.0, &TUNING);
assert!(
half.0 > 10.0 && half.0 < 20.0,
"eased out: past halfway at half time"
);
assert_eq!(glide(from, to, TUNING.glide_seconds, &TUNING), to);
assert_eq!(glide(from, to, 1.0, &TUNING), to);
}
}
@@ -64,10 +64,11 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]);
renderer.set_shader_pack(0, None, &[]).unwrap();
assert!(
!renderer.pack_entity_shadows(),
"selected pack without a compiled post"
@@ -164,6 +165,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
}
let (_, notes) = renderer
.set_shader_pack(dimension, Some(post), &[])
.unwrap()
.unwrap();
if break_pipeline {
assert!(
@@ -190,8 +190,9 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
}
let one = [255u8; 4];
let zero = [0u8; 4];
let pixels = render.pixels.get();
let prepared = PreparedTextures::new(
&render.pixels,
&pixels,
render.sprite_size,
render.texture_layers,
&render.sprite_mips,
@@ -206,7 +207,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&render.pixels,
&pixels,
render.sprite_size,
render.sprite_minimum_size,
render.texture_layers,
@@ -222,7 +223,10 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.graphics_api(),
settings.translucent_batching,
false,
false,
))?;
drop(pixels);
render.pixels.pack();
let state = &scene.renderer;
let [width, height] = o.size.unwrap_or(state.size);
renderer.resize(width, height);
@@ -79,7 +79,10 @@ fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &renderer.pack_inputs());
assert_eq!(post.notes.is_empty(), valid, "{:?}", post.notes);
let (_, notes) = renderer.set_shader_pack(0, Some(post), &[]).unwrap();
let (_, notes) = renderer
.set_shader_pack(0, Some(post), &[])
.unwrap()
.unwrap();
assert!(
!valid || notes.iter().all(|n| !n.contains("skipped")),
"{notes:?}"
@@ -121,6 +124,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
renderer.resize(320, 180);
@@ -351,6 +355,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -373,10 +378,12 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
+100 -2
View File
@@ -84,7 +84,9 @@ pub struct Assets {
pub struct RenderAssets {
pub sprite_size: u32,
pub sprite_minimum_size: u32,
pub pixels: Vec<u8>,
/// The block sprites' RGBA, layer by layer: as built until a renderer has
/// them, then kept compressed ([`SpritePixels`]).
pub pixels: SpritePixels,
pub texture_layers: u32,
pub model_catalog: crate::models::StateModels,
/// Shared parsed resources; item-only validation errors surface at item preparation.
@@ -111,6 +113,76 @@ pub struct RenderAssets {
/// sprites are when it changes (`native_block_sprites_version`).
pub generation: u64,
}
/// The block sprites' pixels for a renderer to upload. Once a renderer has
/// them ([`Self::pack`]) they are kept lz4-compressed: the GPU holds the
/// texture array, and the copy only serves a renderer made again (another
/// window, a lost device, another graphics API), which waits for
/// [`Self::get`] to uncompress them. Shared by the window and the workers'
/// `RenderAssets`, so the compressed form replaces the raw one in place.
pub struct SpritePixels(Arc<PixelStore>);
struct PixelStore {
len: usize,
/// Set by the first `pack`: later calls cost one atomic read.
packing: std::sync::atomic::AtomicBool,
/// As built; dropped once `packed` holds them.
raw: std::sync::Mutex<Option<Arc<Vec<u8>>>>,
packed: std::sync::OnceLock<Vec<u8>>,
}
impl SpritePixels {
pub fn new(pixels: Vec<u8>) -> Self {
Self(Arc::new(PixelStore {
len: pixels.len(),
packing: std::sync::atomic::AtomicBool::new(false),
raw: std::sync::Mutex::new(Some(Arc::new(pixels))),
packed: std::sync::OnceLock::new(),
}))
}
/// Their size in bytes, uncompressed.
pub fn len(&self) -> usize {
self.0.len
}
pub fn is_empty(&self) -> bool {
self.0.len == 0
}
/// The pixels as built, uncompressed again once packed.
pub fn get(&self) -> Arc<Vec<u8>> {
let raw = self.0.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
raw.unwrap_or_else(|| {
let packed = self.0.packed.get().map(Vec::as_slice).unwrap_or_default();
Arc::new(
lz4_flex::decompress_size_prepended(packed)
.ok()
.filter(|p| p.len() == self.0.len)
.unwrap_or_else(|| vec![0; self.0.len]),
)
})
}
/// Keep them compressed from now on, once a renderer has uploaded them:
/// compressed on a thread of their own, the raw copy dropped after.
pub fn pack(&self) {
use std::sync::atomic::Ordering;
if self.0.len == 0 || self.0.packing.swap(true, Ordering::Relaxed) {
return;
}
let store = self.0.clone();
let job = move || {
let raw = store.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(raw) = raw {
let _ = store.packed.set(lz4_flex::compress_prepend_size(&raw));
// `get` reads the compressed copy from here on.
*store.raw.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
};
let fallback = job.clone();
if std::thread::Builder::new()
.name("Sprite packing".into())
.spawn(job)
.is_err()
{
fallback();
}
}
}
/// The last `RenderAssets::generation` handed out.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn invalid(message: &str) -> io::Error {
@@ -280,6 +352,7 @@ impl RenderAssets {
}
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
sprites.insert(root, &format!("minecraft:block/destroy_oit_stage_{stage}"))?;
}
let fusion = if draws {
let _step = crate::spans::startup::step("core.fusion");
@@ -333,7 +406,7 @@ impl RenderAssets {
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
texture_layers: u32::try_from(sprites.count())?,
pixels: sprites.pixels,
pixels: SpritePixels::new(sprites.pixels),
model_catalog: models.catalog,
model_library,
model_placements: models.placements,
@@ -431,4 +504,29 @@ mod tests {
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
));
}
#[test]
fn sprite_pixels_come_back_the_same_once_packed() {
// Two layers of 16x16 RGBA with some structure, as sprites have.
let pixels: Vec<u8> = (0..2 * 16 * 16 * 4).map(|i| (i * 7 % 251) as u8).collect();
let stored = super::SpritePixels::new(pixels.clone());
assert_eq!(*stored.get(), pixels);
stored.pack();
stored.pack();
// The packing thread drops the raw copy once the compressed one is set.
let started = std::time::Instant::now();
while stored.0.raw.lock().unwrap().is_some() {
assert!(started.elapsed().as_secs() < 10, "packing never finished");
std::thread::yield_now();
}
assert!(
stored
.0
.packed
.get()
.is_some_and(|p| p.len() < pixels.len())
);
assert_eq!(*stored.get(), pixels);
assert_eq!(stored.len(), pixels.len());
}
}
@@ -379,14 +379,17 @@ pub fn generate(
let (_, _, key) = load(palette_key, "textures/palettes")?;
let (_, _, target) = load(palette, "textures/palettes")?;
let mut mapped = argb(&pixels);
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
let out = mapped
let key = argb(&key);
let target = argb(&target);
apply_palette(&mut mapped, &key, &target)?;
let out: Vec<u8> = mapped
.iter()
.flat_map(|p| {
let [a, r, g, b] = p.to_be_bytes();
[r, g, b, a]
})
.collect();
drop(mapped);
Some((w, h, out))
}
}
+227 -22
View File
@@ -61,6 +61,8 @@ struct Mixer {
world_names: Vec<String>,
/// The callback's cameras (position, basis, world), kept to be refilled without allocating.
cameras: Vec<([f64; 3], Basis, u32)>,
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
voices: Vec<(ChannelId, [f32; 3], f64)>,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
@@ -206,6 +208,18 @@ fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: b
type Stream = cpal::Stream;
#[cfg(not(feature = "audio"))]
type Stream = std::convert::Infallible;
/// A level sound retained for the network broadcaster, in playback order.
#[derive(Clone, Debug)]
pub struct CapturedSound {
pub event: String,
pub position: [f64; 3],
pub volume: f32,
pub pitch: f32,
pub source: usize,
pub entity: Option<u32>,
pub except_entity: Option<u32>,
}
pub struct Audio {
_stream: Option<Stream>,
device_name: String,
@@ -222,7 +236,7 @@ pub struct Audio {
source_override: Option<usize>,
/// Positional sounds played since the last drain, kept while the world
/// is open to the network so guests hear them too.
pub capture: Option<Vec<(String, [f64; 3], f32, f32)>>,
pub capture: Option<Vec<CapturedSound>>,
/// How many times each positional sound played (the control socket's `status`),
/// with or without an audio device.
pub heard: std::collections::BTreeMap<String, u32>,
@@ -544,9 +558,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
(forward, up, right)
};
// Every camera hearing (splitscreen: the other players' too), in the mixer's own buffer.
let mut cameras = std::mem::take(&mut mixer.cameras);
cameras.clear();
cameras.extend(
mixer.cameras.clear();
mixer.cameras.extend(
std::iter::once((
0,
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
@@ -565,10 +578,11 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
)
}),
);
let shared = cameras.len() > 1;
let shared = mixer.cameras.len() > 1;
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
// Gains and pans per source for this buffer.
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
for (id, c) in &mixer.channels {
let Some((_, source_rate)) = &c.samples else {
continue;
@@ -586,7 +600,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = cameras
let camera = mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
@@ -617,8 +632,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
mixer.cameras = cameras;
for (id, [left, right, gain], step) in voices {
for (id, [left, right, gain], step) in voices.iter().copied() {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
};
@@ -648,6 +662,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
c.position += step;
}
}
mixer.voices = voices;
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 200 ms
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
@@ -690,17 +705,27 @@ fn open_device(
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
let stream = match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, mixer.clone()),
F::F64 => build_stream::<f64>(device, &config, mixer.clone()),
F::I16 => build_stream::<i16>(device, &config, mixer.clone()),
F::I32 => build_stream::<i32>(device, &config, mixer.clone()),
F::I8 => build_stream::<i8>(device, &config, mixer.clone()),
F::U16 => build_stream::<u16>(device, &config, mixer.clone()),
F::U32 => build_stream::<u32>(device, &config, mixer.clone()),
F::U8 => build_stream::<u8>(device, &config, mixer.clone()),
let build = |stream_config: &cpal::StreamConfig| match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, stream_config, mixer.clone()),
F::F64 => build_stream::<f64>(device, &config, stream_config, mixer.clone()),
F::I16 => build_stream::<i16>(device, &config, stream_config, mixer.clone()),
F::I32 => build_stream::<i32>(device, &config, stream_config, mixer.clone()),
F::I8 => build_stream::<i8>(device, &config, stream_config, mixer.clone()),
F::U16 => build_stream::<u16>(device, &config, stream_config, mixer.clone()),
F::U32 => build_stream::<u32>(device, &config, stream_config, mixer.clone()),
F::U8 => build_stream::<u8>(device, &config, stream_config, mixer.clone()),
other => Err(format!("unsupported sample format {other}").into()),
}?;
};
let short = audio_stream_config(&config);
let stream = match build(&short) {
Ok(stream) => stream,
// A device that refuses the short fixed buffer still plays with cpal's default one.
Err(e) if short != config.config() => {
log::info!("Horizon: audio output refused a short buffer ({e}); using the default");
build(&config.config())?
}
Err(e) => return Err(e),
};
stream.play()?;
if let Ok(mut m) = mixer.lock() {
m.device_name = device.name().unwrap_or_default();
@@ -781,10 +806,35 @@ fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
_ => true,
}
}
/// The cpal `StreamConfig` to open an output stream with. On Linux the ALSA
/// backend's `BufferSize::Default` means a ~100 ms buffer, so sound effects
/// come out about 100 ms late: request a short fixed buffer there. Other
/// platforms keep cpal's default behaviour.
#[cfg(feature = "audio")]
fn audio_stream_config(config: &cpal::SupportedStreamConfig) -> cpal::StreamConfig {
#[cfg(target_os = "linux")]
{
let range = match config.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
cpal::SupportedBufferSize::Unknown => (256, 4096),
};
let target = 1024u32.clamp(range.0, range.1);
let mut sc = config.config();
sc.buffer_size = cpal::BufferSize::Fixed(target);
sc
}
#[cfg(not(target_os = "linux"))]
{
config.config()
}
}
#[cfg(feature = "audio")]
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
stream_config: &cpal::StreamConfig,
mixer: Arc<Mutex<Mixer>>,
) -> Result<cpal::Stream> {
let rate = config.sample_rate().0;
@@ -792,7 +842,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
let mut scratch = Vec::new();
let on_error = mixer.clone();
let stream = device.build_output_stream(
&config.config(),
stream_config,
move |data: &mut [T], _| {
scratch.resize(data.len(), 0.0f32);
if let Ok(mut m) = mixer.lock() {
@@ -1259,7 +1309,17 @@ impl Audio {
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));
capture.push(CapturedSound {
event: event.to_owned(),
position: at,
volume,
pitch,
source: self.source_override.unwrap_or_else(|| {
crate::settings::sound_source(&crate::sound_events::normalize(event))
}),
entity: None,
except_entity: None,
});
}
*self.heard.entry(event.to_owned()).or_default() += 1;
self.sync_volumes();
@@ -1298,6 +1358,66 @@ impl Audio {
preview.preview(self, source, volume);
self.preview = preview;
}
/// An incoming entity-sound packet: retain its seed and category without rebroadcasting it.
pub fn play_entity_seeded(
&mut self,
event: &str,
source: usize,
volume: f32,
pitch: f32,
seed: i64,
entity: crate::sound_engine::SoundEntity,
) {
*self.heard.entry(event.to_owned()).or_default() += 1;
let event = crate::sound_events::normalize(event);
let instance = SoundInstance::entity_bound(
&event,
source,
volume,
pitch,
LegacyRandom::new(seed),
entity,
);
self.play_instance(instance, false);
}
/// A level's entity overload: preserve the emitter and excluded player for
/// the server broadcaster while the local sound follows the same object.
pub fn play_bound(
&mut self,
event: &str,
source: usize,
volume: f32,
pitch: f32,
entity: crate::sound_engine::SoundEntity,
except_entity: Option<u32>,
) {
if let Some(capture) = self.capture.as_mut() {
capture.push(CapturedSound {
event: event.to_owned(),
position: entity.state().position,
volume,
pitch,
source,
entity: Some(entity.id),
except_entity,
});
}
let seed = self.random.next_long();
self.play_entity_seeded(event, source, volume, pitch, seed, entity);
}
/// Removal belongs to the old object even if its numeric id is immediately reused.
pub fn remove_sound_entity(&mut self, id: u32) {
for entity in self
.engine
.bound_entities()
.into_iter()
.filter(|e| e.id == id)
{
let mut state = entity.state();
state.removed = true;
entity.update(state);
}
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
@@ -1308,7 +1428,17 @@ impl Audio {
seed: i64,
) {
if let (Some(capture), Some(at)) = (self.capture.as_mut(), at) {
capture.push((event.to_owned(), at, volume, pitch));
capture.push(CapturedSound {
event: event.to_owned(),
position: at,
volume,
pitch,
source: self.source_override.unwrap_or_else(|| {
crate::settings::sound_source(&crate::sound_events::normalize(event))
}),
entity: None,
except_entity: None,
});
}
if at.is_some() {
*self.heard.entry(event.to_owned()).or_default() += 1;
@@ -1448,6 +1578,81 @@ impl Sounds for Audio {
mod tests {
use super::*;
#[test]
fn level_capture_keeps_order_category_entity_and_actor_exclusion() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
audio.capture = Some(Vec::new());
let mut expected = audio.random.clone();
audio.play_on(
7,
"minecraft:block.amethyst_block.chime",
Some([1.0; 3]),
0.5,
1.0,
);
let entity = crate::sound_engine::SoundEntity::new(37, [2.123456789, 64.0, -3.0], false);
audio.play_bound(
"minecraft:block.amethyst_block.chime",
6,
2.0,
1.2,
entity,
Some(17),
);
let sounds = audio.capture.as_ref().unwrap();
assert_eq!(sounds.len(), 2);
assert_eq!(
(sounds[0].source, sounds[0].entity, sounds[0].except_entity),
(7, None, None)
);
let bound = &sounds[1];
assert_eq!(
(bound.source, bound.entity, bound.except_entity),
(6, Some(37), Some(17))
);
assert_eq!(bound.position, [2.123456789, 64.0, -3.0]);
assert_eq!((bound.volume, bound.pitch), (2.0, 1.2));
expected.next_long();
expected.next_long();
assert_eq!(audio.random.next_long(), expected.next_long());
}
#[test]
fn entity_sound_packets_neither_rebroadcast_nor_advance_the_local_seed_generator() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
audio.capture = Some(Vec::new());
let mut expected = audio.random.clone();
let entity = crate::sound_engine::SoundEntity::new(7, [1.0, 2.0, 3.0], false);
audio.play_entity_seeded("minecraft:fixture.bound", 6, 0.7, 1.2, 42, entity);
assert!(audio.capture.as_ref().unwrap().is_empty());
assert_eq!(audio.random.next_long(), expected.next_long());
}
#[test]
fn removal_marks_only_existing_references_to_that_entity() {
use crate::sound_engine::SoundEntity;
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
let old = SoundEntity::new(7, [0.0; 3], false);
let other = SoundEntity::new(8, [0.0; 3], false);
for entity in [old.clone(), other.clone()] {
audio.engine.add(
SoundInstance::entity_bound(
"minecraft:fixture.bound",
6,
1.0,
1.0,
LegacyRandom::new(42),
entity,
),
true,
);
}
audio.remove_sound_entity(7);
let replacement = SoundEntity::new(7, [10.0; 3], false);
assert!(old.state().removed);
assert!(!other.state().removed);
assert!(!replacement.state().removed);
}
#[test]
fn button_click_uses_vanilla_ui_parameters_without_advancing_world_random() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
+108 -11
View File
@@ -49,6 +49,10 @@ impl AuthSession {
access_token: access_token.to_owned(),
}
}
pub fn access_token(&self) -> &str {
&self.access_token
}
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
pub fn parse(text: &str) -> Result<Self, String> {
let v: serde_json::Value =
@@ -101,6 +105,22 @@ impl AuthSession {
let _ = std::fs::remove_file(path);
Self::parse(&text.map_err(|e| format!("{}: {e}", path.display()))?)
}
/// The access token's expiry (its JWT `exp`) has passed, or is a minute away: the session
/// service would refuse it. A token that doesn't read as a JWT counts as good: the server
/// says.
pub fn expired(&self) -> bool {
let exp = self
.access_token
.split('.')
.nth(1)
.and_then(|p| crate::skins::base64_decode(&p.replace('-', "+").replace('_', "/")))
.and_then(|json| serde_json::from_slice::<serde_json::Value>(&json).ok())
.and_then(|v| v.get("exp")?.as_u64());
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
exp.is_some_and(|exp| exp <= now + 60)
}
}
static CURRENT: OnceLock<Arc<AuthSession>> = OnceLock::new();
@@ -436,11 +456,8 @@ struct ChatKeys {
fetching: bool,
next_try: Option<std::time::Instant>,
}
static CHAT_KEYS: Mutex<ChatKeys> = Mutex::new(ChatKeys {
current: None,
fetching: false,
next_try: None,
});
static CHAT_KEYS: std::sync::LazyLock<Mutex<std::collections::HashMap<u128, ChatKeys>>> =
std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
/// then prepareKeyPair: start fetching when there is no key pair or it is
@@ -449,7 +466,14 @@ pub fn prepare_chat_keys(now: bool) {
let Some(session) = current() else {
return;
};
let mut keys = CHAT_KEYS.lock().unwrap();
prepare_chat_keys_for(session, now);
}
/// Prepare keys for this player, independently of the other local accounts.
pub fn prepare_chat_keys_for(session: Arc<AuthSession>, now: bool) {
let uuid = session.uuid;
let mut accounts = CHAT_KEYS.lock().unwrap();
let keys = accounts.entry(uuid).or_default();
let instant = std::time::Instant::now();
if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
return;
@@ -463,12 +487,13 @@ pub fn prepare_chat_keys(now: bool) {
return;
}
keys.fetching = true;
drop(keys);
drop(accounts);
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();
let mut accounts = CHAT_KEYS.lock().unwrap();
let keys = accounts.entry(uuid).or_default();
keys.fetching = false;
match result {
Ok(pair) => keys.current = Some(Arc::new(pair)),
@@ -476,19 +501,24 @@ pub fn prepare_chat_keys(now: bool) {
}
});
if spawned.is_err() {
CHAT_KEYS.lock().unwrap().fetching = false;
CHAT_KEYS.lock().unwrap().entry(uuid).or_default().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_for(current()?.uuid)
}
/// This account's key pair; another local player's keys can never be returned.
pub fn chat_keys_for(uuid: u128) -> Option<Arc<ChatKeyPair>> {
CHAT_KEYS
.lock()
.unwrap()
.get(&uuid)?
.current
.clone()
.filter(|k| !k.public.has_expired(now))
.filter(|k| !k.public.has_expired(now_millis()))
}
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
@@ -632,6 +662,42 @@ mod tests {
}
}
/// base64url without padding, as a JWT's parts are.
fn base64url(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n =
chunk.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)) << (8 * (3 - chunk.len()));
for k in 0..=chunk.len() {
out.push(char::from(ALPHABET[(n >> (18 - 6 * k) & 63) as usize]));
}
}
out
}
#[test]
fn a_token_past_its_exp_is_expired() {
// header.payload.signature, the payload base64url without padding.
let token = |exp: u64| {
let json = format!(r#"{{"exp":{exp}}}"#);
let payload = base64url(json.as_bytes());
AuthSession::new("Ann", UUID, &format!("e30.{payload}.sig"))
};
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(token(now - 10).expired());
assert!(
token(now + 30).expired(),
"a minute from its end: as good as gone"
);
assert!(!token(now + 3600).expired());
assert!(!AuthSession::new("Ann", UUID, "not-a-jwt").expired());
}
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
let path = dir.join("session.json");
std::fs::write(&path, text).unwrap();
@@ -795,6 +861,37 @@ mod tests {
.to_string()
}
#[test]
fn local_accounts_use_their_own_chat_keys() {
let pair = |signature: &[u8]| {
let mut keys = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
keys.public.expires_at = i64::MAX;
keys.refreshed_after = i64::MAX;
keys.public.signature = signature.to_vec();
Arc::new(keys)
};
let ann = pair(b"ann");
let bea = pair(b"bea");
{
let mut accounts = CHAT_KEYS.lock().unwrap();
for (uuid, keys) in [(101, ann.clone()), (102, bea.clone())] {
accounts.insert(
uuid,
ChatKeys {
current: Some(keys),
..Default::default()
},
);
}
}
prepare_chat_keys_for(Arc::new(AuthSession::new("Ann", 101, "unused")), true);
assert!(Arc::ptr_eq(&chat_keys_for(101).unwrap(), &ann));
assert!(Arc::ptr_eq(&chat_keys_for(102).unwrap(), &bea));
assert!(chat_keys_for(103).is_none());
CHAT_KEYS.lock().unwrap().remove(&101);
CHAT_KEYS.lock().unwrap().remove(&102);
}
#[test]
fn certificates_response() {
let pair = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
@@ -89,6 +89,7 @@ pub struct BlockEntities {
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
/// Like `mods`, one taken out stays listed until `put` or `remove`.
boxes: FxHashSet<Pos>,
asleep: FxHashSet<Pos>,
}
/// A structure or test instance block: the block entities whose box the frame may draw.
@@ -229,6 +230,7 @@ impl BlockEntities {
}
/// `removeBlockEntityTicker`: no slot, made or pending.
fn cancel(&mut self, pos: &Pos) {
self.asleep.remove(pos);
self.freed |= self.live.remove(pos).is_some();
self.pending.remove(pos);
}
@@ -297,14 +299,25 @@ impl BlockEntities {
self.map.get(pos)
}
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
self.asleep.remove(pos);
self.map.get_mut(pos)
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (&Pos, &mut BlockEntity)> {
self.asleep.clear();
self.map.iter_mut()
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut BlockEntity> {
self.asleep.clear();
self.map.values_mut()
}
pub fn sleep(&mut self, pos: Pos) {
if self.map.contains_key(&pos) {
self.asleep.insert(pos);
}
}
pub fn is_asleep(&self, pos: &Pos) -> bool {
self.asleep.contains(pos)
}
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
/// block entity may get a slot; one replacing another keeps its slot.
/// One of the same type rewrites it in place (a jukebox taking a disc,
@@ -320,6 +333,7 @@ impl BlockEntities {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
self.asleep.remove(&pos);
let old = self.map.insert(pos, entity);
if old.is_none() {
self.add(pos);
@@ -346,6 +360,7 @@ impl BlockEntities {
if absent {
self.add(pos);
}
self.asleep.remove(&pos);
let entity = self.map.entry(pos).or_insert_with(make);
if absent && matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
@@ -358,6 +373,7 @@ impl BlockEntities {
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.attachments.remove(pos);
self.asleep.remove(pos);
self.cancel(pos);
let removed = self.map.remove(pos);
if matches!(removed, Some(BlockEntity::Mod(_))) {
@@ -369,6 +385,7 @@ impl BlockEntities {
/// The block entity at `pos`, out of the map for a while: its slot stays
/// for `put` to bring it back (a tick working on it).
pub fn take(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.asleep.remove(pos);
let taken = self.map.remove(pos);
if taken.is_some() {
self.out.insert(*pos);
@@ -378,6 +395,7 @@ impl BlockEntities {
/// Brings back what `take` took, in its slot.
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
self.out.remove(&pos);
self.asleep.remove(&pos);
if matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
@@ -399,6 +417,7 @@ impl BlockEntities {
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
self.asleep.clear();
self.map.retain(|p, e| {
let kept = keep(p, e);
if !kept {
@@ -416,6 +435,7 @@ impl BlockEntities {
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.asleep.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
@@ -433,6 +453,7 @@ impl BlockEntities {
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
for (p, e) in entities {
self.attachments.remove(&p);
self.asleep.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
let old = self.map.insert(p, e);
@@ -452,6 +473,7 @@ impl BlockEntities {
/// `LevelChunk.setBlockState` on a block entity's position, from state
/// `old` to `new`: a ticker made, kept or dropped.
pub fn state_changed(&mut self, pos: Pos, old: u32, new: u32) {
self.asleep.remove(&pos);
if self.states.is_empty() || !self.map.contains_key(&pos) {
return;
}
@@ -542,6 +564,51 @@ mod tests {
found
}
#[test]
fn a_sleeping_block_entity_wakes_on_any_change() {
let p = (1, 64, 1);
let mut b = level();
b.sleep(p);
assert!(!b.is_asleep(&p));
b.insert(p, hopper());
let asleep = |b: &mut BlockEntities, wake: &dyn Fn(&mut BlockEntities)| {
b.sleep(p);
assert!(b.is_asleep(&p));
wake(b);
b.is_asleep(&p)
};
assert!(asleep(&mut b, &|_| {}));
assert!(!asleep(&mut b, &|b| {
b.get_mut(&p);
}));
assert!(!asleep(&mut b, &|b| {
b.insert(p, hopper());
}));
assert!(!asleep(&mut b, &|b| {
b.get_or_insert_with(p, hopper);
}));
assert!(!asleep(&mut b, &|b| {
let held = b.take(&p).unwrap();
b.put(p, held);
}));
assert!(!asleep(&mut b, &|b| b.state_changed(p, 1, 1)));
assert!(!asleep(&mut b, &|b| b.forget(&p)));
assert!(!asleep(&mut b, &|b| {
b.iter_mut().count();
}));
assert!(!asleep(&mut b, &|b| {
b.values_mut().count();
}));
assert!(!asleep(&mut b, &|b| b.retain(|_, _| true)));
assert!(!asleep(&mut b, &|b| b.load_chunk(vec![(p, hopper())])));
assert!(!asleep(&mut b, &|b| {
b.remove(&p);
b.insert(p, hopper());
}));
b.sleep(p);
b.clear();
assert!(!b.is_asleep(&p));
}
#[test]
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
let mut b = level();
let modded = || BlockEntity::Mod(Box::default());
@@ -939,6 +939,14 @@ impl BlockEntityAnims {
/// Drops the state of block entities that are gone or changed kind:
/// `present` holds the loaded block entities' tickers, sorted by position.
pub fn retain_present(&mut self, present: &[([i32; 3], Ticker)]) {
self.retain_animations(present, false);
}
/// An integrated server owns bell state independently of renderer visibility.
/// Its world ticker removes bells when their actual block changes.
pub fn retain_present_preserving_bells(&mut self, present: &[([i32; 3], Ticker)]) {
self.retain_animations(present, true);
}
fn retain_animations(&mut self, present: &[([i32; 3], Ticker)], keep_bells: bool) {
let is = |p: &[i32; 3], f: fn(&Ticker) -> bool| {
present
.binary_search_by_key(p, |(q, _)| *q)
@@ -948,7 +956,9 @@ impl BlockEntityAnims {
self.books.retain(|p, _| is(p, |t| *t == Ticker::Book));
self.shulkers
.retain(|p, _| is(p, |t| matches!(t, Ticker::Shulker { .. })));
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
if !keep_bells {
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
}
self.conduits
.retain(|p, _| is(p, |t| *t == Ticker::Conduit));
self.skulls
@@ -11,6 +11,7 @@ use crate::{
block_entity_anim::{self, EnchantingBook},
breaking::entity_geometry,
fast_hash::FxHashMap,
font::Glyph,
gfx::mark_oit,
hud::Rect,
mesher::{CULL_BACK, Vertex},
@@ -660,7 +661,7 @@ fn sign_text_calls(
} else {
(dark, 0, vertex_light(packed))
};
let advance = |c: char| (scene.glyph)(c).1;
let advance = |c: char| (scene.glyph)(c).advance;
let width = |s: &str| s.chars().map(advance).sum::<f32>().ceil();
text.lines
.iter()
@@ -695,14 +696,31 @@ const TEXT_OVER_OUTLINE: f32 = 0.05;
/// reader instead (a twentieth of a font pixel, invisible) rather than
/// z-fighting with the outline under it.
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
text_vertices(scene.glyph, call, out);
}
/// `draw_text` with the font's glyph cells and advances from `glyph`: the
/// text of `call` as glyph quads in its matrix's plane, 1 unit a font pixel,
/// y down as the font's, with its outline when it has one. A native mod's
/// block entity draws its text this way (`native_draw_text`).
pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut Vec<Vertex>) {
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
let mut x = call.x + dx;
let y0 = call.y + dy;
for ch in call.text.chars() {
let (cell, advance) = (scene.glyph)(ch);
if let Some(r) = cell {
let corners = [(x, y0 + 8.0), (x + 8.0, y0 + 8.0), (x + 8.0, y0), (x, y0)];
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
let g = glyph(ch);
if let Some(cell) = g.rect {
// BakedSheetGlyph: the glyph's own box from the pen, not a
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
let (x0, x1) = (x + g.left, x + g.right);
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
let uvs = [
[cell.u0, cell.v1],
[cell.u1, cell.v1],
[cell.u1, cell.v0],
[cell.u0, cell.v0],
];
let c =
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
@@ -721,7 +739,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
});
}
}
x += advance;
x += g.advance;
}
};
if call.outline != 0 {
@@ -818,8 +836,8 @@ pub struct Scene<'a> {
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
pub glyph: &'a dyn Fn(char) -> Glyph,
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
@@ -3342,7 +3360,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// Compare the cached world path with the original model renderer,
@@ -3610,7 +3628,13 @@ mod tests {
w: 8,
h: 8,
};
(Some(rect), 6.0)
Glyph {
rect: Some(rect),
right: 8.0,
bottom: 8.0,
advance: 6.0,
..Glyph::default()
}
};
let scene = Scene {
camera: DVec3::ZERO,
@@ -3660,6 +3684,78 @@ mod tests {
assert!(out.iter().all(|v| v.pos[2] == 0.0));
}
/// BakedSheetGlyph draws a glyph over its own box from the pen
/// (x + left .. x + right, y + up .. y + down), not a fixed 8x8 cell. With
/// Force Unicode Font a Latin letter is an 8x16 Unifont glyph at half
/// scale: 4 units wide and 8 tall, advancing 5 (#8699).
#[test]
fn sign_text_draws_each_glyph_over_its_own_box() {
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let glyph = |_: char| Glyph {
rect: Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.125,
w: 8,
h: 16,
}),
left: 0.0,
top: 0.0,
right: 4.0,
bottom: 8.0,
advance: 5.0,
offset: 0.5,
};
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
translucent_sprites: &HashSet::new(),
atlas_revision: 1,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
};
let call = TextCall {
matrix: Mat4::IDENTITY,
x: 10.0,
y: 2.0,
text: "ab".into(),
color: 0xFF00_0000,
outline: 0,
light: 240 | 240 << 8,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
let boxes: Vec<_> = out
.chunks(4)
.map(|q| {
let xs = q.iter().map(|v| v.pos[0]);
let ys = q.iter().map(|v| v.pos[1]);
[
xs.clone().fold(f32::MAX, f32::min),
xs.fold(f32::MIN, f32::max),
ys.clone().fold(f32::MAX, f32::min),
ys.fold(f32::MIN, f32::max),
]
})
.collect();
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
@@ -3714,7 +3810,13 @@ mod tests {
w: 8,
h: 8,
};
((c != ' ').then_some(rect), wide.get())
Glyph {
rect: (c != ' ').then_some(rect),
right: 8.0,
bottom: 8.0,
advance: wide.get(),
..Glyph::default()
}
};
let (first_sign, changed_sign, glowing_sign) = (
|_| Some(&first_text),
@@ -3879,7 +3981,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let mut verts = Vec::new();
@@ -3964,7 +4066,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
@@ -4,6 +4,7 @@ use crate::{
ModelPart, SIGN_TEXT_KEEP, Scene, StaticSectionRevision, classify,
},
chunk::Chunk,
font::Glyph,
hud::Rect,
mesher::Vertex,
signs::{SignData, SignText},
@@ -355,7 +356,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// Reverse local-position order to check that cache indexing does not
@@ -443,7 +444,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -497,7 +498,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([100, 70, -32], 0)];
@@ -651,18 +652,19 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
};
let sign_data = &data;
let sign = move |_: [i32; 3]| Some(sign_data);
let glyph = |_: char| {
(
Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
}),
6.0,
)
let glyph = |_: char| Glyph {
rect: Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
}),
right: 8.0,
bottom: 8.0,
advance: 6.0,
..Glyph::default()
};
let scene = Scene {
camera: DVec3::new(2.25, 64.5, -3.75),
@@ -735,7 +737,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -836,7 +838,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -890,7 +892,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -951,7 +953,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1046,7 +1048,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pos = [0, 64, 0];
@@ -1134,7 +1136,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
@@ -1209,7 +1211,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1287,7 +1289,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
sign: &|_| None,
banner: &banner_patterns,
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1353,7 +1355,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pos = [0, 64, 0];
@@ -1400,7 +1402,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -1505,7 +1507,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -340,6 +340,17 @@ impl BrewingStand {
}
out
}
pub fn idle(&self) -> bool {
let bottles = [
self.items[0].is_some(),
self.items[1].is_some(),
self.items[2].is_some(),
];
self.brew_time <= 0
&& self.items[3].is_none()
&& (self.fuel > 0 || self.items[4].is_none())
&& self.last_bottles == Some(bottles)
}
fn brew(&mut self, brewing: &Brewing, items: &Items, out: &mut BrewTick) {
let Some(ingredient) = self.items[3].clone() else {
return;
@@ -455,6 +466,27 @@ mod tests {
assert_eq!(stand.brew_time, 0);
}
#[test]
fn a_brewing_stand_is_idle_only_while_its_tick_changes_nothing() {
let mut stand = BrewingStand::new();
assert!(!stand.idle());
stand.last_bottles = Some([false; 3]);
assert!(stand.idle());
stand.items[0] = Some(ItemStack::new("minecraft:potion", 1));
assert!(!stand.idle());
stand.last_bottles = Some([true, false, false]);
assert!(stand.idle());
stand.items[4] = Some(ItemStack::new("minecraft:blaze_powder", 1));
assert!(!stand.idle());
stand.fuel = 3;
assert!(stand.idle());
stand.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
assert!(!stand.idle());
stand.items[3] = None;
stand.brew_time = 5;
assert!(!stand.idle());
}
/// #3312: `PotionContents.getPotionDescription` names the effects with
/// translatable components, so a Russian game reads them from ru_ru.json
/// (the prepared one; skipped without it), as the tooltip's other lines.
+202 -21
View File
@@ -174,6 +174,12 @@ impl ChatOptions {
#[derive(Clone, Debug)]
struct Message {
added: u64,
/// Local hour and minute it came in (PvP & HUD's chat timestamps).
time: (u8, u8),
/// How many times in a row it came (PvP & HUD's compact chat), 1 for once.
repeats: u32,
/// The lines it wrapped to the last time it was laid out (0 while hidden).
wrapped: usize,
content: Text,
tag: Option<std::sync::Arc<Tag>>,
source: ChatSource,
@@ -186,6 +192,32 @@ struct Line {
added: u64,
content: Text,
tag: Option<std::sync::Arc<Tag>>,
/// The whole message's text, for PvP & HUD's copy on click.
message: std::sync::Arc<str>,
}
/// PvP & HUD's chat options (`pvp::ChatQol`): each changes the lines, so a change rewraps them.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ChatQol {
/// Each message starts with the local time it came in.
pub timestamps: bool,
/// A message equal to the one before it raises that one's count ("(x3)") instead of adding lines.
pub compact: bool,
}
/// Whether `next` repeats `last`: the same text, kind and tag, neither one signed (a server may
/// delete a signed message by itself).
fn repeats(last: &Message, next: &Message) -> bool {
last.signature.is_none()
&& next.signature.is_none()
&& last.source == next.source
&& last.tag == next.tag
&& last.content == next.content
}
/// The local hour and minute.
fn local_time() -> (u8, u8) {
use chrono::Timelike;
let now = chrono::Local::now();
(now.hour() as u8, now.minute() as u8)
}
/// `GuiMessageTag`: the bar at the left of a message's lines, the tooltip
@@ -270,6 +302,14 @@ pub struct Chat {
/// `ChatComponent.messageDeletionQueue`: deletions of messages still
/// too new to replace, with the tick from which they may be.
deletions: Vec<(Box<Signature>, u64)>,
qol: ChatQol,
/// PvP & HUD's chat search is on: the screen shows its prompt.
pub search_prompt: bool,
/// PvP & HUD's chat search: only messages holding this (lower case) show.
search: Option<String>,
/// The tests' clock in place of the local time.
#[cfg(test)]
clock: Option<(u8, u8)>,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -331,6 +371,9 @@ impl Chat {
font,
Message {
added: ticks,
time: (0, 0),
repeats: 1,
wrapped: 0,
content,
tag: tag.map(std::sync::Arc::new),
source: ChatSource::Player,
@@ -352,6 +395,9 @@ impl Chat {
font,
Message {
added: ticks,
time: (0, 0),
repeats: 1,
wrapped: 0,
content,
tag,
source,
@@ -360,7 +406,7 @@ impl Chat {
focused,
);
}
fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) {
fn push_message(&mut self, font: &crate::font::Font, mut message: Message, focused: bool) {
if !self.abilities.shows(message.source) {
return;
}
@@ -370,12 +416,52 @@ impl Chat {
if self.log {
println!("[CHAT] {plain}");
}
self.add_lines(font, &message, focused);
message.time = self.now();
self.received += 1;
if self.qol.compact
&& let Some(last) = self.messages.first()
&& repeats(last, &message)
{
// The newest lines are that message's: they make way for its lines with the count.
let mut last = self.messages.remove(0);
let shown = last.wrapped.min(self.lines.len());
self.lines.drain(..shown);
// A reader scrolled up keeps their view: only lines the merge adds move it.
let (scroll, new_since_scroll) = (self.scroll, self.new_since_scroll);
last.repeats += 1;
last.added = message.added;
last.time = message.time;
last.wrapped = self.add_lines(font, &last, focused);
if scroll > 0 {
self.scroll = scroll;
self.new_since_scroll = new_since_scroll;
self.scroll_by(last.wrapped as i32 - shown as i32, focused);
}
self.messages.insert(0, last);
return;
}
message.wrapped = self.add_lines(font, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
self.received += 1;
}
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
fn now(&self) -> (u8, u8) {
#[cfg(test)]
if let Some(time) = self.clock {
return time;
}
local_time()
}
/// The lines `message` wraps to (none when the chat search hides it), added newest first.
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) -> usize {
// What the chat shows: legacy `§` codes format the text, they aren't part of it.
let whole = message.content.strip_legacy().plain();
if let Some(query) = &self.search
&& !whole.to_lowercase().contains(query.as_str())
{
return 0;
}
let whole: std::sync::Arc<str> = whole.into();
let mut count = 0;
// GuiMessage.splitLines: room for the tag's icon (its width, 4 before and 2 after).
let icon = if message.tag.as_ref().is_some_and(|tag| tag.icon) {
9 + 4 + 2
@@ -383,8 +469,9 @@ impl Chat {
0
};
let width = self.max_width() - icon;
let content = self.displayed(&message.content);
let content = self.message_text(message);
for line in font.wrap_components(&content, width) {
count += 1;
if focused && self.scroll > 0 {
self.new_since_scroll = true;
self.scroll_by(1, focused);
@@ -395,10 +482,12 @@ impl Chat {
added: message.added,
content: line,
tag: message.tag.clone(),
message: whole.clone(),
},
);
}
self.lines.truncate(100);
count
}
/// What a message's lines wrap (ComponentRenderUtils.wrapComponents):
/// without Chat Colors its `§` codes are stripped (stripColor); with
@@ -410,6 +499,67 @@ impl Chat {
content.strip_legacy()
}
}
/// A message's lines before wrapping: [`Chat::displayed`], with PvP & HUD's time before it
/// and count after it, in grey.
fn message_text(&self, message: &Message) -> Text {
let content = self.displayed(&message.content);
if !self.qol.timestamps && message.repeats <= 1 {
return content;
}
let mut out = Text::empty();
if self.qol.timestamps {
let (h, m) = message.time;
let time = Text::literal(format!("{h:02}:{m:02}"));
out = out.append(
Text::translate_with_fallback("horizon.chat.timestamp", "[%s] ", &[time])
.with_color(text::GRAY),
);
}
out = out.append(content);
if message.repeats > 1 {
let count = Text::literal(message.repeats.to_string());
out = out.append(
Text::translate_with_fallback("horizon.chat.repeats", " (x%s)", &[count])
.with_color(text::GRAY),
);
}
out
}
/// PvP & HUD's chat options; a change lays the lines out again.
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
if self.qol != qol {
self.qol = qol;
self.rewrap(&atlas.font);
}
}
/// PvP & HUD's chat search: only the messages holding `query` (any case) show; `None` or
/// an empty query shows them all again.
pub fn set_search(&mut self, atlas: &HudAtlas, query: Option<&str>) {
self.set_search_with(&atlas.font, query);
}
fn set_search_with(&mut self, font: &crate::font::Font, query: Option<&str>) {
// Called each frame while searching: no work (nor allocation) for the same query.
let blank = |q: &str| q.trim().is_empty();
let same = match (&self.search, query) {
(None, None) => true,
(None, Some(q)) => blank(q),
(Some(_), None) => false,
(Some(current), Some(q)) => q.chars().flat_map(char::to_lowercase).eq(current.chars()),
};
if same {
return;
}
let query = query
.map(str::to_lowercase)
.filter(|q| !q.trim().is_empty());
if self.search != query {
self.search = query;
self.rewrap(font);
}
}
pub fn searching(&self) -> bool {
self.search.is_some()
}
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
/// keeping only the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
@@ -419,16 +569,15 @@ impl Chat {
self.scroll = 0;
self.new_since_scroll = false;
self.lines.clear();
let messages: Vec<Message> = self
.messages
.iter()
.rev()
.filter(|m| self.abilities.shows(m.source))
.cloned()
.collect();
for m in &messages {
self.add_lines(font, m, false);
let mut messages = std::mem::take(&mut self.messages);
for m in messages.iter_mut().rev() {
m.wrapped = if self.abilities.shows(m.source) {
self.add_lines(font, m, false)
} else {
0
};
}
self.messages = messages;
}
/// `ChatComponent.setVisibleMessageFilter`: the Chat option changed;
/// the messages it now hides leave the chat, those it shows again return.
@@ -490,6 +639,9 @@ impl Chat {
}
*message = Message {
added: message.added,
time: message.time,
repeats: 1,
wrapped: 0,
content: Self::deleted_marker(),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
@@ -694,15 +846,42 @@ impl Chat {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
if focused && self.abilities.any_restriction() {
let restricted = focused && self.abilities.any_restriction();
if restricted {
let prompt = self.restricted_prompt(&atlas.font, batch.height as i32);
hud_layout::paint(batch, atlas, &prompt);
}
if focused && self.search_prompt {
let row = if restricted { 2 } else { 1 };
let prompt = self.search_prompt(&atlas.font, batch.height as i32, row);
hud_layout::paint(batch, atlas, &prompt);
}
}
/// `ChatComponent.extractRenderState` for `FOREGROUND_RESTRICTED`: a
/// background row and the red, underlined "Chat is restricted" line in
/// the row above the messages, clipped to the chat's width.
fn restricted_prompt(&self, font: &crate::font::Font, height: i32) -> Vec<GuiDraw> {
let prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
color: Some(text::RED),
underlined: Some(true),
..Style::default()
});
self.prompt_above(font, height, prompt, 1)
}
/// PvP & HUD's chat search: a yellow "Search chat:" line above the lines while the box holds
/// the query.
fn search_prompt(&self, font: &crate::font::Font, height: i32, row: i32) -> Vec<GuiDraw> {
let label = Text::translate_with_fallback("horizon.chat.search", "Search chat:", &[]);
self.prompt_above(font, height, label.with_color(text::YELLOW), row)
}
/// A line `row` lines above the shown ones, on the chat's background.
fn prompt_above(
&self,
font: &crate::font::Font,
height: i32,
prompt: Text,
row: i32,
) -> Vec<GuiDraw> {
let o = &self.options;
let scale = o.scale as f32;
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
@@ -715,12 +894,8 @@ impl Chat {
.len()
.saturating_sub(self.scroll)
.min(self.lines_per_page(true));
let top = chat_bottom - (shown as i32 + 1) * entry_height;
let mut prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
color: Some(text::RED),
underlined: Some(true),
..Style::default()
});
let top = chat_bottom - (shown as i32 + row) * entry_height;
let mut prompt = prompt;
// ComponentRenderUtils.clipText.
let mut width = 0.0;
let fits = prompt
@@ -765,6 +940,12 @@ impl Chat {
) -> Option<ClickEvent> {
self.style_at(font, height, x, y)?.click
}
/// The whole text of the message under a GUI-space cursor while chat is focused (PvP &
/// HUD's copy on click), without its time or count.
pub fn message_at(&self, height: i32, x: f64, y: f64) -> Option<&str> {
let (line, x) = self.line_at(height, x, y)?;
(x >= 0.0).then_some(&*line.message)
}
/// The hover text under a GUI-space cursor while chat is focused
/// (ChatScreen.render's `renderComponentHoverEffect`).
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
@@ -34,6 +34,7 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
added: 0,
content,
tag: None,
message: "".into(),
},
);
}
@@ -51,6 +52,7 @@ fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
message: "hi".into(),
});
let text_alpha = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
@@ -701,3 +703,140 @@ fn a_chat_message_is_cut_to_the_256_utf16_units_the_packet_holds() {
let mixed = format!("{}\u{1f600}", "b".repeat(255));
assert_eq!(normalize_chat_message(&mixed), "b".repeat(255));
}
/// PvP & HUD's chat options: the time before each message, and a repeated message counted on
/// its own lines instead of filling the chat.
#[test]
fn timestamps_and_compact_chat_change_the_lines_not_the_messages() {
let font = font();
let mut chat = Chat {
clock: Some((9, 5)),
..Chat::default()
};
chat.qol.compact = true;
let says = |chat: &mut Chat, text: &str| {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
false,
);
};
let shown =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
says(&mut chat, "gg");
says(&mut chat, "gg");
says(&mut chat, "gg");
assert_eq!(shown(&chat), ["gg (x3)"]);
says(&mut chat, "wp");
says(&mut chat, "gg");
assert_eq!(shown(&chat), ["gg", "wp", "gg (x3)"]);
assert_eq!(chat.received, 5, "every message still counts as received");
chat.qol.timestamps = true;
chat.rewrap(&font);
assert_eq!(
shown(&chat),
["[09:05] gg", "[09:05] wp", "[09:05] gg (x3)"]
);
// Copying takes the message as it came: no time, no count.
assert_eq!(&*chat.lines[2].message, "gg");
// Turned off, the history shows each repeat as it was counted.
chat.qol = ChatQol::default();
chat.rewrap(&font);
assert_eq!(shown(&chat), ["gg", "wp", "gg (x3)"]);
}
/// PvP & HUD's chat search: only the messages holding the query show, in any case; clearing
/// it brings every line back.
#[test]
fn the_chat_search_keeps_the_matching_messages() {
let font = font();
let mut chat = Chat::default();
for text in ["Alpha one", "beta", "ALPHABET soup"] {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
false,
);
}
let shown =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
chat.set_search_with(&font, Some("alpha"));
assert!(chat.searching());
assert_eq!(shown(&chat), ["ALPHABET soup", "Alpha one"]);
// A new message that doesn't match stays out of sight until the search ends.
chat.add_message(
&font,
Text::literal("gamma"),
None,
ChatSource::Server,
0,
false,
);
assert_eq!(shown(&chat).len(), 2);
chat.set_search_with(&font, Some(" "));
assert!(!chat.searching());
assert_eq!(
shown(&chat),
["gamma", "ALPHABET soup", "beta", "Alpha one"]
);
}
/// Search and copy read a message as the chat shows it: legacy `§` codes format it, they
/// aren't text.
#[test]
fn the_chat_search_and_copy_skip_legacy_codes() {
let font = font();
let mut chat = Chat::default();
for text in ["He§cllo", "§aplain"] {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
false,
);
}
assert_eq!(&*chat.lines[1].message, "Hello");
chat.set_search_with(&font, Some("hello"));
assert_eq!(chat.lines.len(), 1);
// `c` and `a` are codes here, not letters of either message.
chat.set_search_with(&font, Some("c"));
assert_eq!(chat.lines.len(), 0);
}
/// A repeat merged by compact chat leaves a reader scrolled up where they were.
#[test]
fn a_compact_repeat_keeps_the_scrolled_view_still() {
let font = font();
let mut chat = Chat::default();
chat.qol.compact = true;
let says = |chat: &mut Chat, text: &str| {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
true,
);
};
for i in 0..60 {
says(&mut chat, &format!("line {i}"));
}
says(&mut chat, "gg");
chat.scroll_by(5, true);
assert_eq!(chat.scroll, 5);
says(&mut chat, "gg");
assert_eq!(chat.lines[0].content.plain(), "gg (x2)");
assert_eq!(chat.scroll, 5);
// A new message still pushes the view up by its line.
says(&mut chat, "new");
assert_eq!(chat.scroll, 6);
}
@@ -120,6 +120,16 @@ impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
) -> std::result::Result<bool, Self::Error> {
self.blocks.may_reach_out(chunk, section_y)
}
fn section_is_empty(
&mut self,
chunk: Self::Chunk,
section_y: i32,
) -> std::result::Result<bool, Self::Error> {
self.blocks.section_is_empty(chunk, section_y)
}
fn missing_chunks_stay_missing(&self) -> bool {
self.blocks.missing_chunks_stay_missing()
}
fn context_shape(
&mut self,
block: CollisionBlock,
@@ -424,6 +434,26 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
Ok(self.section_reaches_out(chunk, section_y))
}
/// From the section's palette: every block it can hold has an empty shape (air, plants).
/// A palette of the whole registry, or a section outside the column, is read cell by cell.
fn section_is_empty(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
Ok(blocks.is_some_and(|blocks| {
!blocks.is_global()
&& !blocks.maybe_has(|v| {
self.binding
.collision_block(BlockStateId(v))
.map_or(true, |block| !block.empty_shape)
})
}))
}
/// The resident columns do not change while a scan runs, and looking one up changes nothing.
fn missing_chunks_stay_missing(&self) -> bool {
true
}
fn is_suffocating(
&mut self,
_: Self::Chunk,
@@ -555,6 +585,9 @@ impl BlockCollisionWorld for EmptyCollisions {
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
Ok(None)
}
fn missing_chunks_stay_missing(&self) -> bool {
true
}
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
Err(io::Error::other("no chunk is resident"))
}
@@ -215,6 +215,13 @@ impl Campfire {
}
changed
}
pub fn idle(&self, lit: bool) -> bool {
if lit {
self.items.iter().all(Option::is_none)
} else {
self.progress.iter().all(|&p| p <= 0)
}
}
}
impl Skull {
pub fn new(
@@ -729,6 +736,19 @@ mod tests {
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
}
#[test]
fn a_campfire_is_idle_only_while_its_tick_changes_nothing() {
let mut c = Campfire::default();
assert!(c.idle(true) && c.idle(false));
assert!(c.place_food(&mut ItemStack::new("minecraft:beef", 1), 600));
assert!(!c.idle(true));
assert!(c.idle(false));
c.cook_tick();
assert!(!c.idle(false));
c.cooldown_tick();
assert!(c.idle(false));
}
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
-24
View File
@@ -431,30 +431,6 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
)
}
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
if REPORTED.swap(true, Ordering::SeqCst) {
return;
}
let report = Report {
time: Local::now(),
description: "RAM limit reached".into(),
error: message,
stack: String::new(),
thread: thread_name(side),
side,
details: system_details(side),
log: crate::logging::recent_lines(),
};
match report.save(home) {
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
}
}
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
+64 -16
View File
@@ -58,6 +58,24 @@ impl DataPack {
}
out
}
/// The files of `data/<namespace>/<folder>/` in every namespace, as
/// `list` names them; a folder pack walks only those folders.
pub fn list_folder(&self, folder: &str) -> Vec<String> {
let files = self.list_kinds(&[folder]);
let Source::Zip(_) = &self.source else {
return files;
};
files
.into_iter()
.filter(|f| {
f.strip_prefix("data/")
.and_then(|r| r.split_once('/'))
.is_some_and(|(_, r)| {
r.strip_prefix(folder).is_some_and(|r| r.starts_with('/'))
})
})
.collect()
}
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
match &self.source {
Source::Zip(z) => z.read(path).ok(),
@@ -257,19 +275,34 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
/// `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.
/// `dir`; false when `path` is no pack, or when part of it could not be copied.
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
fn copy_dir(from: &Path, at: &Path) {
let _ = std::fs::create_dir_all(at);
fn copy_dir(from: &Path, at: &Path) -> bool {
let mut whole = true;
if let Err(e) = std::fs::create_dir_all(at) {
log::warn!("Horizon: {} is not made: {e}", at.display());
whole = false;
}
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);
let copied = if path.is_dir() {
copy_dir(&path, &to)
} else {
let _ = std::fs::copy(&path, &to);
}
match std::fs::copy(&path, &to) {
Ok(_) => true,
Err(e) => {
log::warn!(
"Horizon: {} is not copied into the pack: {e}",
path.display()
);
false
}
}
};
whole &= copied;
}
whole
}
let name = || {
path.file_name()
@@ -289,8 +322,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
return false;
}
if path.join("pack.mcmeta").is_file() {
copy_dir(path, &dir.join(name()));
return true;
return copy_dir(path, &dir.join(name()));
}
let own: Vec<PathBuf> = std::fs::read_dir(path)
.into_iter()
@@ -300,10 +332,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
.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
}
[one] => copy_dir(one, &dir.join(one.file_name().unwrap_or_default())),
_ => false,
}
}
@@ -579,7 +608,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/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/` 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>>>,
@@ -588,7 +617,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/` 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/")?;
@@ -596,7 +625,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" | "data_maps" => kind,
| "villager_trade" | "dialog" | "damage_type" | "data_maps" => 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).
@@ -627,6 +656,7 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"damage_type",
"data_maps",
"dialog",
"function",
@@ -896,6 +926,24 @@ mod tests {
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
}
#[test]
fn a_pack_that_cannot_be_copied_is_reported() {
let root = std::env::temp_dir().join(format!("hz-datapack-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let pack = root.join("pack");
std::fs::create_dir_all(pack.join("data")).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(pack.join("data/functions.mcfunction"), "").unwrap();
// A file where the pack's folder goes: every copy under it fails.
let blocked = root.join("blocked");
std::fs::write(&blocked, "").unwrap();
assert!(!copy_pack(&pack, &blocked));
let dir = root.join("packs");
assert!(copy_pack(&pack, &dir));
assert!(dir.join("pack/pack.mcmeta").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
+13 -2
View File
@@ -87,7 +87,11 @@ fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
// primed TNT flash (5) and at column overlay - 16 for 16..=31 (a creeper's
// swelling). Alpha 1 changes nothing.
fn overlay_texel(overlay: u32) -> vec4<f32> {
if overlay == 1u || overlay == 8u { return HURT_OVERLAY; }
if overlay == 1u || overlay == 8u {
// PvP & HUD's hit colour (pvp.rs `Hit::texel`) rides in colored_count.y; 0 is vanilla's.
let hit = globals.colored_count.y;
return select(HURT_OVERLAY, unpack4x8unorm(hit), hit != 0u);
}
if overlay == 5u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[15]); }
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
return vec4(1.0);
@@ -246,11 +250,18 @@ fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
return vec2(scaled.x * c - scaled.y * s - offset0,
scaled.x * s + scaled.y * c + offset1);
}
// PvP & HUD's glint colour (pvp.rs `Tweaks::glint_texel`, 0 for vanilla's): the sample's
// brightest channel over the chosen colour, so the shimmer keeps its pattern and strength.
fn tinted_glint(glint: vec4<f32>, packed: u32) -> vec4<f32> {
if packed == 0u { return glint; }
let tint = unpack4x8unorm(packed).rgb;
return vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
}
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
// is squared along with the sample.
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
let alpha = globals.fog_ends.w;
let g = alpha * glint;
let g = alpha * tinted_glint(glint, globals.colored_count.z);
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
}
// CRUMBLING_UV_BEGIN
+129 -23
View File
@@ -8,6 +8,8 @@ use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
/// The name the options screen uses while the sort key waits for a key.
pub const SORT_KEY: &str = "horizon.sort";
/// The 3D skin layers line, here and in the `options.txt` that held it first.
const SKIN_LAYERS_KEY: &str = "threeDSkinLayers";
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
@@ -44,7 +46,7 @@ impl Corner {
Corner::BottomRight => "Bottom right",
}
}
fn parse(s: &str, default: Corner) -> Corner {
pub fn parse(s: &str, default: Corner) -> Corner {
Self::ALL
.into_iter()
.find(|c| c.name() == s)
@@ -76,6 +78,13 @@ pub struct Extras {
pub zoom_smooth: bool,
/// Divide mouse turning by the zoom.
pub zoom_sensitivity: bool,
/// Far terrain a zoom magnifies is planned finer, so a spyglass does not
/// show the coarse cells it was given for an unzoomed screen
/// (`lod::Aim`). Costs far tiles while the zoom is held and gives them
/// back after, and does nothing at all unless far terrain is on
/// (`lodDistance`) and the view is actually zoomed. Off by default until
/// a measurement shows the extra tiles land while the zoom is still held.
pub lod_zoom_detail: bool,
/// The FPS counter and keystrokes extras are official mods now: their
/// old settings stay here, kept as they were.
pub fps: bool,
@@ -152,11 +161,20 @@ pub struct Extras {
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
/// on by default), how much of other players' to show (all by default), and their auras and
/// trails (off by default).
/// on by default), how much of other players' to show (no line until chosen:
/// `effective_others`), and their auras and trails (off by default).
pub cosmetics_mine: bool,
pub cosmetics_others: horizon_cosmetics::prefs::Others,
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
pub cosmetics_auras: bool,
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
/// sets it): with no choice made, others' cosmetics show only then.
pub cosmetics_owner: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
pub three_d_skin_layers: bool,
/// The PvP & HUD page's options (`pvp`).
pub pvp: crate::pvp::Pvp,
}
impl Default for Extras {
fn default() -> Self {
@@ -169,6 +187,7 @@ impl Default for Extras {
zoom_level: 4,
zoom_smooth: true,
zoom_sensitivity: true,
lod_zoom_detail: false,
fps: false,
fps_corner: Corner::TopLeft,
fps_lows: false,
@@ -204,8 +223,11 @@ impl Default for Extras {
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: horizon_cosmetics::prefs::Others::Shown,
cosmetics_others: None,
cosmetics_auras: false,
cosmetics_owner: false,
three_d_skin_layers: false,
pvp: Default::default(),
}
}
}
@@ -213,6 +235,11 @@ fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl Extras {
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
/// account that owns a cosmetic and none for any other (a vanilla game).
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
}
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
@@ -243,6 +270,7 @@ impl Extras {
}
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
"zoomSensitivity" => e.zoom_sensitivity = flag(e.zoom_sensitivity),
"lodZoomDetail" => e.lod_zoom_detail = flag(e.lod_zoom_detail),
"fps" => e.fps = flag(e.fps),
"fpsCorner" => e.fps_corner = Corner::parse(value, e.fps_corner),
"fpsLows" => e.fps_lows = flag(e.fps_lows),
@@ -304,17 +332,20 @@ impl Extras {
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).unwrap_or(e.cosmetics_others)
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
}
"cosmeticsAuras" => e.cosmetics_auras = flag(e.cosmetics_auras),
_ => {}
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
key => {
e.pvp.parse_line(key, value);
}
}
}
e
}
pub fn serialize(&self) -> String {
format!(
"packBrowser:{}\nfullbright:{}\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:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n",
let mut text = format!(
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsAuras:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
@@ -323,6 +354,7 @@ impl Extras {
self.zoom_level,
bool_text(self.zoom_smooth),
bool_text(self.zoom_sensitivity),
bool_text(self.lod_zoom_detail),
bool_text(self.fps),
self.fps_corner.name(),
bool_text(self.fps_lows),
@@ -363,9 +395,33 @@ impl Extras {
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
self.cosmetics_others.name(),
// Written once the player chose.
self.cosmetics_others
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
bool_text(self.cosmetics_auras),
)
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
);
self.pvp.serialize(&mut text);
text
}
/// Adopts the settings `options.txt` held before they moved here, while
/// this file (`own`) has no line of its own: 3D skin layers was an
/// `options.txt` line. Vanilla's `Options.save` drops lines it doesn't
/// know, so it is read there once, the first start after the update.
pub fn read_old_options(&mut self, own: &str, options_txt: &str) {
fn line(text: &str) -> Option<&str> {
text.lines().find_map(|l| {
l.split_once(':')
.filter(|(key, _)| *key == SKIN_LAYERS_KEY)
.map(|(_, value)| value)
})
}
if line(own).is_none()
&& let Some(value) = line(options_txt)
{
self.three_d_skin_layers = value == "true";
}
}
/// Binds the extra key `name` (`SORT_KEY`); false for
/// another name (a vanilla mapping).
@@ -518,6 +574,8 @@ pub enum Item {
CosmeticsMine,
CosmeticsOthers,
CosmeticsAuras,
/// On the PvP & HUD page (`pvp::rows`).
Pvp(crate::pvp::Opt),
}
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -584,6 +642,7 @@ impl Item {
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras",
Item::Pvp(o) => o.caption_key(),
}
}
/// The language key of the description shown as the row's tooltip.
@@ -612,6 +671,7 @@ impl Item {
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras.tooltip",
Item::Pvp(o) => o.description_key(),
}
}
/// The caption in the current language (English when the key is missing).
@@ -649,6 +709,7 @@ impl Item {
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsAuras => "Others' auras and trails",
Item::Pvp(o) => o.caption(),
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -659,6 +720,7 @@ impl Item {
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
ChatIndicator => ChatAnimation,
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
@@ -724,9 +786,10 @@ impl Item {
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
}
Item::CosmeticsOthers => {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden, the game never asks what they wear."
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
}
Item::CosmeticsAuras => "Auras and trails other players wear. Off by default.",
Item::Pvp(o) => o.description(),
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
}
@@ -779,6 +842,7 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::CosmeticsAuras => e.cosmetics_auras,
Item::Pvp(o) => return o.switch(&e.pvp),
_ => return None,
})
}
@@ -786,13 +850,14 @@ impl Item {
pub fn value(self, e: &Extras) -> String {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
Item::CosmeticsMine => match e.cosmetics_mine {
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
},
Item::CosmeticsOthers => {
use horizon_cosmetics::prefs::Others;
match e.cosmetics_others {
match e.effective_others() {
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
Others::NameTags => {
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
@@ -833,10 +898,12 @@ impl Item {
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
let o = e.cosmetics_others;
e.cosmetics_others = if back { o.next().next() } else { o.next() };
// From what the button shows; the result is the player's choice from now on.
let o = e.effective_others();
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
}
Item::CosmeticsAuras => e.cosmetics_auras = !e.cosmetics_auras,
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
Item::SortKey => {}
}
}
@@ -856,31 +923,70 @@ mod tests {
#[test]
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
let mut e = Extras {
cosmetics_owner: true,
..Extras::default()
};
assert_eq!(
(e.cosmetics_others, e.cosmetics_auras, e.cosmetics_mine),
(e.effective_others(), e.cosmetics_auras, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Others::NameTags);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Others::Hidden);
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
// The right button goes back.
Item::CosmeticsOthers.cycle(&mut e, true);
assert_eq!(e.cosmetics_others, Others::NameTags);
assert_eq!(Extras::parse(&e.serialize()), e);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
// Ownership is the service's, never written.
assert_eq!(
Extras::parse(&e.serialize()),
Extras {
cosmetics_owner: false,
..e.clone()
}
);
// The launcher's Wardrobe writes the same lines.
assert_eq!(
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
Others::NameTags
Some(Others::NameTags)
);
// Before this, the line was on or off.
assert_eq!(
Extras::parse("cosmeticsOthers:false").cosmetics_others,
Others::Hidden
Some(Others::Hidden)
);
}
#[test]
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
assert_eq!(e.effective_others(), Others::Hidden);
assert_eq!(
Item::CosmeticsOthers.value(&e),
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
);
assert!(!e.serialize().contains("cosmeticsOthers"));
// The service says the account owns one: shown, still not a choice.
e.cosmetics_owner = true;
assert_eq!(e.effective_others(), Others::Shown);
assert!(!e.serialize().contains("cosmeticsOthers"));
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
e.cosmetics_owner = false;
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Shown));
let saved = Extras::parse(&e.serialize());
assert_eq!(saved.effective_others(), Others::Shown);
// An owner who chose Hidden keeps it.
let hidden = Extras {
cosmetics_owner: true,
..Extras::parse("cosmeticsOthers:hidden\n")
};
assert_eq!(hidden.effective_others(), Others::Hidden);
}
#[test]
fn everything_is_off_by_default_and_round_trips() {
let d = Extras::default();
+35 -2
View File
@@ -212,8 +212,11 @@ pub struct FogInputs<'a> {
pub boss_world_fog: bool,
/// The camera entity is a spectator (`LavaFogEnvironment`).
pub spectator: bool,
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
/// The camera entity has Fire Resistance (`LavaFogEnvironment`), or PvP & HUD's clear lava
/// asks for its fog.
pub fire_resistance: bool,
/// PvP & HUD's clear water: the water vision `FogState` reaches after 600 ticks under water.
pub full_water_vision: bool,
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
@@ -224,7 +227,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
let rd = input.render_distance_chunks as f32 * 16.0;
let sky = input.sky;
let eye_in_water = input.fog_type == FogType::Water;
let water_vision = state.water_vision(eye_in_water);
let water_vision = if input.full_water_vision && eye_in_water {
1.0
} else {
state.water_vision(eye_in_water)
};
// Base colour of the applicable environment.
let mut color = match input.fog_type {
FogType::Lava => rgb(0x99_1A00),
@@ -451,6 +458,7 @@ mod tests {
boss_world_fog: false,
spectator: false,
fire_resistance: false,
full_water_vision: false,
night_vision: 0.0,
}
}
@@ -673,6 +681,31 @@ mod tests {
assert_eq!(s.water_vision(false), 0.0);
}
#[test]
fn clear_water_sees_as_far_as_after_thirty_seconds_under_water() {
let sky = Sky::default();
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&sky);
input.fog_type = FogType::Water;
// Just in: vanilla's water vision is 0, so the fog ends at a quarter of 96 blocks.
let mut state = FogState::default();
state.tick(true);
let first = setup_fog(&tables, &state, &input);
assert_eq!(first.environmental_end, 24.0);
for _ in 0..600 {
state.tick(true);
}
let settled = setup_fog(&tables, &state, &input);
assert_eq!(settled.environmental_end, 96.0);
input.full_water_vision = true;
let mut fresh = FogState::default();
fresh.tick(true);
let clear = setup_fog(&tables, &fresh, &input);
assert_eq!(clear.environmental_end, settled.environmental_end);
assert_eq!(clear.color, settled.color);
}
#[test]
fn rain_fog_pulls_fog_in() {
let sky = Sky {
fog_end: 1024.0,
+108 -2
View File
@@ -24,7 +24,7 @@ pub const DEFAULT: &str = "minecraft:default";
/// A glyph ready to draw: atlas UVs and its box relative to the pen
/// position (GlyphBitmap left/top/right/bottom), in GUI pixels.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, Default)]
pub struct Glyph {
pub rect: Option<Rect>,
pub left: f32,
@@ -134,6 +134,7 @@ pub struct Font {
/// Atlas size and the reserved dynamic region (y, height).
atlas: (u32, u32),
region: (u32, u32),
metrics_only: bool,
cache: RefCell<Cache>,
/// Set anew for each font and each `reset_cache` (see `epoch`).
generation: std::cell::Cell<u64>,
@@ -284,6 +285,7 @@ fn load_bitmap(
sheets: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
metrics_only: bool,
) -> HashMap<char, BitmapGlyph> {
let mut out = HashMap::new();
let file = p["file"].as_str().unwrap_or("");
@@ -349,6 +351,10 @@ fn load_bitmap(
advance: (0.5 + used as f32 * scale).floor() + 1.0,
offset: 1.0,
};
if metrics_only {
out.insert(*ch, BitmapGlyph::Atlas(glyph));
continue;
}
let glyph = match &sheet {
None => BitmapGlyph::Atlas(Glyph {
rect: Some(glyph_rect(
@@ -596,6 +602,30 @@ impl Font {
atlas: (u32, u32),
region: (u32, u32),
white: Rect,
) -> Self {
Self::load_with(root, sprites, pixels, atlas, region, white, false)
}
/// A server measures text but never draws it. Decode bitmap sheets only
/// to obtain advances, then release their pixels; never rasterize glyphs.
pub fn metrics(root: &Path) -> Self {
let white = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
};
Self::load_with(root, &HashMap::new(), &[], (1, 1), (0, 0), white, true)
}
fn load_with(
root: &Path,
sprites: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
region: (u32, u32),
white: Rect,
metrics_only: bool,
) -> Self {
let sheets = sprites
.iter()
@@ -617,6 +647,7 @@ impl Font {
root: root.to_owned(),
atlas,
region,
metrics_only,
cache: RefCell::new(Cache {
sets: Vec::new(),
region: Region {
@@ -665,7 +696,14 @@ impl Font {
)),
"bitmap" => Some(Provider::Bitmap(
Filter::of(p),
load_bitmap(&self.root, p, &self.sheets, pixels, self.atlas),
load_bitmap(
&self.root,
p,
&self.sheets,
pixels,
self.atlas,
self.metrics_only,
),
)),
"unihex" => Some(Provider::Unihex(Filter::of(p), p.clone(), None)),
"ttf" => Some(Provider::TrueType(
@@ -841,6 +879,14 @@ impl Font {
if let Some(id) = ttf.ids.get(&c)
&& !(non_fishy && fishy_advance(ttf.advance(*id)))
{
if self.metrics_only {
return Some(Glyph {
rect: None,
advance: ttf.advance(*id),
offset: 1.0,
..self.missing
});
}
let (glyph, bitmap) = ttf.glyph(*id);
return match bitmap {
Some((w, h, rgba)) => {
@@ -861,6 +907,17 @@ impl Font {
}
fn rasterize_hex(&self, region: &mut Region, h: HexGlyph) -> Option<Glyph> {
let width = h.right.saturating_sub(h.left) + 1;
if self.metrics_only {
return Some(Glyph {
rect: None,
left: 0.0,
top: 0.0,
right: width as f32 / 2.0,
bottom: 8.0,
advance: hex_advance(&h),
offset: 0.5,
});
}
let mut rgba = Vec::with_capacity((width * 16 * 4) as usize);
for row in h.rows {
for bit in h.left..=h.right {
@@ -1361,6 +1418,55 @@ mod tests {
out
}
#[test]
fn server_metrics_preserve_bitmap_true_type_and_unihex_advances() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
let def = br#"{"providers":[{"type":"space","advances":{" ":4}},{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
let ttf = br#"{"providers":[{"type":"ttf","file":"minecraft:mono.ttf","size":11,"oversample":2}]}"#;
let root = root(
"metrics",
&[
("assets/minecraft/font/default.json", def),
("assets/minecraft/font/alt.json", ttf),
(
"assets/minecraft/font/mono.ttf",
horizon_theme::fonts::GEIST_MONO,
),
("assets/minecraft/textures/font/a.png", &sheet),
],
);
let rendered = load(&root);
let metrics = Font::metrics(&root);
assert_eq!(metrics.width("a b"), 14.0);
assert_eq!(metrics.width("§lab"), 12.0);
let text = Text::literal("ab ab");
assert_eq!(
metrics.split_formatted(&text, 10.0),
rendered.split_formatted(&text, 10.0)
);
let alt = Style {
font: Some("minecraft:alt".into()),
bold: Some(true),
..Style::default()
};
for c in ['A', 'i', ' ', '\u{fffd}'] {
assert_eq!(metrics.advance(c, &alt), rendered.advance(c, &alt));
assert!(metrics.glyph_styled(c, &alt).rect.is_none());
}
let mut hex = HashMap::new();
parse_hex(b"0041:0000000018242442427E424242420000\n", &mut hex);
let h = hex[&0x41];
let glyph = metrics
.rasterize_hex(&mut metrics.cache.borrow_mut().region, h)
.unwrap();
assert_eq!(glyph.advance, 4.0);
assert_eq!(glyph.offset, 0.5);
assert!(glyph.rect.is_none());
assert!(metrics.take_patches().is_empty());
assert!(!rendered.take_patches().is_empty());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn true_type_glyphs_are_rasterized_at_the_provider_size() {
let def = br#"{"providers":[{"type":"ttf","file":"minecraft:mono.ttf","size":11,"oversample":2,"shift":[0.5,1],"skip":["B"]}]}"#;
File diff suppressed because it is too large. Load diff
@@ -885,6 +885,7 @@ mod tests {
sorted_indices: None,
flat_id: None,
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1081,6 +1082,7 @@ mod tests {
sorted_indices: None,
flat_id: Some(id),
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1307,6 +1309,7 @@ mod tests {
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -485,6 +485,7 @@ impl Scene {
let f = &self.frame;
Frame {
weather: &[],
hand_items: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: DVec3::from_array(f.camera),
yaw: f.yaw,
@@ -516,6 +517,7 @@ impl Scene {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -547,6 +549,7 @@ impl Scene {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &self.celestial,
stars: f
@@ -60,6 +60,7 @@ struct Pipelines {
luma_pyramid: wgpu::ComputePipeline,
scd_histogram: wgpu::ComputePipeline,
scd_divergence: wgpu::ComputePipeline,
scd_finalize: wgpu::ComputePipeline,
search: wgpu::ComputePipeline,
filter: wgpu::ComputePipeline,
scale: wgpu::ComputePipeline,
@@ -84,6 +85,7 @@ pub struct OpticalFlow {
frames: [Frame; 2],
filter: [wgpu::BindGroup; LEVELS],
scd_divergence: wgpu::BindGroup,
scd_finalize: wgpu::BindGroup,
scd_histogram: wgpu::Buffer,
scd_previous_histogram: wgpu::Buffer,
scd_temp: wgpu::Buffer,
@@ -183,6 +185,7 @@ impl OpticalFlow {
luma_pyramid: pipeline("luma_pyramid"),
scd_histogram: pipeline("scd_histogram_pass"),
scd_divergence: pipeline("scd_divergence_pass"),
scd_finalize: pipeline("scd_finalize_pass"),
search: pipeline("search_pass"),
filter: pipeline("filter_flow"),
scale: pipeline("scale_pass"),
@@ -235,6 +238,7 @@ impl OpticalFlow {
HISTOGRAM_WIDTH * 4,
);
let scd_temp = buffer(device, "Scene change temp", 12);
let scd_next_histogram = buffer(device, "Next scene change histogram", HISTOGRAM_WIDTH * 4);
let scd_output = buffer(device, "Scene change detection", 12);
let frame = |current: usize| {
let (cur, prev) = (&luma[current], &luma[1 - current]);
@@ -298,11 +302,22 @@ impl OpticalFlow {
let scd_divergence = bind(
device,
&pipelines.scd_divergence,
&[
(7, whole(&scd_histogram)),
(8, whole(&scd_previous_histogram)),
(9, whole(&scd_temp)),
(11, whole(&scd_next_histogram)),
],
);
let scd_finalize = bind(
device,
&pipelines.scd_finalize,
&[
(7, whole(&scd_histogram)),
(8, whole(&scd_previous_histogram)),
(9, whole(&scd_temp)),
(10, whole(&scd_output)),
(11, whole(&scd_next_histogram)),
],
);
Self {
@@ -312,6 +327,7 @@ impl OpticalFlow {
frames,
filter,
scd_divergence,
scd_finalize,
scd_histogram,
scd_previous_histogram,
scd_temp,
@@ -406,6 +422,7 @@ impl OpticalFlow {
9,
);
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
run(&p.scd_finalize, &self.scd_finalize, 9, 1, 1);
for level in (0..LEVELS).rev() {
let luma = self.display.map(|v| (v >> level).max(1));
run(
@@ -597,6 +614,22 @@ pub(super) mod tests {
.collect()
}
fn scd(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> u32 {
let mut encoder = device.create_command_encoder(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 12,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
}
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
#[test]
@@ -630,24 +663,40 @@ pub(super) mod tests {
inside.len(),
&inside[..8]
);
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
let mut encoder = device.create_command_encoder(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 12,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
};
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
run(&device, &queue, &mut flow, &other, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
}
#[test]
fn repeated_cuts_and_stable_frames_update_the_history() {
let Some((device, queue)) = device() else {
return;
};
let size = [384, 216];
let bright = upload(
&device,
&queue,
size,
&vec![255; (size[0] * size[1] * 4) as usize],
);
let dark_pixels: Vec<u8> = (0..size[0] * size[1])
.flat_map(|_| [0, 0, 0, 255])
.collect();
let dark = upload(&device, &queue, size, &dark_pixels);
let mut flow = OpticalFlow::new(&device, size);
run(&device, &queue, &mut flow, &bright, true);
for _ in 0..6 {
run(&device, &queue, &mut flow, &bright, false);
}
for frame in 0..16 {
let color = if frame % 2 == 0 { &dark } else { &bright };
run(&device, &queue, &mut flow, color, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 1, "cut {frame}");
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
run(&device, &queue, &mut flow, color, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 0, "stable frame {frame}");
}
}
}
@@ -53,10 +53,10 @@ struct Params {
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
@group(0) @binding(11) var<storage, read_write> scd_next_histogram: array<f32>;
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
let size = vec2<i32>(textureDimensions(t));
@@ -266,7 +266,6 @@ fn scd_divergence_pass(
@builtin(local_invocation_index) local_index: u32,
) {
let factor = 1000000.0;
let where_to_stop = 3u * 9u - 1u;
let histogram_count = 9.0;
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
let li = i32(local_index);
@@ -326,27 +325,32 @@ fn scd_divergence_pass(
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
let res_uint = u32((res_float / histogram_count) * factor);
atomicAdd(&scd_temp[gid.y], res_uint);
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
if finished == where_to_stop {
let res0 = atomicLoad(&scd_temp[0]);
let res1 = atomicLoad(&scd_temp[1]);
let res2 = atomicLoad(&scd_temp[2]);
let scene_change = f32(min(res0, min(res1, res2))) / factor;
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
if scene_change > 0.45 {
history |= 1u;
}
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
atomicStore(&scd_temp[0], 0u);
atomicStore(&scd_temp[1], 0u);
atomicStore(&scd_temp[2], 0u);
}
}
if gid.y == 1u {
scd_previous_histogram[gid.x] = current;
atomicStore(&scd_histogram[gid.x], 0u);
scd_next_histogram[gid.x] = current;
}
}
// All divergence workgroups must finish reading both histograms before either is
// replaced. A separate dispatch also makes every partial sum visible to the reducer.
@compute @workgroup_size(256)
fn scd_finalize_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
scd_previous_histogram[gid.x] = scd_next_histogram[gid.x];
atomicStore(&scd_histogram[gid.x], 0u);
if gid.x == 0u {
let res0 = atomicLoad(&scd_temp[0]);
let res1 = atomicLoad(&scd_temp[1]);
let res2 = atomicLoad(&scd_temp[2]);
let scene_change = f32(min(res0, min(res1, res2))) / 1000000.0;
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
if scene_change > 0.45 {
history |= 1u;
}
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
atomicStore(&scd_temp[0], 0u);
atomicStore(&scd_temp[1], 0u);
atomicStore(&scd_temp[2], 0u);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,15 +1,18 @@
// GPU culling (gfx/gpu_culling.rs). `apply` writes the records that changed.
// `cull`: one invocation per section id keeps the sections whose column is
// within the render distance, that cave culling's graph reaches (occlusion.rs)
// and whose box is in the frustum, writes each one's origin and counts it in
// its page, per block of 64 ids. `scan`: one workgroup per page turns the
// blocks' counts into where each block's draws start, and the page's count.
// `scatter`: each block writes its sections' draws there, in id order, so a
// page's draws come in the same order every frame (atomics alone would order
// them as the GPU schedules, and where faces of two sections meet at the same
// depth the later draw keeps the pixel). Nothing here computes with floats: the
// origins are the CPU's bits, copied, and the frustum test adds integers the
// CPU computed.
// and whose box is in the frustum, writes each one's origin and the runs of its
// solid quads that can face the eye, a draw each, and counts them in its page,
// per block of 64 ids. `scan`: one workgroup per page turns the blocks' counts
// into where each block's draws start, and the page's count. `scatter`: each
// block writes its sections' draws there, in id order and each section's in its
// runs' order, so a page's draws come in the same order every frame (atomics
// alone would order them as the GPU schedules, and where faces of two sections
// meet at the same depth the later draw keeps the pixel). Nothing here computes
// with floats: the origins are the CPU's bits, copied, the frustum test adds
// integers the CPU computed, and facing compares keys that order as the floats
// the CPU compares. `cull` leaves sections fading in or out to the CPU
// (`FADING`).
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
@@ -31,12 +34,13 @@ struct Params {
// Each frustum plane's w term, its margin in, then 0, 0.
w: array<vec4<i32>, 2>,
// Cave culling's graph (occlusion.rs `Reach`): from x, from z, radius, lowest
// section y; its height in sections and the level's; REACH and SKY; 0.
// section y; its height in sections and the level's; REACH, SKY and EYE;
// where the eye's keys start in `tables`.
reach: vec4<i32>,
reach_height: i32,
reach_sections: i32,
flags: u32,
pad: u32,
eyes: u32,
}
// gpu_culling.rs `Record`.
@@ -44,9 +48,16 @@ struct Section {
// Section x, y (from the level's bottom), z, and its arena page (-1: a
// free id).
key: vec4<i32>,
// The first vertex of its solid quads, its solid quads, its box
// (gpu_culling.rs `rounded_box`), then a word unused here.
// The first vertex of its solid quads, its solid quads (where its last
// bucket by facing side ends), its box (gpu_culling.rs `rounded_box`), then
// its flags (`FADING`).
a: vec4<u32>,
// Where its first six buckets end (mesher/sides.rs `BUCKETS`): +X, +Z, the
// other bucket, +Y, -Z, -X.
ends: array<u32, 6>,
// What its buckets facing +X, +Z, +Y, -Z, -X and -Y compare the eye's keys
// with (gpu_culling.rs `Record::facing`).
keys: array<u32, 6>,
}
// gpu_culling.rs `Update`: the record for id `id.x`.
@@ -59,8 +70,9 @@ struct Update {
@group(0) @binding(1) var<storage, read_write> sections: array<Section>;
// gpu_culling.rs `tables`: the origins' f32 bits along x, y and z (n of them);
// per frustum plane its terms for those origins, n each; per plane and axis its
// terms for the 32 box codes; then each page's region of `draws` (its first
// draw and its size).
// terms for the 32 box codes; with an eye (`EYE`), the keys of the eye less
// each origin, n of them; then each page's region of `draws` (its first draw
// and its size).
@group(0) @binding(2) var<storage, read> tables: array<u32>;
@group(0) @binding(3) var<storage, read> updates: array<Update>;
// Sections kept, draws, each page's draws, then per block of ids and page its
@@ -70,7 +82,8 @@ struct Update {
@group(0) @binding(5) var<storage, read_write> draws: array<u32>;
// Per id the instance it is drawn with: origin and UV step, as f32 bits.
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
// Per id its page + 1 when it is drawn this frame, else 0.
// Per id `RUN_WORDS`: its page << PAGE_SHIFT | its draws when it is drawn this
// frame, else 0; then each draw's first quad and quads.
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
// occlusion.rs `Reach::bits`: whether the search reached each cell of its box,
// a bit each (32 a word, lowest first).
@@ -91,6 +104,15 @@ const UNBOUNDED: u32 = 0x80000000u;
// reached the box's top layer (the sky band).
const REACH: u32 = 1u;
const SKY: u32 = 2u;
// In a section's `a.w`: it fades in or out, and the CPU draws it with its fade.
const FADING: u32 = 1u;
// In `params.flags`: solid quads facing away from the eye are left out, its
// keys in `tables` from `params.eyes`.
const EYE: u32 = 4u;
// Words per id in `runs`, and where its page starts in the first, its draws
// (at most 4: gpu_culling.rs `RUNS`) below.
const RUN_WORDS: u32 = 9u;
const PAGE_SHIFT: u32 = 3u;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
@@ -168,6 +190,34 @@ fn in_frustum(c: vec3<u32>, b: u32) -> bool {
return true;
}
// Where bucket `b` of section `id` ends (mesher/sides.rs `BUCKETS` order): its
// solid quads for the last.
fn bucket_end(id: u32, b: u32) -> u32 {
if b == 6u {
return sections[id].a.y;
}
return sections[id].ends[b];
}
// mesher/sides.rs `Sides::visible_from` for bucket `b` of section `id` (+X, +Z,
// the other bucket, +Y, -Z, -X, -Y), with `eye` the keys of the eye less its
// origin: whether its quads can face the eye. -Y compares the eye's own y, as a
// GPU frame has no water mirror. The keys order as the floats the CPU compares.
fn seen(id: u32, b: u32, eye: vec3<u32>) -> bool {
if b == 2u {
return true;
}
let t = sections[id].keys[b - select(0u, 1u, b > 2u)];
switch b {
case 0u: { return eye.x > t; }
case 1u: { return eye.z > t; }
case 3u: { return eye.y > t; }
case 4u: { return eye.z < t; }
case 5u: { return eye.x < t; }
default: { return eye.y < t; }
}
}
// One invocation per changed record; the CPU lists each id once.
@compute @workgroup_size(64)
fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -184,9 +234,11 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
if id >= params.ids {
return;
}
runs[id] = 0u;
let at = id * RUN_WORDS;
runs[at] = 0u;
let s = sections[id];
if s.key.w < 0 || s.a.y == 0u || !near(s.key.x, s.key.z) || !reached(s.key.xyz) {
if s.key.w < 0 || s.a.y == 0u || (s.a.w & FADING) != 0u || !near(s.key.x, s.key.z)
|| !reached(s.key.xyz) {
return;
}
let page = u32(s.key.w);
@@ -196,11 +248,52 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
if page >= params.pages || any(c >= params.cells.xyz) || !in_frustum(c, s.a.z) {
return;
}
// Kept, whether or not a quad of it can face the eye.
atomicAdd(&counts[0], 1u);
let nx = params.cells.x;
let ny = params.cells.y;
origins[id] = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
runs[id] = page + 1u;
atomicAdd(&counts[block_count(id / BLOCK, page)], 1u);
// gfx.rs `facing_runs`: the runs of its buckets that can face the eye, an
// empty bucket breaking none, a draw each; without an eye, its solid quads.
var n = 0u;
if (params.flags & EYE) == 0u {
runs[at + 1u] = 0u;
runs[at + 2u] = s.a.y;
n = 1u;
} else {
let e = params.eyes;
let eye = vec3<u32>(tables[e + c.x], tables[e + nx + c.y], tables[e + nx + ny + c.z]);
var start = 0u;
var first = 0u;
var open = false;
for (var b = 0u; b < 7u; b++) {
let end = bucket_end(id, b);
if end == start {
continue;
}
let shown = seen(id, b, eye);
if shown && !open {
open = true;
first = start;
} else if !shown && open {
runs[at + 1u + 2u * n] = first;
runs[at + 2u + 2u * n] = start - first;
n += 1u;
open = false;
}
start = end;
}
if open {
runs[at + 1u + 2u * n] = first;
runs[at + 2u + 2u * n] = start - first;
n += 1u;
}
}
if n == 0u {
return;
}
runs[at] = (page << PAGE_SHIFT) | n;
atomicAdd(&counts[block_count(id / BLOCK, page)], n);
}
var<workgroup> partial: array<u32, 256>;
@@ -239,49 +332,54 @@ fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index
start += count;
}
if lane == 255u {
// A region has room for every record of its page; were one short,
// its draws past it go.
// A region has room for every draw of its page's records; were one
// short, its draws past it go.
let total = min(partial[255], region(page).y);
atomicStore(&counts[STATS + page], total);
atomicAdd(&counts[0], partial[255]);
atomicAdd(&counts[1], total);
}
}
var<workgroup> held: array<u32, 64>;
// One workgroup per block of ids: each section's draw goes after those of the
// block's earlier sections in its page.
// One workgroup per block of ids: each section's draws go after those of the
// block's earlier sections in its page, in its runs' order.
@compute @workgroup_size(64)
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let block = group.y * ROW + group.x;
let id = block * BLOCK + lane;
var mine = 0u;
if id < params.ids {
mine = runs[id];
mine = runs[id * RUN_WORDS];
}
held[lane] = mine;
workgroupBarrier();
if mine == 0u {
// Its draws below PAGE_SHIFT, its page above.
let n = mine & 7u;
if n == 0u {
return;
}
let page = mine - 1u;
let page = mine >> PAGE_SHIFT;
var slot = atomicLoad(&counts[block_count(block, page)]);
for (var other = 0u; other < lane; other++) {
if held[other] == mine {
slot += 1u;
// A section not drawn holds 0: no draws.
if (held[other] >> PAGE_SHIFT) == page {
slot += held[other] & 7u;
}
}
let r = region(page);
if slot >= r.y {
return;
let at = id * RUN_WORDS;
let base = sections[id].a.x;
for (var k = 0u; k < n; k++) {
if slot + k >= r.y {
return;
}
// A run of its solid quads, from its first vertex, with its origin.
let d = (r.x + slot + k) * 5u;
draws[d] = runs[at + 2u + 2u * k] * 6u;
draws[d + 1u] = 1u;
draws[d + 2u] = 0u;
draws[d + 3u] = base + runs[at + 1u + 2u * k] * 4u;
draws[d + 4u] = id;
}
// All of its solid quads, from its first vertex, with its origin.
let s = sections[id];
let d = (r.x + slot) * 5u;
draws[d] = s.a.y * 6u;
draws[d + 1u] = 1u;
draws[d + 2u] = 0u;
draws[d + 3u] = s.a.x;
draws[d + 4u] = id;
}
+36 -8
View File
@@ -1,8 +1,10 @@
//! HDR output (Horizon setting, off by default): the frame renders exactly as
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
//! (`hdr.wgsl`) writes it to an HDR10 or scRGB swapchain at the player's paper
//! white, expanding the world's highlights towards their peak brightness. The
//! world is copied aside before the hand and HUD so only it is expanded.
//! without it, in vanilla's gamma space, into a 16-bit target when HDR output
//! was on when the renderer was made, else an 8-bit one
//! (`Renderer::frame_format`), and one pass (`hdr.wgsl`) writes it to an HDR10
//! or scRGB swapchain at the player's paper white, expanding the world's
//! highlights towards their peak brightness. The world is copied aside before
//! the hand and HUD so only it is expanded.
/// The swapchain HDR output presents to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -40,18 +42,32 @@ impl Output {
}
}
/// `HORIZON_HDR_OUTPUT`'s names, in order of preference.
const NAMES: [(Output, &str); 2] = [(Output::Pq, "pq"), (Output::ScRgb, "scrgb")];
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
}
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
NAMES
.into_iter()
.filter(|(_, name)| only.is_none_or(|o| o == *name))
.map(|(output, _)| output)
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
}
/// The HDR output an offscreen renderer (`--offscreen`) offers: it has no display to ask, so
/// only the one `HORIZON_HDR_OUTPUT` names, for HDR screenshots without an HDR display.
pub fn offscreen_output() -> Option<Output> {
named(std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
}
fn named(name: Option<&str>) -> Option<Output> {
NAMES
.into_iter()
.find(|(_, n)| name == Some(*n))
.map(|(output, _)| output)
}
struct Targets {
size: [u32; 2],
@@ -152,6 +168,9 @@ pub struct Hdr {
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
pub lighting: bool,
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
/// sun included, so the picture is SDR's on the HDR canvas.
pub look: bool,
/// Calibration's test picture while it runs.
pub calibration: Option<Calibration>,
}
@@ -297,11 +316,12 @@ impl Hdr {
ui: 1.0,
masks: MASKS,
lighting: true,
look: true,
calibration: None,
}
}
/// The 8-bit frame every pass draws into this frame, sized like the window.
/// The frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
let texture = |label, usage| {
@@ -537,7 +557,7 @@ impl Hdr {
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
f32::from(u8::from(world)),
f32::from(u8::from(world && self.look)),
f32::from(u8::from(self.debug)),
f32::from(u8::from(pq)),
]);
@@ -635,7 +655,7 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
#[cfg(test)]
mod tests {
use super::{Output, pick};
use super::{Output, named, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
#[test]
@@ -685,6 +705,14 @@ mod tests {
assert_eq!(pick(&scrgb, Some("pq")), None);
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
}
#[test]
fn offscreen_offers_only_the_named_output() {
assert_eq!(named(Some("pq")), Some(Output::Pq));
assert_eq!(named(Some("scrgb")), Some(Output::ScRgb));
assert_eq!(named(Some("hdr10")), None);
assert_eq!(named(None), None);
}
}
#[cfg(test)]
@@ -173,7 +173,8 @@ struct HorizonMaterialSolid {
@location(0) color: vec4<f32>,
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
// Its four floats' bits (`SURFACE_FORMAT` is an integer format).
@location(1) surface: vec4<u32>,
}
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
@@ -209,7 +210,7 @@ fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
var out: HorizonMaterialSolid;
let mark = horizon_material_mark(in, texel);
out.color = horizon_material_fogged(vec4(color.rgb, mark), in.spherical, in.cylindrical);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
out.surface = bitcast<vec4<u32>>(vec4(horizon_surface.xyz, in.clip.z));
return out;
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
@@ -335,9 +336,12 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
}
";
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
/// bits), y smoothness, z the pack's data, w the fragment's depth.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// The surface target (the post stage's `scene_surface`): the bits of four floats, x the normal
/// (two snorm16 in its bits), y smoothness, z the pack's data, w the fragment's depth. Stored
/// as unsigned integers, not `Rgba32Float`: RGBA32UI is colour-renderable on every backend,
/// where OpenGL (GLES 3 semantics) refuses RGBA32F as a render attachment, and integers keep
/// the packed normal's bits exact.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Uint;
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`, and the vertex stage in its module when `vertex`.
@@ -59,6 +59,25 @@ fn chosen_cells(
chosen.unwrap_or(d.cells)
}
/// `[shadow]` with the option values `constants`: none while its `option` is off, else the
/// resolution and distance its `resolution_option` and `distance_option` pick.
fn chosen_shadow(
s: &horizon_shader_api::manifest::Shadow,
options: &[horizon_shader_api::manifest::PackOption],
constants: &[(String, f64)],
) -> Option<horizon_shader_api::manifest::Shadow> {
let number = |id: Option<&String>| {
let option = options.iter().find(|o| Some(&o.id) == id)?;
let (_, value) = constants.iter().find(|(k, _)| *k == option.id)?;
option.kind.number(*value)
};
option_on(constants, s.option.as_ref()).then(|| horizon_shader_api::manifest::Shadow {
resolution: number(s.resolution_option.as_ref()).map_or(s.resolution, |r| r as u32),
distance: number(s.distance_option.as_ref()).map_or(s.distance, |d| d as f32),
..s.clone()
})
}
impl NativePack {
/// The pack of the checked stages `report` with the option values `constants`, drawing
/// onto the world's targets as `terrain` describes; the sky reads the terrain's group 0.
@@ -74,8 +93,12 @@ impl NativePack {
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let textures = super::native_post::pack_textures(device, queue, &report);
let settings = report
// `[shadow]`, unless its option is off, at the size its options pick.
let chosen = report
.shadow
.as_ref()
.and_then(|s| chosen_shadow(s, &report.manifest.options, constants));
let settings = chosen
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
@@ -103,8 +126,7 @@ impl NativePack {
report.shadow.is_some(),
)
});
let shadow = report
.shadow
let shadow = chosen
.as_ref()
.map(|s| {
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
@@ -240,3 +262,61 @@ impl NativePack {
|| self.post.is_some()
}
}
#[cfg(test)]
mod tests {
use super::chosen_shadow;
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
name: id.into(),
description: String::new(),
kind,
}
}
#[test]
fn the_shadow_map_follows_its_options() {
let options = [
option("shadows", OptionKind::Bool { default: true }),
option(
"quality",
OptionKind::Choice {
values: vec!["1024".into(), "4096".into()],
default: 0,
},
),
option(
"reach",
OptionKind::Int {
default: 96,
min: 32,
max: 512,
},
),
];
let shadow = Shadow {
option: Some("shadows".into()),
resolution_option: Some("quality".into()),
distance_option: Some("reach".into()),
..Shadow::default()
};
let values = |on: f64, quality: f64, reach: f64| {
[("shadows", on), ("quality", quality), ("reach", reach)].map(|(k, v)| (k.into(), v))
};
let size = |values: &[(String, f64)]| {
chosen_shadow(&shadow, &options, values).map(|s| (s.resolution, s.distance))
};
assert_eq!(size(&values(1.0, 1.0, 200.0)), Some((4096, 200.0)));
assert_eq!(size(&values(1.0, 0.0, 64.0)), Some((1024, 64.0)));
assert_eq!(size(&values(0.0, 1.0, 200.0)), None);
// Without the options' keys: the manifest's own numbers, always drawn.
assert_eq!(
chosen_shadow(&Shadow::default(), &options, &values(0.0, 1.0, 200.0))
.map(|s| (s.resolution, s.distance)),
Some((2048, 128.0))
);
}
}
@@ -366,6 +366,7 @@ impl NativeFrame {
// the sun's azimuth while the sun is low.
distance: None,
unculled: None,
every_face: false,
})
}
@@ -686,7 +687,7 @@ impl NativePost {
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
texture_entry(11, wgpu::TextureSampleType::Uint),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
@@ -1546,4 +1547,306 @@ file = \"b.wgsl\"
"{fourth:?}"
);
}
/// OpenGL refuses `Rgba32Float` as a render attachment (GLES 3 semantics, absent
/// `EXT_color_buffer_float`) but always renders to the integer formats, so the surface
/// is an integer format, and `surface_at` gets the floats back from the stored bits.
#[test]
fn the_surface_is_an_integer_target_that_the_post_stage_reads_bit_exact() {
assert_eq!(
super::super::native_material::SURFACE_FORMAT.sample_type(None, None),
Some(wgpu::TextureSampleType::Uint)
);
let Some((device, queue)) = device() else {
return;
};
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[[post]]\nfile = \"a.wgsl\"\n";
// The surface's fields on the colour: normal x, smoothness, data, and 1 when present.
let a = "fn post(input: PostInput) -> vec4<f32> {\n \
let s = surface_at(vec2<i32>(input.pixel));\n \
return vec4<f32>(s.normal.x, s.smoothness, s.data, select(0.0, 1.0, s.present));\n}\n";
let files = [(MANIFEST, manifest), ("a.wgsl", a)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[],
format,
&frame,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let view = screen.create_view(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// What the material stage stores, as the bits of floats: the normal +x (octahedral
// (1, 0), two snorm16), smoothness 0.5, data 0.25 and the fragment's depth 0.5.
let writer = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(f32((i << 1u) & 2u) * 2.0 - 1.0, f32(i & 2u) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<u32> {
return bitcast<vec4<u32>>(vec4<f32>(
bitcast<f32>(pack2x16snorm(vec2<f32>(1.0, 0.0))), 0.5, 0.25, 0.5));
}"
.into(),
),
});
let writer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &writer,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &writer,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: super::super::native_material::SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let mut read = |depth_of_world: f64| {
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame.surface_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&writer);
pass.draw(0..3, 0..1);
}
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(depth_of_world as f32),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
post.run(
&device,
&mut encoder,
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
true,
None,
);
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
screen.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
buffer.unmap();
pixel
};
// Present where the world's depth is the stored one: +x, 0.5, 0.25 as 8-bit.
assert_eq!(read(0.5), [255, 128, 64, 255]);
// Hidden by something nearer drawn since: the stored values no longer show.
assert_eq!(read(0.25), [0, 0, 0, 0]);
}
/// The surface holds float bit patterns, some of which are NaNs (a normal's two snorm16
/// can form one). The material stage writes `bitcast<vec4<u32>>(vec4<f32>(..))`; this
/// runs that same expression (not the whole material fragment, which needs a full terrain
/// pipeline) on bits fed as integers and checks the texture holds them exactly: payloads,
/// signs and denormals included.
#[test]
fn the_surface_keeps_every_bit_of_the_floats_written_through_the_material_stage_bitcast() {
let Some((device, queue)) = device() else {
return;
};
let cases: [[u32; 4]; 4] = [
[0x7fc0_1234, 0xffbf_ffff, 0x7fff_8001, 0x7f80_0001],
[0x8000_0000, 0x0000_0001, 0x007f_ffff, 0x8000_0001],
[0xffff_ffff, 0x7f80_0000, 0xff80_0000, 0x3f80_0000],
[0x7fff_8001, 0x0000_0000, 0x1234_5678, 0xdead_beef],
];
let mut bits = [[0u32; 4]; 16];
bits[..4].copy_from_slice(&cases);
let inputs = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&inputs, 0, bytemuck::cast_slice(&bits));
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@group(0) @binding(0) var<uniform> bits: array<vec4<u32>, 16>;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(f32((i << 1u) & 2u) * 2.0 - 1.0, f32(i & 2u) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<u32> {
let v = bitcast<vec4<f32>>(bits[u32(p.y) * 4u + u32(p.x)]);
return bitcast<vec4<u32>>(v);
}"
.into(),
),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: super::super::native_material::SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: inputs.as_entire_binding(),
}],
});
let surface = texture(
&device,
super::super::native_material::SURFACE_FORMAT,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let row = u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: row * 4,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let view = surface.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
surface.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
surface.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = buffer.slice(..).get_mapped_range().unwrap().to_vec();
buffer.unmap();
for (i, expected) in bits.iter().enumerate() {
let (x, y) = (i % 4, i / 4);
let at = y * row as usize + x * 16;
let got: [u32; 4] = std::array::from_fn(|c| {
u32::from_ne_bytes(data[at + c * 4..at + c * 4 + 4].try_into().unwrap())
});
assert_eq!(
got.map(|v| format!("{v:08x}")),
expected.map(|v| format!("{v:08x}")),
"texel {x},{y}"
);
}
}
}
@@ -76,14 +76,14 @@ pub struct WorldDrawn<'a> {
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
/// frame to frame (API @API:world_drawn_motion@).
/// frame to frame (API 371).
pub motion: &'a [(u32, u64)],
/// 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,
/// The block textures the terrain samples, for drawing blocks with the player's own
/// textures (`native_block_sprites` says where a block's are). API @API:native_block_sprites@.
/// textures (`native_block_sprites` says where a block's are). API 380.
pub block_sprites: BlockSpriteArrays<'a>,
}
@@ -154,7 +154,7 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API @API:hand_drawn@.
/// runs, with no hand too. API 364.
#[inline(never)]
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
@@ -806,12 +806,7 @@ mod tests {
#[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 shadow = camera(&Settings::default());
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),
@@ -827,11 +822,7 @@ mod tests {
#[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 shadow = camera(&Settings::default());
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);
@@ -1,7 +1,14 @@
//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas
//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every
//! layer is copied into a grid, row by row from the top left, and an animated layer again
//! whenever its frame changes.
//! layer is copied into the atlas, and an animated layer again whenever its frame changes.
//!
//! Flat draws, which read a table per sprite layer, get the game's own layout
//! ([`PackAtlas::stitched`]): every sprite at its own size, placed by vanilla's `Stitcher` with its
//! padding; this gives the `atlasSize` and spans our probes read in Iris 1.11.7. Other draws get
//! a grid of array-sized cells ([`PackAtlas::new`]).
#[path = "pack_atlas/stitch.rs"]
mod stitch;
pub(super) struct PackAtlas {
texture: wgpu::Texture,
@@ -14,6 +21,82 @@ pub(super) struct PackAtlas {
split: u32,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
/// The stitched layout, when the atlas has one.
stitched: Option<Stitched>,
}
/// A stitched atlas: where each layer's sprite is, and the pipeline that draws them in.
struct Stitched {
/// Each layer's sprite at the first level: its top left corner and its side.
rects: Vec<[u32; 3]>,
padding: u32,
pipeline: wgpu::RenderPipeline,
/// One per sprite array.
groups: Vec<wgpu::BindGroup>,
}
/// One sprite drawn into one level of a stitched atlas (`BLIT`'s instance).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct BlitSprite {
/// The sprite's top left corner, side and padding at that level.
rect: [i32; 4],
/// Its layer in its array, the array's level it comes from, the atlas level's size.
source: [u32; 4],
}
/// Draws sprites into a stitched atlas level, padding included: a pixel of the padding repeats
/// the sprite's nearest edge pixel, as vanilla's `animate_sprite` shaders do with a clamped sampler.
const BLIT: &str = r#"
struct Out {
@builtin(position) position: vec4<f32>,
@location(0) @interpolate(flat) rect: vec4<i32>,
@location(1) @interpolate(flat) source: vec2<u32>,
}
@group(0) @binding(0) var sprites: texture_2d_array<f32>;
@vertex
fn vs(@builtin(vertex_index) v: u32, @location(0) rect: vec4<i32>, @location(1) source: vec4<u32>) -> Out {
var corners = array<vec2<f32>, 6>(vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0),
vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0));
let pixel = vec2<f32>(rect.xy - rect.w) + corners[v] * f32(rect.z + 2 * rect.w);
let size = vec2<f32>(source.zw);
var out: Out;
out.position = vec4(pixel.x / size.x * 2.0 - 1.0, 1.0 - pixel.y / size.y * 2.0, 0.0, 1.0);
out.rect = rect;
out.source = source.xy;
return out;
}
@fragment
fn fs(in: Out) -> @location(0) vec4<f32> {
let texel = clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
return textureLoad(sprites, texel, i32(in.source.x), i32(in.source.y));
}
"#;
/// The side a layer of `sprite`-wide arrays has in a stitched atlas: its own, when the arrays
/// hold it scaled up by a power of two (else the array's).
fn atlas_side(native: u32, sprite: u32) -> u32 {
if native == 0
|| native > sprite
|| !sprite.is_multiple_of(native)
|| !(sprite / native).is_power_of_two()
{
sprite
} else {
native
}
}
/// `SpriteLoader.stitch`'s mip level: the option, lowered to fit the smallest sprite.
fn stitch_mip_level(sides: &[u32], option: u32) -> u32 {
let mut lowest = 1u32 << option.min(16);
let mut smallest = u32::MAX;
for &side in sides {
smallest = smallest.min(side);
lowest = lowest.min(side & side.wrapping_neg());
}
let level = smallest.min(lowest).max(1).ilog2();
level.min(option)
}
impl PackAtlas {
@@ -26,6 +109,37 @@ impl PackAtlas {
split: u32,
layers: u32,
sprite: u32,
) -> Option<Self> {
Self::with_levels(
device,
encoder,
arrays,
split,
layers,
sprite,
arrays[0].mip_level_count(),
)
}
/// Encoded material channels must select an existing texel, including at distance.
pub fn material(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
layers: u32,
) -> Option<Self> {
Self::with_levels(device, encoder, arrays, split, layers, arrays[0].width(), 1)
}
fn with_levels(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
layers: u32,
sprite: u32,
levels: u32,
) -> Option<Self> {
let layers = layers.max(1);
let columns = (f64::from(layers).sqrt().ceil() as u32).max(1);
@@ -35,7 +149,7 @@ impl PackAtlas {
return None;
}
// Levels past a sprite's single pixel would mix neighbours: stop there.
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
let levels = levels.min(sprite.ilog2() + 1);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack block atlas"),
size: wgpu::Extent3d {
@@ -60,11 +174,173 @@ impl PackAtlas {
sprite,
split,
separate_ao: false,
stitched: None,
};
atlas.copy(encoder, arrays, 0..layers);
atlas.copy(device, encoder, arrays, 0..layers);
Some(atlas)
}
/// Every layer of `arrays` at its own size (`native`, its side before the arrays scaled it up
/// to `sprite`), placed as vanilla's `Stitcher` places them with the padding of the Mipmap
/// Levels option and the anisotropy bit; `None` when they don't fit the device.
#[allow(clippy::too_many_arguments)]
pub fn stitched(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
sprite: u32,
native: &[u32],
mip_option: u32,
anisotropy_bit: u32,
) -> Option<Self> {
let sides: Vec<u32> = if native.is_empty() {
vec![sprite]
} else {
native.iter().map(|&n| atlas_side(n, sprite)).collect()
};
// A level of the atlas comes from the arrays' level as many halvings further down.
let array_levels = arrays[0].mip_level_count();
let deepest = sides
.iter()
.map(|&s| (sprite / s).ilog2())
.max()
.unwrap_or(0);
let mip =
stitch_mip_level(&sides, mip_option).min(array_levels.saturating_sub(deepest + 1));
let max = device.limits().max_texture_dimension_2d;
let layout = stitch::stitch(&sides, mip, anisotropy_bit, max)?;
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack block atlas"),
size: wgpu::Extent3d {
width: layout.width.max(1),
height: layout.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: mip + 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
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader pack atlas sprites"),
source: wgpu::ShaderSource::Wgsl(BLIT.into()),
});
let group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack atlas sprites"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Shader pack atlas sprites"),
bind_group_layouts: &[Some(&group_layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Shader pack atlas sprites"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<BlitSprite>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &wgpu::vertex_attr_array![0 => Sint32x4, 1 => Uint32x4],
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
});
let groups = arrays
.iter()
.map(|array| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack atlas sprites"),
layout: &group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&array.create_view(
&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
},
)),
}],
})
})
.collect();
let rects = layout
.holders
.iter()
.zip(&sides)
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
.collect::<Vec<_>>();
let layers = rects.len() as u32;
let atlas = Self {
texture,
view,
columns: 1,
sprite,
split,
separate_ao: false,
stitched: Some(Stitched {
rects,
padding: layout.padding,
pipeline,
groups,
}),
};
atlas.copy(device, encoder, arrays, 0..layers);
Some(atlas)
}
/// Whether the sprites keep their own sizes ([`PackAtlas::stitched`]).
pub fn is_stitched(&self) -> bool {
self.stitched.is_some()
}
/// The table flat terrain reads (`hz_sprite_rects`): per layer, its sprite's top left corner
/// (x | y << 16) and side, in atlas pixels.
pub fn rects_table(&self) -> Vec<u32> {
if let Some(stitched) = &self.stitched {
return stitched
.rects
.iter()
.flat_map(|&[x, y, side]| [x | (y << 16), side])
.collect();
}
(0..self.columns * self.texture.height() / self.sprite)
.flat_map(|layer| {
let (x, y) = (layer % self.columns, layer / self.columns);
[(x * self.sprite) | ((y * self.sprite) << 16), self.sprite]
})
.collect()
}
/// The atlas's size in pixels (`atlasSize`).
pub fn size(&self) -> [u32; 2] {
[self.texture.width(), self.texture.height()]
@@ -73,10 +349,14 @@ impl PackAtlas {
/// Copies `layers` from the sprite arrays again, every level.
pub fn copy(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
layers: impl IntoIterator<Item = u32>,
) {
if let Some(stitched) = &self.stitched {
return self.draw_sprites(stitched, device, encoder, layers);
}
for layer in layers {
let Some(array) = arrays.get((layer / self.split) as usize) else {
continue;
@@ -114,6 +394,91 @@ impl PackAtlas {
}
}
}
/// Draws `layers` into every level of a stitched atlas, from the arrays' level where each
/// sprite has its own size.
fn draw_sprites(
&self,
stitched: &Stitched,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
layers: impl IntoIterator<Item = u32>,
) {
let mut layers: Vec<u32> = layers
.into_iter()
.filter(|&l| (l as usize) < stitched.rects.len())
.collect();
if layers.is_empty() {
return;
}
layers.sort_unstable_by_key(|&l| (l / self.split, l));
let levels = self.texture.mip_level_count();
let mut sprites = Vec::with_capacity(layers.len() * levels as usize);
for level in 0..levels {
let size = [
self.texture.width() >> level,
self.texture.height() >> level,
];
for &layer in &layers {
let [x, y, side] = stitched.rects[layer as usize];
sprites.push(BlitSprite {
rect: [x, y, side, stitched.padding].map(|v| (v >> level) as i32),
source: [
layer % self.split,
(self.sprite / side).ilog2() + level,
size[0],
size[1],
],
});
}
}
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack atlas sprites"),
contents: bytemuck::cast_slice(&sprites),
usage: wgpu::BufferUsages::VERTEX,
},
);
let per_level = layers.len() as u32;
for level in 0..levels {
let view = self.texture.create_view(&wgpu::TextureViewDescriptor {
base_mip_level: level,
mip_level_count: Some(1),
..Default::default()
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack atlas sprites"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&stitched.pipeline);
pass.set_vertex_buffer(0, buffer.slice(..));
let first = level * per_level;
let mut start = 0;
while start < layers.len() {
let array = layers[start] / self.split;
let end = start
+ layers[start..]
.iter()
.take_while(|&&l| l / self.split == array)
.count();
if let Some(group) = stitched.groups.get(array as usize) {
pass.set_bind_group(0, group, &[]);
pass.draw(0..6, first + start as u32..first + end as u32);
}
start = end;
}
}
}
}
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
@@ -173,7 +538,8 @@ impl PackAtlas {
// vertices (`TerrainVertex`, six words each).
declarations.push_str(
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
);
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
} else {
@@ -181,11 +547,29 @@ impl PackAtlas {
"hz_v_origin"
};
let [width, height] = self.size();
let scale = [
self.sprite as f32 / width as f32,
self.sprite as f32 / height as f32,
];
// A sprite's cell in its own sides, and the share of the atlas a side is: from the table
// in flat draws (`rects_table`), from the grid in others.
let columns = self.columns;
let (cell, side, scale) = if flat {
(
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
.to_owned(),
"float(hz_rect.y)",
[1.0 / width as f32, 1.0 / height as f32],
)
} else {
(
format!(
"hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n "
),
"1.0",
[
self.sprite as f32 / width as f32,
self.sprite as f32 / height as f32,
],
)
};
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
let color_a = if self.separate_ao {
@@ -196,8 +580,8 @@ impl PackAtlas {
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
hz_scale = vec2({:?}, {:?});\n \
{cell}\
hz_scale = vec2({:?}, {:?}) * {side};\n \
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
hz_mid = vec2(0.5);\n \
@@ -230,6 +614,13 @@ impl PackAtlas {
"vec4(0.0)"
};
let attributes = [
// section_instance stores 1 - visibility in a positive origin's mantissa.
// Negative origins encode a distant-terrain handover, fully visible here
// just as terrain.wgsl's instance_visibility treats them.
(
"mc_chunkFade",
"(hz_origin.w > 0.0 ? 1.0 - float(floatBitsToUint(hz_origin.w) & 0x7fffffu) / 8388608.0 : 1.0)",
),
("mc_Entity", entity),
("at_midBlock", mid_block),
(
@@ -252,10 +643,15 @@ impl PackAtlas {
.collect(),
after: String::new(),
alpha_test: None,
item_id_declared: false,
}
}
}
#[cfg(test)]
#[path = "pack_atlas/fade_test.rs"]
mod fade_test;
#[cfg(test)]
#[path = "pack_atlas/midpoint_test.rs"]
mod midpoint_test;
@@ -337,6 +733,128 @@ mod tests {
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
}
/// An array of `sprite`-pixel layers (`sprite` the largest of `native`), as the game builds
/// them: each layer scaled up by whole pixels at level 0, its own pixels from the level where
/// it has its own size. Every pixel of a layer's own image differs: (layer, x, y, 255).
pub(super) fn upscaled_array(
device: &wgpu::Device,
queue: &wgpu::Queue,
native: &[u32],
) -> wgpu::Texture {
let sprite = native.iter().copied().max().unwrap();
let levels = sprite.ilog2() + 1;
let mut data = Vec::new();
for (layer, &side) in native.iter().enumerate() {
for level in 0..levels {
let edge = sprite >> level;
// Above its own size a level repeats each pixel; below, it shrinks to the first.
let scale = (edge / side).max(1);
for y in 0..edge {
for x in 0..edge {
let (x, y) = if edge >= side {
(x / scale, y / scale)
} else {
(0, 0)
};
data.extend([layer as u8 * 50, x as u8 * 16, y as u8 * 16, 255]);
}
}
}
}
wgpu::util::DeviceExt::create_texture_with_data(
device,
queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: sprite,
height: sprite,
depth_or_array_layers: native.len() as u32,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&data,
)
}
/// Sprites of 2, 4 and 1 px in a 4 px array, no mipmaps (one pixel of padding). Worked through
/// vanilla's `Stitcher` by hand: holders of 6, 4 and 3 px at (0, 0), (8, 0) and (8, 4) in a 16
/// by 8 atlas. Each sprite has its own pixels, and its padding repeats its edge.
#[test]
fn stitched_sprites_keep_their_own_size_and_padding() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = upscaled_array(&device, &queue, &[2, 4, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let atlas =
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
assert_eq!(atlas.size(), [16, 8]);
assert_eq!(
atlas.rects_table(),
[9 | 1 << 16, 2, 1 | 1 << 16, 4, 9 | 5 << 16, 1]
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256 * 8,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
atlas.texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 16,
height: 8,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
let at = |x: usize, y: usize| {
let i = y * 256 + x * 4;
[data[i], data[i + 1], data[i + 2], data[i + 3]]
};
// Layer 0's own 2 px: (layer 0, x, y); its padding repeats the nearest edge pixel.
assert_eq!(
[at(9, 1), at(10, 1), at(10, 2)],
[[0, 0, 0, 255], [0, 16, 0, 255], [0, 16, 16, 255]]
);
assert_eq!(
[at(8, 0), at(11, 3), at(11, 1)],
[[0, 0, 0, 255], [0, 16, 16, 255], [0, 16, 0, 255]]
);
// Layer 1 fills its 4 px, padded; layer 2 is one pixel in the middle of its padding.
assert_eq!(
[at(1, 1), at(4, 4), at(0, 5), at(5, 0)],
[
[50, 0, 0, 255],
[50, 48, 48, 255],
[50, 0, 48, 255],
[50, 48, 0, 255]
]
);
assert_eq!([at(9, 5), at(8, 4), at(10, 6)], [[100, 0, 0, 255]; 3]);
}
#[test]
fn terrain_inputs_respects_separate_ao() {
let instance = crate::gfx::test_gpu::instance();
@@ -366,6 +884,7 @@ mod tests {
sprite: 16,
split: 1,
separate_ao: false,
stitched: None,
};
let inputs_default = atlas.terrain_inputs(false);
assert!(
@@ -0,0 +1,406 @@
//! Original pack-to-pixel controls of terrain's visibility input, with no external fixture.
use super::PackAtlas;
use crate::gfx::{section_instance, section_visibility};
use crate::mesher::TerrainVertex;
use std::{
path::PathBuf,
time::{Duration, Instant},
};
use wgpu::util::DeviceExt;
struct ProbePack(PathBuf);
impl Drop for ProbePack {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
fn terrain_fade_reaches_pack_pixels_through_real_instances() {
let instance = crate::gfx::test_gpu::instance();
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
Ok(adapter) => adapter,
Err(error) => {
eprintln!("SKIP terrain fade GPU control: no adapter: {error}");
return;
}
};
eprintln!("terrain fade adapter: {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let mut atlas = PackAtlas::new(&device, &mut encoder, &[array], 1, 1, 2).unwrap();
atlas.separate_ao = true;
queue.submit([encoder.finish()]);
let folder = ProbePack(
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
);
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
// The guard, initializer, varying and integer output are independent probe code.
let vertex = "#version 330\nout vec4 observation;\n\
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
void main() { pixel = floatBitsToUint(observation); }";
let geometry_vertex = vertex
.replace("float first = mc_chunkFade;", "")
.replace("mc_chunkFade, first,", "mc_chunkFade, mc_chunkFade,")
.replace("observation", "vertex_observation");
let geometry_shader = "#version 330\nlayout(triangles) in;\n\
layout(triangle_strip, max_vertices=3) out;\nin vec4 vertex_observation[];\n\
out vec4 observation;\nconst float mc_chunkFade = 0.8125;\nvoid main() {\n\
for (int k=0; k<3; ++k) {\nobservation = vertex_observation[k];\n\
observation.y = mc_chunkFade;\n\
gl_Position = vec4(k == 1 ? 3.0 : -1.0, k == 2 ? 3.0 : -1.0, 0.0, 1.0);\n\
EmitVertex();\n}\nEndPrimitive();\n}";
for name in ["gbuffers_terrain", "gbuffers_water", "shadow"] {
for (extension, code) in [("vsh", vertex), ("fsh", fragment)] {
std::fs::write(folder.0.join(format!("shaders/{name}.{extension}")), code).unwrap();
}
}
let uploaded = Instant::now();
let cases = [
("first frame", Some(&uploaded), 0, 1000, 0, 0.0),
("137ms", Some(&uploaded), 137, 1000, 0, 0.137),
("midpoint", Some(&uploaded), 500, 1000, 0, 0.5),
("finished", Some(&uploaded), 1000, 1000, 0, 1.0),
("disabled", Some(&uploaded), 0, 0, 0, 1.0),
("no upload transition", None, 0, 1000, 0, 1.0),
("distant handover", Some(&uploaded), 500, 1000, 17, 1.0),
];
let mut count = 0;
for requested in [false, true] {
std::fs::write(
folder.0.join("shaders/shaders.properties"),
if requested {
"shadow.enabled=true\niris.features.optional=FADE_VARIABLE\niris.features.required=FADE_VARIABLE\n"
} else {
"shadow.enabled=true\n"
},
)
.unwrap();
let pack = horizon_shaders::Pack::open(&folder.0).unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, notes) = horizon_shaders::read_properties(&pack, &mut macros);
assert!(notes.is_empty(), "{notes:?}");
for flat in [false, true] {
for geometry in [false, true].into_iter().filter(|g| !*g || flat) {
let geometry_path = folder.0.join("shaders/shadow.gsh");
if geometry {
std::fs::write(&geometry_path, geometry_shader).unwrap();
} else if geometry_path.exists() {
std::fs::remove_file(&geometry_path).unwrap();
}
std::fs::write(
folder.0.join("shaders/shadow.vsh"),
if geometry {
geometry_vertex.as_str()
} else {
vertex
},
)
.unwrap();
let post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&horizon_shaders::GameInputs {
terrain: Some(atlas.terrain_inputs(flat)),
terrain_geometry: geometry.then(|| horizon_shaders::GeometryInputs {
inputs: atlas.terrain_inputs(true),
pull: super::PULL.to_owned(),
index: "@".to_owned(),
}),
..Default::default()
},
);
for (name, program) in [
("terrain", &post.terrain),
("water", &post.water),
("shadow", &post.shadow),
] {
if geometry && name != "shadow" {
continue;
}
let program = program
.as_ref()
.unwrap_or_else(|| panic!("{name}: {:?}", post.notes));
for &(case, timestamp, elapsed, fade_ms, handover, expected) in &cases {
let visibility = section_visibility(
timestamp,
fade_ms,
uploaded + Duration::from_millis(elapsed),
);
let origin = section_instance([48.0, 0.0, -16.0], handover, visibility);
let pixel = sample(
&device,
&queue,
&atlas,
program.program.as_ref().unwrap(),
flat,
geometry,
origin,
);
let fade = if requested { expected } else { 1.0 };
let expected = [
fade,
if geometry { 0.8125 } else { fade },
128.0 / 255.0,
0.25,
];
assert!(
pixel
.iter()
.zip(expected)
.all(|(a, b)| a.is_finite() && (a - b).abs() < 2e-7),
"{name} {case} flat={flat} geometry={geometry} requested={requested}: {pixel:?} != {expected:?}"
);
count += 1;
}
}
}
}
}
eprintln!("terrain fade pack-to-pixel cases executed: {count}");
assert_eq!(count, 98);
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
program: &horizon_shaders::Program,
flat: bool,
geometry: bool,
origin: [f32; 4],
) -> [f32; 4] {
let buffer = |bytes: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytes,
usage,
})
};
let uniform = buffer(
&vec![0; program.interface.uniform_size as usize],
wgpu::BufferUsages::UNIFORM,
);
let origins = buffer(
bytemuck::bytes_of(&origin),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
let vertices: Vec<_> = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]
.into_iter()
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, 0x80ffffff, 0))
.collect();
let data = buffer(
bytemuck::cast_slice(&vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let ids = buffer(bytemuck::bytes_of(&[0i32; 3]), wgpu::BufferUsages::STORAGE);
let rects = buffer(
bytemuck::cast_slice(&atlas.rects_table()),
wgpu::BufferUsages::STORAGE,
);
let binding = |n, storage| wgpu::BindGroupLayoutEntry {
binding: n,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: if storage {
wgpu::BufferBindingType::Storage { read_only: true }
} else {
wgpu::BufferBindingType::Uniform
},
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let entries = [
vec![binding(0, false)],
if flat {
vec![binding(0, true), binding(1, true)]
} else {
vec![]
},
vec![],
if flat {
vec![binding(0, true), binding(1, true), binding(2, true)]
} else {
vec![]
},
];
let layouts: Vec<_> = entries
.iter()
.map(|entries| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries,
})
})
.collect();
let bindings = [
vec![(0, &uniform)],
if flat {
vec![(0, &slots), (1, &origins)]
} else {
vec![]
},
vec![],
if flat {
vec![(0, &ids), (1, &data), (2, &rects)]
} else {
vec![]
},
];
let groups: Vec<_> = layouts
.iter()
.zip(bindings)
.map(|(layout, buffers)| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout,
entries: &buffers
.into_iter()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
})
.collect::<Vec<_>>(),
})
})
.collect();
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &layouts.iter().map(Some).collect::<Vec<_>>(),
..Default::default()
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let vs = module(program.vertex.clone());
let fs = module(program.fragment.clone());
let vertices = [
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &crate::gfx::TERRAIN_ATTRIBUTES,
}),
Some(wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &wgpu::vertex_attr_array![5 => Float32x4],
}),
];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &vertices[..if flat { 1 } else { 2 }],
},
primitive: wgpu::PrimitiveState {
topology: if geometry {
wgpu::PrimitiveTopology::TriangleList
} else {
wgpu::PrimitiveTopology::PointList
},
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Uint,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let output = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba32Uint,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 16,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let view = output.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &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(&pipeline);
for (n, group) in groups.iter().enumerate() {
pass.set_bind_group(n as u32, group, &[]);
}
pass.set_vertex_buffer(0, data.slice(..));
if !flat {
pass.set_vertex_buffer(1, origins.slice(..));
}
pass.draw(0..if geometry { 3 } else { 1 }, 0..1);
}
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: Default::default(),
},
output.size(),
);
queue.submit([encoder.finish()]);
read.map_async(wgpu::MapMode::Read, .., |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
*bytemuck::from_bytes(&read.get_mapped_range(..).unwrap())
}
@@ -123,6 +123,54 @@ fn mid_tex_coord_follows_each_quads_mapped_corners() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// A stitched atlas (the one `stitched_sprites_keep_their_own_size_and_padding` works out: 16 by
/// 8, sprites of 2, 4 and 1 px at (9, 1), (1, 1) and (9, 5)): flat draws map a quad's UVs onto its
/// sprite's own pixels, so a corner is half the sprite's own side from `mc_midTexCoord`.
#[test]
fn flat_draws_map_uvs_onto_a_stitched_sprite() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = super::tests::upscaled_array(&device, &queue, &[2, 4, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let atlas =
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
queue.submit([encoder.finish()]);
let whole = [[0., 0.], [1., 0.], [1., 1.], [0., 1.]];
let positions = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]];
let vertices: Vec<_> = (0..3)
.flat_map(|layer| {
(0..4).map(move |k| {
TerrainVertex::new(positions[k], whole[k], layer as u16 | 0x8000, u32::MAX, 0)
})
})
.collect();
// Corner and side of each layer's sprite, in atlas pixels.
let sprites = [([9., 1.], 2.), ([1., 1.], 4.), ([9., 5.], 1.)];
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
let pixels = sample(&device, &queue, &atlas, &vertices, origin, [true, false]);
let mut failures = Vec::new();
for (layer, ([x, y], side)) in sprites.into_iter().enumerate() {
let mid = [(x + side / 2.) / 16., (y + side / 2.) / 8.];
for (corner, [u, v]) in whole.iter().enumerate() {
let expected = [mid[0], mid[1], (x + u * side) / 16., (y + v * side) / 8.];
let actual = pixels[4 * layer + corner];
if actual
.iter()
.zip(expected)
.any(|(a, b)| (a - b).abs() > 1e-6)
{
failures.push(format!(
"layer {layer} corner {corner}: {actual:?} != {expected:?}"
));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -183,7 +231,7 @@ fn sample(
}],
vec![storage(0), storage(1)],
vec![],
vec![storage(0), storage(1)],
vec![storage(0), storage(1), storage(2)],
];
let layouts: Vec<_> = entries
.iter()
@@ -268,11 +316,15 @@ fn sample(
bytemuck::cast_slice(vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let rects = buffer(
bytemuck::cast_slice(&atlas.rects_table()),
wgpu::BufferUsages::STORAGE,
);
let bindings = [
vec![(0, &uniform)],
vec![(0, &slots), (1, &origin)],
vec![],
vec![(0, &ids), (1, &data)],
vec![(0, &ids), (1, &data), (2, &rects)],
];
let groups: Vec<_> = bindings
.iter()
@@ -0,0 +1,178 @@
//! Where a shader pack's block atlas puts each sprite: vanilla's `Stitcher`, so every sprite keeps
//! its own size and the atlas comes out as large as the game's (`atlasSize`). Holders are sorted
//! by height, then width, then (where vanilla compares names) sprite layer.
/// The atlas a set of sprites stitches into.
#[derive(Debug, PartialEq)]
pub(super) struct Stitched {
pub width: u32,
pub height: u32,
/// Each sprite's padded holder: its top left corner. The sprite itself starts `padding` in.
pub holders: Vec<[u32; 2]>,
pub padding: u32,
}
/// `Stitcher.smallestFittingMinTexel`.
fn fit(input: u32, mip_level: u32) -> u32 {
let step = 1 << mip_level;
input.div_ceil(step) * step
}
/// `Mth.smallestEncompassingPowerOfTwo` (0 stays 0).
fn power_of_two(input: u32) -> u32 {
if input == 0 {
0
} else {
input.next_power_of_two()
}
}
/// `Stitcher.Region`.
struct Region {
x: u32,
y: u32,
width: u32,
height: u32,
holder: bool,
sub: Vec<Region>,
}
impl Region {
fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
Self {
x,
y,
width,
height,
holder: false,
sub: Vec::new(),
}
}
/// `Region.add`: the holder's top left corner, when it fits.
fn add(&mut self, [w, h]: [u32; 2]) -> Option<[u32; 2]> {
if self.holder || w > self.width || h > self.height {
return None;
}
if w == self.width && h == self.height {
self.holder = true;
return Some([self.x, self.y]);
}
if self.sub.is_empty() {
let (x, y) = (self.x, self.y);
self.sub.push(Region::new(x, y, w, h));
let (spare_w, spare_h) = (self.width - w, self.height - h);
if spare_h > 0 && spare_w > 0 {
let right = self.height.max(spare_w);
let bottom = self.width.max(spare_h);
if right >= bottom {
self.sub.push(Region::new(x, y + h, w, spare_h));
self.sub.push(Region::new(x + w, y, spare_w, self.height));
} else {
self.sub.push(Region::new(x + w, y, spare_w, h));
self.sub.push(Region::new(x, y + h, self.width, spare_h));
}
} else if spare_w == 0 {
self.sub.push(Region::new(x, y + h, w, spare_h));
} else if spare_h == 0 {
self.sub.push(Region::new(x + w, y, spare_w, h));
}
}
self.sub.iter_mut().find_map(|r| r.add([w, h]))
}
}
/// `Stitcher` over square sprites of `sides` pixels, with vanilla's padding for `mip_level` and
/// `anisotropy_bit`; `None` when they don't fit in `max` by `max`.
pub(super) fn stitch(
sides: &[u32],
mip_level: u32,
anisotropy_bit: u32,
max: u32,
) -> Option<Stitched> {
let padding = (1 << mip_level) << anisotropy_bit.saturating_sub(1).min(4);
let mut order: Vec<usize> = (0..sides.len()).collect();
let holder = |i: usize| fit(sides[i] + padding * 2, mip_level);
order.sort_by_key(|&i| (std::cmp::Reverse(holder(i)), i));
let (mut storage_x, mut storage_y) = (0u32, 0u32);
let mut storage: Vec<Region> = Vec::new();
let mut holders = vec![[0, 0]; sides.len()];
for i in order {
let size = [holder(i); 2];
if let Some(at) = storage.iter_mut().find_map(|r| r.add(size)) {
holders[i] = at;
continue;
}
// `Stitcher.expand`.
let (x_now, y_now) = (power_of_two(storage_x), power_of_two(storage_y));
let (x_new, y_new) = (
power_of_two(storage_x + size[0]),
power_of_two(storage_y + size[1]),
);
let (x_can, y_can) = (x_new <= max, y_new <= max);
if !x_can && !y_can {
return None;
}
let (x_will, y_will) = (x_can && x_now != x_new, y_can && y_now != y_new);
let grow_x = if x_will ^ y_will {
x_will
} else {
x_can && x_now <= y_now
};
let mut slot = if grow_x {
if storage_y == 0 {
storage_y = y_new;
}
let slot = Region::new(storage_x, 0, x_new - storage_x, storage_y);
storage_x = x_new;
slot
} else {
let slot = Region::new(0, storage_y, storage_x, y_new - storage_y);
storage_y = y_new;
slot
};
holders[i] = slot.add(size)?;
storage.push(slot);
}
Some(Stitched {
width: storage_x,
height: storage_y,
holders,
padding,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Five 16 px sprites with one pixel of padding (no mipmaps), worked through vanilla's
/// `Stitcher` by hand: 18 px holders in a 64 by 64 atlas.
#[test]
fn equal_sprites_follow_vanillas_regions() {
let stitched = stitch(&[16; 5], 0, 0, 1 << 14).unwrap();
assert_eq!(
(stitched.width, stitched.height, stitched.padding),
(64, 64, 1)
);
assert_eq!(
stitched.holders,
[[0, 0], [32, 0], [0, 32], [18, 32], [36, 32]]
);
}
/// The largest sprite goes first; padding and holders round up to the mip step.
#[test]
fn larger_sprites_go_first_and_holders_round_to_the_mip_step() {
// Mip level 2: 4 px padding, holders 24 (16 + 8) and 40 (32 + 8) px.
let stitched = stitch(&[16, 32], 2, 0, 1 << 14).unwrap();
assert_eq!(stitched.padding, 4);
assert_eq!(stitched.holders[1], [0, 0]);
// 40 px grows the atlas to 64 by 64; the 24 px holder fits below the first.
assert_eq!((stitched.width, stitched.height), (64, 64));
assert_eq!(stitched.holders[0], [0, 40]);
// Anisotropy 16x (bit 4) multiplies the padding by 8.
assert_eq!(stitch(&[16], 4, 4, 1 << 14).unwrap().padding, 128);
assert_eq!(stitch(&[16; 2], 0, 0, 16), None);
}
}
@@ -58,7 +58,8 @@ impl EntitySpan {
}
}
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, a pad, `entityColor`.
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, `currentRenderedItemId`,
/// `entityColor`.
const SIZE: u32 = 32;
pub(super) struct DrawUniforms {
@@ -73,8 +74,10 @@ 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.
// `blockEntityId` outside block entities, as Iris has it; `currentRenderedItemId`
// outside items, as Iris 1.11.7 gives it to terrain, water and the sky (observed).
slot[8..12].copy_from_slice(&(-1i32).to_le_bytes());
slot[12..16].copy_from_slice(&(-1i32).to_le_bytes());
}
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
@@ -101,6 +101,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
item_id_declared: false,
}
}
@@ -162,6 +163,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
alpha_test: None,
item_id_declared: false,
}
}
@@ -426,7 +428,9 @@ impl PackEntities {
}),
};
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = part == Part::Plain && program.is_some_and(|p| shadow.cast(&span, p));
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
// draws them); beams do not.
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
extend_without_portals(rest, run);
}
@@ -1032,6 +1036,57 @@ mod gpu_span_tests {
assert_eq!(skin.spans.len(), 1);
}
#[test]
fn end_portal_faces_reach_the_shadow_pass_with_their_block_entity() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
// One block entity: a plain face, then an End portal face (overlay layer 9).
let cpu: Vec<Vertex> = [0, 0, 0, 0, 9, 9, 9, 9]
.into_iter()
.map(|layer| Vertex {
layer,
..bytemuck::Zeroable::zeroed()
})
.collect();
let spans = [EntitySpan {
start: 0,
id: 99,
stage: render_stage("BLOCK_ENTITIES"),
..Default::default()
}];
let mut rest = Vec::new();
geometry.prepare(
&device,
&queue,
&layout.layout,
&cpu,
&spans,
[true; PROGRAMS],
([0.; 4], [1; 2]),
Casters {
blocks: true,
..Default::default()
},
&mut rest,
None,
);
assert_eq!(
(geometry.draws.iter())
.map(|d| (d.portal, d.shadow))
.collect::<Vec<_>>(),
[(false, true), (true, true)],
"the portal face casts as its block entity does"
);
}
#[test]
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
let instance = crate::gfx::test_gpu::instance();
@@ -13,9 +13,14 @@ const HAND_DEPTH: f32 = 0.125;
/// `HzHand`: the hand's projection and its inverse.
const UNIFORM: u64 = 2 * 64;
/// `HzDraw`'s size (pack_draw.rs).
const SLOT: u64 = 32;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
inputs.item_id_declared = true;
inputs.declarations.push_str(
"layout(set = 1, binding = 1) uniform HzHand { mat4 hz_hand_projection; mat4 hz_hand_projection_inverse; };\n",
);
@@ -45,10 +50,17 @@ pub struct PackHand {
capacity: u32,
/// Quads uploaded this frame.
pub quads: u32,
/// `HzDraw` (set 2) of each run: the arm or one held item, and its `currentRenderedItemId`.
slots: wgpu::Buffer,
pub slot_group: wgpu::BindGroup,
stride: u64,
/// This frame's runs: first quad, quads, and the dynamic offset of their slot.
pub runs: Vec<(u32, u32, u32)>,
}
impl PackHand {
pub fn new(device: &wgpu::Device) -> Self {
/// `draws`: the layout of `HzDraw` (pack_draw.rs).
pub fn new(device: &wgpu::Device, draws: &wgpu::BindGroupLayout) -> Self {
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
@@ -74,6 +86,28 @@ impl PackHand {
});
let capacity = 256;
let (vertices, group) = Self::vertex_buffer(device, &layout, &uniform, capacity);
let stride = SLOT.max(u64::from(
device.limits().min_uniform_buffer_offset_alignment,
));
// The arm and the two hands' items: a few runs a frame.
let slots = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack hand draws"),
size: SLOTS * stride,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let slot_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack hand draws"),
layout: draws,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &slots,
offset: 0,
size: wgpu::BufferSize::new(SLOT),
}),
}],
});
Self {
indices: super::pack_entities::quad_indices(device, capacity),
layout,
@@ -82,6 +116,10 @@ impl PackHand {
group,
capacity,
quads: 0,
slots,
slot_group,
stride,
runs: Vec::new(),
}
}
@@ -116,18 +154,31 @@ impl PackHand {
(buffer, group)
}
/// Uploads this frame's hand and its projection.
/// Uploads this frame's hand, its projection, and the `HzDraw` of each run `items` starts
/// (first vertex, `currentRenderedItemId`; the slots past [`SLOTS`] share the last).
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
hand: &[Vertex],
items: &[(u32, i32)],
projection: Mat4,
) {
self.quads = (hand.len() / 4) as u32;
self.runs.clear();
if self.quads == 0 {
return;
}
let mut contents = Vec::new();
for (start, quads, id) in runs(self.quads, items) {
let slot = (self.runs.len() as u64).min(SLOTS - 1);
if slot as usize == self.runs.len() {
contents.extend_from_slice(&draw_slot(id));
contents.resize(((slot + 1) * self.stride) as usize, 0);
}
self.runs.push((start, quads, (slot * self.stride) as u32));
}
queue.write_buffer(&self.slots, 0, &contents);
if self.quads > self.capacity {
self.capacity = self.quads.next_power_of_two();
(self.vertices, self.group) =
@@ -144,10 +195,54 @@ impl PackHand {
}
}
/// The hand's runs a frame keeps slots for.
const SLOTS: u64 = 8;
/// `HzDraw` of a hand run: `renderStage` the solid hand's, `blockEntityId` -1 as outside block
/// entities, and `currentRenderedItemId`.
fn draw_slot(id: i32) -> [u8; SLOT as usize] {
let mut bytes = [0u8; SLOT as usize];
let stage = horizon_shaders::render_stage("HAND_SOLID") as i32;
bytes[0..4].copy_from_slice(&stage.to_le_bytes());
bytes[8..12].copy_from_slice(&(-1i32).to_le_bytes());
bytes[12..16].copy_from_slice(&id.to_le_bytes());
bytes
}
/// The runs of `quads` quads that `items` starts (first vertex, id), as first quad, quads and
/// id; geometry before the first start, or with no starts at all, reads 0, as the arm does.
fn runs(quads: u32, items: &[(u32, i32)]) -> Vec<(u32, u32, i32)> {
let mut starts: Vec<(u32, i32)> = vec![(0, 0)];
for &(start, id) in items {
let q = (start / 4).min(quads);
match starts.last_mut() {
Some(last) if last.0 == q => last.1 = id,
_ => starts.push((q, id)),
}
}
(0..starts.len())
.map(|i| {
let end = starts.get(i + 1).map_or(quads, |s| s.0);
(starts[i].0, end - starts[i].0, starts[i].1)
})
.filter(|r| r.1 > 0)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// Each item's quads draw with its id; geometry before the first item is the arm's (0).
#[test]
fn runs_split_the_hand_by_item() {
assert_eq!(runs(3, &[]), [(0, 3, 0)]);
assert_eq!(runs(5, &[(0, 11), (8, 65535)]), [(0, 2, 11), (2, 3, 65535)]);
assert_eq!(runs(4, &[(4, 21)]), [(0, 1, 0), (1, 3, 21)]);
// An item that drew nothing gives way to the next.
assert_eq!(runs(2, &[(0, 11), (0, 22)]), [(0, 2, 22)]);
}
/// The hand's window depth lies within `MC_HAND_DEPTH` of the middle (packs tell the hand
/// apart by it), and x, y match the plain projection.
#[test]
@@ -50,6 +50,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after,
alpha_test: None,
item_id_declared: false,
}
}
@@ -0,0 +1,259 @@
//! Original synthetic material transport oracle; no external shader algorithms or assets.
use super::*;
use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a host GPU; missing adapter is a failure"]
fn terrain_material_samplers_preserve_surface_and_encoded_bytes() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Material transport GPU: {:?}", adapter.get_info());
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.unwrap();
let root = std::env::temp_dir().join(format!("horizon-material-pack-{}", std::process::id()));
std::fs::create_dir_all(root.join("shaders")).unwrap();
std::fs::write(
root.join("shaders/gbuffers_terrain.vsh"),
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
)
.unwrap();
std::fs::write(
root.join("shaders/gbuffers_terrain.fsh"),
r#"#version 120
uniform sampler2D gtexture;
uniform sampler2D normals;
uniform sampler2D specular;
void main() {
int x = int(floor(gl_FragCoord.x));
int layer = x - (x / 5) * 5;
// Source coordinate (1.25,1.25): inside a texel, away from both its centre and boundary.
vec2 uv = (vec2(float(layer - (layer / 3) * 3), float(layer / 3)) + 0.3125) / vec2(3.0, 2.0);
if (x < 5) gl_FragData[0] = texture2DLod(specular, uv, 12.0);
else if (x < 10) gl_FragData[0] = texture2DLod(normals, uv, 12.0);
else gl_FragData[0] = texture2DLod(gtexture, uv, 0.0);
}
"#,
)
.unwrap();
let (split, layers) = (2u32, 5u32);
let mut encoder = device.create_command_encoder(&Default::default());
let atlases: Vec<_> = (0..3)
.map(|kind| {
let side = if kind == 0 { 4 } else { 2 };
let levels = if kind == 0 { 3 } else { 1 };
let arrays: Vec<_> = (0..layers.div_ceil(split))
.map(|array| {
let count = (layers - array * split).min(split);
let pixels: Vec<u8> = (0..count)
.flat_map(|l| {
let layer = (array * split + l) as u8;
(0..levels).flat_map(move |level| {
let edge = side >> level;
(0..edge * edge).flat_map(move |pixel| {
if level != 0 {
// Sampling unused source mip levels must fail the oracle.
[211, 212, 213, 214]
} else {
match kind {
0 if layer == 3 => crate::sprites::NO_SPECULAR,
0 => [
17 + layer,
if pixel % 2 == 0 { 0 } else { 240 },
65,
if pixel % 2 == 0 { 0 } else { 255 },
],
1 if layer == 2 => crate::sprites::FLAT_NORMAL,
1 => [128 + layer, 126, 255, 19 + layer],
_ => [40 + layer, 50, 60, 255],
}
}
})
})
})
.collect();
device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some("Original fixture companion array"),
size: wgpu::Extent3d {
width: side,
height: side,
depth_or_array_layers: count,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&pixels,
)
})
.collect();
super::super::pack_atlas::PackAtlas::material(
&device,
&mut encoder,
&arrays,
split,
layers,
)
.unwrap()
})
.collect();
queue.submit([encoder.finish()]);
let pack = horizon_shaders::Pack::open(&root).unwrap();
let macros = horizon_shaders::Macros::standard();
// Encoded zero alpha is data here and must remain observable in readback.
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", &macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
..Default::default()
}),
..Default::default()
};
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(post.terrain.is_some(), "{:?}", post.notes);
let world = World {
sprite_rects: &[],
atlas: Some((atlases[2].view.clone(), atlases[2].size())),
material_maps: Some([atlases[1].view.clone(), atlases[0].view.clone()]),
lightmap: atlases[2].view.clone(),
terrain: Some(Geometry {
buffers: &[],
table: None,
depth_write: true,
atlas: None,
material_maps: true,
ids: None,
cull: false,
}),
block_ids: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: false,
hand: false,
};
let mut post = PackPost::new(
&device,
&queue,
&adapter,
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
)
.expect("the pack runs");
let mut pass = post
.terrain
.take()
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
// Request the existing copy usage for the fixture's colour readback.
post.copied |= 1;
// First draw preserves production Geometry -> Pass scope, so a routing regression fails.
for case in 0..3 {
if case == 1 {
pass.material_maps = false;
}
if case == 2 {
pass.material_maps = true;
post.material_maps = None;
}
let maps_expected = case == 0;
pass.groups.clear();
post.resize(&device, [15, 1]);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Initialize fixture depth"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &post.targets.as_ref().unwrap().depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
{
let mut render = post.begin(&mut encoder, &pass);
render.set_pipeline(pass.render());
render.set_bind_group(0, &group, &[]);
if pass.draw {
post.draws.bind(&mut render, "TERRAIN_SOLID");
}
render.draw(0..3, 0..1);
}
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(256),
..Default::default()
},
},
wgpu::Extent3d {
width: 15,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
for i in 0..15usize {
let layer = (i % 5) as u8;
let expected = if i >= 10 {
[40 + layer, 50, 60, 255]
} else if i >= 5 && (!maps_expected || layer == 2) {
crate::sprites::FLAT_NORMAL
} else if i >= 5 {
[128 + layer, 126, 255, 19 + layer]
} else if !maps_expected || layer == 3 {
crate::sprites::NO_SPECULAR
} else {
[17 + layer, 240, 65, 255]
};
assert_eq!(&data[i * 4..i * 4 + 4], &expected, "case={case}, pixel={i}");
}
eprintln!("Material oracle case={case}: 15 exact RGBA pixels pass");
}
// Fault injection: the same production binding must refuse a filtering sampler.
// If a regression changed both the binding and sampler, the fractional positive
// probes above would instead mix 0.75*240=180 and 0.75*255=191.25, not 240/255.
pass.material_maps = true;
post.material_maps = Some([atlases[1].view.clone(), atlases[0].view.clone()]);
post.nearest_repeat = sampler(&device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat);
pass.groups.clear();
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let _invalid_group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
let error = pollster::block_on(validation.pop())
.expect("production material binding must reject a filtering sampler");
assert!(
error.to_string().to_ascii_lowercase().contains("filtering"),
"{error}"
);
eprintln!("Material oracle negative filtering control: rejected by production binding");
std::fs::remove_dir_all(root).unwrap();
}
File diff suppressed because it is too large. Load diff
@@ -22,7 +22,7 @@ fn hdr_pack(sized: bool, format: &str) -> Post {
add(
&format!("{program}.fsh"),
&format!(
"#version 120\n/* RENDERTARGETS:{buffer} */\n/* const int colortex{buffer}Format={format}; */\nvoid main() {{ gl_FragData[0]=vec4(0,0.5,0,1); }}\n"
"#version 120\n/* RENDERTARGETS:{buffer} */\n/*\nconst int colortex{buffer}Format={format};\n*/\nvoid main() {{ gl_FragData[0]=vec4(0,0.5,0,1); }}\n"
),
);
if sized {
@@ -62,6 +62,16 @@ fn hdr_pack(sized: bool, format: &str) -> Post {
post
}
// Check the fixture contract even when the render tests cannot find an adapter.
#[test]
fn clear_fixture_loads_the_requested_formats_without_a_gpu() {
for sized in [false, true] {
for format in ["RGBA16F", "RGBA8"] {
hdr_pack(sized, format);
}
}
}
fn hdr_clear_case(sized: bool, attachment_limit: u32, byte_budget: u32, format: &str) {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
@@ -0,0 +1,111 @@
//! A buffer that isn't cleared, in passes that leave pixels alone or blend: what is under them.
//! The expected values are Iris 1.11.7's, measured with probe packs of our own (#9009).
use super::*;
/// colortex6 is written three times a frame, the first write leaving the right half alone:
/// there it reads the previous frame's last write, 0.125 (Iris 1.11.7), not the 0.25 written into
/// that texture before. colortex8 is written twice a frame the same way: its right half was never
/// written and reads 0 (Iris 1.11.7). colortex10 is only blended onto (`ONE ONE`), 0.125 a frame:
/// it grows by 0.125 every frame (Iris 1.11.7 adds once per frame).
#[test]
fn a_buffer_that_is_not_cleared_starts_the_frame_with_its_last_write() {
let Some(gpu) = Fixture::new() else { return };
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let fill = |v: f32| format!("vec4({v},{v},{v},1)");
let programs = [
(
"composite",
format!(
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:6 */\n\
/*\nconst int colortex6Format=RGBA16F;\nconst int colortex7Format=RGBA16F;\n\
const int colortex8Format=RGBA16F;\nconst int colortex9Format=RGBA16F;\n\
const int colortex10Format=RGBA16F;\nconst bool colortex6Clear=false;\n\
const bool colortex8Clear=false;\nconst bool colortex10Clear=false;\n*/\n\
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
fill(0.5)
),
),
(
"composite1",
format!(
"#version 120\nuniform sampler2D colortex6;\nvarying vec2 uv;\n/* RENDERTARGETS:7,6 */\n\
void main() {{ gl_FragData[0]=texture2D(colortex6,uv); gl_FragData[1]={}; }}\n",
fill(0.25)
),
),
(
"composite2",
format!(
"#version 120\n/* RENDERTARGETS:6 */\nvoid main() {{ gl_FragData[0]={}; }}\n",
fill(0.125)
),
),
(
"composite3",
format!(
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:8 */\n\
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
fill(0.5)
),
),
(
"composite4",
format!(
"#version 120\nuniform sampler2D colortex8;\nvarying vec2 uv;\n/* RENDERTARGETS:9,8 */\n\
void main() {{ gl_FragData[0]=texture2D(colortex8,uv); gl_FragData[1]={}; }}\n",
fill(0.125)
),
),
(
"composite5",
"#version 120\n/* RENDERTARGETS:10 */\nvoid main() { gl_FragData[0]=vec4(0.125,0.125,0.125,0); }\n"
.to_owned(),
),
(
"final",
"#version 120\nuniform sampler2D colortex7;\nuniform sampler2D colortex9;\n\
uniform sampler2D colortex10;\nvarying vec2 uv;\n\
void main() { gl_FragColor=vec4(texture2D(colortex7,uv).r,texture2D(colortex9,uv).r,texture2D(colortex10,uv).r,1); }\n"
.to_owned(),
),
];
for (program, fragment) in &programs {
add(&format!("{program}.vsh"), VERTEX);
add(&format!("{program}.fsh"), fragment);
}
add("shaders.properties", "blend.composite5=ONE ONE ONE ONE\n");
let pack = horizon_shaders::Pack::from_zip_bytes(
"clear-false-history",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
let mut post = gpu.build(post);
for frame in 0..4u8 {
let actual = gpu.render(&mut post, [16, 16]);
let sum = 32 * (frame + 1);
let wrong: Vec<_> = (actual.iter().enumerate())
.filter(|(n, p)| {
let expected = match (n % 16 < 8, frame) {
(true, _) => [128, 128, sum, 255],
(false, 0) => [0, 0, sum, 255],
(false, _) => [32, 0, sum, 255],
};
p.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1)
})
.map(|(n, p)| (n, *p))
.take(4)
.collect();
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
@@ -0,0 +1,70 @@
//! A texel offset given by a `const` local reads the texel that far away, as a literal offset
//! does (GLSL 4.60, 4.3.3 and 8.9.2, as #8462 quotes them). #8462's probe.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
// Each texel of colortex1 holds its own column and row, in eighths.
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 1 */\n\
void main() { gl_FragData[0] = vec4(floor(gl_FragCoord.xy) / 8.0, 0.0, 1.0); }\n",
);
add("final.vsh", VERTEX);
// Red and green: what the texel two columns right and one row down holds; blue: this row.
add(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex1;\nin vec2 uv;\n\
vec4 f(vec2 p) { const ivec2 offset = ivec2(2, -1); return textureLodOffset(colortex1, p, 0.0, offset); }\n\
void main() { gl_FragColor = vec4(f(uv).rg, floor(gl_FragCoord.y) / 8.0, 1.0); }\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-const-offset",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
post
}
#[test]
fn a_const_local_offset_reads_the_offset_texel() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [8, 8]);
let eighth = |n: u8| (f32::from(n) * 255.0 / 8.0).round() as u8;
let mut checked = 0;
for (n, p) in pixels.iter().enumerate() {
let x = (n % 8) as u8;
// The bottom row (GL's row 0) and the two right columns read past the edge.
if x >= 6 || p[2] == 0 {
continue;
}
let row = (0..8)
.find(|&r| eighth(r).abs_diff(p[2]) <= 1)
.expect("row in blue");
let want = [eighth(x + 2), eighth(row - 1)];
assert!(
p[0].abs_diff(want[0]) <= 1 && p[1].abs_diff(want[1]) <= 1,
"pixel {n} (column {x}, GL row {row}): {p:?}, expected red and green {want:?}"
);
checked += 1;
}
assert_eq!(checked, 6 * 7);
}
@@ -83,7 +83,8 @@ impl Gpu {
post,
T::Rgba8Unorm,
world,
);
)
.expect("the pack runs");
assert!(
built.passes.iter().any(|p| p.group == Group::Final),
"{:?}",
@@ -315,23 +316,26 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
shaders(sampler, stage, scene),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: terrain.then_some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
@@ -591,6 +595,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -614,9 +619,11 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -635,3 +642,144 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
post_effects: crate::post_chain::Request::none(),
}
}
/// shadowtex0 and shadowtex1 read through a plain sampler a quarter texel past a texel's centre,
/// over a shadow map whose depth rises a sixteenth a texel: red and green are how far each
/// moved toward the next texel, times four (0 nearest, 255 linear). `nearest` is a line for
/// both stage files.
fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
let gpu = Gpu::new()?;
if !gpu
.device
.features()
.contains(wgpu::Features::FLOAT32_FILTERABLE)
{
eprintln!("DEPTH_FIXTURE_NO_FLOAT32_FILTERABLE");
return None;
}
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
attributes: vec![],
after: String::new(),
alpha_test: None,
item_id_declared: false,
}),
..Default::default()
};
let settings = format!("const int shadowMapResolution = 16;\n{nearest}\n");
let files = vec![
("final.vsh".to_string(), VERTEX.to_string()),
(
"final.fsh".into(),
format!(
"#version 330 compatibility\n{settings}\
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n\
float moved(sampler2D s, ivec2 t) {{\n\
float a = texelFetch(s, t, 0).r;\n\
return (texture(s, (vec2(t) + vec2(0.75, 0.5)) / 16.0).r - a) * 16.0;\n\
}}\n\
void main() {{\n\
ivec2 t = ivec2(int(gl_FragCoord.x) % 15, 8);\n\
gl_FragColor = vec4(moved(shadowtex0, t) * 4.0, moved(shadowtex1, t) * 4.0, 0.0, 1.0);\n\
}}\n"
),
),
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
(
"shadow.vsh".into(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}".into(),
),
(
"shadow.fsh".into(),
format!("#version 120\n{settings}void main(){{gl_FragData[0]=vec4(1.0);}}"),
),
];
let mut post = gpu.load(
files,
World {
sprite_rects: &[],
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: Some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: false,
material_maps: false,
}),
block_ids: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
inputs,
);
assert!(post.has_shadow(), "{:?}", post.notes);
let positions: [[f32; 3]; 6] = [
[-1., -1., 0.],
[1., -1., 0.],
[1., 1., 0.],
[-1., -1., 0.],
[1., 1., 0.],
[-1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original shadow depth ramp"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
let hud = HudBatch::new(SIZE);
let frame = fixture_frame(&hud);
Some(
gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
if list == TerrainList::ShadowSolid {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..6, 0..1);
}
}),
)
}
#[test]
fn shadow_depth_is_read_linearly_through_a_plain_sampler() {
// Iris 1.11.7 filters both (probe packs of our own; the PR has the readings).
if let Some(pixels) = shadow_depth_filtering("") {
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex linear");
}
}
#[test]
fn shadowtex_nearest_settings_read_the_nearest_texel() {
for (line, expected) in [
("const bool shadowtex0Nearest = true;", [0, 255, 0, 255]),
("const bool shadowtexNearest = true;", [0, 255, 0, 255]),
("const bool shadow0MinMagNearest = true;", [0, 255, 0, 255]),
("const bool shadowtex1Nearest = true;", [255, 0, 0, 255]),
("const bool shadow1MinMagNearest = true;", [255, 0, 0, 255]),
] {
if let Some(pixels) = shadow_depth_filtering(line) {
all_pixels(&pixels, expected, line);
}
}
}
@@ -199,25 +199,7 @@ impl Fixture {
}
pub(super) fn build(&self, post: Post) -> PackPost {
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
let post = PackPost::new(
&self.device,
&self.queue,
&self.adapter,
post,
T::Rgba8Unorm,
World {
atlas: None,
lightmap,
terrain: None,
block_ids: &[],
celestial: None,
gui_size: [16, 16],
clouds: None,
weather: false,
hand: false,
},
);
let post = self.try_build(post).expect("the pack runs");
assert!(
post.notes.iter().all(|n| n.starts_with("shadow map ")),
"{:?}",
@@ -226,7 +208,56 @@ impl Fixture {
post
}
/// [`Self::build`] for a pack that may be refused.
pub(super) fn try_build(&self, post: Post) -> Result<PackPost, Refused> {
self.try_build_with_terrain(post, None)
}
/// `build`, with the game's terrain geometry handed to the pack.
pub(super) fn build_with_terrain(&self, post: Post, terrain: Option<Geometry<'_>>) -> PackPost {
self.try_build_with_terrain(post, terrain)
.expect("the pack runs")
}
fn try_build_with_terrain(
&self,
post: Post,
terrain: Option<Geometry<'_>>,
) -> Result<PackPost, Refused> {
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
PackPost::new(
&self.device,
&self.queue,
&self.adapter,
post,
T::Rgba8Unorm,
World {
atlas: None,
material_maps: None,
lightmap,
terrain,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: [16, 16],
clouds: None,
weather: false,
hand: false,
},
)
}
pub(super) fn render(&self, post: &mut PackPost, size: [u32; 2]) -> Vec<[u8; 4]> {
self.render_with_terrain(post, size, &mut |_, _, _, _| {})
}
/// `render`, with `terrain` drawing the game's terrain lists.
pub(super) fn render_with_terrain(
&self,
post: &mut PackPost,
size: [u32; 2],
terrain: &mut TerrainDraw<'_>,
) -> Vec<[u8; 4]> {
let world = self.texture(size, T::Rgba8Unorm);
let depth = self.texture(size, T::Depth32Float);
let screen = self.texture(size, T::Rgba8Unorm);
@@ -251,7 +282,7 @@ impl Fixture {
size,
size,
&[],
&mut |_, _, _, _| {},
terrain,
);
encoder.copy_texture_to_buffer(
screen.texture().as_image_copy(),
@@ -346,8 +377,8 @@ fn frame_clear_and_retained_history_apply_before_partial_post_writes() {
let Some(gpu) = Fixture::new() else { return };
for clear in [true, false] {
let mut post = gpu.build(pack(Coverage::Full, false, false, Some(clear), false));
// Every frame changes which physical image is main. The other image is fully
// seeded red before the partial write, independent of its initial clearing.
// The other image is fully seeded red before the partial write, independent of its
// initial clearing.
for frame in 0..3 {
let actual = gpu.render(&mut post, [16, 16]);
assert!(
@@ -407,6 +438,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -430,9 +462,11 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -521,3 +555,6 @@ fn a_cleared_buffer_starts_every_frame_cleared_on_both_textures() {
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
#[path = "clear_false_tests.rs"]
mod clear_false_tests;
@@ -0,0 +1,73 @@
//! `flip.<program>.<buffer> = true` on a buffer the program writes (#9010). The expected values
//! are Iris 1.11.7's, measured with probe packs of our own.
use super::destination_tests::Fixture;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// `composite` writes 0.25 to colortex2 and colortex4 and forces both to flip: the forced flip
/// cancels the write's own, so `composite1` samples colortex2's cleared side (Iris 1.11.7: 0),
/// and `composite2`, blended `ONE ONE`, adds 0.5 onto colortex4's 0.25 (Iris 1.11.7: 0.749).
#[test]
fn a_forced_flip_of_a_written_buffer_leaves_it_unflipped() {
let Some(gpu) = Fixture::new() else { return };
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
for (program, fragment) in [
(
"composite",
"#version 120\n/* RENDERTARGETS:2,4 */\n\
void main() { gl_FragData[0]=vec4(0.25,0.25,0.25,1); gl_FragData[1]=vec4(0.25,0.25,0.25,1); }\n",
),
(
"composite1",
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\n/* RENDERTARGETS:3 */\n\
void main() { gl_FragData[0]=texture2D(colortex2,uv); }\n",
),
(
"composite2",
"#version 120\n/* RENDERTARGETS:4 */\nvoid main() { gl_FragData[0]=vec4(0.5,0.5,0.5,0); }\n",
),
(
"final",
"#version 120\nuniform sampler2D colortex3;\nuniform sampler2D colortex4;\nvarying vec2 uv;\n\
void main() { gl_FragColor=vec4(texture2D(colortex4,uv).r,texture2D(colortex3,uv).r,0,1); }\n",
),
] {
add(&format!("{program}.vsh"), VERTEX);
add(&format!("{program}.fsh"), fragment);
}
add(
"shaders.properties",
"flip.composite.colortex2=true\nflip.composite.colortex4=true\nblend.composite2=ONE ONE ONE ONE\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"forced-flip-of-a-written-buffer",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
let mut post = gpu.build(post);
for frame in 0..2 {
let actual = gpu.render(&mut post, [16, 16]);
let wrong: Vec<_> = (actual.iter().enumerate())
.filter(|(_, p)| {
p.iter()
.zip([191, 0, 0, 255])
.any(|(a, b)| a.abs_diff(b) > 1)
})
.map(|(n, p)| (n, *p))
.take(4)
.collect();
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
@@ -0,0 +1,89 @@
//! A pack may give colortex0 an integer format (OptiFine's `shaders.txt`, "Texture Formats",
//! lists `RGBA32UI`, as #8459 quotes it): the pack still loads, and reads back what it wrote
//! there. #8459's probe; #8459 reports Iris 1.11.7 drawing (0, 200, 0) at every point
//! sampled from it. Without a `final` that draws it, the pack is refused (#9165).
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// A `final` that shows the integer colortex0.
const FINAL: &str = "#version 330 compatibility\nuniform usampler2D colortex0;\n\
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n";
/// A `final` that doesn't compile (it calls a function nobody declares), so it is skipped.
const BROKEN_FINAL: &str =
"#version 330 compatibility\nvoid main() { gl_FragColor = undeclared_function(); }\n";
/// `depth`: the composite reads depthtex0, so the blit copies the world's depth; `last`: the
/// pack's `final`, if it has one.
fn pack(depth: bool, last: Option<&str>) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
// With depthtex0: green where it holds the empty world's far plane (1), red elsewhere.
let value = if depth {
"texelFetch(depthtex0, ivec2(gl_FragCoord.xy), 0).r == 1.0 ? uvec4(0u, 200u, 0u, 255u) : uvec4(200u, 0u, 0u, 255u)"
} else {
"uvec4(0u, 200u, 0u, 255u)"
};
add(
"composite.fsh",
&format!(
"#version 330 compatibility\n/* RENDERTARGETS: 0 */\n/*\nconst int colortex0Format = RGBA32UI;\n*/\n\
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
),
);
if let Some(source) = last {
add("final.vsh", VERTEX);
add("final.fsh", source);
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-integer-colortex0",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn an_integer_colortex0_keeps_what_the_pack_wrote() {
let Some(gpu) = Fixture::new() else { return };
for depth in [false, true] {
let mut post = gpu.build(pack(depth, Some(FINAL)));
// Twice: the second frame's world blit runs over a colortex0 the first frame wrote.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [8, 8]);
let wrong: Vec<_> = pixels.iter().filter(|p| **p != [0, 200, 0, 255]).collect();
assert!(wrong.is_empty(), "depth {depth}, frame {frame}: {wrong:?}");
}
}
}
/// Without a `final` left to draw it (none, or one that is skipped), an integer colortex0 has
/// no colours the screen can show: the pack is refused, so the game turns it off and draws
/// without it instead of a black screen (#9165). The refusal keeps why `final` was skipped.
#[test]
fn an_integer_colortex0_without_a_working_final_is_refused() {
let Some(gpu) = Fixture::new() else { return };
for last in [None, Some(BROKEN_FINAL)] {
let Err(refused) = gpu.try_build(pack(false, last)) else {
panic!("{last:?}: a pack that can't show colortex0 runs");
};
assert_eq!(
refused.notes.iter().any(|n| n.starts_with("final")),
last.is_some(),
"{refused:?}"
);
}
}
@@ -0,0 +1,205 @@
//! The small packed colortex formats `RGB5_A1`, `RGB565` and `R3_G3_B2`, read back as in Iris
//! 1.11.7. wgpu has no such formats, so these buffers are 8-bit (`texture_format`); what a pass
//! writes into one and its clear colour go through [`quantise`] first.
//!
//! Probe pack p8106c of our own wrote 600 values to each format and read them back with
//! `texelFetch` in Iris 1.11.7 (NVIDIA 615.78.08). Our probe's Iris values fit this, with no
//! exception: each channel becomes the byte an `RGBA8` buffer stores for it, keeps that byte's
//! top bits (5, 5, 5 and 1 for `RGB5_A1`; 5, 6 and 5 for `RGB565` and also for `R3_G3_B2`), and
//! reads back those bits repeated to fill 8 (`0.3337` → byte 85 → 10 of 31 → 82 / 255). This is
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
use horizon_shaders::{Format, Kind};
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
/// 8; a three-channel format's alpha keeps 8 (it reads 1 anyway).
pub(super) fn bits(f: Format) -> Option<[u8; 4]> {
match (f.kind, f.channels, f.bits) {
(Kind::Unorm, 4, 5) => Some([5, 5, 5, 1]),
// `RGB565`, and `R3_G3_B2` (3 × 3 bits in `config.rs`), which our probe's Iris values
// put in 5, 6 and 5 bits too.
(Kind::Unorm, 3, 6 | 3) => Some([5, 6, 5, 8]),
_ => None,
}
}
/// The value a buffer keeping `bits` per channel reads back after `v` is written or cleared.
pub(super) fn quantise(v: [f32; 4], bits: [u8; 4]) -> [f32; 4] {
std::array::from_fn(|i| {
let byte = (v[i].clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u32;
let n = u32::from(bits[i]);
let kept = byte >> (8 - n);
// The kept bits, repeated until there are 8.
let mut wide = 0;
let mut have = 0;
while have < 8 {
wide = wide << n | kept;
have += n;
}
(wide >> (have - 8)) as f32 / 255.0
})
}
/// The factors of the shader's [`quantise`] for a channel keeping `n` bits: the kept value is
/// `floor(byte / step)`, and it reads back `kept × step + floor(kept × repeat)`.
fn factors(n: u8) -> (f32, f32) {
let step = f32::from(1u16 << (8 - n));
// `kept × repeat` is the kept bits shifted down to fill what `kept × step` leaves: one bit
// fills the other 7 (× 127); 5 or 6 bits fill 3 or 2 with their top bits.
let repeat = match n {
1 => 127.0,
_ => 2f32.powi(8 - 2 * i32::from(n)),
};
(step, repeat)
}
/// Makes the fragment entry point of `module` write each output location of `outputs` as
/// [`quantise`] gives it for its bits. The translator's entry point ends in one `return` of its
/// outputs; a module without one is left as it is.
pub(super) fn quantise_outputs(module: &mut naga::Module, outputs: &[(u32, [u8; 4])]) {
let Some(ep) =
(module.entry_points.iter_mut()).find(|e| e.stage == naga::ShaderStage::Fragment)
else {
return;
};
let Some(result) = &ep.function.result else {
return;
};
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
return;
};
let vec4 = |ty: naga::Handle<naga::Type>| {
matches!(
module.types[ty].inner,
naga::TypeInner::Vector {
size: VectorSize::Quad,
scalar: naga::Scalar::F32
}
)
};
// (member, bits, the member's type) of each output to change.
let changed: Vec<(usize, [u8; 4], naga::Handle<naga::Type>)> = (members.iter().enumerate())
.filter_map(|(i, m)| {
let Some(naga::Binding::Location { location, .. }) = m.binding else {
return None;
};
let bits = outputs.iter().find(|o| o.0 == location)?.1;
vec4(m.ty).then_some((i, bits, m.ty))
})
.collect();
if changed.is_empty() {
return;
}
let struct_ty = result.ty;
let f = &mut ep.function;
let body = std::mem::take(&mut f.body);
let mut out = naga::Block::with_capacity(body.len() + 1);
for (statement, span) in body.span_into_iter() {
let Statement::Return { value: Some(value) } = statement else {
out.push(statement, span);
continue;
};
let Expression::Compose { components, .. } = &f.expressions[value] else {
out.push(statement, span);
continue;
};
let mut components = components.clone();
let e = &mut f.expressions;
let lit = |e: &mut naga::Arena<Expression>, v: f32| {
e.append(Expression::Literal(Literal::F32(v)), span)
};
// Literals need no emitting: they come before the range that is emitted.
let scalars: Vec<_> = (changed.iter())
.map(|&(_, bits, _)| {
let per = bits.map(factors);
(
per.map(|(step, _)| lit(e, step)),
per.map(|(step, _)| lit(e, 1.0 / step)),
per.map(|(_, repeat)| lit(e, repeat)),
)
})
.collect();
let (zero, one, max, half) = (lit(e, 0.0), lit(e, 1.0), lit(e, 255.0), lit(e, 0.5));
let mut add = |x: Expression| e.append(x, span);
let splat = |add: &mut dyn FnMut(Expression) -> _, value| {
add(Expression::Splat {
size: VectorSize::Quad,
value,
})
};
let (zero, one, max, half) = (
splat(&mut add, zero),
splat(&mut add, one),
splat(&mut add, max),
splat(&mut add, half),
);
let binary = |add: &mut dyn FnMut(Expression) -> _, op, left, right| {
add(Expression::Binary { op, left, right })
};
let math = |add: &mut dyn FnMut(Expression) -> _, fun, arg, arg1, arg2| {
add(Expression::Math {
fun,
arg,
arg1,
arg2,
arg3: None,
})
};
for (&(member, _, ty), (steps, inverse, repeats)) in changed.iter().zip(scalars) {
let mut vector = |components: [_; 4]| {
add(Expression::Compose {
ty,
components: components.to_vec(),
})
};
let (step, inverse, repeat) = (vector(steps), vector(inverse), vector(repeats));
let x = components[member];
let clamped = math(&mut add, MathFunction::Clamp, x, Some(zero), Some(one));
let scaled = binary(&mut add, BinaryOperator::Multiply, clamped, max);
let rounded = binary(&mut add, BinaryOperator::Add, scaled, half);
let byte = math(&mut add, MathFunction::Floor, rounded, None, None);
let shifted = binary(&mut add, BinaryOperator::Multiply, byte, inverse);
let kept = math(&mut add, MathFunction::Floor, shifted, None, None);
let high = binary(&mut add, BinaryOperator::Multiply, kept, step);
let low = binary(&mut add, BinaryOperator::Multiply, kept, repeat);
let low = math(&mut add, MathFunction::Floor, low, None, None);
let wide = binary(&mut add, BinaryOperator::Add, high, low);
components[member] = binary(&mut add, BinaryOperator::Divide, wide, max);
}
let value = add(Expression::Compose {
ty: struct_ty,
components,
});
// Everything from the first splat on is new and not a literal.
let range = naga::Range::new_from_bounds(zero, value);
out.push(Statement::Emit(range), span);
out.push(Statement::Return { value: Some(value) }, span);
}
f.body = out;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clear_colours_keep_the_same_bits() {
// The CPU side, for clear colours. Iris 1.11.7 read p8106d's clear colour back as below in
// RGB5_A1 and RGB565, and p8106c's 0.3337 as 82/255 in the 5-bit channels and 85/255 in
// the 6-bit one.
let to_255 = |v: [f32; 4]| v.map(|c| (c * 255.0 * 1000.0).round() / 1000.0);
let rgb5_a1 = bits(Format::parse("RGB5_A1").unwrap()).unwrap();
let rgb565 = bits(Format::parse("RGB565").unwrap()).unwrap();
let r3_g3_b2 = bits(Format::parse("R3_G3_B2").unwrap()).unwrap();
let clear = [0.3337, 0.1, 0.6, 0.3];
assert_eq!(to_255(quantise(clear, rgb5_a1)), [82.0, 24.0, 156.0, 0.0]);
assert_eq!(to_255(quantise(clear, rgb565))[..3], [82.0, 24.0, 156.0]);
assert_eq!(
to_255(quantise([0.3337; 4], r3_g3_b2))[..3],
[82.0, 85.0, 82.0]
);
assert!(bits(Format::parse("RGBA4").unwrap()).is_none());
}
}
@@ -0,0 +1,95 @@
//! `RGB5_A1`, `RGB565` and `R3_G3_B2` buffers read back as in Iris 1.11.7. Expected values are
//! those probe packs p8106c (written values) and p8106d (clear colours) read back in Iris (the PR
//! has them); the programs here are the probes', cut down. Written values are ones whose byte
//! (× 255) is not near a half: GPUs may round those either way.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 1,2,3 */\n\
/*\nconst int colortex1Format = RGB5_A1;\nconst int colortex2Format = RGB565;\n\
const int colortex3Format = R3_G3_B2;\nconst int colortex4Format = RGB5_A1;\n\
const int colortex5Format = RGB565;\n*/\n\
const vec4 colortex4ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
const vec4 colortex5ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
void main() {\n\
gl_FragData[0] = vec4(175.0, 176.0, 766.5, 256.0) / 511.0;\n\
gl_FragData[1] = vec4(400.0, 175.0, 44.0, 0.0) / 511.0;\n\
gl_FragData[2] = vec4(-255.5, 176.0, 300.0, 0.0) / 511.0;\n}\n",
);
add("final.vsh", VERTEX);
// Pixel column x shows component x % 4 of buffer 1 + x / 4, as the bytes of its bits.
add(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex1;\nuniform sampler2D colortex2;\n\
uniform sampler2D colortex3;\nuniform sampler2D colortex4;\nuniform sampler2D colortex5;\n\
void main() {\n ivec2 p = ivec2(gl_FragCoord.xy);\n vec4 c = vec4(0.0);\n\
switch (p.x / 4) {\n\
case 0: c = texelFetch(colortex1, p, 0); break;\n\
case 1: c = texelFetch(colortex2, p, 0); break;\n\
case 2: c = texelFetch(colortex3, p, 0); break;\n\
case 3: c = texelFetch(colortex4, p, 0); break;\n\
case 4: c = texelFetch(colortex5, p, 0); break;\n\
}\n uint u = floatBitsToUint(c[p.x % 4]);\n\
gl_FragColor = vec4(uvec4(u >> 24u, u >> 16u, u >> 8u, u) & 255u) / 255.0;\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-packed",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
post
}
#[test]
fn small_packed_buffers_keep_their_bits() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
// Twice: the second frame writes the buffers' other sides.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [20, 1]);
let read: Vec<f32> = pixels
.iter()
.map(|p| f32::from_bits(u32::from_be_bytes(*p)))
.collect();
// Iris 1.11.7, in 255ths: RGB5_A1 written (175, 176, 766.5, 256) / 511; RGB565 written
// (400, 175, 44, 0) / 511; R3_G3_B2 written (-255.5, 176, 300, 0) / 511; RGB5_A1 and
// RGB565 cleared to (0.3337, 0.1, 0.6, 0.3). Three-channel formats read alpha 1.
let expected: [f32; 20] = [
82.0, 90.0, 255.0, 255.0, //
206.0, 85.0, 16.0, 255.0, //
0.0, 89.0, 148.0, 255.0, //
82.0, 24.0, 156.0, 0.0, //
82.0, 24.0, 156.0, 255.0,
];
for (n, (got, want)) in read.iter().zip(expected).enumerate() {
assert!(
(got - want / 255.0).abs() <= 1e-6,
"frame {frame}, buffer {} component {}: {}/255, expected {want}/255; all {:?}",
1 + n / 4,
n % 4,
got * 255.0,
read.iter().map(|v| v * 255.0).collect::<Vec<_>>()
);
}
}
}
@@ -0,0 +1,129 @@
//! The shadow pass draws terrain faces from both sides, as Iris 1.11.7 does (#9033: a probe pack
//! of ours writing `gl_FrontFacing` into `shadowcolor0` found back faces in Iris's shadow map and
//! none in Horizon's). The programs here are original.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
use wgpu::util::DeviceExt;
const FINAL_VSH: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
// The terrain's positions are already in clip space.
add(
"shadow.vsh",
"#version 120\nvoid main() { gl_Position=vec4(gl_Vertex.xy,0.5,1.0); }\n",
);
// Red where a front face was drawn, blue where a back face was.
add(
"shadow.fsh",
"#version 120\nvoid main() {\n\
gl_FragData[0]=gl_FrontFacing ? vec4(1.0,0.0,0.0,1.0) : vec4(0.0,0.0,1.0,1.0);\n}\n",
);
add("final.vsh", FINAL_VSH);
add(
"final.fsh",
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-shadow-faces",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
attributes: vec![],
after: String::new(),
alpha_test: None,
}),
..Default::default()
};
horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs)
}
#[test]
fn shadow_pass_draws_terrain_back_faces() {
let Some(gpu) = Fixture::new() else { return };
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
// Culled as the game's own terrain is (`Renderer` hands packs `cull: true`).
let mut post = gpu.build_with_terrain(
pack(),
Some(Geometry {
material_maps: false,
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: true,
}),
);
assert!(post.has_shadow(), "{:?}", post.notes);
// GL clip space: the left half counter-clockwise (a front face), the right half
// clockwise (a back face).
let positions: [[f32; 3]; 12] = [
[-1., -1., 0.],
[0., -1., 0.],
[0., 1., 0.],
[-1., -1., 0.],
[0., 1., 0.],
[-1., 1., 0.],
[0., -1., 0.],
[1., 1., 0.],
[1., -1., 0.],
[0., -1., 0.],
[0., 1., 0.],
[1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original shadow faces geometry"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
let size = [8, 8];
let pixels = gpu.render_with_terrain(&mut post, size, &mut |rp, list, _, _| {
if list == TerrainList::ShadowSolid {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..12, 0..1);
}
});
let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1);
for (i, got) in pixels.iter().enumerate() {
let x = i as u32 % size[0];
let want = if x < size[0] / 2 {
[255, 0, 0, 255]
} else {
[0, 0, 255, 255]
};
assert!(
near(*got, want),
"pixel ({x}, {}): {got:?}, expected {want:?} (red: front face, blue: back face)",
i as u32 / size[0]
);
}
}
Loaded 100 of 1121 files, more files were not shown because too many files have changed in this diff. Show more