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 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 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 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
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 63219718ff Merge remote-tracking branch 'origin/main' into cg38/9310
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-04 18:51:19 +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
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 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 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 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 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 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
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
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 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
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 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
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
yosifdheef 5d9886fb34 Merge remote-tracking branch 'upstream/main' into fix/7992-shulker-open-support 2026-11-04 18:54:46 +03: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
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
abit-foggyandCodex 0b9a5a478c feat(studio): add mcpr recording archive container
Co-Authored-By: Codex <[email protected]>
2026-11-04 05:10:24 -10: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 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
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
voidsipandCodex 4b8643a18f Let Breezes deflect fishing hooks
Co-Authored-By: Codex <[email protected]>
2026-11-04 08:44:55 -06: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
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
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
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
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
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
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
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
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 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 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
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 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
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
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
utophii fefde42033 feat: add musl linux release build 2026-11-03 20:32:47 +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
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
560 changed files with 37981 additions and 2649 deletions

No files matched your search

+1 -1
View File
@@ -158,7 +158,7 @@ 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
+23 -6
View File
@@ -83,10 +83,10 @@ jobs:
build:
needs: exporters
name: build ${{ matrix.name }}
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
# 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
# ARM64 files out with a warning.
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
# optional archives out with a warning.
continue-on-error: ${{ matrix.name == 'windows-arm64' || matrix.name == 'linux-musl' }}
strategy:
fail-fast: false
matrix:
@@ -98,6 +98,11 @@ 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
@@ -155,17 +160,19 @@ 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; }
@@ -193,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: |
@@ -208,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' && matrix.pe_arch != 'arm64'
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
@@ -270,7 +279,15 @@ jobs:
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 }}"
+10 -1
View File
@@ -174,8 +174,10 @@ jobs:
# 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[@]}" \
"${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@v7
@@ -241,11 +243,18 @@ jobs:
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)" \
+1 -1
View File
@@ -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).
@@ -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")
})
+192 -3
View File
@@ -388,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
@@ -413,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);
}
}
}
@@ -875,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:?}");
}
}
@@ -194,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(
@@ -826,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
+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}"),
+52 -1
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 {
@@ -454,6 +475,10 @@ pub struct PadSettings {
/// holding it, unless vanilla's toggle crouch is on already.
pub toggle_sneak: bool,
pub glyphs: GlyphStyle,
/// 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,
@@ -478,6 +503,7 @@ impl Default for PadSettings {
trigger_threshold: 0.4,
toggle_sneak: true,
glyphs: GlyphStyle::Auto,
nintendo_letters: cfg!(target_os = "horizon"),
keyboard: TextKeyboard::Auto,
pointer_speed: 1.0,
scroll_speed: 12.0,
@@ -545,6 +571,7 @@ impl PadSettings {
}
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
"glyphs" => s.glyphs = GlyphStyle::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),
@@ -568,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:{}\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),
@@ -581,6 +608,7 @@ impl PadSettings {
self.trigger_threshold,
bool_text(self.toggle_sneak),
self.glyphs.name(),
bool_text(self.nintendo_letters),
self.keyboard.name(),
);
text.push_str(&format!(
@@ -1391,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"));
@@ -1437,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]);
@@ -355,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: &[],
@@ -377,6 +378,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
@@ -352,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");
+216 -14
View File
@@ -208,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,
@@ -224,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>,
@@ -693,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();
@@ -784,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;
@@ -795,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() {
@@ -1262,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();
@@ -1301,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,
@@ -1311,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;
@@ -1451,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"));
+4
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 =
@@ -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());
@@ -696,11 +696,19 @@ 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 g = (scene.glyph)(ch);
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).
@@ -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();
+4 -3
View File
@@ -608,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>>>,
@@ -617,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/")?;
@@ -625,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).
@@ -656,6 +656,7 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"damage_type",
"data_maps",
"dialog",
"function",
+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
+1 -1
View File
@@ -46,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)
+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,
+37 -5
View File
@@ -209,6 +209,7 @@ mod view;
mod visbuffer;
mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -883,6 +884,8 @@ pub struct Frame<'a> {
pub outline_after_terrain: bool,
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
pub outline_high_contrast: bool,
/// The PvP & HUD page's outline and hit colour (`pvp::Frame`; default: vanilla's).
pub pvp: crate::pvp::Frame,
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
/// over everything.
@@ -933,6 +936,9 @@ pub struct Frame<'a> {
pub additive: &'a [Vertex],
/// Name tags over everything, without depth (`TEXT_SEE_THROUGH`).
pub see_through: &'a [Vertex],
/// Native mods' lines, boxes, beams and text (`native_world_primitives`), drawn at the end
/// of the world pass, before the first-person hand.
pub world_primitives: world_primitives::Batch<'a>,
/// Opaque entity quads only a native pack's shadow pass draws (the local player in first
/// person).
pub shadow_only: &'a [Vertex],
@@ -1158,6 +1164,8 @@ pub struct Renderer {
lightning_pipeline: wgpu::RenderPipeline,
lightning: lightning::Renderer,
see_through_pipeline: wgpu::RenderPipeline,
/// Native mods' world primitives (`Frame::world_primitives`).
world_primitives: world_primitives::WorldPrimitives,
text_polygon_offset_pipeline: wgpu::RenderPipeline,
celestial_pipeline: wgpu::RenderPipeline,
celestial_buffer: wgpu::Buffer,
@@ -4455,6 +4463,7 @@ impl Renderer {
on_off(gpu_timer.is_some()),
));
}
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
Ok(Self {
display: DisplayWatch::default(),
display_fresh: false,
@@ -4542,6 +4551,7 @@ impl Renderer {
lightning_pipeline,
lightning: lightning::Renderer::new(),
see_through_pipeline,
world_primitives,
text_polygon_offset_pipeline,
celestial_pipeline,
celestial_buffer,
@@ -7252,8 +7262,10 @@ impl Renderer {
},
colored_count: [
frame.colored_lights.len().min(crate::dynamic_light::MAX) as u32,
0,
0,
// PvP & HUD's hit colour: entity.wgsl's hurt texel, 0 for vanilla's.
frame.pvp.hurt_texel,
// PvP & HUD's glint colour: entity.wgsl's `tinted_glint`, 0 for vanilla's.
frame.pvp.glint,
0,
],
emissive_layers: {
@@ -8118,6 +8130,19 @@ impl Renderer {
frame.weather,
frame.weather_time,
);
// Native mods' world primitives, drawn as the world is: its jittered projection, the
// line widths in the frame's pixels as the gizmo lines'.
let world_primitives = !frame.title && {
let (uploads, encoder) = (&mut self.uploads, &mut encoder);
self.world_primitives.prepare(
&self.device,
&mut |buffer, bytes| uploads.buffer(encoder, buffer, 0, bytes),
&frame.world_primitives,
jittered,
[self.config.width, self.config.height],
&self.entity_atlas,
)
};
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
if outline_edges > 0 {
// The same per-line colour and pixel width layout as the gizmo lines. A high
@@ -8133,12 +8158,14 @@ impl Renderer {
line
})
};
// A custom outline (PvP & HUD) takes the bright stroke's colour, scales both widths.
let (custom, scale) = frame.pvp.outline.map_or((None, 1.0), |(c, s)| (Some(c), s));
let lines: Vec<[f32; 8]> = if frame.outline_high_contrast {
stroke(0xff00_0000, 7.0)
.chain(stroke(0xff57_ffe1, width))
stroke(0xff00_0000, 7.0 * scale)
.chain(stroke(custom.unwrap_or(0xff57_ffe1), width * scale))
.collect()
} else {
stroke(0x6600_0000, width).collect()
stroke(custom.unwrap_or(0x6600_0000), width * scale).collect()
};
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
@@ -10039,6 +10066,11 @@ impl Renderer {
0..1,
);
}
// Native mods' primitives: after the entities and the rest of the world, into its
// targets (its resolution, reversed depth and TAA), before the hand.
if world_primitives {
self.world_primitives.draw(&mut pass);
}
if hand_in_world {
pass.set_pipeline(&self.depth_reset);
pass.set_bind_group(0, &self.group, &[]);
@@ -517,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: &[],
@@ -548,6 +549,7 @@ impl Scene {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &self.celestial,
stars: f
@@ -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`.
@@ -687,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
@@ -1547,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}"
);
}
}
}
@@ -614,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),
(
@@ -641,6 +648,10 @@ impl PackAtlas {
}
}
#[cfg(test)]
#[path = "pack_atlas/fade_test.rs"]
mod fade_test;
#[cfg(test)]
#[path = "pack_atlas/midpoint_test.rs"]
mod midpoint_test;
@@ -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())
}
@@ -678,6 +678,43 @@ fn texture_format(f: Format, features: wgpu::Features) -> wgpu::TextureFormat {
}
}
/// `format` where `renders` says it can be a colour target, else the nearest float format that can:
/// the same channels at 16 bits, then `Rgba8Unorm`. On OpenGL the 32-bit and 16-bit float formats
/// render only with `GL_EXT_color_buffer_float`/`half_float`, and wgpu aborts on creating a target
/// without. Other formats pass through. Float colour buffers are only sampled (never blended), so
/// RENDER_ATTACHMENT is the only usage that decides.
fn renderable_float(
format: wgpu::TextureFormat,
renders: impl Fn(wgpu::TextureFormat) -> bool,
) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let half = match format {
T::R32Float | T::R16Float => T::R16Float,
T::Rg32Float | T::Rg16Float => T::Rg16Float,
T::Rgba32Float | T::Rgba16Float => T::Rgba16Float,
_ => return format,
};
if renders(format) {
format
} else if renders(half) {
half
} else {
T::Rgba8Unorm
}
}
/// [`texture_format`] made renderable on this adapter (see [`renderable_float`]).
fn target_format(
f: Format,
features: wgpu::Features,
adapter: &wgpu::Adapter,
) -> wgpu::TextureFormat {
renderable_float(texture_format(f, features), |format| {
(adapter.get_texture_format_features(format).allowed_usages)
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
})
}
/// The SNORM wgpu format of SNORM pack format `f`, where this adapter can render to it. Iris
/// stores such a buffer clamped to [-1, 1] and quantised to its bits; elsewhere the half floats of
/// [`texture_format`] stand in.
@@ -1891,7 +1928,8 @@ impl PackPost {
let features = device.features();
let formats: [wgpu::TextureFormat; BUFFERS] = std::array::from_fn(|i| {
let f = post.config.buffers[i].format;
snorm_format(f, features, adapter).unwrap_or_else(|| texture_format(f, features))
snorm_format(f, features, adapter)
.unwrap_or_else(|| target_format(f, features, adapter))
});
let sky_clear = Some(&post.config.buffers[0])
.filter(|b| b.clear)
@@ -1944,7 +1982,9 @@ impl PackPost {
// buffers 0 to 7.
let shadow_formats: Option<[wgpu::TextureFormat; SHADOW_COLORS]> =
post.shadow_pass.then(|| {
std::array::from_fn(|n| texture_format(shadow_config.colors[n].format, features))
std::array::from_fn(|n| {
target_format(shadow_config.colors[n].format, features, adapter)
})
});
let shadow_space: [wgpu::TextureFormat; BUFFERS] = std::array::from_fn(|i| {
shadow_formats
@@ -5284,6 +5324,27 @@ mod tests {
assert_eq!(g("R11F_G11F_B10F"), T::Rg11b10Ufloat);
}
#[test]
fn float_targets_fall_back_when_not_renderable() {
let all = |_| true;
assert_eq!(renderable_float(T::Rgba32Float, all), T::Rgba32Float);
// OpenGL without GL_EXT_color_buffer_float: only half floats render.
let half_only = |f: T| matches!(f, T::R16Float | T::Rg16Float | T::Rgba16Float);
assert_eq!(renderable_float(T::Rgba32Float, half_only), T::Rgba16Float);
assert_eq!(renderable_float(T::Rg32Float, half_only), T::Rg16Float);
assert_eq!(renderable_float(T::R32Float, half_only), T::R16Float);
assert_eq!(renderable_float(T::Rgba16Float, half_only), T::Rgba16Float);
// Neither renders: the 8-bit last resort.
assert_eq!(renderable_float(T::Rgba32Float, |_| false), T::Rgba8Unorm);
assert_eq!(renderable_float(T::Rg16Float, |_| false), T::Rgba8Unorm);
// Other formats are never replaced.
assert_eq!(renderable_float(T::Rgba8Uint, |_| false), T::Rgba8Uint);
assert_eq!(
renderable_float(T::Rgb10a2Unorm, |_| false),
T::Rgb10a2Unorm
);
}
#[test]
fn samplers_read_custom_textures_of_their_type() {
use wgpu::TextureSampleType as T;
@@ -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 {
@@ -595,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: &[],
@@ -618,6 +619,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -438,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: &[],
@@ -461,6 +462,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -328,6 +328,7 @@ fn 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: &[],
@@ -351,6 +352,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -1447,6 +1447,7 @@ mod initial_history_tests {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -1470,6 +1471,7 @@ mod initial_history_tests {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -0,0 +1,563 @@
//! Native mods' world primitives (`native_world_primitives`, docs/native-mods.md): lines, boxes,
//! beams and text a mod hands the game each frame, drawn here with Horizon's own pipelines at
//! the end of the world pass, after the entities and before the first-person hand. They draw
//! into the world's targets, so they take the world's reversed depth, its draw resolution and
//! TAA's jitter; a mod never touches wgpu for them.
use super::Vertex;
use glam::Mat4;
/// A frame's primitives, camera-relative, as the game tessellated them: quads of 4 vertices
/// from the shared atlas (plain colours on its white texel, text on its glyphs, beams on the
/// beacon beam sprite) and lines as `gizmos::camera_relative` gives them. `*_through` ones are
/// seen through walls: no depth test.
#[derive(Clone, Copy, Debug, Default)]
pub struct Batch<'a> {
pub quads: &'a [Vertex],
pub quads_through: &'a [Vertex],
pub lines: &'a [[f32; 8]],
pub lines_through: &'a [[f32; 8]],
}
impl Batch<'_> {
pub fn is_empty(&self) -> bool {
self.quads.is_empty()
&& self.quads_through.is_empty()
&& self.lines.is_empty()
&& self.lines_through.is_empty()
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Globals {
view_proj: [[f32; 4]; 4],
viewport: [f32; 4],
}
/// The pipelines and buffers that draw a [`Batch`].
pub struct WorldPrimitives {
/// Depth tested, then seen through walls.
quad_pipelines: [wgpu::RenderPipeline; 2],
line_pipelines: [wgpu::RenderPipeline; 2],
layout: wgpu::BindGroupLayout,
globals: wgpu::Buffer,
sampler: wgpu::Sampler,
quads: wgpu::Buffer,
lines: wgpu::Buffer,
indices: wgpu::Buffer,
index_quads: u32,
group: Option<wgpu::BindGroup>,
/// Quads depth tested, quads through walls, lines depth tested, lines through walls.
counts: [u32; 4],
}
/// Writes bytes into a buffer as part of the frame (the renderer's staging uploads, or the
/// queue in a test).
pub type Upload<'u> = &'u mut dyn FnMut(&wgpu::Buffer, &[u8]);
impl WorldPrimitives {
/// Pipelines drawing into `color_format` over a reversed Depth32Float depth.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("World primitives"),
source: wgpu::ShaderSource::Wgsl(include_str!("world_primitives.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("World primitives"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("World primitives"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let quad_attributes =
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint32];
let line_attributes =
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Unorm8x4,3=>Float32];
let quad_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &quad_attributes,
})];
let line_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: 32,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &line_attributes,
})];
let pipeline = |label: &str,
entry: [&str; 2],
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology,
through: bool| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(entry[0]),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState {
topology,
// Seen from inside or behind as well: a box around the camera, a beam.
cull_mode: None,
..Default::default()
},
// Blended over what is drawn, writing no depth: a translucent box doesn't hide
// the primitives behind it. Reversed depth: nearer is greater.
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(if through {
wgpu::CompareFunction::Always
} else {
wgpu::CompareFunction::GreaterEqual
}),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry[1]),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let quads = ["vs_quad", "fs_quad"];
let lines = ["vs_line", "fs_line"];
let list = wgpu::PrimitiveTopology::TriangleList;
let strip = wgpu::PrimitiveTopology::TriangleStrip;
let quad_pipelines = [
pipeline("World primitives", quads, &quad_buffers, list, false),
pipeline(
"World primitives through walls",
quads,
&quad_buffers,
list,
true,
),
];
let line_pipelines = [
pipeline("World primitive lines", lines, &line_buffers, strip, false),
pipeline(
"World primitive lines through walls",
lines,
&line_buffers,
strip,
true,
),
];
let buffer = |label, size, usage| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage: usage | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
Self {
quad_pipelines,
line_pipelines,
globals: buffer(
"World primitive globals",
std::mem::size_of::<Globals>() as u64,
wgpu::BufferUsages::UNIFORM,
),
sampler: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("World primitives"),
..Default::default()
}),
layout,
quads: buffer("World primitive quads", 0, wgpu::BufferUsages::VERTEX),
lines: buffer("World primitive lines", 0, wgpu::BufferUsages::VERTEX),
indices: buffer("World primitive indices", 0, wgpu::BufferUsages::INDEX),
index_quads: 0,
group: None,
counts: [0; 4],
}
}
/// Uploads this frame's `batch`, drawn with `view_proj` (camera-relative world to clip,
/// jittered as the world is) into a frame `viewport` pixels big, sampling `atlas`. Whether
/// there is anything to draw.
pub fn prepare(
&mut self,
device: &wgpu::Device,
upload: Upload<'_>,
batch: &Batch<'_>,
view_proj: Mat4,
viewport: [u32; 2],
atlas: &wgpu::TextureView,
) -> bool {
let quads = [batch.quads, batch.quads_through].map(|q| (q.len() / 4) as u32);
let lines = [batch.lines, batch.lines_through].map(|l| l.len() as u32);
self.counts = [quads[0], quads[1], lines[0], lines[1]];
if batch.is_empty() {
return false;
}
let grow = |buffer: &mut wgpu::Buffer, bytes: u64| {
if buffer.size() < bytes {
*buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("World primitives"),
size: bytes.next_power_of_two(),
usage: buffer.usage(),
mapped_at_creation: false,
});
}
};
let total_quads = quads[0] + quads[1];
if total_quads > 0 {
let vertices: Vec<Vertex> = [batch.quads, batch.quads_through]
.into_iter()
.flat_map(|q| &q[..q.len() / 4 * 4])
.copied()
.collect();
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
grow(&mut self.quads, bytes.len() as u64);
upload(&self.quads, bytes);
if total_quads > self.index_quads {
self.index_quads = total_quads.next_power_of_two();
let indices: Vec<u32> = (0..self.index_quads)
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q * 4 + i))
.collect();
let bytes: &[u8] = bytemuck::cast_slice(&indices);
grow(&mut self.indices, bytes.len() as u64);
upload(&self.indices, bytes);
}
}
if lines[0] + lines[1] > 0 {
let all: Vec<[f32; 8]> = batch
.lines
.iter()
.chain(batch.lines_through)
.copied()
.collect();
let bytes: &[u8] = bytemuck::cast_slice(&all);
grow(&mut self.lines, bytes.len() as u64);
upload(&self.lines, bytes);
}
let globals = Globals {
view_proj: view_proj.to_cols_array_2d(),
viewport: [viewport[0] as f32, viewport[1] as f32, 0.0, 0.0],
};
upload(&self.globals, bytemuck::bytes_of(&globals));
self.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("World primitives"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(atlas),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
}));
true
}
/// Draws what [`Self::prepare`] uploaded into a pass over the world's colour and depth:
/// the depth-tested quads and lines, then those seen through walls over everything.
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
let Some(group) = &self.group else {
return;
};
let [quads, quads_through, lines, lines_through] = self.counts;
pass.set_bind_group(0, group, &[]);
for (through, (quad_range, line_range)) in [
(0..quads, 0..lines),
(quads..quads + quads_through, lines..lines + lines_through),
]
.into_iter()
.enumerate()
{
if !quad_range.is_empty() {
pass.set_pipeline(&self.quad_pipelines[through]);
pass.set_vertex_buffer(0, self.quads.slice(..));
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(quad_range.start * 6..quad_range.end * 6, 0, 0..1);
}
if !line_range.is_empty() {
pass.set_pipeline(&self.line_pipelines[through]);
pass.set_vertex_buffer(0, self.lines.slice(..));
pass.draw(0..4, line_range);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use glam::Vec3;
const SIZE: u32 = 64;
/// RGBA, as `Vertex::color` holds it.
const RED: u32 = 0xFF00_00FF;
const GREEN: u32 = 0xFF00_FF00;
const BLUE: u32 = 0xFFFF_0000;
/// ARGB, as a line carries it.
const MAGENTA_ARGB: u32 = 0xFFFF_00FF;
/// A camera-relative quad of plain colour facing the camera, `z` blocks ahead of it, over
/// the pixels `x0..x1`, `y0..y1` of the frame (y down).
fn quad(view_proj: Mat4, [x0, y0, x1, y1]: [f32; 4], z: f32, rgba: u32) -> [Vertex; 4] {
let depth = view_proj.project_point3(Vec3::new(0.0, 0.0, -z)).z;
let inverse = view_proj.inverse();
let at = |x: f32, y: f32| {
let ndc = Vec3::new(
x / SIZE as f32 * 2.0 - 1.0,
1.0 - y / SIZE as f32 * 2.0,
depth,
);
inverse.project_point3(ndc).to_array()
};
[at(x0, y0), at(x0, y1), at(x1, y1), at(x1, y0)].map(|pos| Vertex {
pos,
uv: [0.5, 0.5],
layer: 0,
color: rgba,
light: 0,
normal: [0; 4],
glint_uv: [0.0, 0.0],
})
}
/// A horizontal line, 4 pixels wide, over the pixels `x0..x1` of row `y`, `z` blocks ahead.
fn line(view_proj: Mat4, [x0, x1]: [f32; 2], y: f32, z: f32, argb: u32) -> [f32; 8] {
let q = quad(view_proj, [x0, y, x1, y], z, 0);
let [a, b] = [q[0].pos, q[3].pos];
[
a[0],
a[1],
a[2],
b[0],
b[1],
b[2],
f32::from_bits(argb),
4.0,
]
}
/// A wall 5 blocks ahead fills the depth (reversed: nearer is greater). Behind it, a
/// depth-tested quad and line stay hidden while the same seen through walls show; a
/// depth-tested quad in front of the wall shows.
#[test]
fn depth_tested_primitives_hide_behind_walls_and_see_through_ones_do_not() {
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
pollster::block_on(instance.request_adapter(&Default::default()))
.ok()
.and_then(|adapter| {
pollster::block_on(adapter.request_device(&Default::default())).ok()
})
else {
eprintln!("no GPU adapter: skipped");
return;
};
let view_proj = super::super::world_projection(70.0, 1.0, 512.0);
let wall = view_proj.project_point3(Vec3::new(0.0, 0.0, -5.0)).z;
// Reversed depth: 10 blocks away is less than the wall, 2 blocks away more.
assert!(view_proj.project_point3(Vec3::new(0.0, 0.0, -10.0)).z < wall);
assert!(view_proj.project_point3(Vec3::new(0.0, 0.0, -2.0)).z > wall);
let format = wgpu::TextureFormat::Rgba8Unorm;
let texture = |size: u32, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let color = texture(
SIZE,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(
SIZE,
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT,
);
// The atlas: one white texel, what plain colours sample.
let atlas = texture(
1,
format,
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
);
queue.write_texture(
atlas.as_image_copy(),
&[255; 4],
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4),
rows_per_image: None,
},
atlas.size(),
);
let (half, full) = (SIZE as f32 / 2.0, SIZE as f32);
// Top left: behind the wall, depth tested. Top right: behind it, through walls.
// Bottom left: in front of it, depth tested. Bottom right: lines behind it, the
// depth-tested one along row 40, the one through walls along row 56.
let quads = [
quad(view_proj, [0.0, 0.0, half, half], 10.0, RED),
quad(view_proj, [0.0, half, half, full], 2.0, BLUE),
]
.concat();
let quads_through = quad(view_proj, [half, 0.0, full, half], 10.0, GREEN);
let lines = [line(view_proj, [half, full], 40.0, 10.0, MAGENTA_ARGB)];
let lines_through = [line(view_proj, [half, full], 56.0, 10.0, MAGENTA_ARGB)];
let batch = Batch {
quads: &quads,
quads_through: &quads_through,
lines: &lines,
lines_through: &lines_through,
};
let mut primitives = WorldPrimitives::new(&device, format);
let atlas_view = atlas.create_view(&Default::default());
assert!(primitives.prepare(
&device,
&mut |buffer, bytes| queue.write_buffer(buffer, 0, bytes),
&batch,
view_proj,
[SIZE, SIZE],
&atlas_view,
));
let mut encoder = device.create_command_encoder(&Default::default());
{
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(wall),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
primitives.draw(&mut pass);
}
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(SIZE * SIZE * 4),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: None,
},
},
color.size(),
);
queue.submit([encoder.finish()]);
readback.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let texels = readback.slice(..).get_mapped_range().unwrap().to_vec();
let at = |x: u32, y: u32| {
let i = ((y * SIZE + x) * 4) as usize;
[texels[i], texels[i + 1], texels[i + 2], texels[i + 3]]
};
let black = [0, 0, 0, 255];
assert_eq!(
at(16, 16),
black,
"a depth-tested quad behind the wall is hidden"
);
assert_eq!(
at(48, 16),
[0, 255, 0, 255],
"a quad through walls shows behind it"
);
assert_eq!(
at(16, 48),
[0, 0, 255, 255],
"a depth-tested quad before it shows"
);
assert_eq!(
at(48, 40),
black,
"a depth-tested line behind the wall is hidden"
);
assert_eq!(
at(48, 56),
[255, 0, 255, 255],
"a line through walls shows behind it"
);
// The line is its width (4 pixels) thick, not more.
assert_eq!(at(48, 52), black);
}
}
@@ -0,0 +1,77 @@
// Native mods' world primitives (gfx/world_primitives.rs): quads from the shared atlas and lines
// with a width in pixels, camera-relative, at the end of the world pass.
struct Globals {
// Camera-relative world to clip space, with TAA's jitter (what the world was drawn with).
view_proj: mat4x4<f32>,
// The frame's size in pixels (line widths are in its pixels).
viewport: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@group(0) @binding(2) var atlas_sampler: sampler;
struct QuadOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
// A beam's sprite v range (start, length), else 0.
@location(2) @interpolate(flat) wrap: vec2<f32>,
}
// `Vertex` (mesher.rs): position, atlas UV, layer, RGBA colour, light.
@vertex fn vs_quad(@location(0) pos: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: u32) -> QuadOut {
var out: QuadOut;
out.clip = globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
out.color = color;
// Layer 6 is a beacon beam's: its v repeats inside the sprite, whose range rides in the
// light bytes (block_entity_render::beam).
if (layer & 0xffffu) == 6u {
out.wrap = vec2(f32(light & 0xffffu), f32(light >> 16u)) / 65535.0;
} else {
out.wrap = vec2(0.0);
}
return out;
}
@fragment fn fs_quad(in: QuadOut) -> @location(0) vec4<f32> {
var uv = in.uv;
if in.wrap.y > 0.0 { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let color = textureSample(atlas, atlas_sampler, uv) * in.color;
if color.a <= 0.0 { discard; }
return color;
}
struct LineOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec4<f32>,
}
// One instance per line (gizmos::camera_relative): its ends, ARGB colour and width in pixels,
// a strip of 4 vertices across it, as terrain.wgsl's `vs_debug_line`.
@vertex fn vs_line(@builtin(vertex_index) vertex: u32,
@location(0) start: vec3<f32>, @location(1) end: vec3<f32>,
@location(2) argb: vec4<f32>, @location(3) width: f32) -> LineOut {
let position = select(start, end, vertex >= 2u);
let view_shrink = 1.0 - 1.0 / 256.0;
let line_start = globals.view_proj * vec4(position * view_shrink, 1.0);
let line_end = globals.view_proj * vec4((position + (end - start)) * view_shrink, 1.0);
let ndc1 = line_start.xyz / line_start.w;
let ndc2 = line_end.xyz / line_end.w;
let screen = globals.viewport.xy;
let direction = normalize((ndc2.xy - ndc1.xy) * screen);
var offset = vec2(-direction.y, direction.x) * width / screen;
if offset.x < 0.0 { offset = -offset; }
if vertex % 2u != 0u { offset = -offset; }
var out: LineOut;
out.clip = vec4((ndc1 + vec3(offset, 0.0)) * line_start.w, line_start.w);
// ARGB in a little-endian u32 reads as B, G, R, A.
out.color = argb.zyxw;
return out;
}
@fragment fn fs_line(in: LineOut) -> @location(0) vec4<f32> {
return in.color;
}
@@ -1689,6 +1689,9 @@ impl GraphicsUi {
crate::widgets::widget_cursor(hovered(rect), true);
crate::widgets::target(rect);
let primary = target == Target::Done;
if primary {
crate::widgets::declare_action(crate::widgets::Action::Done, rect, true);
}
style::button(batch, atlas, rect, &label, (hover, true, primary));
}
}
@@ -207,6 +207,16 @@ impl Calibration {
(l.next, if last { save } else { next }, true),
] {
let hover = over(cursor, rect) && active;
let action = if rect == l.cancel {
Some(crate::widgets::Action::Cancel)
} else if rect == l.back {
Some(crate::widgets::Action::Back)
} else {
None
};
if let Some(action) = action {
crate::widgets::declare_action(action, [rect.0, rect.1, rect.2, 20.0], active);
}
ui::button(
batch,
atlas,
+64 -13
View File
@@ -598,15 +598,16 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
let path = texture.split_once(':').map_or(texture.as_str(), |(_, p)| p);
let file = atlas_sources::texture_path(root, texture, "textures");
decode(&file).ok().map(|image| {
// Animated strips keep their first frame and animate,
// like the packed files.
if path.starts_with("block/") || path.starts_with("item/") {
let mcmeta = horizon_core::prepared_files::read_to_string(
&file.with_extension("png.mcmeta"),
)
.ok();
let animation = animation_of(mcmeta.as_deref());
animated = animation.is_some();
// Animated strips keep their first frame and animate, like the
// packed files, under any folder: vanilla animates every sprite of
// these atlases (a server pack's `wynn/...` as well as `item/...`).
let mcmeta = horizon_core::prepared_files::read_to_string(
&file.with_extension("png.mcmeta"),
)
.ok();
let animation = animation_of(mcmeta.as_deref());
animated = animation.is_some();
if animated || path.starts_with("block/") || path.starts_with("item/") {
first_frame(image, animation.as_ref())
} else {
image
@@ -1115,7 +1116,7 @@ impl HudAtlas {
}
files.retain(|p| {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
!s.contains("title/background") && !s.contains("presets")
});
// OptiFine random entity variants, under the names their scan gives them (those
// outside `textures/` are `<ns>:optifine/random/...`).
@@ -1515,6 +1516,8 @@ pub struct HudBatch {
pub end_portal: EndPortalFill,
/// `options.glintStrength` for the glint pass (`GlintAlpha`).
pub glint_alpha: f32,
/// PvP & HUD's glint colour (`pvp::Tweaks::glint_texel`), 0 for vanilla's.
pub glint_colour: u32,
/// A resource pack text shader's `GameTime`: the day's fraction
/// (`gameTime % 24000` and the partial tick, over 24000).
pub game_time: f32,
@@ -1575,6 +1578,7 @@ impl HudBatch {
blends: Vec::new(),
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
glint_colour: 0,
game_time: 0.0,
blur_at: 0,
inline_profiles: Vec::new(),
@@ -2255,13 +2259,19 @@ struct HudUniform {
end_portal: [[f32; 4]; 3],
}
impl HudUniform {
fn new(size: [f32; 4], fill: EndPortalFill) -> Self {
fn new(size: [f32; 4], fill: EndPortalFill, glint_colour: u32) -> Self {
let [r, g, b, _] = glint_colour.to_le_bytes().map(|c| f32::from(c) / 255.0);
let tint = if glint_colour == 0 {
[-1.0, 0.0, 0.0]
} else {
[r, g, b]
};
Self {
size,
end_portal: [
fill.sprites[0],
fill.sprites[1],
[fill.game_time, 0.0, 0.0, 0.0],
[fill.game_time, tint[0], tint[1], tint[2]],
],
}
}
@@ -2318,7 +2328,7 @@ impl HudRenderer {
});
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("HUD size"),
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default(), 0)),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -2691,6 +2701,7 @@ impl HudRenderer {
batch.glint_alpha,
],
batch.end_portal,
batch.glint_colour,
)),
);
if let Some(pack_text) = &self.pack_text {
@@ -3356,6 +3367,45 @@ mod tests {
.expect("the namespaced sprite animates");
assert_eq!(animation.frame_count(), 2);
}
#[test]
fn an_animated_texture_outside_item_folders_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-dir-anim-{}", std::process::id()));
let atlases = root.join("assets/minecraft/atlases");
let custom = root.join("assets/minecraft/textures/custom");
fs::create_dir_all(&atlases).unwrap();
fs::create_dir_all(&custom).unwrap();
fs::write(
atlases.join("items.json"),
r#"{"sources": [{"type": "directory", "source": "custom", "prefix": "custom/"}]}"#,
)
.unwrap();
// A server pack's item under its own folder: two 16x16 frames, blue then yellow, and
// a still 16x32 texture beside it.
write_strip(
&custom.join("gem.png"),
16,
32,
&[[0, 0, 255, 255], [255, 255, 0, 255]],
Some(r#"{"animation": {"frametime": 20}}"#),
);
write_strip(&custom.join("banner.png"), 16, 32, &[[9, 9, 9, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["custom/gem"];
// One frame, not the whole strip squeezed into a slot.
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 0, 255, 255]);
let (_, animation) = atlas
.sprite_animations
.iter()
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
.expect("the sprite animates");
assert_eq!(animation.frame_count(), 2);
// A still texture there stays whole, as vanilla stitches it.
let banner = atlas.sprites["custom/banner"];
assert_eq!(banner.texels((atlas.width, atlas.height)), (16, 32));
}
/// A resource root with one plain item texture and these CIT files (relative path, contents).
fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf {
let root =
@@ -4131,6 +4181,7 @@ texture=wide",
blends: Vec::new(),
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
glint_colour: 0,
game_time: 0.0,
blur_at: 0,
inline_profiles: Vec::new(),
+7 -2
View File
@@ -1,7 +1,8 @@
struct Hud {
size: vec4<f32>,
// Blend::EndPortal: atlas rects (u0, v0, width, height) of end_sky.png and
// end_portal.png, then vanilla's GameTime in x.
// end_portal.png, then vanilla's GameTime in x and PvP & HUD's glint colour in yzw
// (y below 0: vanilla's).
end_portal: array<vec4<f32>, 3>,
}
@group(0) @binding(0) var<uniform> hud: Hud;
@@ -35,7 +36,11 @@ struct Out {
if in.uv.x >= 2.0 {
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a;
if mask < 0.1 { discard; }
let glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
var glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
let tint = hud.end_portal[2].yzw;
if tint.x >= 0.0 {
glint = vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
}
return vec4(glint.rgb * glint.rgb, mask);
}
let c = textureSample(atlas, atlas_sampler, in.uv) * in.color;
+1
View File
@@ -84,6 +84,7 @@ pub mod potent_sulfur;
pub mod preedit;
pub mod providers;
pub mod pvp;
pub mod pvp_hud;
pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
+268 -21
View File
@@ -202,6 +202,17 @@ const DETAIL_LEVEL: u8 = 2;
/// The most chunks one detail request asks for, one request each 250 ms:
/// generated ones arrive at the decoration threads' pace, cached ones at once.
const DETAIL_REQUEST: usize = 512;
/// How long one frame spends applying far detail and visited summaries
/// (`Lod::receive`).
///
/// Cached chunks answer a `DETAIL_REQUEST` all 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 whole
/// burst in one frame cost 273 ms of it on the render thread, flying over far
/// terrain at `lodDistance` 256: 18 FPS average, 482 ms at the 99th
/// percentile, where the median frame was 6 ms. What the budget leaves waits
/// in `Lod::detail_backlog` for the frames after it.
const DETAIL_BUDGET: std::time::Duration = std::time::Duration::from_millis(3);
/// Top solid face and optional fluid face, in absolute block coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Surface {
@@ -1036,21 +1047,40 @@ impl ReducedColumns {
levels: std::array::from_fn(|_| std::sync::OnceLock::new()),
}
}
/// Builds the reduction levels whose bit is set in `levels`
/// (`Shared::detail_widths`), off the render thread.
fn warm(self, levels: u8) -> Self {
for level in 0..self.levels.len() {
if levels & (1 << level) != 0 {
self.level(level);
}
}
self
}
/// The chunk reduced to cells `2 << level` blocks wide, built once.
fn level(&self, level: usize) -> &[Column] {
let width = 2 << level;
self.levels[level].get_or_init(|| {
// Timed on whichever thread first asks for this width, so the
// frame profile tells a reduction apart from the column copying
// around it (`Lod::apply_detail` does both).
crate::spans::time("lod_reduce", || {
let columns = &self.columns;
(0..16 / width)
.flat_map(|z| {
(0..16 / width)
.map(move |x| Column::reduced(columns, x * width, z * width, width))
})
.collect()
})
})
}
fn get(&self, x: usize, z: usize, width: usize) -> &Column {
if width == 1 {
return &self.columns[z * 16 + x];
}
let level = width.trailing_zeros() as usize - 1;
let reduced = self.levels[level].get_or_init(|| {
let columns = &self.columns;
(0..16 / width)
.flat_map(|z| {
(0..16 / width)
.map(move |x| Column::reduced(columns, x * width, z * width, width))
})
.collect()
});
&reduced[(z / width) * (16 / width) + x / width]
&self.level(level)[(z / width) * (16 / width) + x / width]
}
}
enum Job {
@@ -1065,6 +1095,14 @@ enum Job {
refreshes: u64,
},
}
/// A chunk's columns on their way to the tiles over it: what `Lod::receive`
/// defers when a frame's [`DETAIL_BUDGET`] is spent.
enum Summary {
/// A chunk's detail, and the `refreshes` of the request it answers.
Detail((i32, i32), ReducedColumns, u64),
/// A visited chunk's summary.
Observed((i32, i32), ReducedColumns),
}
enum Done {
/// A chunk's detail, and the `refreshes` of the request it answers.
Detail((i32, i32), ReducedColumns, u64),
@@ -1149,6 +1187,19 @@ struct Shared {
stop: AtomicBool,
/// Reduced far detail: samplers approximate wide cells.
approximate: AtomicBool,
/// The reduction levels `Lod::apply_detail` reads, one bit each (level
/// `i` is a cell `2 << i` blocks wide), so the far terrain thread builds
/// them before it sends a chunk's columns on.
///
/// Learned rather than derived from the plan: a chunk's ancestors are
/// read only where they were sampled, which the plan alone does not say.
/// `apply_detail` sets a bit the first time it reads that width, 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: a width
/// the view has stopped using costs the worker a reduction nothing
/// reads, and holding all four is exactly the eager behaviour this
/// replaces.
detail_widths: std::sync::atomic::AtomicU8,
/// On small machines, hold queued far meshing while near terrain loads.
mesh_held: AtomicBool,
/// The near terrain is loading: the far threads step behind it
@@ -1183,6 +1234,11 @@ pub struct Lod {
pending: FxHashSet<TileKey>,
detail_masks: FxHashMap<TileKey, [u64; 4]>,
detail_request_pending: bool,
/// Detail and visited summaries past a frame's [`DETAIL_BUDGET`], applied
/// on the frames after it. Bounded by what the worker sends: a detail
/// request is one per 250 ms and only for chunks whose detail is missing,
/// so this drains between bursts rather than growing with the view.
detail_backlog: std::collections::VecDeque<Summary>,
/// `refresh`es so far (`Job::RequestDetail`), and the areas of the last
/// ones, each with its number.
refreshes: u64,
@@ -1290,6 +1346,7 @@ impl Lod {
mesh_wake: Condvar::new(),
stop: AtomicBool::new(false),
approximate: AtomicBool::new(false),
detail_widths: std::sync::atomic::AtomicU8::new(0),
mesh_held: AtomicBool::new(false),
near_loading: AtomicBool::new(false),
});
@@ -1468,7 +1525,12 @@ impl Lod {
'work: loop {
for (key, columns) in sampler.drain_details() {
if tx
.send(Done::Detail(key, ReducedColumns::new(columns), asked))
.send(Done::Detail(
key,
ReducedColumns::new(columns)
.warm(worker.detail_widths.load(Ordering::Relaxed)),
asked,
))
.is_err()
{
break 'work;
@@ -1514,7 +1576,11 @@ impl Lod {
};
let columns = summarize(&chunk, kinds);
sampler.observed(key, &columns);
Done::Observed(key, ReducedColumns::new(columns))
Done::Observed(
key,
ReducedColumns::new(columns)
.warm(worker.detail_widths.load(Ordering::Relaxed)),
)
}
Job::RequestDetail {
chunks,
@@ -1570,6 +1636,7 @@ impl Lod {
pending: FxHashSet::default(),
detail_masks: FxHashMap::default(),
detail_request_pending: false,
detail_backlog: std::collections::VecDeque::new(),
refreshes: 0,
refreshed: VecDeque::new(),
detail_request_at: std::time::Instant::now(),
@@ -2098,6 +2165,12 @@ impl Lod {
};
let [ox, _, oz] = key.origin();
let stride = key.size() / 16;
// What the far terrain thread should have ready next time.
if stride > 1 {
self.shared
.detail_widths
.fetch_or(1 << (stride.trailing_zeros() - 1), Ordering::Relaxed);
}
let x0 = ((cx * 16 - ox + stride - 1).div_euclid(stride)).clamp(0, 16);
let x1 = (((cx + 1) * 16 - ox + stride - 1).div_euclid(stride)).clamp(0, 16);
let z0 = ((cz * 16 - oz + stride - 1).div_euclid(stride)).clamp(0, 16);
@@ -2269,6 +2342,12 @@ impl Lod {
fn request_detail(&mut self, camera: [i32; 2]) {
if self.held()
|| self.detail_request_pending
// Nothing new while summaries wait: a chunk whose detail is still
// in the backlog has its mask clear, so it would be asked for
// again, and answered from the cache at once. Without this the
// backlog grew by a request every 250 ms and never drained --
// 0.5 FPS, with `update` averaging 9.7 s a frame.
|| !self.detail_backlog.is_empty()
|| self.detail_request_at.elapsed() < std::time::Duration::from_millis(250)
{
return;
@@ -2495,17 +2574,25 @@ impl Lod {
self.wanted_stale = true;
}
fn receive(&mut self, meshes: &mut Vec<(TileKey, SectionMesh)>) {
let started = std::time::Instant::now();
// What an earlier frame left, before anything new: a chunk's detail
// must not overtake the detail of a chunk that arrived before it.
while started.elapsed() < DETAIL_BUDGET {
let Some(summary) = self.detail_backlog.pop_front() else {
break;
};
self.apply_summary(summary);
}
while let Ok(done) = self.results.try_recv() {
match done {
// Read before a `refresh` over it since: asked again (its
// bits stay clear), never kept over the newer data.
Done::Detail(key, _, asked) if self.refreshed_since(key, asked) => {}
Done::Detail(key, columns, _) => {
crate::spans::time("lod_detail", || self.finish_detail(key, columns))
}
Done::Observed(key, columns) => {
crate::spans::time("lod_detail", || self.finish_observed(key, columns))
}
Done::Detail(key, columns, asked) => self.take_summary(
Summary::Detail(key, columns, asked),
started.elapsed() < DETAIL_BUDGET,
),
Done::Observed(key, columns) => self.take_summary(
Summary::Observed(key, columns),
started.elapsed() < DETAIL_BUDGET,
),
Done::DetailRequested => self.detail_request_pending = false,
Done::Sample {
key,
@@ -2530,6 +2617,28 @@ impl Lod {
}
}
}
/// Applies a summary while the frame has budget and nothing is already
/// waiting, else leaves it for a later frame.
fn take_summary(&mut self, summary: Summary, within_budget: bool) {
if within_budget && self.detail_backlog.is_empty() {
self.apply_summary(summary);
} else {
self.detail_backlog.push_back(summary);
}
}
/// A chunk's columns applied to the tiles over it.
///
/// `refreshed_since` is tested here and not when the result arrives, so a
/// refresh over the chunk while the summary waited still drops it: it was
/// read before that refresh, and the tiles ask again with their bits
/// clear.
fn apply_summary(&mut self, summary: Summary) {
crate::spans::time("lod_detail", || match summary {
Summary::Detail(key, _, asked) if self.refreshed_since(key, asked) => {}
Summary::Detail(key, columns, _) => self.finish_detail(key, columns),
Summary::Observed(key, columns) => self.finish_observed(key, columns),
});
}
/// A finished mesh: shown when it is newer than what the tile shows now.
/// A result newer than the frame's current one is still useful while a
/// newer job is pending — re-marking a tile re-dirties its neighbours and
@@ -3640,6 +3749,144 @@ mod tests {
reduced.get(0, 0, 16);
assert_eq!(built(&reduced), [4, 16]);
}
/// A summary past a frame's budget waits for a later frame rather than
/// being dropped, and no new detail is asked for while any waits.
///
/// Without the second part the backlog grew by a `DETAIL_REQUEST` every
/// 250 ms and never drained: a chunk whose summary is still waiting has
/// its detail mask clear, so it was asked for again and the cache
/// answered at once. That measured 0.5 FPS, `update` averaging 9.7 s.
#[test]
fn a_summary_past_the_budget_waits_and_holds_back_the_next_request() {
let mut lod = Lod::scheduler_for_test();
let chunk = (3, 5);
let columns = || ReducedColumns::new(Arc::new(std::array::from_fn(|_| Column::default())));
// Within budget and with nothing waiting, a summary applies at once.
lod.take_summary(Summary::Observed(chunk, columns()), true);
assert!(lod.detail_backlog.is_empty(), "applied, not deferred");
assert!(lod.saved.contains_key(&chunk), "and it reached `saved`");
// Past the budget it waits, and anything after it waits behind it so
// a chunk's detail cannot overtake a chunk that arrived earlier.
lod.take_summary(Summary::Observed((9, 9), columns()), false);
lod.take_summary(Summary::Observed((10, 9), columns()), true);
assert_eq!(
lod.detail_backlog.len(),
2,
"the second waits behind the first"
);
assert!(!lod.saved.contains_key(&(9, 9)));
// Nothing new is asked for while summaries wait, whatever the
// request interval allows. Asserted on `detail_request_at`, which
// `request_detail` records once it is past its guards: a test
// scheduler has no active tile wanting detail, so `pending` would
// stay false either way and prove nothing.
let long_ago = std::time::Instant::now() - std::time::Duration::from_secs(10);
lod.detail_request_at = long_ago;
lod.detail_request_pending = false;
lod.request_detail([0, 0]);
assert_eq!(lod.detail_request_at, long_ago, "held back by the backlog");
// A later frame applies what waited, in the order it arrived.
while let Some(summary) = lod.detail_backlog.pop_front() {
lod.apply_summary(summary);
}
assert!(lod.saved.contains_key(&(9, 9)), "nothing was dropped");
assert!(lod.saved.contains_key(&(10, 9)));
// Drained, requests resume.
lod.detail_request_at = long_ago;
lod.request_detail([0, 0]);
assert!(
lod.detail_request_at > long_ago,
"asked once the backlog drained"
);
}
/// `warm` builds exactly the levels asked for, off the render thread, and
/// `get` then finds them built.
#[test]
fn warming_builds_the_levels_asked_for_and_no_others() {
let built = |reduced: &ReducedColumns| {
reduced
.levels
.iter()
.enumerate()
.filter(|(_, level)| level.get().is_some())
.map(|(level, _)| 2usize << level)
.collect::<Vec<_>>()
};
let chunk = || {
Arc::new(std::array::from_fn::<Column, 256, _>(|i| Column {
surface: None,
spans: vec![Span {
min: 0,
max: 4 + (i % 3) as i16,
state: BlockStateId(1),
biome: 0,
light: 240,
}]
.into(),
}))
};
// No bits: nothing is built, as before.
assert!(built(&ReducedColumns::new(chunk()).warm(0)).is_empty());
// Widths 2 and 8 are levels 0 and 2.
let warmed = ReducedColumns::new(chunk()).warm(0b101);
assert_eq!(built(&warmed), [2, 8]);
// Reading a warmed width builds nothing more; an unwarmed one does.
warmed.get(0, 0, 2);
warmed.get(6, 6, 8);
assert_eq!(built(&warmed), [2, 8], "already built");
warmed.get(0, 0, 4);
assert_eq!(built(&warmed), [2, 4, 8]);
// Warming is the same reduction as reading would have made.
let lazy = ReducedColumns::new(chunk());
for (x, z, width) in [(0, 0, 2), (6, 6, 8), (4, 8, 4), (0, 0, 16)] {
assert_eq!(
warmed.get(x, z, width),
lazy.get(x, z, width),
"width {width} at {x},{z}"
);
}
}
/// Applying a chunk's detail records the cell widths it read, so the far
/// terrain thread reduces them before it sends the next chunk.
#[test]
fn applying_detail_records_the_widths_it_read() {
let mut lod = Lod::scheduler_for_test();
assert_eq!(lod.shared.detail_widths.load(Ordering::Relaxed), 0);
let chunk = (0, 0);
let columns = || ReducedColumns::new(Arc::new(std::array::from_fn(|_| Column::default())));
// Only tiles in `ready` are read. A level-0 tile has cells one block
// wide, which is the chunk's own columns: no reduction, no bit.
lod.ready.insert(
TileKey {
x: 0,
z: 0,
level: 0,
},
Arc::new(vec![Column::default(); CELLS * CELLS]),
);
lod.apply_detail(chunk, &columns());
assert_eq!(
lod.shared.detail_widths.load(Ordering::Relaxed),
0,
"full-detail cells read the chunk itself"
);
// A level-2 tile has cells 4 blocks wide: reduction level 1.
lod.ready.insert(
TileKey {
x: 0,
z: 0,
level: 2,
},
Arc::new(vec![Column::default(); CELLS * CELLS]),
);
lod.apply_detail(chunk, &columns());
assert_eq!(lod.shared.detail_widths.load(Ordering::Relaxed), 0b10);
}
/// Detail read before a refresh over its chunk is dropped (asked
/// again); elsewhere, or read after, it is kept.
#[test]
@@ -131,6 +131,16 @@ impl ModList {
/// Turns installed mod `i` on or off (a dev mod stays on). A native mod's
/// line says `false` when off, so that the launcher's next build leaves
/// it out, and `true` when on again.
/// Turn the installed mod `id` on (nothing when it isn't installed).
pub fn turn_on(&mut self, id: &str) {
let Some(m) = self.installed.iter().find(|m| m.id == id && !m.dev) else {
return;
};
if m.native.is_some() {
self.disabled.remove(&m.id);
}
self.enabled.insert(m.id.clone());
}
pub fn toggle(&mut self, i: usize) {
let Some(m) = self.installed.get(i).filter(|m| !m.dev) else {
return;
+49 -4
View File
@@ -1309,6 +1309,8 @@ enum Act {
ResetPadKey(&'static str),
ResetPadKeys,
ToggleMod(usize),
/// PvP & HUD's PvP preset (`pvp::apply_preset`).
PvpPreset,
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
PickShaderPack(usize),
OpenShaderPackFolder,
@@ -1566,6 +1568,9 @@ pub struct OptionsUi {
pub selected_pad_action: Option<&'static str>,
/// The glyphs the binds list shows (the setting, else the pad's family).
pub pad_style: GlyphStyle,
/// The pad goes by its letters (`PadSettings::nintendo_letters`) and the glyphs are
/// Nintendo's: each part shows the letter of the button playing it.
pub pad_by_letter: bool,
pub languages: Vec<LanguageInfo>,
language_choice: Option<String>,
language_search: ui::EditBox,
@@ -1647,6 +1652,7 @@ impl Default for OptionsUi {
selected_native_key: None,
selected_pad_action: None,
pad_style: GlyphStyle::Xbox,
pad_by_letter: false,
languages: Vec::new(),
language_choice: None,
language_search: search,
@@ -1939,6 +1945,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
Act::Cycle(opt) => Some(opt.tooltip_key(s)),
Act::Push(Page::Language) => Some("options.language.tooltip".into()),
Act::Push(Page::Accessibility) => Some("options.accessibility.tooltip".into()),
Act::PvpPreset => Some("horizon.pvp.preset.tooltip".into()),
_ => None,
};
if let Some(key) = key.filter(|key| lang::has(key)) {
@@ -2469,7 +2476,20 @@ impl OptionsUi {
widget
}
};
let mut rows = Vec::new();
// The PvP preset first; on the Extras page beside the way to the PvP & HUD page.
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
let mut rows = vec![Row::Buttons(
preset,
(self.page() == Page::Extras).then(|| {
button(
0.0,
0.0,
150.0,
tr("horizon.pages.open.pvp"),
Act::Push(Page::Pvp),
)
}),
)];
let mut section = Vec::new();
let flush = |rows: &mut Vec<Row>, section: &mut Vec<crate::extras::Item>| {
rows.extend(
@@ -3244,6 +3264,7 @@ impl OptionsUi {
});
let mut message = match pad.binding(action) {
Some(b) => {
let b = if self.pad_by_letter { b.by_letter() } else { b };
let [r, g, bl] = b.color(self.pad_style);
Text::literal(b.label(self.pad_style))
.with_color(u32::from(r) << 16 | u32::from(g) << 8 | u32::from(bl))
@@ -3603,9 +3624,10 @@ impl OptionsUi {
true,
);
}
ui::button(
ui::action_button(
batch,
atlas,
crate::widgets::Action::Done,
w / 2.0 - 100.0,
h - 28.0,
200.0,
@@ -3759,9 +3781,24 @@ impl OptionsUi {
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
}
match &wd.kind {
WKind::Button { label, active, .. } => {
WKind::Button { label, active, act } => {
let label = fitted.as_ref().map_or(label, |f| &f.0);
ui::button(batch, atlas, wd.x, wd.y, wd.w, wd.h, label, *active, hover);
if matches!(act, Act::Done) {
ui::action_button(
batch,
atlas,
crate::widgets::Action::Done,
wd.x,
wd.y,
wd.w,
wd.h,
label,
*active,
hover,
);
} else {
ui::button(batch, atlas, wd.x, wd.y, wd.w, wd.h, label, *active, hover);
}
crate::widgets::last_narratable(|n| {
narrate_widget(n, s, wd, hover, list_key, framebuffer);
});
@@ -4299,6 +4336,10 @@ impl OptionsUi {
}
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::PvpPreset => {
crate::pvp::apply_preset(s);
events.push(Event::Changed);
}
Act::AccessibilityHelp => events.push(Event::AccessibilityHelp),
Act::PickShaderPack(i) => {
if let Some(pack) = s.shader_packs.get(i) {
@@ -5240,6 +5281,10 @@ impl OptionsUi {
}
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::PvpPreset => {
crate::pvp::apply_preset(s);
events.push(Event::Changed);
}
Act::AccessibilityHelp => events.push(Event::AccessibilityHelp),
Act::PickShaderPack(i) => {
if let Some(pack) = s.shader_packs.get(i) {
@@ -1161,6 +1161,11 @@ impl PackScreen {
d::fill(out, 0, 0, self.width, TOPBAR_H, SIDEBAR_BG);
let (bx, by) = self.back_button();
let back_hover = d::inside(mouse, bx, by, BACK_W, BACK_H);
crate::widgets::declare_action(
crate::widgets::Action::Back,
[bx as f32, by as f32, BACK_W as f32, BACK_H as f32],
true,
);
d::button(
out,
m,
@@ -1377,6 +1382,13 @@ impl PackScreen {
let hover = d::inside(mouse, bx, by, 150, 20);
// The cursor follows the mouse; the highlight also keyboard focus.
crate::widgets::widget_cursor(hover, true);
if i == 1 && self.browser.is_none() {
crate::widgets::declare_action(
crate::widgets::Action::Done,
[bx as f32, by as f32, 150.0, 20.0],
true,
);
}
d::button(
out,
m,
+841 -7
View File
@@ -1,4 +1,5 @@
//! PvP and HUD options (`docs/pvp.md`): a custom crosshair. Kept in
//! PvP and HUD options (`docs/pvp.md`): a custom crosshair, block outline, hit colour and HUD
//! widgets (`pvp_hud`). Kept in
//! `horizon-extras.txt` with the other extras ([`crate::extras::Extras::pvp`]) and shown on
//! Horizon Settings > PvP & HUD. Every option is off by default, so the game looks like
//! vanilla until the player changes one, and none of them shows anything vanilla doesn't
@@ -71,7 +72,7 @@ impl Colour {
pub fn argb(self) -> u32 {
match self {
Colour::Inverted | Colour::White => 0xFFFF_FFFF,
Colour::Red => 0xFFFF_3030,
Colour::Red => 0xFFFF_0000,
Colour::Orange => 0xFFFF_A020,
Colour::Yellow => 0xFFFF_FF40,
Colour::Green => 0xFF40_FF40,
@@ -87,6 +88,18 @@ impl Colour {
fn step(self, back: bool) -> Colour {
step(&Self::ALL, self, back)
}
/// The next solid colour (`Inverted` left out).
fn step_solid(self, back: bool) -> Colour {
step(&Self::ALL[1..], self, back)
}
/// The colour at `percent` opacity, as ARGB.
pub fn with_opacity(self, percent: u8) -> u32 {
self.argb() & 0x00FF_FFFF | alpha(percent) << 24
}
}
/// `percent` of 255, rounded.
fn alpha(percent: u8) -> u32 {
(u32::from(percent.min(100)) * 255 + 50) / 100
}
/// The next (or previous) value of `all` after `v`, wrapping.
@@ -97,11 +110,15 @@ fn step<T: Copy + PartialEq>(all: &[T], v: T, back: bool) -> T {
}
/// `v` one step up or down within `lo..=hi`, wrapping.
fn step_num(v: u8, lo: u8, hi: u8, back: bool) -> u8 {
step_by(v, (lo, hi), 1, back)
}
/// `v` `by` up or down within `lo..=hi`, wrapping.
fn step_by(v: u8, (lo, hi): (u8, u8), by: u8, back: bool) -> u8 {
match (back, v) {
(false, v) if v >= hi => lo,
(false, v) => v + 1,
(false, v) => (v + by).min(hi),
(true, v) if v <= lo => hi,
(true, v) => v - 1,
(true, v) => v.saturating_sub(by).max(lo),
}
}
@@ -158,6 +175,103 @@ impl Shape {
}
}
/// A time of day this player's view can stay at (`ViewTime::day_time` in vanilla's ticks).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ViewTime {
Morning,
Noon,
Sunset,
Midnight,
}
impl ViewTime {
pub const ALL: [ViewTime; 4] = [
ViewTime::Morning,
ViewTime::Noon,
ViewTime::Sunset,
ViewTime::Midnight,
];
pub fn name(self) -> &'static str {
match self {
ViewTime::Morning => "morning",
ViewTime::Noon => "noon",
ViewTime::Sunset => "sunset",
ViewTime::Midnight => "midnight",
}
}
fn english(self) -> &'static str {
match self {
ViewTime::Morning => "Morning",
ViewTime::Noon => "Noon",
ViewTime::Sunset => "Sunset",
ViewTime::Midnight => "Midnight",
}
}
/// The time of day it shows, as `/time set` takes it.
pub fn day_time(self) -> i64 {
match self {
ViewTime::Morning => 1000,
ViewTime::Noon => 6000,
ViewTime::Sunset => 12500,
ViewTime::Midnight => 18000,
}
}
pub fn label(self) -> String {
crate::lang::get_or(&format!("horizon.pvp.time.{}", self.name()), self.english())
}
fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.name() == s)
}
}
/// The weather this player's view can show.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ViewWeather {
Clear,
Rain,
Thunder,
}
impl ViewWeather {
pub const ALL: [ViewWeather; 3] = [ViewWeather::Clear, ViewWeather::Rain, ViewWeather::Thunder];
pub fn name(self) -> &'static str {
match self {
ViewWeather::Clear => "clear",
ViewWeather::Rain => "rain",
ViewWeather::Thunder => "thunder",
}
}
fn english(self) -> &'static str {
match self {
ViewWeather::Clear => "Clear",
ViewWeather::Rain => "Rain",
ViewWeather::Thunder => "Thunder",
}
}
/// `Level.getRainLevel` and `getThunderLevel` as `/weather` leaves them.
pub fn levels(self) -> (f32, f32) {
match self {
ViewWeather::Clear => (0.0, 0.0),
ViewWeather::Rain => (1.0, 0.0),
ViewWeather::Thunder => (1.0, 1.0),
}
}
pub fn label(self) -> String {
crate::lang::get_or(
&format!("horizon.pvp.weather.{}", self.name()),
self.english(),
)
}
fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.name() == s)
}
}
/// The day time this player sees with `fixed` set: the same day (the moon's phase follows the
/// world's), at that time.
pub fn view_day_time(world: i64, fixed: Option<ViewTime>) -> i64 {
match fixed {
Some(t) => world.div_euclid(24000) * 24000 + t.day_time(),
None => world,
}
}
/// The crosshair's look. Its defaults draw vanilla's: a 15-pixel cross, one pixel thick,
/// inverting what is under it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -339,9 +453,243 @@ impl CrosshairCache {
}
}
/// The block outline (`LevelRenderer.submitBlockOutline`). Off, vanilla's: black at 40%, or the
/// accessibility option's high contrast one.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Outline {
pub custom: bool,
/// A solid colour, at `opacity` percent.
pub colour: Colour,
pub opacity: u8,
/// Times vanilla's width (`Window.getAppropriateLineWidth`).
pub thickness: u8,
/// The block's faces tinted with this colour, lightly.
pub fill: Option<Colour>,
}
pub const OPACITIES: (u8, u8) = (10, 100);
pub const OUTLINE_THICKNESSES: (u8, u8) = (1, 4);
/// How much the fill covers the block's faces, in percent.
pub const FILL_OPACITY: u8 = 25;
impl Default for Outline {
fn default() -> Self {
Self {
custom: false,
colour: Colour::Black,
opacity: 40,
thickness: 1,
fill: None,
}
}
}
impl Outline {
/// The stroke's ARGB and its width as a multiple of vanilla's; none for vanilla's.
pub fn stroke(&self) -> Option<(u32, f32)> {
self.custom.then(|| {
(
self.colour.with_opacity(self.opacity),
f32::from(self.thickness),
)
})
}
/// The fill's ARGB, when there is one.
pub fn fill(&self) -> Option<u32> {
self.fill
.filter(|_| self.custom)
.map(|c| c.with_opacity(FILL_OPACITY))
}
}
/// The tint a hurt entity flashes (`OverlayTexture`'s red rows). Off, vanilla's: red over 30%.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Hit {
pub custom: bool,
pub colour: Colour,
/// How much of the colour covers the entity, in percent.
pub opacity: u8,
}
pub const HIT_OPACITIES: (u8, u8) = (0, 100);
impl Default for Hit {
fn default() -> Self {
Self {
custom: false,
colour: Colour::Red,
opacity: 30,
}
}
}
impl Hit {
/// The overlay texel as RGBA bytes in a little-endian u32 (WGSL's `unpack4x8unorm`), its alpha
/// what of the entity's own colour stays (`entity.fsh` mixes by it); 0 for vanilla's texel.
pub fn texel(&self) -> u32 {
if !self.custom {
return 0;
}
let [_, r, g, b] = self.colour.argb().to_be_bytes();
u32::from_le_bytes([r, g, b, (255 - alpha(self.opacity)) as u8])
}
}
/// What the renderer reads of these options each frame (`FrameInputs::pvp`); the default is
/// vanilla's look.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Frame {
/// The block outline's stroke ([`Outline::stroke`]).
pub outline: Option<(u32, f32)>,
/// The hurt overlay texel ([`Hit::texel`]), 0 for vanilla's.
pub hurt_texel: u32,
/// The glint's colour ([`Tweaks::glint_texel`]), 0 for vanilla's.
pub glint: u32,
}
impl Pvp {
pub fn frame(&self) -> Frame {
Frame {
outline: self.outline.stroke(),
hurt_texel: self.hit.texel(),
glint: self.tweaks.glint_texel(),
}
}
}
/// The HUD widgets (`pvp_hud`), each in a corner of its own.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Hud {
/// The armour worn, head first, with each piece's durability.
pub armour: bool,
pub armour_corner: Corner,
pub armour_percent: bool,
/// The active effects: icon, name and level, time left. They replace vanilla's icons.
pub effects: bool,
pub effects_corner: Corner,
/// How many of the held item the inventory holds (arrows with a bow or crossbow).
pub held: bool,
pub held_corner: Corner,
/// The latency the server reports for this player (the tab list's bars), in multiplayer.
pub ping: bool,
pub ping_corner: Corner,
/// The block position and the direction faced, as F3 shows them; hidden when F3 is
/// reduced (`reducedDebugInfo`).
pub coords: bool,
pub coords_corner: Corner,
/// The server's address, in multiplayer.
pub server: bool,
pub server_corner: Corner,
/// Hits in a row the server confirmed, until this player is hurt or 3 seconds pass.
pub combo: bool,
pub combo_corner: Corner,
/// A dark box behind each widget.
pub background: bool,
}
impl Default for Hud {
fn default() -> Self {
Self {
armour: false,
armour_corner: Corner::BottomRight,
armour_percent: false,
effects: false,
effects_corner: Corner::TopRight,
held: false,
held_corner: Corner::BottomRight,
ping: false,
ping_corner: Corner::TopLeft,
coords: false,
coords_corner: Corner::TopLeft,
server: false,
server_corner: Corner::TopLeft,
combo: false,
combo_corner: Corner::TopLeft,
background: true,
}
}
}
pub use crate::extras::Corner;
/// The value of the view options while they follow the world.
fn world_label() -> String {
crate::lang::get_or("horizon.pvp.view.world", "World's")
}
fn corner_label(c: Corner) -> String {
crate::lang::get_or(&format!("horizon.pvp.corner.{}", c.name()), c.label())
}
/// Smaller changes to what vanilla shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Tweaks {
/// Enchantment levels as numbers in tooltips ("Sharpness 5").
pub numeric_enchants: bool,
/// How far down the first-person fire overlay moves, in percent of its height (0: vanilla).
pub fire_lower: u8,
/// The first-person shield's size, in percent (100: vanilla).
pub shield_size: u8,
/// The totem animation's size, in percent (100: vanilla).
pub totem_size: u8,
/// The enchantment glint's colour; `None` is vanilla's purple.
pub glint: Option<Colour>,
/// Water fog as after 30 seconds under water (vanilla's clearest), from the first moment.
pub clear_water: bool,
/// Lava fog as with Fire Resistance (vanilla's clearest), without the effect.
pub clear_lava: bool,
/// The time of day this player sees, `None` for the world's. Never sent to the server.
pub time: Option<ViewTime>,
/// The weather this player sees, `None` for the world's. Never sent to the server.
pub weather: Option<ViewWeather>,
}
impl Default for Tweaks {
fn default() -> Self {
Self {
numeric_enchants: false,
fire_lower: 0,
shield_size: 100,
totem_size: 100,
glint: None,
clear_water: false,
clear_lava: false,
time: None,
weather: None,
}
}
}
pub const FIRE_LOWER: (u8, u8) = (0, 80);
pub const SHIELD_SIZES: (u8, u8) = (50, 100);
pub const TOTEM_SIZES: (u8, u8) = (25, 100);
impl Tweaks {
/// `colour` as the glint shaders read it: `0` for vanilla's, else `0xFF` alpha over RGB in
/// little-endian bytes (`unpack4x8unorm`'s order).
pub fn glint_texel(&self) -> u32 {
self.glint.map_or(0, |c| {
let [_, r, g, b] = c.argb().to_be_bytes();
u32::from_le_bytes([r, g, b, 255])
})
}
}
/// Chat options.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ChatOptions {
/// The local time before each message.
pub timestamps: bool,
/// A message repeated in a row shows once with its count.
pub compact: bool,
/// A click on a message (where it has no click action) copies it.
pub copy: bool,
/// Ctrl+F in the chat searches the chat's messages.
pub search: bool,
}
impl ChatOptions {
/// What `chat::Chat` lays its lines out with.
pub fn qol(&self) -> crate::chat::ChatQol {
crate::chat::ChatQol {
timestamps: self.timestamps,
compact: self.compact,
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Pvp {
pub crosshair: Crosshair,
pub outline: Outline,
pub hit: Hit,
pub hud: Hud,
pub tweaks: Tweaks,
pub chat: ChatOptions,
}
fn flag(value: &str, d: bool) -> bool {
match value {
@@ -357,6 +705,8 @@ impl Pvp {
/// One `key:value` line of `horizon-extras.txt`; false when the key isn't a PvP one.
pub fn parse_line(&mut self, key: &str, value: &str) -> bool {
let c = &mut self.crosshair;
let (o, h, u) = (&mut self.outline, &mut self.hit, &mut self.hud);
let t = &mut self.tweaks;
match key {
"crosshair" => c.custom = flag(value, c.custom),
"crosshairShape" => c.shape = Shape::parse(value).unwrap_or(c.shape),
@@ -372,6 +722,63 @@ impl Pvp {
v => Colour::parse(v).or(c.hover),
}
}
"outline" => o.custom = flag(value, o.custom),
"outlineColour" => o.colour = Colour::parse(value).unwrap_or(o.colour),
"outlineOpacity" => o.opacity = num(value, OPACITIES, o.opacity),
"outlineThickness" => o.thickness = num(value, OUTLINE_THICKNESSES, o.thickness),
"outlineFill" => {
o.fill = match value {
"off" => None,
v => Colour::parse(v).or(o.fill),
}
}
"hit" => h.custom = flag(value, h.custom),
"hitColour" => h.colour = Colour::parse(value).unwrap_or(h.colour),
"hitOpacity" => h.opacity = num(value, HIT_OPACITIES, h.opacity),
"hudArmour" => u.armour = flag(value, u.armour),
"hudArmourCorner" => u.armour_corner = Corner::parse(value, u.armour_corner),
"hudArmourPercent" => u.armour_percent = flag(value, u.armour_percent),
"hudEffects" => u.effects = flag(value, u.effects),
"hudEffectsCorner" => u.effects_corner = Corner::parse(value, u.effects_corner),
"hudHeld" => u.held = flag(value, u.held),
"hudHeldCorner" => u.held_corner = Corner::parse(value, u.held_corner),
"hudBackground" => u.background = flag(value, u.background),
"hudPing" => u.ping = flag(value, u.ping),
"hudPingCorner" => u.ping_corner = Corner::parse(value, u.ping_corner),
"hudCoords" => u.coords = flag(value, u.coords),
"hudCoordsCorner" => u.coords_corner = Corner::parse(value, u.coords_corner),
"hudServer" => u.server = flag(value, u.server),
"hudServerCorner" => u.server_corner = Corner::parse(value, u.server_corner),
"hudCombo" => u.combo = flag(value, u.combo),
"hudComboCorner" => u.combo_corner = Corner::parse(value, u.combo_corner),
"numericEnchants" => t.numeric_enchants = flag(value, t.numeric_enchants),
"lowFire" => t.fire_lower = num(value, FIRE_LOWER, t.fire_lower),
"shieldSize" => t.shield_size = num(value, SHIELD_SIZES, t.shield_size),
"totemSize" => t.totem_size = num(value, TOTEM_SIZES, t.totem_size),
"glintColour" => {
t.glint = match value {
"off" => None,
v => Colour::parse(v).or(t.glint),
}
}
"clearWater" => t.clear_water = flag(value, t.clear_water),
"clearLava" => t.clear_lava = flag(value, t.clear_lava),
"chatTimestamps" => self.chat.timestamps = flag(value, self.chat.timestamps),
"chatCompact" => self.chat.compact = flag(value, self.chat.compact),
"chatCopy" => self.chat.copy = flag(value, self.chat.copy),
"chatSearch" => self.chat.search = flag(value, self.chat.search),
"viewTime" => {
t.time = match value {
"off" => None,
v => ViewTime::parse(v).or(t.time),
}
}
"viewWeather" => {
t.weather = match value {
"off" => None,
v => ViewWeather::parse(v).or(t.weather),
}
}
_ => return false,
}
true
@@ -393,6 +800,91 @@ impl Pvp {
c.dot,
c.hover.map_or("off", Colour::name),
);
let (o, h) = (&self.outline, &self.hit);
let _ = write!(
out,
"outline:{}\noutlineColour:{}\noutlineOpacity:{}\noutlineThickness:{}\noutlineFill:{}\nhit:{}\nhitColour:{}\nhitOpacity:{}\n",
o.custom,
o.colour.name(),
o.opacity,
o.thickness,
o.fill.map_or("off", Colour::name),
h.custom,
h.colour.name(),
h.opacity,
);
let u = &self.hud;
let _ = write!(
out,
"hudArmour:{}\nhudArmourCorner:{}\nhudArmourPercent:{}\nhudEffects:{}\nhudEffectsCorner:{}\nhudHeld:{}\nhudHeldCorner:{}\nhudBackground:{}\n",
u.armour,
u.armour_corner.name(),
u.armour_percent,
u.effects,
u.effects_corner.name(),
u.held,
u.held_corner.name(),
u.background,
);
let _ = write!(
out,
"hudPing:{}\nhudPingCorner:{}\nhudCoords:{}\nhudCoordsCorner:{}\nhudServer:{}\nhudServerCorner:{}\nhudCombo:{}\nhudComboCorner:{}\n",
u.ping,
u.ping_corner.name(),
u.coords,
u.coords_corner.name(),
u.server,
u.server_corner.name(),
u.combo,
u.combo_corner.name(),
);
let t = &self.tweaks;
let _ = write!(
out,
"numericEnchants:{}\nlowFire:{}\nshieldSize:{}\ntotemSize:{}\nglintColour:{}\nclearWater:{}\nclearLava:{}\n",
t.numeric_enchants,
t.fire_lower,
t.shield_size,
t.totem_size,
t.glint.map_or("off", Colour::name),
t.clear_water,
t.clear_lava,
);
let _ = write!(
out,
"viewTime:{}\nviewWeather:{}\n",
t.time.map_or("off", ViewTime::name),
t.weather.map_or("off", ViewWeather::name),
);
let c = &self.chat;
let _ = write!(
out,
"chatTimestamps:{}\nchatCompact:{}\nchatCopy:{}\nchatSearch:{}\n",
c.timestamps, c.compact, c.copy, c.search,
);
}
}
/// The official mods the PvP preset turns on when they are installed: the FPS counter and the
/// keystrokes with clicks per second.
pub const PRESET_MODS: [&str; 2] = ["fps_overlay", "keystrokes"];
/// Fire overlay's place under the PvP preset (percent of its height down).
const PRESET_FIRE_LOWER: u8 = 40;
/// The PvP preset: the custom crosshair, the armour and effects widgets, vanilla's toggle sprint
/// and toggle sneak, lower fire, and the FPS and keystrokes mods when installed. It only turns
/// things on; each keeps its own settings, so it can be undone option by option.
pub fn apply_preset(s: &mut crate::settings::Settings) {
let p = &mut s.extras.pvp;
p.crosshair.custom = true;
p.hud.armour = true;
p.hud.effects = true;
if p.tweaks.fire_lower == 0 {
p.tweaks.fire_lower = PRESET_FIRE_LOWER;
}
s.toggle_sprint = true;
s.toggle_crouch = true;
for id in PRESET_MODS {
s.mods.turn_on(id);
}
}
@@ -433,6 +925,61 @@ opts! {
CrosshairDot, "crosshair_dot", "Centre dot", "A dot in the middle of the crosshair.";
CrosshairHover, "crosshair_hover", "Colour on entities",
"Another colour while the crosshair is over a mob or a player you could hit, as vanilla's attack indicator knows it.";
Outline, "outline", "Custom block outline", "Changes the outline around the block you look at.";
OutlineColour, "outline_colour", "Colour", "The outline's colour. Black at 40% is vanilla's.";
OutlineOpacity, "outline_opacity", "Opacity", "How solid the outline is.";
OutlineThickness, "outline_thickness", "Thickness", "The outline's width, in times vanilla's.";
OutlineFill, "outline_fill", "Fill", "Tints the faces of the block you look at, lightly.";
Hit, "hit", "Custom hit colour",
"Changes the colour mobs and players flash when they are hurt. Red at 30% is vanilla's.";
HitColour, "hit_colour", "Colour", "The colour hurt mobs and players flash.";
HitOpacity, "hit_opacity", "Opacity", "How much of the colour covers them.";
HudArmour, "hud_armour", "Armour",
"The armour you wear, head first, with how many uses each piece has left.";
HudArmourCorner, "hud_armour_corner", "Armour corner", "Where the armour shows.";
HudArmourPercent, "hud_armour_percent", "Durability in %",
"Uses left as a percent of the piece's maximum instead of a number.";
HudEffects, "hud_effects", "Effects",
"Your effects with their names, levels and time left, in place of the icons at the top right.";
HudEffectsCorner, "hud_effects_corner", "Effects corner", "Where the effects show.";
HudHeld, "hud_held", "Held item count",
"How many of the item in your hand your inventory holds; arrows with a bow or crossbow.";
HudHeldCorner, "hud_held_corner", "Held count corner", "Where the count shows.";
HudPing, "hud_ping", "Ping", "The latency the server reports for you, in milliseconds. Multiplayer only.";
HudPingCorner, "hud_ping_corner", "Ping corner", "Where the ping shows.";
HudCoords, "hud_coords", "Coordinates",
"Your block position and the direction you face, as F3 shows them. Hidden when the world or server reduces F3.";
HudCoordsCorner, "hud_coords_corner", "Coordinates corner", "Where the coordinates show.";
HudServer, "hud_server", "Server address", "The address of the server you play on. Multiplayer only.";
HudServerCorner, "hud_server_corner", "Server corner", "Where the address shows.";
HudCombo, "hud_combo", "Combo counter",
"Hits in a row the server confirmed, until you are hurt or 3 seconds pass without one.";
HudComboCorner, "hud_combo_corner", "Combo corner", "Where the combo shows.";
HudBackground, "hud_background", "Widget backgrounds", "A dark box behind each widget.";
NumericEnchants, "numeric_enchants", "Enchantment levels as numbers",
"Tooltips and the enchanting table show Sharpness 5 instead of Sharpness V.";
FireLower, "fire_lower", "Lower fire",
"Moves the flames you see while burning down, so they cover less of the screen.";
ShieldSize, "shield_size", "Shield size", "The size of the shield in your hand, in first person.";
TotemSize, "totem_size", "Totem animation size",
"The size of the totem that fills the screen when it saves you.";
GlintColour, "glint_colour", "Enchantment glint colour",
"The colour of the shimmer on enchanted items and armour. Off is vanilla's purple.";
ClearWater, "clear_water", "Clear water",
"Under water, see as far as vanilla lets you after 30 seconds there, from the start.";
ClearLava, "clear_lava", "Clear lava",
"In lava, see as far as vanilla's Fire Resistance lets you, without the effect.";
ViewTime, "view_time", "Time of day",
"The time of day you see: the sky and its light only. The world's time goes on and nothing is sent to the server.";
ViewWeather, "view_weather", "Weather",
"The weather you see: rain, its light and fog only. The world's weather goes on and nothing is sent to the server.";
ChatTimestamps, "chat_timestamps", "Timestamps", "The time each message came in, before it.";
ChatCompact, "chat_compact", "Compact chat",
"A message repeated in a row shows once, with how many times it came: (x3).";
ChatCopy, "chat_copy", "Copy on click",
"Clicking a message in the open chat copies its text, unless the click does something else.";
ChatSearch, "chat_search", "Chat search",
"Ctrl+F in the open chat: what you type shows only the messages holding it. Enter or Ctrl+F again stops.";
}
/// The PvP & HUD page: headers (language keys) and options.
pub fn rows() -> Vec<Result<Opt, &'static str>> {
@@ -452,14 +999,73 @@ pub fn rows() -> Vec<Result<Opt, &'static str>> {
]
.map(Ok),
);
rows.push(Err("horizon.pvp.header.outline"));
rows.extend(
[
Outline,
OutlineColour,
OutlineOpacity,
OutlineThickness,
OutlineFill,
]
.map(Ok),
);
rows.push(Err("horizon.pvp.header.hit"));
rows.extend([Hit, HitColour, HitOpacity].map(Ok));
rows.push(Err("horizon.pvp.header.hud"));
rows.extend(
[
HudArmour,
HudArmourCorner,
HudArmourPercent,
HudEffects,
HudEffectsCorner,
HudHeld,
HudHeldCorner,
HudPing,
HudPingCorner,
HudCoords,
HudCoordsCorner,
HudServer,
HudServerCorner,
HudCombo,
HudComboCorner,
HudBackground,
]
.map(Ok),
);
rows.push(Err("horizon.pvp.header.items"));
rows.push(Ok(NumericEnchants));
rows.push(Ok(GlintColour));
rows.push(Err("horizon.pvp.header.visual"));
rows.extend([FireLower, ShieldSize, TotemSize, ClearWater, ClearLava].map(Ok));
rows.push(Err("horizon.pvp.header.view"));
rows.extend([ViewTime, ViewWeather].map(Ok));
rows.push(Err("horizon.pvp.header.chat"));
rows.extend([ChatTimestamps, ChatCompact, ChatCopy, ChatSearch].map(Ok));
rows
}
impl Opt {
/// The switch this option belongs to.
pub fn parent(self) -> Option<Opt> {
use Opt::*;
match self {
Opt::Crosshair => None,
_ => Some(Opt::Crosshair),
Crosshair | Outline | Hit => None,
OutlineColour | OutlineOpacity | OutlineThickness | OutlineFill => Some(Outline),
HitColour | HitOpacity => Some(Hit),
HudArmour | HudEffects | HudHeld | HudPing | HudCoords | HudServer | HudCombo
| HudBackground | NumericEnchants => None,
FireLower | ShieldSize | TotemSize | GlintColour | ClearWater | ClearLava => None,
ViewTime | ViewWeather => None,
ChatTimestamps | ChatCompact | ChatCopy | ChatSearch => None,
HudPingCorner => Some(HudPing),
HudCoordsCorner => Some(HudCoords),
HudServerCorner => Some(HudServer),
HudComboCorner => Some(HudCombo),
HudArmourCorner | HudArmourPercent => Some(HudArmour),
HudEffectsCorner => Some(HudEffects),
HudHeldCorner => Some(HudHeld),
_ => Some(Crosshair),
}
}
pub fn switch(self, p: &Pvp) -> Option<bool> {
@@ -468,6 +1074,24 @@ impl Opt {
Opt::Crosshair => c.custom,
Opt::CrosshairOutline => c.outline,
Opt::CrosshairDot => c.dot,
Opt::Outline => p.outline.custom,
Opt::Hit => p.hit.custom,
Opt::HudArmour => p.hud.armour,
Opt::HudArmourPercent => p.hud.armour_percent,
Opt::HudEffects => p.hud.effects,
Opt::HudHeld => p.hud.held,
Opt::HudBackground => p.hud.background,
Opt::HudPing => p.hud.ping,
Opt::HudCoords => p.hud.coords,
Opt::HudServer => p.hud.server,
Opt::HudCombo => p.hud.combo,
Opt::NumericEnchants => p.tweaks.numeric_enchants,
Opt::ClearWater => p.tweaks.clear_water,
Opt::ClearLava => p.tweaks.clear_lava,
Opt::ChatTimestamps => p.chat.timestamps,
Opt::ChatCompact => p.chat.compact,
Opt::ChatCopy => p.chat.copy,
Opt::ChatSearch => p.chat.search,
_ => return None,
})
}
@@ -484,12 +1108,53 @@ impl Opt {
Some(colour) => colour.label(),
None => crate::lang::get_or("Off", "Off"),
},
Opt::OutlineColour => p.outline.colour.label(),
Opt::OutlineOpacity => format!("{}%", p.outline.opacity),
Opt::OutlineThickness => format!("{}x", p.outline.thickness),
Opt::OutlineFill => match p.outline.fill {
Some(colour) => colour.label(),
None => crate::lang::get_or("Off", "Off"),
},
Opt::HitColour => p.hit.colour.label(),
Opt::HitOpacity => format!("{}%", p.hit.opacity),
Opt::HudArmourCorner => corner_label(p.hud.armour_corner),
Opt::HudEffectsCorner => corner_label(p.hud.effects_corner),
Opt::HudHeldCorner => corner_label(p.hud.held_corner),
Opt::HudPingCorner => corner_label(p.hud.ping_corner),
Opt::HudCoordsCorner => corner_label(p.hud.coords_corner),
Opt::HudServerCorner => corner_label(p.hud.server_corner),
Opt::HudComboCorner => corner_label(p.hud.combo_corner),
Opt::FireLower => match p.tweaks.fire_lower {
0 => crate::lang::get_or("Off", "Off"),
v => format!("{v}%"),
},
Opt::ShieldSize => format!("{}%", p.tweaks.shield_size),
Opt::TotemSize => format!("{}%", p.tweaks.totem_size),
Opt::GlintColour => match p.tweaks.glint {
Some(colour) => colour.label(),
None => crate::lang::get_or("Off", "Off"),
},
Opt::ViewTime => p.tweaks.time.map_or_else(world_label, ViewTime::label),
Opt::ViewWeather => p
.tweaks
.weather
.map_or_else(world_label, ViewWeather::label),
_ => return None,
})
}
/// A click; `back` (right button, wheel up) goes the other way.
pub fn cycle(self, p: &mut Pvp, back: bool) {
let c = &mut p.crosshair;
let (o, h, u) = (&mut p.outline, &mut p.hit, &mut p.hud);
let t = &mut p.tweaks;
let corner = |c: Corner| step(&Corner::ALL, c, back);
// Off, then every solid colour.
let solid_or_off = |v: Option<Colour>| {
let all = [None]
.into_iter()
.chain(Colour::ALL[1..].iter().copied().map(Some));
step(&all.collect::<Vec<_>>(), v, back)
};
match self {
Opt::Crosshair => c.custom = !c.custom,
Opt::CrosshairShape => c.shape = step(&Shape::ALL, c.shape, back),
@@ -508,6 +1173,55 @@ impl Opt {
.collect();
c.hover = step(&all, c.hover, back);
}
Opt::Outline => o.custom = !o.custom,
Opt::OutlineColour => o.colour = o.colour.step_solid(back),
Opt::OutlineOpacity => o.opacity = step_by(o.opacity, OPACITIES, 10, back),
Opt::OutlineThickness => {
o.thickness = step_by(o.thickness, OUTLINE_THICKNESSES, 1, back)
}
Opt::OutlineFill => o.fill = solid_or_off(o.fill),
Opt::Hit => h.custom = !h.custom,
Opt::HitColour => h.colour = h.colour.step_solid(back),
Opt::HitOpacity => h.opacity = step_by(h.opacity, HIT_OPACITIES, 10, back),
Opt::HudArmour => u.armour = !u.armour,
Opt::HudArmourCorner => u.armour_corner = corner(u.armour_corner),
Opt::HudArmourPercent => u.armour_percent = !u.armour_percent,
Opt::HudEffects => u.effects = !u.effects,
Opt::HudEffectsCorner => u.effects_corner = corner(u.effects_corner),
Opt::HudHeld => u.held = !u.held,
Opt::HudHeldCorner => u.held_corner = corner(u.held_corner),
Opt::HudBackground => u.background = !u.background,
Opt::HudPing => u.ping = !u.ping,
Opt::HudPingCorner => u.ping_corner = corner(u.ping_corner),
Opt::HudCoords => u.coords = !u.coords,
Opt::HudCoordsCorner => u.coords_corner = corner(u.coords_corner),
Opt::HudServer => u.server = !u.server,
Opt::HudServerCorner => u.server_corner = corner(u.server_corner),
Opt::HudCombo => u.combo = !u.combo,
Opt::HudComboCorner => u.combo_corner = corner(u.combo_corner),
Opt::NumericEnchants => p.tweaks.numeric_enchants = !p.tweaks.numeric_enchants,
Opt::FireLower => t.fire_lower = step_by(t.fire_lower, FIRE_LOWER, 10, back),
Opt::ShieldSize => t.shield_size = step_by(t.shield_size, SHIELD_SIZES, 10, back),
Opt::TotemSize => t.totem_size = step_by(t.totem_size, TOTEM_SIZES, 25, back),
Opt::GlintColour => t.glint = solid_or_off(t.glint),
Opt::ClearWater => t.clear_water = !t.clear_water,
Opt::ClearLava => t.clear_lava = !t.clear_lava,
Opt::ViewTime => {
let all: Vec<_> = std::iter::once(None)
.chain(ViewTime::ALL.map(Some))
.collect();
t.time = step(&all, t.time, back);
}
Opt::ChatTimestamps => p.chat.timestamps = !p.chat.timestamps,
Opt::ChatCompact => p.chat.compact = !p.chat.compact,
Opt::ChatCopy => p.chat.copy = !p.chat.copy,
Opt::ChatSearch => p.chat.search = !p.chat.search,
Opt::ViewWeather => {
let all: Vec<_> = std::iter::once(None)
.chain(ViewWeather::ALL.map(Some))
.collect();
t.weather = step(&all, t.weather, back);
}
}
}
}
@@ -645,6 +1359,17 @@ mod tests {
assert_eq!(p.crosshair.shape, Shape::T);
assert_eq!(p.crosshair.size, 8);
assert_eq!(p.crosshair.hover, Some(Colour::Inverted));
// From vanilla's values, one click: the overlays' sizes wrap to their smallest.
let t = p.tweaks;
assert_eq!((t.fire_lower, t.shield_size, t.totem_size), (10, 50, 25));
assert_eq!(t.glint, Some(Colour::White));
assert!(t.clear_water && t.clear_lava);
assert_eq!(
(t.time, t.weather),
(Some(ViewTime::Morning), Some(ViewWeather::Clear))
);
let c = p.chat;
assert!(c.timestamps && c.compact && c.copy && c.search);
let mut text = String::new();
p.serialize(&mut text);
let mut read = Pvp::default();
@@ -662,6 +1387,107 @@ mod tests {
assert!(p.parse_line("crosshairSize", "99") && p.crosshair.size == SIZES.1);
assert!(p.parse_line("crosshairColour", "plaid") && p.crosshair.colour == Colour::White);
assert!(!p.parse_line("fullbright", "true"));
assert!(p.parse_line("totemSize", "5") && p.tweaks.totem_size == TOTEM_SIZES.0);
}
#[test]
fn the_pvp_preset_turns_its_options_on_and_keeps_their_settings() {
use crate::mod_list::{Installed, NativePage};
let mut s = crate::settings::Settings::default();
s.extras.pvp.crosshair.colour = Colour::Green;
s.extras.pvp.tweaks.fire_lower = 60;
let installed = |id: &str| Installed {
id: id.into(),
title: id.into(),
description: String::new(),
problem: None,
dev: false,
native: Some(NativePage::default()),
};
s.mods.installed = vec![installed("fps_overlay"), installed("zoom")];
s.mods.disabled.insert("fps_overlay".into());
apply_preset(&mut s);
let p = &s.extras.pvp;
assert!(p.crosshair.custom && p.hud.armour && p.hud.effects);
assert!(s.toggle_sprint && s.toggle_crouch);
// What the player had chosen stays: the crosshair's colour, a lower fire already set.
assert_eq!(p.crosshair.colour, Colour::Green);
assert_eq!(p.tweaks.fire_lower, 60);
// The FPS counter the player had turned off is on; keystrokes isn't installed: nothing.
assert!(!s.mods.disabled.contains("fps_overlay"));
assert!(s.mods.enabled.contains("fps_overlay") && !s.mods.enabled.contains("keystrokes"));
let mut fresh = crate::settings::Settings::default();
apply_preset(&mut fresh);
assert_eq!(fresh.extras.pvp.tweaks.fire_lower, PRESET_FIRE_LOWER);
}
#[test]
fn a_fixed_time_keeps_the_worlds_day() {
// Day 9 at 5000: the moon's phase (the day number) stays, the time of day is the chosen.
let world = 9 * 24000 + 5000;
assert_eq!(view_day_time(world, None), world);
assert_eq!(
view_day_time(world, Some(ViewTime::Midnight)),
9 * 24000 + 18000
);
assert_eq!(view_day_time(-100, Some(ViewTime::Noon)), -24000 + 6000);
}
#[test]
fn the_glint_colour_reaches_the_shaders_as_rgb_bytes() {
let mut t = Tweaks::default();
assert_eq!(t.glint_texel(), 0, "vanilla's glint");
t.glint = Some(Colour::Red);
// unpack4x8unorm reads the low byte first: r = 1, g = 0, b = 0, a = 1.
assert_eq!(t.glint_texel().to_le_bytes(), [255, 0, 0, 255]);
}
#[test]
fn outline_and_hit_colours_default_to_vanillas() {
// Turned on with their first settings, they give vanilla's colours: the outline's
// 0x66000000 (LevelRenderer) and OverlayTexture's hurt texel 0xB2FF0000.
let o = Outline {
custom: true,
..Default::default()
};
assert_eq!(o.stroke(), Some((0x6600_0000, 1.0)));
assert_eq!(o.fill(), None);
let [r, g, b, a] = crate::overlay::HURT_TEXEL;
let hit = Hit {
custom: true,
..Default::default()
};
assert_eq!(hit.texel(), u32::from_le_bytes([r, g, b, a]));
assert_eq!(
(Outline::default().stroke(), Hit::default().texel()),
(None, 0)
);
// Fully opaque cyan covers the entity entirely: none of its own colour stays.
let cyan = Hit {
custom: true,
colour: Colour::Cyan,
opacity: 100,
};
assert_eq!(cyan.texel().to_le_bytes(), [0x40, 0xFF, 0xFF, 0]);
let mut p = Pvp::default();
Opt::Outline.cycle(&mut p, false);
Opt::OutlineFill.cycle(&mut p, false);
assert_eq!(p.outline.fill(), Some(0x40FF_FFFF), "white at 25%");
Opt::OutlineOpacity.cycle(&mut p, true);
Opt::OutlineOpacity.cycle(&mut p, true);
Opt::OutlineOpacity.cycle(&mut p, true);
Opt::OutlineOpacity.cycle(&mut p, true);
assert_eq!(p.outline.opacity, 100, "down from 10% wraps to 100%");
Opt::OutlineColour.cycle(&mut p, true);
assert_eq!(p.outline.colour, Colour::Magenta, "never Inverted");
let mut text = String::new();
p.serialize(&mut text);
let mut read = Pvp::default();
for line in text.lines() {
let (k, v) = line.split_once(':').unwrap();
assert!(read.parse_line(k, v), "{k}");
}
assert_eq!(read, p);
}
#[test]
@@ -678,9 +1504,17 @@ mod tests {
for header in rows().into_iter().filter_map(Result::err) {
assert!(en.get(header).is_some(), "{header}");
}
for c in Corner::ALL {
let key = format!("horizon.pvp.corner.{}", c.name());
assert_eq!(en.get(&key), Some(c.label()), "{key}");
}
let names = Colour::ALL.map(|c| (format!("horizon.pvp.colour.{}", c.name()), c.english()));
let shapes = Shape::ALL.map(|c| (format!("horizon.pvp.shape.{}", c.name()), c.english()));
for (key, english) in names.iter().chain(&shapes) {
let times = ViewTime::ALL.map(|c| (format!("horizon.pvp.time.{}", c.name()), c.english()));
let weathers =
ViewWeather::ALL.map(|c| (format!("horizon.pvp.weather.{}", c.name()), c.english()));
assert_eq!(en.get("horizon.pvp.view.world"), Some("World's"));
for (key, english) in names.iter().chain(&shapes).chain(&times).chain(&weathers) {
assert_eq!(en.get(key), Some(*english), "{key}");
}
}
+341
View File
@@ -0,0 +1,341 @@
//! The PvP & HUD page's HUD widgets (`docs/pvp.md`): boxes of lines in the screen corners,
//! placed by `extras_hud::Corners` beside mods' boxes. Each widget keeps its lines, their
//! text and widths, and rebuilds them only when what it shows changes, so a frame that
//! changes nothing allocates nothing.
use crate::text::Text;
/// The icon at a line's left: an item the game draws, or an atlas sprite.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Icon {
None,
/// The armour slot (`Game::armor`: 0 head to 3 feet).
Armour(u8),
/// An inventory slot's item (`OFFHAND_SLOT` for the off hand), drawn without its count.
Slot(usize),
/// A sprite by its atlas name (`mob_effect/speed`), 18 pixels square.
Sprite(String),
}
impl Icon {
pub fn size(&self) -> f32 {
match self {
Icon::None => 0.0,
Icon::Armour(_) | Icon::Slot(_) => 16.0,
Icon::Sprite(_) => 18.0,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Line {
pub icon: Icon,
pub text: Text,
/// A second, grey text under the first (an effect's time left).
pub detail: Option<Text>,
/// The widest of its texts, in GUI pixels.
pub text_width: f32,
pub colour: u32,
}
/// Padding inside a widget's box.
pub const PAD: f32 = 2.0;
const TEXT_HEIGHT: f32 = 8.0;
/// Space between an icon and its text.
const ICON_GAP: f32 = 2.0;
impl Line {
pub fn new(icon: Icon, text: String, width: &dyn Fn(&str) -> f32) -> Self {
Self {
icon,
text_width: width(&text),
text: Text::literal(text),
detail: None,
colour: 0xFFFF_FFFF,
}
}
pub fn with_detail(mut self, detail: String, width: &dyn Fn(&str) -> f32) -> Self {
self.text_width = self.text_width.max(width(&detail));
self.detail = Some(Text::literal(detail));
self
}
pub fn height(&self) -> f32 {
let text = if self.detail.is_some() {
2.0 * TEXT_HEIGHT + 2.0
} else {
TEXT_HEIGHT
};
text.max(self.icon.size()) + 2.0
}
/// Where the text starts, from the line's left.
pub fn text_x(&self, icon_column: f32) -> f32 {
if icon_column > 0.0 {
icon_column + ICON_GAP
} else {
0.0
}
}
}
/// One widget's lines, rebuilt from `K` (what they show) only when it changes. The caller
/// fills [`Widget::next`] each frame (reusing its storage), then calls [`Widget::refresh`].
#[derive(Default)]
pub struct Widget<K> {
shown: Option<K>,
/// What the widget shows this frame, filled by the caller.
pub next: K,
pub lines: Vec<Line>,
/// The box's size, padding included.
pub size: (f32, f32),
}
impl<K: PartialEq + Default> Widget<K> {
/// Rebuilds the lines with `build` when `next` differs from what they show; true if so.
pub fn refresh(&mut self, build: impl FnOnce(&K, &mut Vec<Line>)) -> bool {
if self.shown.as_ref() == Some(&self.next) {
return false;
}
self.lines.clear();
build(&self.next, &mut self.lines);
self.size = size(&self.lines);
let next = std::mem::take(&mut self.next);
// The old key's storage comes back as `next` for the following frames.
self.next = self.shown.replace(next).unwrap_or_default();
true
}
/// Forget the lines (the widget was turned off).
pub fn clear(&mut self) {
self.shown = None;
self.lines.clear();
self.size = (0.0, 0.0);
}
}
/// The width of the icons' column: the widest icon of the lines.
pub fn icon_column(lines: &[Line]) -> f32 {
lines.iter().map(|l| l.icon.size()).fold(0.0, f32::max)
}
/// A box around `lines`, one under the other.
pub fn size(lines: &[Line]) -> (f32, f32) {
if lines.is_empty() {
return (0.0, 0.0);
}
let icons = icon_column(lines);
let width = lines
.iter()
.map(|l| l.text_x(icons) + l.text_width)
.fold(0.0, f32::max);
let height = lines.iter().map(Line::height).sum::<f32>();
(width + 2.0 * PAD, height + 2.0 * PAD - 2.0)
}
/// How armour durability shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Durability {
/// Uses left.
Number,
/// Uses left in percent of the item's maximum, rounded.
Percent,
}
/// An item's durability text: none for an item without durability.
pub fn durability(damage: u32, max: u32, mode: Durability) -> Option<String> {
if max == 0 {
return None;
}
let left = max.saturating_sub(damage);
Some(match mode {
Durability::Number => left.to_string(),
Durability::Percent => format!("{}%", (left * 100 + max / 2) / max),
})
}
/// The colour of a durability text: `ItemStack.getBarColor`'s hue, green full to red empty.
pub fn durability_colour(damage: u32, max: u32) -> u32 {
if max == 0 {
return 0xFFFF_FFFF;
}
let left = max.saturating_sub(damage) as f32 / max as f32;
// Mth.hsvToRgb(f / 3, 1, 1): from red (0) through yellow to green (1/3).
let (r, g) = if left >= 0.5 {
((2.0 - 2.0 * left) * 255.0, 255.0)
} else {
(255.0, 2.0 * left * 255.0)
};
0xFF00_0000 | (r as u32) << 16 | (g as u32) << 8
}
/// `Direction.fromYRot`: the horizontal direction a yaw faces, as F3's "Facing" names it.
pub fn facing(yaw: f32) -> &'static str {
match (f64::from(yaw) / 90.0 + 0.5).floor() as i64 & 3 {
0 => "south",
1 => "west",
2 => "north",
_ => "east",
}
}
/// The combo counter: hits in a row on what this player attacked, each counted when the
/// server reports that entity hurt (`ClientboundDamageEventPacket`) soon after the attack;
/// back to 0 when this player is hurt or no hit lands for [`Combo::EXPIRE`] seconds.
#[derive(Default)]
pub struct Combo {
count: u32,
/// The entity attacked last and when.
target: Option<(i32, f64)>,
last_hit: f64,
}
impl Combo {
/// How long after an attack the server's hurt report still counts it.
pub const CONFIRM: f64 = 1.0;
pub const EXPIRE: f64 = 3.0;
pub fn attacked(&mut self, id: i32, now: f64) {
self.target = Some((id, now));
}
/// The server reports entity `id` hurt. `by_me`: whether this player is
/// the damage's direct entity, when the report names one (melee and
/// spear hits do; Horizon's LAN hub names none for player victims). A
/// report that names nobody counts only as the answer to the last swing.
pub fn hurt(&mut self, id: i32, by_me: Option<bool>, now: f64) {
let confirmed = match by_me {
Some(mine) => mine,
None => self
.target
.is_some_and(|(t, at)| t == id && now - at <= Self::CONFIRM),
};
if confirmed {
self.target = None;
self.count += 1;
self.last_hit = now;
}
}
/// This player was hurt.
pub fn broken(&mut self) {
self.count = 0;
self.target = None;
}
pub fn count(&mut self, now: f64) -> u32 {
if now - self.last_hit > Self::EXPIRE {
self.count = 0;
}
self.count
}
}
#[cfg(test)]
mod tests {
use super::*;
fn width(s: &str) -> f32 {
6.0 * s.chars().count() as f32
}
#[test]
fn durability_shows_uses_left_as_a_number_or_a_percent() {
// A diamond chestplate (528 uses) after 100 hits.
assert_eq!(
durability(100, 528, Durability::Number).as_deref(),
Some("428")
);
assert_eq!(
durability(100, 528, Durability::Percent).as_deref(),
Some("81%")
);
assert_eq!(
durability(0, 528, Durability::Percent).as_deref(),
Some("100%")
);
assert_eq!(
durability(528, 528, Durability::Number).as_deref(),
Some("0")
);
assert_eq!(
durability(0, 0, Durability::Number),
None,
"a pumpkin on the head"
);
assert_eq!(durability_colour(0, 100), 0xFF00_FF00);
assert_eq!(durability_colour(100, 100), 0xFFFF_0000);
assert_eq!(durability_colour(50, 100), 0xFFFF_FF00);
}
#[test]
fn facing_follows_direction_from_y_rot() {
// Yaw 0 looks toward +Z (south); turning right (yaw up) goes west, north, east.
let names: Vec<_> = [0.0, 44.9, 45.0, 90.0, 180.0, -90.0, 270.0, -180.0, 720.0]
.map(facing)
.to_vec();
assert_eq!(
names,
[
"south", "south", "west", "west", "north", "east", "east", "north", "south"
]
);
}
#[test]
fn combos_count_confirmed_hits_until_this_player_is_hurt() {
let mut c = Combo::default();
c.attacked(7, 0.0);
c.hurt(7, None, 0.1);
c.attacked(7, 0.6);
c.hurt(7, None, 0.7);
assert_eq!(c.count(0.8), 2);
// Another entity hurt, or a report too late after the swing, adds nothing.
c.attacked(7, 1.0);
c.hurt(9, None, 1.1);
c.hurt(7, None, 2.5);
assert_eq!(c.count(2.5), 2);
// One report per swing: a second one (thorns, fire) for the same attack doesn't count.
c.attacked(7, 3.0);
c.hurt(7, None, 3.1);
c.hurt(7, None, 3.2);
assert_eq!(c.count(3.2), 3);
c.broken();
assert_eq!(c.count(3.3), 0);
c.attacked(7, 4.0);
c.hurt(7, None, 4.1);
assert_eq!(c.count(7.0), 1);
assert_eq!(c.count(7.2), 0, "more than 3 s since the last hit");
// A report that names who hit decides alone: a spear jab (no swing
// remembered) counts, a teammate's hit right after a swing doesn't.
c.hurt(7, Some(true), 8.0);
c.attacked(7, 8.5);
c.hurt(7, Some(false), 8.6);
assert_eq!(c.count(8.7), 1);
}
#[test]
fn a_widget_rebuilds_only_when_what_it_shows_changes() {
let mut w: Widget<Vec<u32>> = Widget::default();
let mut builds = 0;
for frame in 0..10 {
w.next.clear();
w.next.push(if frame < 5 { 7 } else { 8 });
w.refresh(|k, lines| {
builds += 1;
lines.push(Line::new(Icon::None, k[0].to_string(), &width));
});
}
assert_eq!(builds, 2);
assert_eq!(w.lines[0].text.plain(), "8");
w.clear();
w.next.clear();
w.next.push(8);
assert!(w.refresh(|_, lines| lines.push(Line::new(Icon::None, "x".into(), &width))));
}
#[test]
fn the_box_fits_its_widest_line_and_icons_line_up() {
let lines = [
Line::new(Icon::Armour(3), "428".into(), &width),
Line::new(Icon::None, "12345".into(), &width),
Line::new(
Icon::Sprite("mob_effect/speed".into()),
"Speed II".into(),
&width,
)
.with_detail("01:30".into(), &width),
];
assert_eq!(icon_column(&lines), 18.0);
// Texts start after the 18-pixel column and a 2-pixel gap: the widest is "Speed II"
// (48 pixels).
let (w, h) = size(&lines);
assert_eq!(w, 2.0 + 18.0 + 2.0 + 48.0 + 2.0);
// 18 + 2 for the icon lines (the effect's two texts take 18), 8 + 2 for the text one.
assert_eq!(h, 18.0 + 10.0 + 20.0 + 2.0 * PAD - 2.0);
assert!(size(&[]) == (0.0, 0.0));
}
}
@@ -61,6 +61,9 @@ pub struct ScreenInputs<'a> {
/// Eye in water: (lightmap brightness at the eye, yaw, pitch).
pub water: Option<(f32, f32, f32)>,
pub on_fire: bool,
/// How far the fire overlay moves down, in its own heights (PvP & HUD's lower fire; 0 is
/// vanilla's place).
pub fire_lower: f32,
/// `Hud.extractCameraOverlays` draws: the full-screen blits become
/// screen quads (the spyglass, its fills and the nausea swirl do not).
pub gui_overlays: &'a [GuiDraw],
@@ -201,7 +204,7 @@ impl ScreenEffects {
for (dx, angle) in [(0.24f32, -10.0f32), (-0.24, 10.0)] {
let (s, c) = angle.to_radians().sin_cos();
// rotateY about the quad then translate; z = -0.5 feeds into x.
let corner = |x: f32, y: f32| [x * c + -0.5 * s + dx, y - 0.3];
let corner = |x: f32, y: f32| [x * c + -0.5 * s + dx, y - 0.3 - input.fire_lower];
let corners = [
corner(-0.5, -0.5),
corner(0.5, -0.5),
@@ -347,6 +350,7 @@ mod tests {
block_overlay: None,
water: Some((1.0, 0.0, 0.0)),
on_fire: false,
fire_lower: 0.0,
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
};
let quads = e.build(&input);
@@ -401,6 +405,7 @@ mod tests {
block_overlay: Some("block/stone".to_owned()),
water: Some((1.0, 0.0, 0.0)),
on_fire: false,
fire_lower: 0.0,
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
});
let sampling: Vec<_> = quads.iter().map(|q| q.sampling).collect();
@@ -11,7 +11,10 @@
//! backend through `stopped`.
use crate::sound_events::{INTENTIONALLY_EMPTY, Sentinel, Sound, SoundRegistry};
use horizon_core::random::LegacyRandom;
use std::collections::HashMap;
use std::{
collections::HashMap,
sync::{Arc, RwLock},
};
/// `SoundSource` indices (vanilla order).
pub const MASTER: usize = 0;
@@ -41,11 +44,45 @@ pub enum Attenuation {
None,
Linear,
}
/// The object an entity-bound sound follows. Clones retain the same object,
/// even after the world replaces another entity with the same numeric id.
#[derive(Clone, Debug)]
pub struct SoundEntity {
pub id: u32,
state: Arc<RwLock<SoundEntityState>>,
}
#[derive(Clone, Copy, Debug)]
pub struct SoundEntityState {
pub position: [f64; 3],
pub silent: bool,
pub removed: bool,
}
impl SoundEntity {
pub fn new(id: u32, position: [f64; 3], silent: bool) -> Self {
Self {
id,
state: Arc::new(RwLock::new(SoundEntityState {
position,
silent,
removed: false,
})),
}
}
pub fn state(&self) -> SoundEntityState {
*self.state.read().unwrap()
}
pub fn update(&self, state: SoundEntityState) {
*self.state.write().unwrap() = state;
}
}
/// The tickable instance kinds Horizon plays (`TickableSoundInstance`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Ticking {
/// Not tickable.
No,
/// `EntityBoundSoundInstance`.
EntityBound,
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
BiomeLoop { fade: i32, direction: i32 },
/// `UnderLiquidAmbientSoundInstance`.
@@ -145,6 +182,8 @@ pub struct SoundInstance {
pub random: LegacyRandom,
pub can_start_silent: bool,
pub ticking: Ticking,
/// A stable entity reference, separate from its reusable network id.
pub bound_entity: Option<SoundEntity>,
/// `AbstractTickableSoundInstance.stopped`.
pub stopped: bool,
/// `sound` after `getOrResolve`, or the sound a caller chose for the
@@ -184,6 +223,7 @@ impl SoundInstance {
random,
can_start_silent: false,
ticking: Ticking::No,
bound_entity: None,
stopped: false,
sound: None,
event: None,
@@ -212,6 +252,27 @@ impl SoundInstance {
false,
)
}
/// `EntityBoundSoundInstance`: initial coordinates are widened floats too.
pub fn entity_bound(
id: &str,
source: usize,
volume: f32,
pitch: f32,
random: LegacyRandom,
entity: SoundEntity,
) -> Self {
let position = entity.state().position.map(|v| f64::from(v as f32));
let mut instance = Self::at(id, source, volume, pitch, random, position);
instance.ticking = Ticking::EntityBound;
instance.bound_entity = Some(entity);
instance
}
/// `EntityBoundSoundInstance.canPlaySound`, also checked on replay.
pub fn can_play_sound(&self) -> bool {
self.bound_entity
.as_ref()
.is_none_or(|entity| !entity.state().silent)
}
/// `SimpleSoundInstance.forUI(sound, pitch, volume)`.
pub fn for_ui(id: &str, pitch: f32, volume: f32, random: LegacyRandom) -> Self {
Self::new(
@@ -551,6 +612,18 @@ impl SoundInstance {
pub fn tick(&mut self, ctx: &TickContext) {
match &mut self.ticking {
Ticking::No | Ticking::MusicLayer => {}
Ticking::EntityBound => {
let entity = self
.bound_entity
.as_ref()
.expect("entity-bound sound target")
.state();
if entity.removed {
self.stop();
} else {
self.position = entity.position.map(|v| f64::from(v as f32));
}
}
Ticking::BiomeLoop { fade, direction } => {
let stop = *fade < 0;
*fade += *direction;
@@ -966,6 +1039,13 @@ impl SoundEngine {
pub fn category_gain(&self, source: usize) -> f32 {
self.gain_by_source[source]
}
/// Live object references used by entity-bound instances, including queued ones.
pub fn bound_entities(&self) -> Vec<SoundEntity> {
self.instances
.values()
.filter_map(|s| s.bound_entity.clone())
.collect()
}
/// `stop(SoundInstance)`.
pub fn stop(&mut self, id: InstanceId, backend: &mut impl Backend) {
if !self.loaded {
@@ -1111,6 +1191,9 @@ impl SoundEngine {
if !self.loaded || !self.instances.contains_key(&id) {
return PlayResult::NotStarted;
}
if !self.instances[&id].can_play_sound() {
return PlayResult::NotStarted;
}
self.resolve(id);
let instance = &self.instances[&id];
let Some(subtitle) = instance.event.clone() else {
@@ -1250,9 +1333,14 @@ impl SoundEngine {
self.play(id, backend);
}
for id in self.ticking.clone() {
let Some(instance) = self.instances.get_mut(&id) else {
let Some(instance) = self.instances.get(&id) else {
continue;
};
if !instance.can_play_sound() {
// SoundEngine still ticks the instance after stopping its channel.
self.stop(id, backend);
}
let instance = self.instances.get_mut(&id).expect("present");
instance.tick(ctx);
if instance.stopped {
self.stop(id, backend);
@@ -1764,3 +1852,66 @@ mod tests {
assert!(underwater.stopped);
}
}
#[cfg(test)]
mod entity_bound_tests {
use super::*;
fn instance(entity: SoundEntity) -> SoundInstance {
SoundInstance::entity_bound(
"minecraft:entity.allay.item_given",
NEUTRAL,
0.7,
1.2,
LegacyRandom::new(42),
entity,
)
}
#[test]
fn an_entity_bound_sound_tracks_float_positions_and_stops_before_the_removed_position() {
let entity = SoundEntity::new(7, [0.123456789, 64.123456789, -3.123456789], false);
let mut sound = instance(entity.clone());
assert_eq!(sound.position[0].to_bits(), 0x3fbf9add40000000);
entity.update(SoundEntityState {
position: [0.248456789, 65.25, -3.623456789],
silent: false,
removed: false,
});
sound.tick(&TickContext::default());
assert_eq!(sound.position[0].to_bits(), 0x3fcfcd6ea0000000);
let last = sound.position;
entity.update(SoundEntityState {
position: [100.0; 3],
silent: false,
removed: true,
});
sound.tick(&TickContext::default());
assert!(sound.stopped);
assert_eq!(sound.position, last);
}
#[test]
fn silence_is_read_from_the_entity_including_before_a_replay() {
let entity = SoundEntity::new(7, [0.0; 3], true);
let sound = instance(entity.clone());
assert!(!sound.can_play_sound());
entity.update(SoundEntityState {
position: [0.0; 3],
silent: false,
removed: false,
});
assert!(sound.can_play_sound());
}
#[test]
fn reusing_a_network_id_does_not_retarget_an_old_sound() {
let old = SoundEntity::new(7, [0.0; 3], false);
let mut sound = instance(old.clone());
old.update(SoundEntityState {
position: [0.0; 3],
silent: false,
removed: true,
});
let replacement = SoundEntity::new(7, [10.0; 3], false);
sound.tick(&TickContext::default());
assert!(sound.stopped);
assert_ne!(sound.position, replacement.state().position);
}
}
@@ -351,6 +351,14 @@ impl SplitProfiles {
if matches!(target, Target::Forget) {
text = text.with_color(0xFF5555);
}
let action = match target {
Target::Keep | Target::Cancel => Some(crate::widgets::Action::Cancel),
Target::Back => Some(crate::widgets::Action::Back),
_ => None,
};
if let Some(action) = action {
crate::widgets::declare_action(action, [*x, *y, *bw, *bh], *enabled);
}
ui::button(batch, atlas, *x, *y, *bw, *bh, &text, *enabled, hover);
if remove {
crate::widgets::record(label, [*x, *y, *bw, *bh], *enabled);
@@ -691,6 +691,16 @@ impl SplitUi {
Target::Three => Some(layout_glyph(true, d.three)),
_ => None,
});
let action = match target {
Target::Apply => Some(crate::widgets::Action::Apply),
Target::Done => Some(crate::widgets::Action::Done),
_ => None,
};
if let Some(action) = action {
// Off during a hold: every button goes to the held player.
let enabled = *active && self.held.is_none();
crate::widgets::declare_action(action, [x, y, bw, bh], enabled);
}
// The selected player's name in yellow: picked, not just under the pointer.
let text = if glyph.is_some() {
Text::literal("")
@@ -1499,6 +1509,30 @@ mod tests {
assert!(ui.give_up_pad);
}
#[test]
fn done_is_declared_off_while_a_player_is_held() {
use crate::widgets::{Action, end_frame, last_actions, set_action_screen};
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let mut ui = screen(2);
ui.size = SIZE;
let done = |ui: &SplitUi, frame: u64| {
let mut batch = HudBatch::new([427, 300]);
ui.draw(&mut batch, &atlas, (0.0, 0.0), false);
set_action_screen(9);
end_frame(frame);
last_actions(9)
.into_iter()
.find(|a| a.action == Action::Done)
.map(|a| a.enabled)
};
assert_eq!(done(&ui, 9001), Some(true));
assert!(ui.pad_pick_up(centre(&ui, "P2")));
assert_eq!(done(&ui, 9002), Some(false));
assert!(ui.pad_button(PadButton::East));
assert_eq!(done(&ui, 9003), Some(true));
}
#[test]
fn b_puts_a_held_player_back() {
let mut ui = screen(2);
+20 -4
View File
@@ -144,8 +144,15 @@ pub fn set_labpbr_maps(on: bool) {
pub fn labpbr_maps() -> bool {
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Budget for either complete normal or specular image set.
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
/// Budget for either complete normal or specular image set: a quarter of the block textures'
/// budget, so raising `textureBudgetMiB` sharpens a pack's LabPBR maps along with its colours.
fn map_bytes(texture_budget: u64) -> u64 {
texture_budget / 4
}
/// [`map_bytes`] for the budget the next resource load keeps to.
fn max_map_bytes() -> u64 {
map_bytes(TEXTURE_BUDGET.load(std::sync::atomic::Ordering::Relaxed))
}
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
/// loading assumes at least these many share the array.
const LAYERS_AT_LEAST: u64 = 1024;
@@ -275,7 +282,7 @@ impl Sprites {
self.specular.clear();
let largest_map = self.pbr.iter().flatten().flatten().map(|m| m.0).max();
self.pbr_size = largest_map.map_or(SPRITE_SIZE, |l| {
side_within(l.min(size), layers, MAX_MAP_BYTES)
side_within(l.min(size), layers, max_map_bytes())
});
if largest_map.is_some() {
let pbr_size = self.pbr_size;
@@ -783,7 +790,7 @@ fn pbr_map(
return None;
}
let side = w.max(h);
let output_side = side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES).min(side);
let output_side = side_within(side, LAYERS_AT_LEAST, max_map_bytes()).min(side);
let frames = (0..columns * rows)
.map(|index| {
let (x, y) = (index % columns * w, index / columns * h);
@@ -856,6 +863,15 @@ mod tests {
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
}
#[test]
fn labpbr_maps_follow_the_texture_budget() {
// 1300 layers of 1024 px maps: 64 px at the default budget, 256 px at 1536 MiB.
assert_eq!(
side_within(1024, 1300, map_bytes(DEFAULT_TEXTURE_BUDGET)),
64
);
assert_eq!(side_within(1024, 1300, map_bytes(1536 << 20)), 256);
}
#[test]
fn a_labpbr_map_selects_whole_texels_and_missing_layers_get_defaults() {
// Selecting a texel preserves encoded channels, including categorical values.
let frame = [
+49 -7
View File
@@ -502,6 +502,10 @@ pub struct Streaming {
pub radius: i32,
/// `/forceload`ed chunks: kept loaded whatever the distance.
pub forced: HashSet<(i32, i32)>,
/// Chunks a native mod's tickets hold (NeoForge's `TicketController`):
/// kept loaded and ticking as forced ones are, but neither saved with
/// them nor listed by `/forceload`.
pub mod_forced: HashSet<(i32, i32)>,
/// No work is scheduled while paused (title screen).
pub paused: bool,
/// Dimension flags: sky light, Nether face shading.
@@ -569,6 +573,7 @@ struct GenerationScan {
radius: i32,
extra: Vec<((i32, i32), i32)>,
forced: HashSet<(i32, i32)>,
mod_forced: HashSet<(i32, i32)>,
band: Option<Band>,
chunk_revision: u64,
plan: Vec<(i64, i32, i32)>,
@@ -584,6 +589,7 @@ impl GenerationScan {
&& self.radius == ranges.radius
&& self.extra == ranges.extra
&& self.forced == *ranges.forced
&& self.mod_forced == *ranges.mod_forced
&& self.band.as_ref() == ranges.band
}
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
@@ -622,6 +628,7 @@ impl GenerationScan {
self.extra.clear();
self.extra.extend_from_slice(ranges.extra);
self.forced.clone_from(ranges.forced);
self.mod_forced.clone_from(ranges.mod_forced);
self.band = ranges.band.copied();
self.chunk_revision = world.chunks.membership_revision();
}
@@ -719,7 +726,7 @@ impl GenerationScan {
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for &(fx, fz) in input.ranges.forced_chunks() {
for dz in -reach..=reach {
for dx in -reach..=reach {
let (x, z) = (fx + dx, fz + dz);
@@ -739,6 +746,7 @@ impl GenerationScan {
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
mod_forced: input.ranges.mod_forced,
cover: input.ranges.cover,
band: input.ranges.band,
};
@@ -1063,6 +1071,7 @@ impl Streaming {
center: (0, 0),
radius: 8,
forced: HashSet::new(),
mod_forced: HashSet::new(),
paused: false,
sky_light: true,
nether_shading: false,
@@ -1737,7 +1746,7 @@ impl Streaming {
> r4 * r4)
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges.forced.iter().any(|f| {
&& !ranges.forced_chunks().any(|f| {
(x - f.0).abs() <= FORCED_TERRAIN_RING
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
})
@@ -1808,6 +1817,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
};
@@ -1974,7 +1984,11 @@ impl Streaming {
// vanilla draws). Sections set aside past it come back
// when the player moves: a column that loads with its light gets no
// other request (#228).
let area = (self.center, self.radius, self.forced.len());
let area = (
self.center,
self.radius,
self.forced.len() + self.mod_forced.len(),
);
if self.waiting_area != area {
self.waiting_area = area;
let ranges = Ranges {
@@ -1982,6 +1996,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
};
@@ -2077,6 +2092,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
},
@@ -2210,6 +2226,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
}
@@ -2486,6 +2503,8 @@ struct Ranges<'a> {
radius: i32,
extra: &'a [((i32, i32), i32)],
forced: &'a HashSet<(i32, i32)>,
/// The chunks native mods' tickets hold, kept as forced ones are.
mod_forced: &'a HashSet<(i32, i32)>,
/// `extra` indexed by a grid or buckets: coverage queries without a
/// scan of every area.
cover: Option<&'a ExtraCover>,
@@ -2830,10 +2849,20 @@ impl Ranges<'_> {
.iter()
.any(|&(c, radius)| (x - c.0).abs().max((z - c.1).abs()) <= radius + margin)
}
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
fn is_forced(&self, x: i32, z: i32) -> bool {
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
}
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
self.forced
.iter()
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
}
/// The columns meshed for drawing: those vanilla draws around the
/// player (`in_view_distance`), and forced ones.
fn in_view(&self, x: i32, z: i32) -> bool {
in_view_distance(self.center, self.radius, x, z) || self.forced.contains(&(x, z))
in_view_distance(self.center, self.radius, x, z) || self.is_forced(x, z)
}
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
let r = i64::from(self.radius + margin);
@@ -2851,8 +2880,7 @@ impl Ranges<'_> {
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
self.forced
.iter()
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
}
}
@@ -3075,6 +3103,10 @@ fn forget_pending_sections(
}
}
/// No chunk held by a native mod's ticket, for the tests' ranges.
#[cfg(test)]
static NO_CHUNKS: std::sync::LazyLock<HashSet<(i32, i32)>> = std::sync::LazyLock::new(HashSet::new);
#[cfg(test)]
mod tests {
use super::*;
@@ -3291,6 +3323,7 @@ mod tests {
radius: 32,
extra: &[],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -3561,6 +3594,7 @@ mod tests {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
},
@@ -3637,7 +3671,7 @@ mod tests {
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for &(fx, fz) in input.ranges.forced_chunks() {
for dz in -forced_reach..=forced_reach {
for dx in -forced_reach..=forced_reach {
let (x, z) = (fx + dx, fz + dz);
@@ -3943,6 +3977,7 @@ mod tests {
radius,
extra: &[],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -3999,6 +4034,7 @@ mod tests {
radius,
extra: &[((radius * 2 + 9, 3), 4)],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4035,6 +4071,7 @@ mod tests {
radius: -1,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4068,6 +4105,7 @@ mod tests {
radius: 2,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4122,6 +4160,7 @@ mod tests {
radius: 2,
extra: &[],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4193,6 +4232,7 @@ mod cover_tests {
radius: 10,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
band: None,
};
@@ -4235,6 +4275,7 @@ mod cover_tests {
radius: 10,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
band: None,
};
@@ -4300,6 +4341,7 @@ mod cover_tests {
radius: 10,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
band: None,
};
@@ -429,10 +429,10 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
let step = (start + select(corner, 4u - corner, dir == 1u)) & 3u;
let du = select(0u, 1u, step == 1u || step == 2u);
let dw = select(0u, 1u, step >= 2u);
var pos = block;
pos[axis] = block[axis] + side;
pos[(axis + 1u) % 3u] = block[(axis + 1u) % 3u] + du;
pos[(axis + 2u) % 3u] = block[(axis + 2u) % 3u] + dw;
// No dynamic component stores: FXC (DirectX 12) rejects them (X3500). A component's offset
// from the axis (0: the axis, 1: the next, 2: the one after) picks what it gets.
let rel = (vec3(0u, 1u, 2u) + vec3(3u - axis)) % vec3(3u);
let pos = block + select(select(vec3(dw), vec3(du), rel == vec3(1u)), vec3(side), rel == vec3(0u));
let sel = lo.z >> 24u;
let us = (sel >> (2u * corner)) & 1u;
let vs = (sel >> (2u * corner + 1u)) & 1u;
@@ -512,7 +512,8 @@ fn corner_vertex(c: ArenaCorner, origin: vec4<f32>) -> TerrainOut {
}
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
return corner_vertex(s.c, slot_origins[block_slots[s.slot >> 8u]]);
let origin = slot_origins[block_slots[s.slot >> 8u]];
return corner_vertex(s.c, origin);
}
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
+85 -2
View File
@@ -543,6 +543,38 @@ pub fn button(
crate::widgets::Narratable::button(label.plain(), rect, active)
});
}
/// A [`button`] with a job on its screen: its controller button and glyph come from `action`, so
/// a new screen gets them by drawing this for its Done, Back, Cancel or Apply.
#[allow(clippy::too_many_arguments)]
pub fn action_button(
batch: &mut HudBatch,
atlas: &HudAtlas,
action: crate::widgets::Action,
x: f32,
y: f32,
w: f32,
h: f32,
label: &Text,
active: bool,
hover: bool,
) {
crate::widgets::declare_action(action, [x, y, w, h], active);
let room = if active {
crate::widgets::action_glyph_room()
} else {
0.0
};
button_with_room(
batch,
atlas,
[x, y, w, h],
label,
active,
hover,
room,
|rect| crate::widgets::Narratable::button(label.plain(), rect, active),
);
}
/// A [`button`] that vanilla narrates as another widget (a `CycleButton`, a
/// `LockIconButton`): `narration` builds what it records from its rectangle,
/// and only while the narrator records.
@@ -558,6 +590,29 @@ pub fn button_as(
active: bool,
hover: bool,
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
) {
button_with_room(
batch,
atlas,
[x, y, w, h],
label,
active,
hover,
0.0,
narration,
);
}
/// A button whose label keeps `room` free at its left.
#[allow(clippy::too_many_arguments)]
fn button_with_room(
batch: &mut HudBatch,
atlas: &HudAtlas,
[x, y, w, h]: [f32; 4],
label: &Text,
active: bool,
hover: bool,
room: f32,
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
) {
crate::widgets::record(&label.plain(), [x, y, w, h], active);
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover));
@@ -575,15 +630,18 @@ pub fn button_as(
} else {
rgba(160, 160, 160, 255)
};
scrolling_text(
// Centred on the button, as the title's are, and only moved out from under a glyph.
scrolling_text_around_at(
batch,
atlas,
label,
x + 2.0,
x + (w / 2.0).floor(),
x + 2.0 + room,
x + w - 2.0,
y,
y + h,
y + ((h - 8.0) / 2.0).floor(),
crate::hud_overlays::now_ms(),
color,
);
}
@@ -1740,6 +1798,31 @@ mod tests {
/// The label of a widget it does not fit is clipped to the widget's box
/// instead of being drawn past it (the bug: `so_so`'s 590 px Create World
/// label ran over the button beside it).
#[test]
fn a_glyph_leaves_a_label_that_fits_centred() {
let atlas = atlas();
crate::widgets::set_action_glyphs(true);
let label = Text::literal("Done");
let mut plain = HudBatch::new([640, 480]);
button(
&mut plain, &atlas, 10.0, 20.0, 150.0, 20.0, &label, true, false,
);
let mut declared = HudBatch::new([640, 480]);
action_button(
&mut declared,
&atlas,
crate::widgets::Action::Done,
10.0,
20.0,
150.0,
20.0,
&label,
true,
false,
);
assert_eq!(quads(&plain), quads(&declared));
}
#[test]
fn a_label_wider_than_its_widget_stays_inside_it() {
let atlas = atlas();
+114 -2
View File
@@ -96,6 +96,12 @@ thread_local! {
/// on-screen keyboard's keys), labelled or not: where a controller can move focus. Drawn since
/// `end_frame`, the last frame's, and how many frames rotated.
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
/// The buttons drawn since the last `end_frame` that declared their job, and the last frame's.
static ACTIONS: RefCell<(Vec<DeclaredAction>, Vec<DeclaredAction>)> = const { RefCell::new((Vec::new(), Vec::new())) };
/// The pad drives, so declared buttons show their glyph (labels leave it room).
static ACTION_GLYPHS: Cell<bool> = const { Cell::new(false) };
/// The screen the buttons in `ACTIONS` were declared on, the same two frames.
static ACTION_SCREENS: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
@@ -104,17 +110,94 @@ thread_local! {
pub struct Saved(
(Vec<Widget>, Vec<Widget>, u64),
(Vec<Target>, Vec<Target>, u64),
(Vec<DeclaredAction>, Vec<DeclaredAction>),
bool,
(u64, u64),
);
/// This thread's buttons and targets, leaving none.
pub fn take() -> Saved {
Saved(WIDGETS.take(), TARGETS.take())
Saved(
WIDGETS.take(),
TARGETS.take(),
ACTIONS.take(),
ACTION_GLYPHS.take(),
ACTION_SCREENS.take(),
)
}
/// Bring back what [`take`] kept.
pub fn put(saved: Saved) {
WIDGETS.set(saved.0);
TARGETS.set(saved.1);
ACTIONS.set(saved.2);
ACTION_GLYPHS.set(saved.3);
ACTION_SCREENS.set(saved.4);
}
/// A button's job on its screen. A screen declares it ([`crate::ui::action_button`]) and the
/// controller's button, its glyph and its place on every pad follow (docs/controllers.md,
/// "Adding a screen").
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// Leaves the screen, keeping what was set.
Done,
/// Goes back one screen.
Back,
/// Leaves the screen, dropping what was set.
Cancel,
/// Applies what was set and stays or leaves as the screen does.
Apply,
/// Opens the options.
Options,
/// Leaves the game or the world: the pad moves the focus here, and A confirms.
Exit,
}
/// A button that declared its job, as last drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DeclaredAction {
pub action: Action,
pub rect: [f32; 4],
pub enabled: bool,
}
/// `rect` is the button doing `action` on this screen; see [`crate::ui::action_button`], for a
/// screen that draws its buttons itself.
pub fn declare_action(action: Action, rect: [f32; 4], enabled: bool) {
ACTIONS.with_borrow_mut(|(current, _)| {
if current.len() < 16 {
current.push(DeclaredAction {
action,
rect,
enabled,
});
}
});
}
/// The buttons of the last drawn frame that declared their job, if it drew `screen`: until the
/// screen just opened is drawn, the one it replaced can't be pressed.
pub fn last_actions(screen: u64) -> Vec<DeclaredAction> {
if ACTION_SCREENS.get().1 != screen {
return Vec::new();
}
ACTIONS.with_borrow(|(_, last)| last.clone())
}
/// The frame being drawn shows `screen` (the controller's own key for it).
pub fn set_action_screen(screen: u64) {
ACTION_SCREENS.set((screen, ACTION_SCREENS.get().1));
}
/// Whether declared buttons are showing their glyph: only while the pad drives.
pub fn set_action_glyphs(on: bool) {
ACTION_GLYPHS.set(on);
}
/// How much of a declared button's left the glyph takes from its label.
pub fn action_glyph_room() -> f32 {
if ACTION_GLYPHS.get() { 14.0 } else { 0.0 }
}
/// An interactive rectangle (GUI pixels) a controller can focus and click.
@@ -225,6 +308,10 @@ pub fn end_frame(frames: u64) {
*last = std::mem::take(current);
*generation += 1;
});
ACTIONS.with_borrow_mut(|(current, last)| {
*last = std::mem::take(current);
});
ACTION_SCREENS.set((ACTION_SCREENS.get().0, ACTION_SCREENS.get().0));
NARRATION.with_borrow_mut(|(current, last)| {
*last = std::mem::replace(current, Narration::EMPTY);
});
@@ -700,8 +787,10 @@ pub fn narrating() -> bool {
/// The screen being drawn: what it narrates and which one it is. The
/// narratables recorded before it in the frame are dropped: the last screen
/// drawn (an overlay over the screen under it) is the one narrated.
/// drawn (an overlay over the screen under it) is the one narrated. So are the
/// jobs declared before it: a popup's buried buttons can't be pressed.
pub fn begin_screen(info: impl FnOnce() -> ScreenInfo) {
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
if NARRATING.get() {
NARRATION.with_borrow_mut(|(current, _)| {
current.screen = Some(info());
@@ -1153,6 +1242,29 @@ mod tests {
assert_eq!(slug("Économie"), "économie");
}
#[test]
fn declared_buttons_are_only_those_of_the_screen_drawn() {
let done = |x| declare_action(Action::Done, [x, 0.0, 5.0, 5.0], true);
done(0.0);
set_action_screen(7);
end_frame(200);
assert_eq!(last_actions(7).len(), 1);
// Another screen opened and hasn't been drawn yet.
assert!(last_actions(8).is_empty());
}
#[test]
fn a_popup_drops_the_buttons_declared_under_it() {
declare_action(Action::Done, [0.0, 0.0, 5.0, 5.0], true);
begin_screen(|| ScreenInfo::new("popup", ""));
declare_action(Action::Cancel, [9.0, 0.0, 5.0, 5.0], true);
set_action_screen(7);
end_frame(300);
let actions = last_actions(7);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].action, Action::Cancel);
}
#[test]
fn last_lists_buttons_only() {
end_frame(100);
+4
View File
@@ -142,6 +142,10 @@ path = "tests/launch_api.rs"
name = "mod_api_hooks"
path = "tests/mod_api_hooks.rs"
[[example]]
name = "native_gui_overlay_probe"
path = "examples/native_gui_overlay_probe.rs"
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
@@ -358,7 +358,7 @@
"horizon.moderation.remaining": "für weitere %s",
"horizon.moderation.mutelist.entry": "%1$s wurde von %2$s %3$s stummgeschaltet",
"horizon.moderation.invalid_duration": "%s ist keine Zeitdauer: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <Spieler> [Grund]",
"horizon.moderation.usage.mute": "/mute <Spieler> [Dauer] [Grund]",
"horizon.moderation.usage.tempmute": "/tempmute <Spieler> <Dauer> [Grund]",
"horizon.moderation.usage.unmute": "/unmute <Spieler>",
"horizon.moderation.usage.mutelist": "/mutelist",
@@ -369,5 +369,8 @@
"horizon.tempban.usage": "/tempban <Spieler> <Dauer> [Grund]",
"horizon.tempban.invalid_duration": "%s ist keine Zeitdauer: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Temporäre Sperren benötigen einen dedizierten Server",
"horizon.tempban.success": "%1$s für %2$s, bis %3$s"
"horizon.tempban.success": "%1$s für %2$s, bis %3$s",
"horizon.tempban.list.empty": "Es gibt keine befristeten Sperren",
"horizon.tempban.list.count": "Es gibt %s befristete Sperre(n):",
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)"
}
@@ -222,6 +222,7 @@
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
"horizon.realms.not_signed_in": "Sign in with your Microsoft account in the Horizon launcher to play on Realms.",
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
@@ -337,6 +338,7 @@
"horizon.friends.stopped": "Stopped sharing the invite code.",
"horizon.friends.short_code_changed": "Someone kept trying a wrong short code, so it changed.",
"horizon.friends.wrong_code": "Someone tried a wrong code.",
"horizon.friends.relay_stopped": "Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
"horizon.friends.code_ready": "Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
"horizon.friends.share_failed": "Couldn't share the world: %s",
"horizon.friends.finding_route": "Finding how friends can reach you…",
@@ -536,7 +538,7 @@
"horizon.moderation.remaining": "for another %s",
"horizon.moderation.mutelist.entry": "%1$s was muted by %2$s %3$s",
"horizon.moderation.invalid_duration": "%s isn't a duration: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <player> [reason]",
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
"horizon.moderation.usage.unmute": "/unmute <player>",
"horizon.moderation.usage.mutelist": "/mutelist",
@@ -548,6 +550,9 @@
"horizon.tempban.invalid_duration": "%s isn't a duration: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Temporary bans need a dedicated server",
"horizon.tempban.success": "%1$s for %2$s, until %3$s",
"horizon.tempban.list.empty": "There are no temporary bans",
"horizon.tempban.list.count": "There are %s temporary ban(s):",
"horizon.tempban.list.entry": "%1$s was banned by %2$s %3$s (until %4$s)",
"horizon.options.pointer_speed": "Menu Pointer Speed",
"horizon.options.scroll_speed": "Menu Scroll Speed",
"horizon.options.repeat_delay": "Navigation Repeat Delay",
@@ -590,5 +595,112 @@
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Circle",
"horizon.pvp.shape.square": "Square",
"horizon.pvp.shape.dot": "Dot"
"horizon.pvp.shape.dot": "Dot",
"horizon.pvp.outline": "Custom block outline",
"horizon.pvp.outline.tooltip": "Changes the outline around the block you look at.",
"horizon.pvp.outline_colour": "Colour",
"horizon.pvp.outline_colour.tooltip": "The outline's colour. Black at 40% is vanilla's.",
"horizon.pvp.outline_opacity": "Opacity",
"horizon.pvp.outline_opacity.tooltip": "How solid the outline is.",
"horizon.pvp.outline_thickness": "Thickness",
"horizon.pvp.outline_thickness.tooltip": "The outline's width, in times vanilla's.",
"horizon.pvp.outline_fill": "Fill",
"horizon.pvp.outline_fill.tooltip": "Tints the faces of the block you look at, lightly.",
"horizon.pvp.hit": "Custom hit colour",
"horizon.pvp.hit.tooltip": "Changes the colour mobs and players flash when they are hurt. Red at 30% is vanilla's.",
"horizon.pvp.hit_colour": "Colour",
"horizon.pvp.hit_colour.tooltip": "The colour hurt mobs and players flash.",
"horizon.pvp.hit_opacity": "Opacity",
"horizon.pvp.hit_opacity.tooltip": "How much of the colour covers them.",
"horizon.pvp.header.outline": "Block outline",
"horizon.pvp.header.hit": "Hit colour",
"horizon.pvp.hud_armour": "Armour",
"horizon.pvp.hud_armour.tooltip": "The armour you wear, head first, with how many uses each piece has left.",
"horizon.pvp.hud_armour_corner": "Armour corner",
"horizon.pvp.hud_armour_corner.tooltip": "Where the armour shows.",
"horizon.pvp.hud_armour_percent": "Durability in %",
"horizon.pvp.hud_armour_percent.tooltip": "Uses left as a percent of the piece's maximum instead of a number.",
"horizon.pvp.hud_effects": "Effects",
"horizon.pvp.hud_effects.tooltip": "Your effects with their names, levels and time left, in place of the icons at the top right.",
"horizon.pvp.hud_effects_corner": "Effects corner",
"horizon.pvp.hud_effects_corner.tooltip": "Where the effects show.",
"horizon.pvp.hud_held": "Held item count",
"horizon.pvp.hud_held.tooltip": "How many of the item in your hand your inventory holds; arrows with a bow or crossbow.",
"horizon.pvp.hud_held_corner": "Held count corner",
"horizon.pvp.hud_held_corner.tooltip": "Where the count shows.",
"horizon.pvp.hud_background": "Widget backgrounds",
"horizon.pvp.hud_background.tooltip": "A dark box behind each widget.",
"horizon.pvp.header.hud": "HUD widgets",
"horizon.pvp.corner.top_left": "Top left",
"horizon.pvp.corner.top_right": "Top right",
"horizon.pvp.corner.bottom_left": "Bottom left",
"horizon.pvp.corner.bottom_right": "Bottom right",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "The latency the server reports for you, in milliseconds. Multiplayer only.",
"horizon.pvp.hud_ping_corner": "Ping corner",
"horizon.pvp.hud_ping_corner.tooltip": "Where the ping shows.",
"horizon.pvp.hud_coords": "Coordinates",
"horizon.pvp.hud_coords.tooltip": "Your block position and the direction you face, as F3 shows them. Hidden when the world or server reduces F3.",
"horizon.pvp.hud_coords_corner": "Coordinates corner",
"horizon.pvp.hud_coords_corner.tooltip": "Where the coordinates show.",
"horizon.pvp.hud_server": "Server address",
"horizon.pvp.hud_server.tooltip": "The address of the server you play on. Multiplayer only.",
"horizon.pvp.hud_server_corner": "Server corner",
"horizon.pvp.hud_server_corner.tooltip": "Where the address shows.",
"horizon.pvp.hud_combo": "Combo counter",
"horizon.pvp.hud_combo.tooltip": "Hits in a row the server confirmed, until you are hurt or 3 seconds pass without one.",
"horizon.pvp.hud_combo_corner": "Combo corner",
"horizon.pvp.hud_combo_corner.tooltip": "Where the combo shows.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Facing: %s",
"horizon.pvp.hud.combo": "Combo: %s",
"horizon.pvp.hud.direction.north": "North",
"horizon.pvp.hud.direction.south": "South",
"horizon.pvp.hud.direction.east": "East",
"horizon.pvp.hud.direction.west": "West",
"horizon.pvp.numeric_enchants": "Enchantment levels as numbers",
"horizon.pvp.numeric_enchants.tooltip": "Tooltips and the enchanting table show Sharpness 5 instead of Sharpness V.",
"horizon.pvp.header.items": "Items",
"horizon.pvp.fire_lower": "Lower fire",
"horizon.pvp.fire_lower.tooltip": "Moves the flames you see while burning down, so they cover less of the screen.",
"horizon.pvp.shield_size": "Shield size",
"horizon.pvp.shield_size.tooltip": "The size of the shield in your hand, in first person.",
"horizon.pvp.totem_size": "Totem animation size",
"horizon.pvp.totem_size.tooltip": "The size of the totem that fills the screen when it saves you.",
"horizon.pvp.glint_colour": "Enchantment glint colour",
"horizon.pvp.glint_colour.tooltip": "The colour of the shimmer on enchanted items and armour. Off is vanilla's purple.",
"horizon.pvp.clear_water": "Clear water",
"horizon.pvp.clear_water.tooltip": "Under water, see as far as vanilla lets you after 30 seconds there, from the start.",
"horizon.pvp.clear_lava": "Clear lava",
"horizon.pvp.clear_lava.tooltip": "In lava, see as far as vanilla's Fire Resistance lets you, without the effect.",
"horizon.pvp.header.visual": "Visual tweaks",
"horizon.pvp.view_time": "Time of day",
"horizon.pvp.view_time.tooltip": "The time of day you see: the sky and its light only. The world's time goes on and nothing is sent to the server.",
"horizon.pvp.view_weather": "Weather",
"horizon.pvp.view_weather.tooltip": "The weather you see: rain, its light and fog only. The world's weather goes on and nothing is sent to the server.",
"horizon.pvp.header.view": "Your view",
"horizon.pvp.view.world": "World's",
"horizon.pvp.time.morning": "Morning",
"horizon.pvp.time.noon": "Noon",
"horizon.pvp.time.sunset": "Sunset",
"horizon.pvp.time.midnight": "Midnight",
"horizon.pvp.weather.clear": "Clear",
"horizon.pvp.weather.rain": "Rain",
"horizon.pvp.weather.thunder": "Thunder",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Chat",
"horizon.chat.copied": "Message copied",
"horizon.chat.search": "Search chat:",
"horizon.pvp.chat_timestamps": "Timestamps",
"horizon.pvp.chat_timestamps.tooltip": "The time each message came in, before it.",
"horizon.pvp.chat_compact": "Compact chat",
"horizon.pvp.chat_compact.tooltip": "A message repeated in a row shows once, with how many times it came: (x3).",
"horizon.pvp.chat_copy": "Copy on click",
"horizon.pvp.chat_copy.tooltip": "Clicking a message in the open chat copies its text, unless the click does something else.",
"horizon.pvp.chat_search": "Chat search",
"horizon.pvp.chat_search.tooltip": "Ctrl+F in the open chat: what you type shows only the messages holding it. Enter or Ctrl+F again stops.",
"horizon.pvp.preset": "PvP preset",
"horizon.pvp.preset.tooltip": "Turns on the custom crosshair, the armour and effects widgets, toggle sprint and sneak, lower fire, and the FPS counter and keystrokes mods if installed. Your settings for each stay."
}
@@ -240,7 +240,7 @@
"horizon.moderation.remaining": "durante %s más",
"horizon.moderation.mutelist.entry": "%1$s fue silenciado por %2$s %3$s",
"horizon.moderation.invalid_duration": "%s no es una duración: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <jugador> [motivo]",
"horizon.moderation.usage.mute": "/mute <jugador> [duración] [motivo]",
"horizon.moderation.usage.tempmute": "/tempmute <jugador> <duración> [motivo]",
"horizon.moderation.usage.unmute": "/unmute <jugador>",
"horizon.moderation.usage.mutelist": "/mutelist",
@@ -251,5 +251,8 @@
"horizon.tempban.usage": "/tempban <jugador> <duración> [motivo]",
"horizon.tempban.invalid_duration": "%s no es una duración: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Los bloqueos temporales necesitan un servidor dedicado",
"horizon.tempban.success": "%1$s durante %2$s, hasta %3$s"
"horizon.tempban.success": "%1$s durante %2$s, hasta %3$s",
"horizon.tempban.list.empty": "No hay baneos temporales",
"horizon.tempban.list.count": "Hay %s baneo(s) temporal(es):",
"horizon.tempban.list.entry": "%1$s fue baneado por %2$s %3$s (hasta %4$s)"
}
@@ -456,7 +456,7 @@
"horizon.moderation.remaining": "pour encore %s",
"horizon.moderation.mutelist.entry": "%1$s a été réduit au silence par %2$s %3$s",
"horizon.moderation.invalid_duration": "%s n'est pas une durée : 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <joueur> [raison]",
"horizon.moderation.usage.mute": "/mute <joueur> [durée] [raison]",
"horizon.moderation.usage.tempmute": "/tempmute <joueur> <durée> [raison]",
"horizon.moderation.usage.unmute": "/unmute <joueur>",
"horizon.moderation.usage.mutelist": "/mutelist",
@@ -468,6 +468,9 @@
"horizon.tempban.invalid_duration": "%s n'est pas une durée : 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Les bannissements temporaires nécessitent un serveur dédié",
"horizon.tempban.success": "%1$s pour %2$s, jusqu'au %3$s",
"horizon.tempban.list.empty": "Il n'y a aucun bannissement temporaire",
"horizon.tempban.list.count": "Il y a %s bannissement(s) temporaire(s) :",
"horizon.tempban.list.entry": "%1$s a été banni par %2$s %3$s (jusqu'au %4$s)",
"horizon.options.pointer_speed": "Vitesse du pointeur des menus",
"horizon.options.scroll_speed": "Vitesse de défilement des menus",
"horizon.options.repeat_delay": "Délai de répétition de navigation",
@@ -186,6 +186,7 @@
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.realms.not_signed_in": "Войдите в лаунчере Horizon с учётной записью Microsoft, чтобы играть в Realms.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
@@ -0,0 +1,81 @@
//! A real-renderer consumer of the public GUI API. Run in a disposable game home:
//! `cargo run -p horizon-client --example native_gui_overlay_probe --
//! --resources PATH --world OverlayProbe --creative --world-frames 120`.
//! Every world frame turns the camera 90 degrees; GUI callbacks assert that
//! Host::camera already sees that frame. A magenta square stays above menus/F1.
use horizon_client::mod_api::{
CameraPoseEvent, GuiRenderPostEvent, Hooks, Host, HudEvent, ScreenRenderPostEvent,
};
use std::sync::atomic::{AtomicU32, Ordering};
struct Probe {
frame: AtomicU32,
yaw: AtomicU32,
}
impl Probe {
fn observe(&self, host: &dyn Host, phase: &str, flags: Option<(bool, bool)>) {
let camera = host.camera();
if let Some(camera) = &camera {
assert_eq!(
camera.yaw.to_bits(),
self.yaw.load(Ordering::SeqCst),
"previous frame's camera in {phase}"
);
assert!(
camera
.view
.iter()
.chain(camera.projection.iter())
.all(|v| v.is_finite())
);
}
eprintln!(
"native_gui_probe {}",
serde_json::json!({
"frame": self.frame.load(Ordering::SeqCst), "phase": phase,
"yaw": camera.map(|c| c.yaw), "flags": flags
})
);
}
}
impl Hooks for Probe {
fn camera_pose(&self, _: &dyn Host, event: &mut CameraPoseEvent<'_>) {
let frame = self.frame.fetch_add(1, Ordering::SeqCst);
event.yaw = [0.0_f32, 90.0, 180.0, -90.0][frame as usize % 4];
self.yaw.store(event.yaw.to_bits(), Ordering::SeqCst);
}
fn hud(&self, host: &mut dyn Host, _: &mut HudEvent<'_>) {
self.observe(host, "hud", None);
}
fn screen_render_post(&self, host: &dyn Host, event: &mut ScreenRenderPostEvent<'_>) {
self.observe(host, event.screen.id, None);
}
fn gui_render_post(&self, host: &dyn Host, event: &mut GuiRenderPostEvent<'_>) {
let (w, h) = (event.batch.width, event.batch.height);
host.fill(
event.batch,
[w * 0.5 - 8.0, h - 24.0, 16.0, 16.0],
0xffff00ff,
);
self.observe(
host,
"gui_render_post",
Some((event.gui_hidden, event.focused)),
);
}
}
fn main() {
horizon_client::entry::main_with(
vec![Box::new(Probe {
frame: AtomicU32::new(0),
yaw: AtomicU32::new(0),
}) as Box<dyn Hooks>]
.into(),
);
}
@@ -0,0 +1,141 @@
//! Client interaction state through the production Allay predictor; no prepared resources.
use horizon_client::{
Result,
items::ItemStack,
mobs::{
Anim, Brain, Flags, Mob, MobType, Part, Species,
allay::interaction::{Interaction, predict},
},
};
use horizon_core::geometry::Vec3;
use std::{io::Write, sync::Arc};
fn kind() -> Arc<MobType> {
Arc::new(MobType {
id: "minecraft:allay".into(),
species: Species::Allay,
width: 0.6,
height: 1.95,
eye_height: 0.36,
category: "monster".into(),
max_health: 20.0,
speed: 0.23,
flying_speed: 0.4,
attack_damage: 3.0,
attack_knockback: 0.0,
follow_range: 35.0,
step_height: 0.6,
armor: 2.0,
armor_toughness: 0.0,
knockback_resistance: 0.0,
jump_strength: 0.42,
attributes: Arc::default(),
item_stack_limits: Arc::default(),
gravity: 0.08,
safe_fall_distance: 3.0,
fall_damage_multiplier: 1.0,
water_efficiency: 0.0,
water_slowdown: 0.8,
air_speed: 0.02,
baby_scale: 0.5,
head_rot_speed: 10.0,
max_head_x_rot: 40.0,
max_head_y_rot: 75.0,
ambient_interval: 120,
flags: Flags::default(),
model: Arc::new(Part {
name: "root".into(),
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: Vec::new(),
children: Vec::new(),
}),
layers: Default::default(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Allay,
sounds: [""; 3],
step_sound: None,
food: Arc::default(),
tempt: Arc::default(),
})
}
fn stack(s: Option<&ItemStack>) -> String {
s.filter(|s| s.count > 0)
.map_or_else(|| "empty".into(), |s| format!("{}x{}", s.item, s.count))
}
pub fn main() -> Result<()> {
let args: Vec<String> = crate::args().collect();
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[1])?);
for tag in 0..3 {
for main in [true, false] {
for item in [
"minecraft:air",
"minecraft:stone",
"minecraft:amethyst_shard",
] {
for holding in [false, true] {
for dancing in [false, true] {
for ready in [false, true] {
for creative in [false, true] {
let mut mob = Mob::new(37, kind(), Vec3::ZERO, 0.0);
mob.extra.flag = dancing;
mob.extra.egg_time = i32::from(!ready);
if holding {
mob.extra.set_main_hand_stack(Some(ItemStack::new(
"minecraft:stone",
1,
)));
}
let mut offer =
(item != "minecraft:air").then(|| ItemStack::new(item, 1));
for step in 0..3 {
let duplicates = offer.as_ref().is_some_and(|s| {
(tag != 2 && &*s.item == "minecraft:amethyst_shard")
|| (tag == 1 && &*s.item == "minecraft:stone")
});
let result = predict(
&mut mob,
offer.as_ref(),
main,
duplicates,
[0, 0, 0, 11],
);
let sounds = result.as_ref().map_or_else(String::new, |r| {
format!("{}:6:2.0:1.0:true", r.sound())
});
let mut returned = String::new();
match result {
Some(Interaction::Take(s)) => returned = stack(Some(&s)),
Some(Interaction::Duplicate | Interaction::Give)
if !creative =>
{
if let Some(s) = offer.as_mut() {
s.count -= 1;
}
if offer.as_ref().is_some_and(|s| s.count == 0) {
offer = None;
}
}
_ => {}
}
let hand = if main { "MAIN_HAND" } else { "OFF_HAND" };
writeln!(
out,
"{tag} {hand} {item} {holding} {dancing} {ready} {creative} {step} => held={} offer={} can={} liked={} returned=[{returned}] sounds=[{sounds}]",
stack(mob.extra.main_hand_stack()),
stack(offer.as_ref()),
mob.extra.egg_time == 0,
mob.extra.owner.is_some()
)?;
}
}
}
}
}
}
}
}
Ok(())
}
@@ -119,6 +119,7 @@ pub fn main() -> Result<()> {
assets: &assets,
collision_binding: &binding,
resources: &resources,
health: &resources.health,
}
.update_auto_jump(fl(&c["xa"]), fl(&c["za"]))?;
}
@@ -23,6 +23,9 @@ impl World for Empty {
fn dimension(&self) -> &str {
"minecraft:overworld"
}
fn weather(&self) -> (bool, bool) {
(false, false)
}
fn biome(&self, _: [i32; 3]) -> String {
"minecraft:plains".into()
}
@@ -0,0 +1,48 @@
//! Captured level sounds through the production packet builder and protocol codec.
use horizon_client::{audio::CapturedSound, lan::containers::captured_sound_packet};
use horizon_protocol::{buf::Reader, packets::play::Clientbound as C};
use std::io::Write;
pub fn main() {
let args: Vec<String> = crate::args().collect();
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[2]).unwrap());
for line in std::fs::read_to_string(&args[1]).unwrap().lines() {
let c: serde_json::Value = serde_json::from_str(line).unwrap();
let float =
|key: &str| f32::from_bits(u32::from_str_radix(c[key].as_str().unwrap(), 16).unwrap());
let position = std::array::from_fn(|i| {
f64::from_bits(u64::from_str_radix(c["position"][i].as_str().unwrap(), 16).unwrap())
});
let sound = CapturedSound {
event: "fixture:bound".into(),
position,
volume: float("volume"),
pitch: float("pitch"),
source: c["source"].as_u64().unwrap() as usize,
entity: c["bound"]
.as_bool()
.unwrap()
.then(|| c["entity"].as_i64().unwrap() as u32),
except_entity: None,
};
let (packet, range) = captured_sound_packet(
&sound,
c["sound_id"].as_i64().map(|n| n as i32),
c["seed"].as_i64().unwrap(),
);
let kind = match packet {
C::SoundEntity(_) => "entity",
C::Sound(_) => "position",
_ => unreachable!(),
};
let bytes = packet.encode();
let mut reader = Reader::new(&bytes);
let id = reader.varint().unwrap();
// The native StreamCodec writes the payload only; also require our own
// decoder to consume this packet fully before comparing payload bytes.
let prefix = bytes.len() - reader.remaining();
C::decode(id, &bytes[prefix..]).unwrap();
let payload: String = bytes[prefix..].iter().map(|b| format!("{b:02x}")).collect();
writeln!(out, "{kind} {:016x} {payload}", range.to_bits()).unwrap();
}
}
@@ -0,0 +1,86 @@
//! Late trident pairing and subsequent updates through the production tracker.
use horizon_client::{
Result,
lan::entity::{Entities, EntityState, Riding, Tracker, Viewer},
};
use horizon_protocol::{
buf::Reader,
entity_data::{Value, serializer},
packets::play::Clientbound as C,
};
use std::io::Write;
fn dump(out: &mut impl Write, index: usize, phase: &str, packets: &mut Vec<Vec<u8>>) -> Result<()> {
let mut encoded = Vec::new();
for bytes in packets.drain(..) {
let mut r = Reader::new(&bytes);
let id = r.varint()?;
let payload = r.rest();
let name = match C::decode(id, payload)? {
C::AddEntity(_) => "add",
C::SetEntityMotion(_) => "motion",
C::MoveEntityPosRot(_) => "posrot",
C::SetEntityData(_) => "data",
other => panic!("unexpected late-pair packet {other:?}"),
};
let hex: String = payload.iter().map(|b| format!("{b:02x}")).collect();
encoded.push(format!("{name}:{hex}"));
}
writeln!(out, "{index} {phase} {}", encoded.join(","))?;
Ok(())
}
pub fn main() -> Result<()> {
let args: Vec<String> = crate::args().collect();
let kind: i32 = args[1].parse()?;
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[2])?);
writeln!(out, "type {kind}")?;
let mut index = 0;
for position in [[0.5, 65.5, 0.5], [-0.123456789, 64.125, -0.5]] {
for velocity in [[0.25, 0.0, 0.0], [0.0; 3], [0.25, -0.5, 0.125]] {
for landed in [false, true] {
let mut level = Entities::default();
let mut tracker = Tracker::default();
let mut packets = Vec::new();
let chunks = [(0, 0), (-1, -1), (0, -1), (-1, 0)].into_iter().collect();
let mut viewers = [Viewer {
entity_id: 1,
spectator: false,
permission: 0,
moved: false,
position: [0.5, 64.0, 0.5],
view_distance: 10,
chunks: &chunks,
tracker: &mut tracker,
out: &mut packets,
gone: None,
}];
let riding = Riding::default();
level.tick(&[], &riding, &mut viewers, |_| Some(kind));
let mut e = EntityState {
id: 37,
uuid: 37,
kind: "minecraft:trident".into(),
position,
velocity,
..EntityState::default()
};
level.pair_after_tick(std::slice::from_ref(&e), &riding, &mut viewers, |_| {
Some(kind)
});
dump(&mut out, index, "pair", viewers[0].out)?;
e.position[0] += 0.25;
e.velocity = [0.0; 3];
if landed {
e.meta.push((10, serializer::BOOLEAN, Value::Bool(true)));
}
level.tick(&[&e], &riding, &mut viewers, |_| Some(kind));
dump(&mut out, index, "change", viewers[0].out)?;
level.tick(&[&e], &riding, &mut viewers, |_| Some(kind));
dump(&mut out, index, "again", viewers[0].out)?;
index += 1;
}
}
}
Ok(())
}
@@ -10,6 +10,7 @@ mod dispatch;
use dispatch::{args, args_os};
mod allay_interaction_fixture;
mod ambient_fixture;
mod arrow_probe;
mod audio_attributes_fixture;
@@ -41,6 +42,7 @@ mod entity_anim_fixture;
mod entity_components_probe;
mod entity_death_loot_fixture;
mod entity_particles_probe;
mod entity_sound_packet_fixture;
mod environment_fixture;
mod finalize_spawn_fixture;
mod fire_starter_sound_probe;
@@ -57,6 +59,7 @@ mod inline_object_fixture;
mod input_probe;
mod interaction_input_fixture;
mod lan_probe;
mod late_entity_pair_fixture;
mod light_parity;
mod lore_tooltip_fixture;
mod marker_particles_probe;
@@ -87,6 +90,7 @@ mod scoreboard_fixture;
mod screen_layout_fixture;
mod section_mesh_fixture;
mod server_files_probe;
mod sheep_dye_fixture;
mod situational_music_fixture;
mod skin_image_fixture;
mod sound_engine_fixture;
@@ -191,6 +195,10 @@ probes! {
server_files_probe,
situational_music_fixture,
skin_image_fixture,
late_entity_pair_fixture,
sheep_dye_fixture,
allay_interaction_fixture,
entity_sound_packet_fixture,
sound_engine_fixture,
sound_event_fixture,
spawn_rules_fixture,
@@ -1611,7 +1611,14 @@ pub fn main() -> horizon_client::Result<()> {
let _ = writeln!(out, "{t} leads {dropped_leads}");
}
for e in &events {
if let MobEvent::Sound(name, _, volume, pitch) = e {
if let MobEvent::Sound(name, _, volume, pitch)
| MobEvent::EntitySound {
name,
volume,
pitch,
..
} = e
{
let _ = writeln!(
out,
"{t} sound minecraft:{name} {} {}",
@@ -324,6 +324,7 @@ pub fn main() -> Result<()> {
assets: &assets,
collision_binding: &binding,
resources: &resources,
health: &resources.health,
};
match op["mode"].as_str().unwrap() {
"shift" => world.player.shift_key_down = op["value"].as_bool().unwrap(),
@@ -0,0 +1,115 @@
//! Actual production sheep-dye interaction without prepared resources.
use horizon_client::{
Result,
items::ItemStack,
mobs::{Anim, Brain, Flags, Mob, MobType, Part, Species, sheep_dye},
};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::Tag;
use std::{io::Write, sync::Arc};
fn kind() -> Arc<MobType> {
Arc::new(MobType {
id: "minecraft:sheep".into(),
species: Species::Sheep,
width: 0.6,
height: 1.95,
eye_height: 0.36,
category: "monster".into(),
max_health: 20.0,
speed: 0.23,
flying_speed: 0.4,
attack_damage: 3.0,
attack_knockback: 0.0,
follow_range: 35.0,
step_height: 0.6,
armor: 2.0,
armor_toughness: 0.0,
knockback_resistance: 0.0,
jump_strength: 0.42,
attributes: Arc::default(),
item_stack_limits: Arc::default(),
gravity: 0.08,
safe_fall_distance: 3.0,
fall_damage_multiplier: 1.0,
water_efficiency: 0.0,
water_slowdown: 0.8,
air_speed: 0.02,
baby_scale: 0.5,
head_rot_speed: 10.0,
max_head_x_rot: 40.0,
max_head_y_rot: 75.0,
ambient_interval: 120,
flags: Flags::default(),
model: Arc::new(Part {
name: "root".into(),
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: Vec::new(),
children: Vec::new(),
}),
layers: Default::default(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Quadruped,
sounds: [""; 3],
step_sound: None,
food: Arc::default(),
tempt: Arc::default(),
})
}
pub fn main() -> Result<()> {
let args: Vec<String> = crate::args().collect();
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[1])?);
for initial in [0, 7, 14, 15] {
for hand in ["MAIN_HAND", "OFF_HAND"] {
for item in [
"minecraft:white_dye",
"minecraft:red_dye",
"minecraft:black_dye",
] {
for patch in 0..3 {
for sheared in [false, true] {
for alive in [false, true] {
for client in [false, true] {
let mut mob = Mob::new(37, kind(), Vec3::ZERO, 0.0);
mob.extra.color = initial;
mob.extra.sheared = sheared;
if !alive {
mob.health = 0.0;
}
let mut stack = ItemStack::new(item, 2);
if patch == 1 {
stack.data_mut().set_other(
"minecraft:dye",
Some(Tag::String("blue".into())),
);
}
if patch == 2 {
stack.data_mut().set_other(
"!minecraft:dye",
Some(Tag::Compound(Default::default())),
);
}
for step in 0..2 {
let result = sheep_dye::interact(&mut mob, &mut stack, client);
let sound = if result {
"minecraft:item.dye.use:7:1.0:1.0:true"
} else {
""
};
writeln!(
out,
"{initial} {hand} {item} {patch} {sheared} {alive} {client} {step} => result={result} color={} count={} sounds=[{sound}]",
mob.extra.color, stack.count
)?;
}
}
}
}
}
}
}
}
Ok(())
}
@@ -4,7 +4,8 @@
use crate::audio_common as common;
use common::{Fake, d, describe, f, flush, result_name};
use horizon_client::sound_engine::{
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundInstance, TickContext,
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundEntity, SoundEntityState,
SoundInstance, TickContext,
};
use horizon_client::sound_events::SoundRegistry;
use horizon_core::random::LegacyRandom;
@@ -21,6 +22,7 @@ pub fn main() {
// Script cart names to entity ids (a removed cart leaves the level).
let mut cart_ids: BTreeMap<String, u32> = BTreeMap::new();
let mut removed: BTreeMap<u32, CartSound> = BTreeMap::new();
let mut bound_entities: BTreeMap<String, SoundEntity> = BTreeMap::new();
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
@@ -51,6 +53,14 @@ pub fn main() {
"loop" => SoundInstance::biome_loop(&event, random),
"uloop" => SoundInstance::under_liquid_loop(&event, random),
"usub" => SoundInstance::under_liquid_sub(&event, random),
"bound" => SoundInstance::entity_bound(
&event,
source(4),
f(a[5]),
f(a[6]),
random,
bound_entities[a[15]].clone(),
),
"cart" | "rcart" | "rcartu" => {
let id = cart_ids[a[3]];
let cart = ctx
@@ -109,7 +119,7 @@ pub fn main() {
ctx.player_underwater = a[1] == "1";
ctx.player_removed = a[2] == "1";
}
"cart" => {
"cart" | "cartnew" => {
let next = cart_ids.len() as u32 + 1;
let id = *cart_ids.entry(a[1].to_owned()).or_insert(next);
ctx.carts.retain(|c| c.id != id);
@@ -119,6 +129,22 @@ pub fn main() {
// Vec3.horizontalDistance.
speed: (d(a[5]) * d(a[5]) + d(a[7]) * d(a[7])).sqrt(),
};
let silent = a.get(9) == Some(&"1");
if a[0] == "cartnew"
&& let Some(old) = bound_entities.remove(a[1])
{
let mut state = old.state();
state.removed = true;
old.update(state);
}
let entity = bound_entities
.entry(a[1].to_owned())
.or_insert_with(|| SoundEntity::new(id, cart.position, silent));
entity.update(SoundEntityState {
position: cart.position,
silent,
removed: a[8] == "1",
});
if a[8] == "1" {
// Out of the level, still there for a later play.
removed.insert(id, cart);
File diff suppressed because it is too large. Load diff
+9 -5
View File
@@ -18,11 +18,12 @@ pub use horizon_client_render::{
json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, par, particles,
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, screen_draw, screen_effects,
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
@@ -111,6 +112,7 @@ pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod random_tick_sections;
pub mod realms;
pub mod recipe_book;
pub mod recipe_book_screen;
pub mod reconnect;
@@ -128,6 +130,7 @@ pub mod sprint;
pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod studio;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
@@ -136,6 +139,7 @@ pub mod toasts;
pub mod ui_theme;
pub mod vanillagen;
pub mod win_screen;
pub mod world_primitives;
pub mod world_spawn;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
@@ -67,6 +67,11 @@ impl Confirm {
) {
self.draw_active(batch, atlas, cursor, labels, true);
}
/// The second button answers no, as Escape does (`widgets::Action`).
pub fn declare_no(&self) {
let (x, y) = self.buttons[1];
crate::widgets::declare_action(crate::widgets::Action::Cancel, [x, y, 150.0, 20.0], true);
}
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
pub fn draw_active(
&self,
@@ -183,6 +188,16 @@ impl Choice {
.iter()
.position(|&(x, y)| ui::hovered(cursor, x, y, self.width, 20.0))
}
/// Button `i` answers no, as Escape does (`widgets::Action`).
pub fn declare_no(&self, i: usize) {
if let Some(&(x, y)) = self.buttons.get(i) {
crate::widgets::declare_action(
crate::widgets::Action::Cancel,
[x, y, self.width, 20.0],
true,
);
}
}
/// The lines and the buttons, over whatever is behind.
pub fn draw(
&self,
+55 -1
View File
@@ -227,7 +227,10 @@ impl Click {
pub fn parse(t: &Tag, kind: &str) -> Option<Self> {
let s = |key| string(t.get(key));
Some(match path(kind) {
"open_url" => Self::OpenUrl(s("url")?),
// ClickEvent.OpenUrl's url is an ExtraCodecs.UNTRUSTED_URI.
"open_url" => {
Self::OpenUrl(s("url").filter(|url| crate::component::untrusted_http_url(url))?)
}
"run_command" => Self::RunCommand(s("command")?),
"suggest_command" => Self::SuggestCommand(s("command")?),
"show_dialog" => Self::ShowDialog(t.get("dialog")?.clone()),
@@ -639,6 +642,19 @@ impl Dialog {
| Kind::DialogList { exit, .. } => exit.as_ref().and_then(|b| b.action.as_ref()),
}
}
/// Which of [`Self::buttons`] does what Escape does, if Escape does anything.
pub fn escape_button(&self) -> Option<usize> {
if !self.can_close_with_escape {
return None;
}
let last = self.buttons().len().checked_sub(1);
match &self.kind {
Kind::Notice(_) | Kind::Confirmation { .. } => last,
Kind::MultiAction { exit, .. }
| Kind::ServerLinks { exit, .. }
| Kind::DialogList { exit, .. } => exit.as_ref().and(last),
}
}
/// The buttons it draws, in order: the list, then the footer's.
pub fn buttons(&self) -> Vec<&Button> {
match &self.kind {
@@ -829,6 +845,28 @@ mod tests {
assert_eq!(key, "password");
}
#[test]
fn open_url_actions_take_only_web_links() {
let open = |url: &str| {
let action = Tag::compound([("type", s("minecraft:open_url")), ("url", s(url))]);
Action::parse(&action).and_then(|a| a.click(&[], &[]))
};
assert_eq!(
open("https://example.com/a"),
Some(Click::OpenUrl("https://example.com/a".into()))
);
assert!(open("HTTP://example.com").is_some());
for url in [
"file:///C:/Windows/System32/cmd.exe",
"\\\\attacker\\share\\x.exe",
"search-ms:query=x&crumb=location:\\\\attacker\\share",
"javascript:alert(1)",
"/usr/bin/xterm",
] {
assert_eq!(open(url), None, "{url}");
}
}
#[test]
fn actions_carry_the_input_values() {
let d = Dialog::parse(&register()).unwrap();
@@ -857,6 +895,22 @@ mod tests {
assert_eq!(command, Some(Click::RunCommand("say p\\\"w !".into())));
}
#[test]
fn the_button_that_does_what_escape_does() {
let notice = |escape: i8| {
Dialog::parse(&Tag::compound([
("type", s("notice")),
("title", s("Hi")),
("can_close_with_escape", Tag::Byte(escape)),
]))
.unwrap()
};
assert_eq!(notice(1).escape_button(), Some(0));
assert_eq!(notice(0).escape_button(), None);
// Multi-action with no exit button: Escape has no button to be.
assert_eq!(Dialog::parse(&register()).unwrap().escape_button(), None);
}
#[test]
fn a_notice_without_an_action_has_ok() {
let d = Dialog::parse(&Tag::compound([("type", s("notice")), ("title", s("Hi"))])).unwrap();
+330 -11
View File
@@ -90,7 +90,7 @@ fn slime_at(world: &World, facts: &BlockFacts, x: i32, y: i32, z: i32) -> bool {
}
mod fluids;
pub use fluids::update_entity_fluids;
pub use fluids::{fluid_flow, update_entity_fluids};
#[cfg(test)]
mod drop_motion_tests {
@@ -1038,10 +1038,13 @@ pub fn merge_with_neighbours(
(j, i)
};
// ItemEntity.merge(toStack, fromStack, 64).
let room = max_stack(&items[to].stack).min(64) - items[to].stack.count;
let moved = room.min(items[from].stack.count);
items[to].stack.count += moved;
items[from].stack.count -= moved;
// A component limit above 64 permits an overstacked target. Vanilla's
// signed room then moves the excess back into the source stack.
let room =
i64::from(max_stack(&items[to].stack).min(64)) - i64::from(items[to].stack.count);
let moved = room.min(i64::from(items[from].stack.count));
items[to].stack.count = (i64::from(items[to].stack.count) + moved) as u32;
items[from].stack.count = (i64::from(items[from].stack.count) - moved) as u32;
let (delay, from_age, to_age) =
(items[from].pickup_delay, age(items, from), age(items, to));
let to_item = &mut items[to];
@@ -1187,6 +1190,15 @@ impl ExperienceOrb {
let sky = light >> 4;
(u32::try_from(sky).unwrap_or(0) * 16) | ((u32::try_from(block).unwrap_or(15) * 16) << 8)
}
/// `ExperienceOrb.setUnderwaterMovement`: buoyancy and drag before moving.
fn set_underwater_movement(&mut self) {
// Vanilla widens float constants before applying double arithmetic.
self.velocity = Vec3::new(
self.velocity.x * f64::from(0.99_f32),
(self.velocity.y + f64::from(5.0e-4_f32)).min(f64::from(0.06_f32)),
self.velocity.z * f64::from(0.99_f32),
);
}
pub fn tick(
&mut self,
world: &World,
@@ -1216,11 +1228,7 @@ impl ExperienceOrb {
// inside blocks takes no gravity until it is pushed out below.
let colliding = !crate::collisions::no_collision_empty(&mut scene, bounds);
if facts.get(block).fluid == Fluid::Water {
self.velocity = Vec3::new(
self.velocity.x * 0.99,
(self.velocity.y + 5.0e-4).min(0.06),
self.velocity.z * 0.99,
);
self.set_underwater_movement();
} else if !colliding {
self.velocity.y -= self.get_gravity();
}
@@ -1285,6 +1293,8 @@ impl ExperienceOrb {
if moved.y != wanted.y {
self.velocity.y = 0.0;
}
// ExperienceOrb.tick applies block effects after moving, before drag.
let discarded = server && self.apply_lava_contact_damage(world, fluids, wanted);
// `float friction = getAirDrag()` (0.98F), the ground's block
// friction multiplied in as a float, widened once for the scale.
let mut friction = 0.98_f32;
@@ -1311,7 +1321,38 @@ impl ExperienceOrb {
self.velocity.y = -wanted.y * 0.4;
}
self.age += 1;
self.age < 6000
!discarded && self.age < 6000
}
/// `LavaFluid.entityInside` -> `Entity.lavaHurt`: four damage on the
/// server, through the orb's existing hurt/discard policy. This is separate
/// from the lava impulse at the start of the tick.
fn apply_lava_contact_damage(
&mut self,
world: &World,
fluids: &crate::motion::FluidResources,
wanted: Vec3,
) -> bool {
let contacts = orb_lava_contacts(self.previous, self.position, wanted, |p| {
let fluid = fluids.fluid(world.block(p.x, p.y, p.z));
if !fluid
.kind()
.same_family(horizon_world::fluid::FluidKind::Lava)
{
return None;
}
let above = fluids.fluid(world.block(p.x, p.y + 1, p.z));
Some(if fluid.kind().same_family(above.kind()) {
1.0
} else {
f64::from(fluid.own_height())
})
});
for _ in 0..contacts {
if self.hurt(4.0) {
return true;
}
}
false
}
/// `Entity.moveTowardsClosestSpace` from the middle of the orb's box:
/// towards the nearest side of its block (north, south, west, east or
@@ -2459,6 +2500,35 @@ mod merge_tests {
assert_eq!(items[0].age, 8, "age 7, plus the rest of this tick");
}
/// ItemEntity.merge uses signed room, including zero and negative transfers.
#[test]
fn overstacked_items_transfer_back_without_losing_merge_metadata() {
for (counts, expected) in [([80, 10], [64, 26]), ([64, 26], [64, 26])] {
let mut items = [
item("minecraft:stone", counts[0], 0.5, 41),
item("minecraft:stone", counts[1], 0.6, 7),
];
for item in &mut items {
item.stack.data_mut().set_other(
"minecraft:max_stack_size",
Some(horizon_world::nbt::Tag::Int(99)),
);
}
let before = [items[0].stack.clone(), items[1].stack.clone()];
items[1].pickup_delay = 30;
let mut removed = [false; 2];
let mut cells = ItemCells::new(&items);
merge_with_neighbours(&mut items, &mut removed, &mut cells, 0, &|_| 99);
assert_eq!([items[0].stack.count, items[1].stack.count], expected);
assert_eq!(removed, [false, false]);
assert_eq!(items[0].pickup_delay, 30);
assert_eq!(items[0].age, 8, "younger age 7, then this tick ends");
for (item, stack) in items.iter().zip(&before) {
assert!(item.stack.same_item_same_components(stack));
}
}
}
#[test]
fn items_merge_only_within_half_a_block_sideways() {
// Boxes 0.25 wide grown by 0.5: centres closer than 0.75 meet.
@@ -3832,6 +3902,72 @@ mod item_destroyed_tests {
);
}
}
#[cfg(test)]
mod orb_underwater_tests {
use super::*;
#[test]
fn matches_the_executed_java_private_method() {
// Actual 26.3 setUnderwaterMovement calls on server-level orbs,
// captured by test_orb_water_parity.py (orb-water-reference.json).
// Cover both horizontal signs, falling/rising, and the vertical cap.
let cases: [([f64; 3], [u64; 3]); 6] = [
(
[0.0, 0.0, 0.0],
[0x0000000000000000, 0x3f40624de0000000, 0x0000000000000000],
),
(
[0.1, 0.01, -0.2],
[0x3fb9581066666667, 0x3f85810625ae147b, 0xbfc9581066666667],
),
(
[-0.25, -0.03, 0.125],
[0xbfcfae1480000000, 0xbf9e353f7c851eb8, 0x3fbfae1480000000],
),
(
[0.0, 0.059, 0.0],
[0x0000000000000000, 0x3fae76c8b46d9168, 0x0000000000000000],
),
(
[0.5, 0.0595, -0.5],
[0x3fdfae1480000000, 0x3faeb851e0000000, 0xbfdfae1480000000],
),
(
[-1.0, 0.2, 1.0],
[0xbfefae1480000000, 0x3faeb851e0000000, 0x3fefae1480000000],
),
];
let actual: Vec<_> = cases
.iter()
.map(|(v, _)| {
let mut orb = ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
position: Vec3::ZERO,
previous: Vec3::ZERO,
velocity: Vec3::new(v[0], v[1], v[2]),
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
};
orb.set_underwater_movement();
[
orb.velocity.x.to_bits(),
orb.velocity.y.to_bits(),
orb.velocity.z.to_bits(),
]
})
.collect();
let expected: Vec<_> = cases.into_iter().map(|(_, bits)| bits).collect();
assert_eq!(actual, expected);
}
}
#[cfg(test)]
mod render_light_tests {
use super::*;
@@ -3905,3 +4041,186 @@ mod render_light_tests {
}
}
crate::impl_joined!(ItemEntity, ExperienceOrb, ArmorStand);
#[cfg(test)]
mod orb_lava_tests {
use super::*;
#[test]
fn a_server_orb_takes_lava_damage_after_moving_and_discards_at_zero() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
eprintln!("orb lava fixture unavailable: prepared resources missing");
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let lava = assets.registry.default_state("minecraft:lava").unwrap();
world.set(20, 4, 20, lava).unwrap();
let at = Vec3::new(20.5, 4.1, 20.5);
let mut orb = ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
position: at,
previous: at,
velocity: Vec3::ZERO,
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
};
let mut client = orb.clone();
for expected_age in 1..=2 {
assert!(client.tick(
&world,
&facts,
&collision,
None,
false,
&mut || 0.0,
None,
&fluids
));
assert_eq!((client.health, client.age), (5, expected_age));
}
assert!(orb.tick(
&world,
&facts,
&collision,
None,
true,
&mut || 0.0,
None,
&fluids
));
assert_eq!((orb.health, orb.age), (1, 1));
assert!(!orb.tick(
&world,
&facts,
&collision,
None,
true,
&mut || 0.0,
None,
&fluids
));
assert_eq!((orb.health, orb.age), (-3, 2));
}
}
/// `Entity.checkInsideBlocks` and `collidedWithFluid`: fluid AABB contacts
/// along Entity.move's axis-ordered path, each block visited once.
fn orb_lava_contacts(
from: Vec3,
to: Vec3,
wanted: Vec3,
mut lava_height: impl FnMut(BlockPos) -> Option<f64>,
) -> usize {
let delta = to.subtract(from);
let mut visited = std::collections::HashSet::new();
let mut contacts = 0;
let mut remaining = 16;
let mut segment = |start: Vec3, end: Vec3, budget: i32| {
let mut iterations = 0;
let epsilon = f64::from(1.0e-5_f32);
let target = ExperienceOrb::box_at(end).deflate(Vec3::new(epsilon, epsilon, epsilon));
let _ = horizon_core::geometry::sweep_intersected_blocks(
start,
end,
target,
1_000_000,
|p, step| {
if step >= budget {
return false;
}
iterations = step;
if visited.contains(&p) {
return true;
}
if let Some(height) = lava_height(p) {
let min = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
let fluid = Aabb::new(min, min.add_vector(Vec3::new(1.0, height, 1.0)));
if ExperienceOrb::box_at(start)
.collided_along_vector(end.subtract(start), &[fluid])
{
visited.insert(p);
contacts += 1;
}
}
true
},
);
iterations + 1
};
if delta.length_squared() > 0.0 {
// Direction.axisStepOrder uses the original, uncollided movement.
let order = if wanted.x.abs() < wanted.z.abs() {
[1, 2, 0]
} else {
[1, 0, 2]
};
let components = delta.components();
let mut start = from;
for axis in order {
if components[axis] != 0.0 {
let mut end = start.components();
end[axis] += components[axis];
let end = Vec3::new(end[0], end[1], end[2]);
remaining -= segment(start, end, remaining);
start = end;
}
}
} else {
remaining -= segment(from, to, remaining);
}
if remaining <= 0 {
segment(to, to, 1);
}
contacts
}
#[cfg(test)]
mod orb_lava_shape_tests {
use super::*;
fn contacts(from: Vec3, to: Vec3) -> usize {
orb_lava_contacts(from, to, to.subtract(from), |p| {
(p == BlockPos::new(0, 0, 0)).then_some(f64::from(8.0_f32 / 9.0))
})
}
#[test]
fn lava_uses_the_swept_box_and_fluid_height_not_the_feet_block() {
let side = Vec3::new(-0.2, 0.1, 0.5);
assert_eq!(contacts(side, side), 1);
let above = Vec3::new(0.5, 0.95, 0.5);
assert_eq!(contacts(above, above), 0);
let through = Vec3::new(-1.0, 0.1, 0.5);
let end = Vec3::new(2.0, 0.1, 0.5);
assert_eq!(contacts(through, end), 1);
let away = Vec3::new(-1.0, 0.1, -1.0);
assert_eq!(contacts(away, away), 0);
}
#[test]
fn axis_segments_do_not_damage_twice_for_the_same_lava_cell() {
let from = Vec3::new(0.1, 0.1, 0.1);
let to = Vec3::new(0.7, 0.2, 0.7);
assert_eq!(contacts(from, to), 1);
assert_eq!(
orb_lava_contacts(from, to, Vec3::new(0.1, 0.1, 0.9), |p| {
(p == BlockPos::new(0, 0, 0)).then_some(1.0)
}),
1
);
}
}
+64 -4
View File
@@ -26,6 +26,25 @@ struct EntityFluidWorld<'a> {
world: &'a World,
fluids: &'a FluidResources,
}
/// `FluidState.getFlow(level, pos)`: the current of the fluid at `pos`, the
/// one that pushes an item or a boat in it, normalised (zero where there is no
/// fluid or it doesn't move: a source with only sources and walls around it).
/// A falling fluid's current points down.
pub fn fluid_flow(world: &World, fluids: &FluidResources, pos: [i32; 3]) -> [f64; 3] {
let mut velocity = Vec3::ZERO;
let mut scene = EntityFluidWorld {
bounds: Aabb::new(Vec3::ZERO, Vec3::ZERO),
position: Vec3::ZERO,
eye_y: 0.0,
velocity: &mut velocity,
world,
fluids,
};
let at = BlockPos::new(pos[0], pos[1], pos[2]);
let state = fluids.fluid(world.block(pos[0], pos[1], pos[2]));
let flow = flow_at(&mut scene, state, at).unwrap_or(Vec3::ZERO);
[flow.x, flow.y, flow.z]
}
/// `Entity.baseTick`'s fluid step for an entity that is pushed by fluid: its
/// immersion heights, then the current of each fluid it sits in, added to
/// `velocity` with vanilla's scales (water 0.014, lava 0.007 fast or
@@ -215,6 +234,45 @@ impl FluidFlowWorld for EntityFluidWorld<'_> {
#[cfg(test)]
mod tests {
use super::*;
/// `FluidState.getFlow`: water running out of a pool flows away from it,
/// while the pool itself, its edge on a solid floor and the air beside it
/// have no current.
#[test]
fn fluid_flow_runs_away_from_a_pool_and_is_zero_in_still_water() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = FluidResources::new(&assets, &facts).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
let mut state = assets.registry.state(water).unwrap().clone();
state.properties.insert("level".into(), "1".into());
let flowing = assets.registry.resolve(&state).unwrap();
for x in 16..=22 {
for z in 16..=20 {
world.set(x, 3, z, stone).unwrap();
}
}
for x in 17..=19 {
for z in 17..=19 {
world.set(x, 4, z, water).unwrap();
}
}
world.set(20, 4, 18, flowing).unwrap();
// A level-1 flow beside sources: the source is 1/9 higher to the west,
// the air to the east on stone gives nothing, so the current is +x.
assert_eq!(fluid_flow(&world, &fluids, [20, 4, 18]), [1.0, 0.0, 0.0]);
for still in [[18, 4, 18], [17, 4, 17], [21, 4, 18]] {
assert_eq!(fluid_flow(&world, &fluids, still), [0.0; 3], "{still:?}");
}
}
#[test]
fn items_follow_flowing_water_but_still_water_has_no_horizontal_push() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -385,10 +443,12 @@ mod tests {
None,
&fluids,
));
// Current pushes the orb, underwater drag damps before motion, then air
// drag: `ExperienceOrb.tick`'s `0.98F`, a float widened to double.
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
assert!((carried.velocity.x - 0.014 * 0.99 * f64::from(0.98_f32)).abs() < 1e-12);
// Current pushes the orb, then underwater and air drag each widen
// their float constants before multiplying the double motion.
assert!((carried.position.x - at.x - 0.014 * f64::from(0.99_f32)).abs() < 1e-12);
assert!(
(carried.velocity.x - 0.014 * f64::from(0.99_f32) * f64::from(0.98_f32)).abs() < 1e-12
);
for _ in 0..20 {
carried.tick(
&world,
+240
View File
@@ -370,6 +370,18 @@ pub fn mob_to_nbt(
] {
put(&mut tag, key, value);
}
// Mob.addAdditionalSaveData: hasHome means radius != -1, including -2.
// Species writers below retain their own home_pos override.
let Tag::Compound(map) = &mut tag else {
unreachable!()
};
if let Some((pos, radius)) = m.ai.home.filter(|(_, radius)| *radius != -1) {
map.insert("home_radius".into(), Tag::Int(radius));
map.insert("home_pos".into(), Tag::IntArray(pos.to_vec()));
} else {
map.remove(&"home_radius".into());
map.remove(&"home_pos".into());
}
// AbstractFish/Axolotl.addAdditionalSaveData always write the flag.
// Tadpole.fromBucket is always true, and its setter ignores loaded input.
let from_bucket = match m.species() {
@@ -692,6 +704,14 @@ pub fn mob_to_nbt(
Tag::Byte(i8::from(m.extra.flag2)),
);
map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2));
if id == "minecraft:hoglin" {
// Hoglin.addAdditionalSaveData writes it only when set.
if m.extra.cannot_be_hunted {
map.insert("CannotBeHunted".into(), Tag::Byte(1));
} else {
map.remove(&"CannotBeHunted".into());
}
}
if id == "minecraft:piglin" {
// InventoryCarrier.writeInventoryToTag.
map.insert(
@@ -1422,6 +1442,44 @@ pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
float(bonus.get("amount"))
}
/// `Mob.readAdditionalSaveData`: negative radii retain the live homePosition.
/// Used after the command adapter restores runtime AI, as well as fresh load.
pub fn read_mob_home(m: &mut Mob, tag: &Tag) {
let radius = match tag.get("home_radius") {
Some(Tag::Float(v)) => Some(f32::from_bits(*v).floor() as i32),
Some(Tag::Double(v)) => Some(f64::from_bits(*v).floor() as i32),
other => number(other).map(|v| v as i32),
}
.unwrap_or(-1);
let pos = if radius >= 0 {
home_block_pos(tag.get("home_pos")).unwrap_or([0; 3])
} else {
m.ai.home_position()
};
m.ai.restore_home(pos, radius);
}
/// `BlockPos.CODEC` through NbtOps.getIntStream: numeric collection entries
/// use Java Number's int conversion (floating entries truncate, unlike getIntOr above).
fn home_block_pos(tag: Option<&Tag>) -> Option<[i32; 3]> {
match tag? {
Tag::IntArray(v) => v.as_slice().try_into().ok(),
Tag::ByteArray(v) if v.len() == 3 => Some([
i32::from(v[0] as i8),
i32::from(v[1] as i8),
i32::from(v[2] as i8),
]),
Tag::LongArray(v) if v.len() == 3 => Some([v[0] as i32, v[1] as i32, v[2] as i32]),
Tag::List(v) if v.len() == 3 => {
let int = |t: &Tag| match t {
Tag::Float(v) => Some(f32::from_bits(*v) as i32),
Tag::Double(v) => Some(f64::from_bits(*v) as i32),
other => number(Some(other)).map(|v| v as i32),
};
Some([int(&v[0])?, int(&v[1])?, int(&v[2])?])
}
_ => None,
}
}
/// The mob a saved compound describes. `registry` is what reads a field holding
/// a block state (an enderman's `carriedBlockState`); without one, such a field
/// is left out, as it was before it was modelled.
@@ -1437,6 +1495,7 @@ pub fn mob_from_nbt(
let mut m = Mob::new(id, kind, position, yaw);
m.velocity = motion(tag);
m.head_pitch = pitch;
read_mob_home(&mut m, tag);
m.on_ground = flag(tag, "OnGround");
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
// LivingEntity.readAdditionalSaveData: the saved attribute bases, then
@@ -1687,6 +1746,9 @@ pub fn mob_from_nbt(
) {
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
if m.species() == crate::mobs::Species::Hoglin {
m.extra.cannot_be_hunted = flag(tag, "CannotBeHunted");
}
if m.species() == crate::mobs::Species::Piglin
&& let Some(Tag::List(items)) = tag.get("Inventory")
{
@@ -5090,6 +5152,42 @@ mod sheep_nbt_tests {
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
/// `Hoglin.readAdditionalSaveData` and `addAdditionalSaveData`:
/// `CannotBeHunted` (a bastion's hoglins) stays with the hoglin, and such a
/// hoglin is no piglin's prey (`Hoglin.canBeHunted`).
#[test]
fn a_hoglin_keeps_cannot_be_hunted_and_is_no_prey() {
use crate::mobs::Species;
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.species = Species::Hoglin;
ty.id = "minecraft:hoglin".into();
let types = HashMap::from([("minecraft:hoglin".to_owned(), kind)]);
let hoglin = |cannot: Option<i8>| {
let mut tag = Tag::compound([
("id", string("minecraft:hoglin")),
("Pos", doubles(&[0.0, 64.0, 0.0])),
]);
if let (Some(b), Tag::Compound(map)) = (cannot, &mut tag) {
map.insert("CannotBeHunted".into(), Tag::Byte(b));
}
tag
};
let raw = hoglin(Some(1));
let protected = mob_from_nbt(&raw, &types, 1, None).unwrap();
assert!(protected.extra.cannot_be_hunted);
assert!(!protected.seen(None).huntable);
let saved = mob_to_nbt(&protected, [0; 4], Some(&raw), None);
assert_eq!(saved.get("CannotBeHunted"), Some(&Tag::Byte(1)));
for raw in [hoglin(None), hoglin(Some(0))] {
let prey = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert!(!prey.extra.cannot_be_hunted);
assert!(prey.seen(None).huntable);
let saved = mob_to_nbt(&prey, [0; 4], Some(&raw), None);
assert_eq!(saved.get("CannotBeHunted"), None);
}
}
/// `Mob.addAdditionalSaveData` and `readAdditionalSaveData`:
/// `DeathLootTable` and a set `DeathLootTableSeed` stay with the mob.
#[test]
@@ -6602,3 +6700,145 @@ mod axolotl_memory_ttl_tests {
}
}
}
#[cfg(test)]
mod generic_home_nbt_tests {
use super::*;
use crate::mobs::Species;
fn mob(species: Species, id: &str) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.species = species;
ty.id = id.into();
Mob::new(1, kind, Vec3::new(3.5, 201.0, 0.5), 0.0)
}
fn stale() -> Tag {
Tag::compound([
("home_radius", Tag::Int(99)),
("home_pos", Tag::IntArray(vec![90, 91, 92])),
("audit:unrelated", Tag::Int(7)),
])
}
#[test]
fn generic_home_output_uses_model_and_removes_stale_raw_fields() {
let mut m = mob(Species::Pig, "minecraft:pig");
let raw = stale();
for raw in [None, Some(&raw)] {
for radius in [0, 5, -2, -1] {
m.ai.home = Some(([4, 5, 6], radius));
let saved = mob_to_nbt(&m, [0; 4], raw, None);
if radius == -1 {
assert!(saved.get("home_radius").is_none());
assert!(saved.get("home_pos").is_none());
} else {
assert_eq!(saved.get("home_radius"), Some(&Tag::Int(radius)));
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 5, 6])));
}
}
m.ai.home = None;
let saved = mob_to_nbt(&m, [0; 4], raw, None);
assert!(saved.get("home_radius").is_none());
assert!(saved.get("home_pos").is_none());
if raw.is_some() {
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
}
}
}
#[test]
fn negative_home_updates_retain_position_without_replacing_runtime_ai() {
let mut m = mob(Species::Pig, "minecraft:pig");
m.ai.home = Some(([4, 5, 6], 5));
m.ai.jump = true;
m.ai.speed = 0.37;
m.ai.no_action_time = 29;
for radius in [-2, -1, -2] {
read_mob_home(&mut m, &Tag::compound([("home_radius", Tag::Int(radius))]));
assert_eq!(m.ai.home, (radius != -1).then_some(([4, 5, 6], radius)));
assert_eq!(m.ai.home_position(), [4, 5, 6]);
assert!(m.ai.jump);
assert_eq!((m.ai.speed, m.ai.no_action_time), (0.37, 29));
}
// Runtime setters still assign ai.home directly. clearHome must retain
// that latest live position, rather than a previous load's position.
m.ai.home = Some(([7, 8, 9], 5));
m.ai.clear_home();
assert!(m.ai.home.is_none());
read_mob_home(&mut m, &Tag::compound([("home_radius", Tag::Int(-2))]));
assert_eq!(m.ai.home, Some(([7, 8, 9], -2)));
}
#[test]
fn fresh_home_loads_apply_radius_and_position_codec_defaults() {
let m = mob(Species::Pig, "minecraft:pig");
let types = [(m.kind.id.clone(), m.kind.clone())].into();
for radius in [0, 5, -1, -2] {
for pos in [
None,
Some(Tag::IntArray(vec![4, 5, 6])),
Some(Tag::IntArray(vec![4, 5])),
] {
let mut raw = Tag::compound([
("id", string("minecraft:pig")),
("Pos", doubles(&[3.5, 201.0, 0.5])),
("home_radius", Tag::Int(radius)),
]);
if let Some(pos) = pos.clone() {
put(&mut raw, "home_pos", pos);
}
let loaded = mob_from_nbt(&raw, &types, 1, None).unwrap();
let expected_pos = if radius >= 0 && pos == Some(Tag::IntArray(vec![4, 5, 6])) {
[4, 5, 6]
} else {
[0; 3]
};
assert_eq!(
loaded.ai.home,
(radius != -1).then_some((expected_pos, radius))
);
}
}
let mut loaded = m;
read_mob_home(
&mut loaded,
&Tag::compound([
("home_radius", Tag::Float((-2.5_f32).to_bits())),
("home_pos", Tag::IntArray(vec![4, 5, 6])),
]),
);
assert_eq!(loaded.ai.home, Some(([0; 3], -3)));
read_mob_home(
&mut loaded,
&Tag::compound([
("home_radius", Tag::Int(5)),
("home_pos", doubles(&[-2.5, 5.5, 6.5])),
]),
);
assert_eq!(loaded.ai.home, Some(([-2, 5, 6], 5)));
}
#[test]
fn turtle_and_creaking_keep_their_own_home_position_writer() {
let raw = stale();
for (species, id) in [
(Species::Turtle, "minecraft:turtle"),
(Species::Creaking, "minecraft:creaking"),
] {
let mut m = mob(species, id);
m.extra.home = Some([4, 5, 6]);
for raw in [None, Some(&raw)] {
for home in [None, Some(([7, 8, 9], 5))] {
m.ai.home = home;
let saved = mob_to_nbt(&m, [0; 4], raw, None);
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 5, 6])));
assert_eq!(
saved.get("home_radius"),
home.map(|(_, r)| Tag::Int(r)).as_ref()
);
}
}
}
}
}
@@ -244,6 +244,13 @@ pub fn draw(
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if matches!(b.action, Action::Done) {
crate::widgets::declare_action(
crate::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
}
ui::button(
batch,
atlas,
+27
View File
@@ -30,6 +30,7 @@ pub trait FurnaceCooking {
providers: &Providers,
drops: &mut Vec<ItemStack>,
) -> Option<bool>;
fn idle(&self) -> bool;
}
impl FurnaceCooking for Furnace {
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
@@ -171,6 +172,11 @@ impl FurnaceCooking for Furnace {
}
(was_lit != lit).then_some(lit)
}
fn idle(&self) -> bool {
self.lit_remaining <= 0
&& self.cooking_timer <= 0
&& !(self.items[0].is_some() && self.items[1].is_some())
}
}
fn total_time(time: u32, speed: f32) -> i32 {
if speed > 0.0 {
@@ -187,6 +193,27 @@ mod tests {
use horizon_world::loot::PackOverlay;
use std::{collections::HashMap, fs};
#[test]
fn a_furnace_is_idle_only_while_its_tick_changes_nothing() {
let stack = |id: &str| Some(ItemStack::new(id, 1));
let furnace = |input, fuel, lit_remaining, cooking_timer| Furnace {
kind: FurnaceKind::Furnace,
items: [input, fuel, stack("minecraft:iron_ingot")],
lit_remaining,
lit_total: 0,
cooking_timer,
cooking_total: 200,
speed: 1.0,
recipes_used: HashMap::new(),
};
assert!(furnace(None, None, 0, 0).idle());
assert!(furnace(stack("minecraft:iron_ore"), None, 0, 0).idle());
assert!(furnace(None, stack("minecraft:coal"), 0, 0).idle());
assert!(!furnace(stack("minecraft:iron_ore"), stack("minecraft:coal"), 0, 0).idle());
assert!(!furnace(None, None, 1, 0).idle());
assert!(!furnace(stack("minecraft:iron_ore"), None, 0, 5).idle());
}
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a
/// data root holding that one recipe and an item table with two fuels: `test:coal`
/// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`).
+264 -82
View File
@@ -120,6 +120,7 @@ mod enchant_effects;
mod end;
mod entity;
mod entity_culling;
mod entity_sounds;
mod event_loop;
mod events;
mod execution_role;
@@ -204,7 +205,9 @@ mod narrator;
mod native_biome_colors;
pub mod native_block_entity_draw;
mod native_block_sprites;
mod native_chunk_tickets;
mod native_claimed_items;
pub mod native_client_services;
mod native_components;
mod native_configs;
mod native_criteria;
@@ -216,6 +219,7 @@ mod native_entity_refs;
mod native_explosions;
mod native_falling;
mod native_hooks;
mod native_host_bridge;
mod native_inventories;
mod native_item_container;
mod native_item_energy;
@@ -223,6 +227,8 @@ mod native_item_fluids;
mod native_level_refs;
mod native_lifecycle;
mod native_living_refs;
mod native_machines;
mod native_menu_stacks;
mod native_menus;
mod native_mob_draw;
mod native_mobs;
@@ -235,6 +241,7 @@ mod native_projectiles;
mod native_quick_move;
mod native_recipes;
mod native_reload_listeners;
mod native_scene;
mod native_server;
mod native_shaders;
mod native_ticks;
@@ -252,6 +259,7 @@ mod options;
mod ordered_tick;
mod packs;
mod pad;
mod pad_actions;
mod pad_menu;
mod paintings;
mod pandas;
@@ -301,6 +309,7 @@ mod server_links;
mod server_packs;
mod server_travel;
mod shader_packs;
mod sheep_dye;
mod shelf;
mod shield;
mod shoulders;
@@ -352,6 +361,7 @@ mod win;
mod wind;
mod wither;
mod world_loading;
mod world_primitives;
mod zombie_reinforcements;
mod zombie_villagers;
use event_loop::EventLoopCtl;
@@ -416,19 +426,23 @@ use horizon_world::{
},
registry::BlockStateId,
};
pub use machines::native_fluids::NativeFluidTank;
pub use maps::{NativeMapDecoration, NativeMapInfo};
pub use native_block_entity_draw::BlockEntityDraw;
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
pub use native_components::NativeComponentError;
pub use native_configs::{NativeConfig, NativeConfigFile, NativeConfigKind};
pub use native_criteria::NativeCriterion;
pub use native_data_maps::NativeDataMapType;
pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeShake};
pub use native_hooks::{NativeClock, NativeItemEntity, NativePlayer, NativeShake};
pub use native_item_energy::ItemEnergyBuffer;
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_reload_listeners::NativeReloadListener;
pub use native_server::NativeExperienceOrb;
pub use native_world_columns::NativeColumnBlocks;
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
pub use native_world_read::NativeBlockReadError;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -991,6 +1005,12 @@ struct GameLevel<'w> {
debug: bool,
native_sounds: &'w mut Vec<creatures::NativeSound>,
native_particles: &'w mut Vec<lan_particles::QueuedParticle>,
/// The tool a mod's machine breaks blocks with (`native_machine_destroy_block`):
/// `Block.dropResources` rolls the loot with it instead of with none.
drop_tool: Option<ItemStack>,
/// Where `Block.dropResources` puts the drops instead of popping them,
/// while a mod's machine breaks a block (`native_machine_destroy_block`).
drop_into: Option<Vec<ItemStack>>,
}
struct PlayerCollision {
y: f64,
@@ -1815,6 +1835,14 @@ impl LevelAccess for GameLevel<'_> {
Some(BlockEntity::Mod(_))
)
}
/// `ShulkerBoxBlockEntity.isClosed`: the lid state the block-entity tick
/// copied from `be_anims` (`ShulkerBoxBlock.getBlockSupportShape`).
fn shulker_is_closed(&self, pos: BlockPos) -> Option<bool> {
self.world
.shulkers
.get(&[pos.x, pos.y, pos.z])
.map(|s| s.status == horizon_client::block_entity_anim::ShulkerStatus::Closed)
}
fn remove_block_entity(&mut self, pos: BlockPos, old: BlockStateId, side_effects: bool) -> u8 {
self.remove_entity(pos, old, side_effects)
}
@@ -2351,6 +2379,9 @@ struct Game<'a> {
world_renderer_ready: bool,
collision: &'a BlockCollisionRegistry<'a>,
motion: &'a MotionResources,
/// This world's frozen damage definitions and reloadable tag snapshot.
pub(super) health_resources: horizon_client::health::HealthResources,
damage_tag_generation: u64,
/// This game's resource packs (`reload_packs` replaces them).
pack: PackResources,
/// The title and menu bundle (GUI atlas, font and panorama).
@@ -2871,6 +2902,9 @@ struct Game<'a> {
cart_models: minecarts::CartModels,
/// The boat the player rides.
riding: Option<u32>,
/// Vehicles that gained a rider this tick whose `START_RIDING_TRIGGER` was
/// not fired yet (`game/boarding.rs`), for a guest's turn.
started_riding: Vec<u32>,
/// `Entity.boardingCooldown` of the acting player, and its vehicle at the
/// last tick (`game/boarding.rs`).
boarding_cooldown: i32,
@@ -2919,6 +2953,9 @@ struct Game<'a> {
mod_screen: Option<native_hooks::OpenNativeScreen>,
/// The players with each mod container open, by level (`tick_mod_container_viewers`).
mod_container_viewers: HashMap<(&'static str, (i32, i32, i32)), u32>,
/// The owners of each chunk a native mod's ticket holds, by level
/// (`native_force_chunk`); `streaming.mod_forced` is the level's chunks.
mod_chunk_tickets: HashMap<Dim, HashMap<(i32, i32), std::collections::BTreeSet<String>>>,
/// Where item entities are, while the block entities tick (hoppers).
item_columns: Option<machines::ItemColumns>,
creative_screen: Creative,
@@ -3053,6 +3090,7 @@ struct Game<'a> {
text_selection: horizon_client::clipboard::AppendSelection,
/// The world was saved for the application ending (`save_for_exit`).
saved_for_exit: bool,
native_client_stopped: bool,
pending_save: Option<saving::ClosingSave>,
/// The last world frame reached the swap chain. A window the compositor
/// stops showing gets no frames, and the event loop must not spin on it.
@@ -4333,9 +4371,7 @@ impl Game<'_> {
&& (alive || item.age == 6000 && item.position.y > -128.0)
&& item.ticks.is_multiple_of(rate)
{
let catalog = world_items!(self);
let max_stack =
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
let max_stack = |stack: &ItemStack| self.stack_max_size(stack).max(1);
horizon_client::entities::merge_with_neighbours(
&mut items,
&mut removed,
@@ -4674,6 +4710,8 @@ impl Game<'_> {
debug: self.world_debug,
native_sounds: &mut self.creatures.lan_sounds,
native_particles: &mut self.creatures.lan_particles,
drop_tool: None,
drop_into: None,
};
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
self.flush_spill();
@@ -4865,7 +4903,20 @@ impl Game<'_> {
.filter(|&(_, spectator)| game_mode::loads_chunks(rule, spectator))
.map(|(chunk, _)| chunk)
.collect(),
forced: self.streaming.forced.iter().copied().collect(),
// A native mod's ticket holds its chunk as `/forceload` does
// (NeoForge's `TicketController` uses the same `FORCED` level).
forced: self
.streaming
.forced
.iter()
.chain(
self.streaming
.mod_forced
.iter()
.filter(|c| !self.streaming.forced.contains(c)),
)
.copied()
.collect(),
// Filled from the `PORTAL` tickets by `update`.
portal: Vec::new(),
// EnderDragonFight.tick: while a player watches the fight, the
@@ -5140,15 +5191,19 @@ impl Game<'_> {
let sent = self.lan_broadcast_changes();
self.with_level(|b, level, ticks| {
b.run_block_events(level, ticks);
b.tick_block_entities(level, ticks);
});
// `PistonMovingBlockEntity.tick` pushes what its block just ran into,
// with the progress it is about to take.
self.process_level_events()?;
self.sync_moving_pistons();
Ok(sent)
}
/// `ServerLevel.tick`: moving block entities push after entity travel.
fn tick_moving_pistons(&mut self) -> Result<()> {
self.with_level(|b, level, ticks| b.tick_block_entities(level, ticks));
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.process_level_events()?;
self.sync_moving_pistons();
Ok(sent)
Ok(())
}
/// Copies the moving-piston block entities into the world, where the
/// collision queries and the renderer read them.
@@ -7034,12 +7089,7 @@ impl Game<'_> {
return;
};
self.start_using_item();
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
self.play_player_bound_sound(&instrument.sound, 2, instrument.range / 16.0, 1.0, true);
self.post_entity_event(
"minecraft:instrument_play",
horizon_client::mobs::Who::Player,
@@ -7852,6 +7902,7 @@ impl Game<'_> {
self.apply_late_wind_pushes();
}
if running {
horizon_client::spans::time("tick_moving_pistons", || self.tick_moving_pistons())?;
horizon_client::spans::time("tick_block_entities", || self.tick_block_entities())?;
horizon_client::spans::time("tick_sculk_entities", || self.tick_sculk_entities());
horizon_client::spans::time("tick_sculk_blocks", || self.tick_sculk_blocks())?;
@@ -8555,11 +8606,11 @@ impl Game<'_> {
self.frame_graph
.draw(self.settings.horizon.overlay, &mut batch, &self.gui.atlas);
self.draw_pad_cursor(&mut batch);
self.resolve_inline_profiles(&batch.inline_profiles);
batch
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
let mut batch = HudBatch::new(size);
batch.glint_colour = self.settings.extras.pvp.tweaks.glint_texel();
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
batch.game_time = ((self.world.game_time.rem_euclid(24000)) as f32
+ self.clock.fraction() as f32)
@@ -8711,7 +8762,10 @@ impl Game<'_> {
}
});
self.gui_layer("minecraft:effects", &mut batch, |g, batch| {
g.draw_effect_icons(batch);
// The PvP & HUD effects widget takes the icons' place.
if !g.pvp_effects_shown() {
g.draw_effect_icons(batch);
}
});
// Hud: the action bar and titles draw over the hotbar (nextStratum).
self.gui_layer("minecraft:boss_overlay", &mut batch, |g, batch| {
@@ -11608,6 +11662,35 @@ impl Game<'_> {
}
}
/// `ServerLevel.destroyBlockProgress`: breaker `breaker`'s crack on `pos`
/// (0..=9, anything else takes it away), for every player within 64 blocks.
pub(super) fn server_destroy_block_progress(
&mut self,
breaker: i32,
pos: [i32; 3],
progress: i32,
) {
self.destroy_block_progress(breaker, pos, progress);
if let Some(host) = self.lan.as_ref() {
host.hub.broadcast_near(
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
64.0,
&horizon_protocol::packets::play::Clientbound::BlockDestruction(
horizon_protocol::packets::play::BlockDestruction {
id: breaker,
pos,
progress: if (0..10).contains(&progress) {
progress as u8
} else {
255
},
},
),
None,
);
}
}
/// The cracks to draw and their stage sprite's atlas origin: our own mining and the
/// other players' (`renderBlockDestroyAnimation`: within 32 blocks of the camera, the
/// furthest progress on each block).
@@ -11626,6 +11709,25 @@ impl Game<'_> {
"block/destroy_stage_8",
"block/destroy_stage_9",
];
// `ModelBakery.DESTROY_STAGES_OIT`: with improved transparency the cracks are
// ordinary translucent quads (`OIT_CRUMBLING`) and use their own sprites.
const DESTROY_STAGES_OIT: [&str; 10] = [
"block/destroy_oit_stage_0",
"block/destroy_oit_stage_1",
"block/destroy_oit_stage_2",
"block/destroy_oit_stage_3",
"block/destroy_oit_stage_4",
"block/destroy_oit_stage_5",
"block/destroy_oit_stage_6",
"block/destroy_oit_stage_7",
"block/destroy_oit_stage_8",
"block/destroy_oit_stage_9",
];
let names = if self.settings.improved_transparency {
&DESTROY_STAGES_OIT
} else {
&DESTROY_STAGES
};
let mut stages: Vec<([i32; 3], u32)> = Vec::new();
let mut add = |pos: [i32; 3], stage: u32| match stages.iter_mut().find(|c| c.0 == pos) {
Some(c) => c.1 = c.1.max(stage),
@@ -11651,7 +11753,7 @@ impl Game<'_> {
d.length_squared() <= 1024.0
})
.filter_map(|(pos, stage)| {
let name = DESTROY_STAGES.get(stage as usize)?;
let name = names.get(stage as usize)?;
let r = self.gui.atlas.sprites.get(*name)?;
Some((pos, [r.u0, r.v0]))
})
@@ -12207,7 +12309,7 @@ impl Game<'_> {
"first_quarter",
"waxing_gibbous",
];
let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize];
let phase = phases[(self.view_day_time() / 24000).rem_euclid(8) as usize];
body(
&format!("environment/celestial/moon/{phase}"),
sky.moon_angle,
@@ -12392,16 +12494,19 @@ impl Game<'_> {
self.share_spawn_conditions();
self.score = Default::default();
self.fns = Default::default();
self.world_tables = self.build_world_tables();
(self.world_tables, self.health_resources) =
self.build_world_tables(false, &level.enabled_features)?;
self.damage_tag_generation = self.damage_tag_generation.wrapping_add(1);
// Native tag reads use the lock to tell that the tag tables are ready.
self.session_lock = Some(session_lock);
// The registries a preload or an earlier world read are not this
// world's packs': a world with packs sends its own (their dialogs,
// recipes and advancements). For a world without packs, `sync_data`
// drops what was read with packs.
if self.world_tables.is_some() {
lan::forget_sync_data();
}
// Keep the no-pack preload; a world's packs replace it atomically.
// A later /reload keeps this session's synchronized registries.
lan::open_world_sync_data(
&self.options.resources,
&pack_files(&self.world_tables),
&self.health_resources.damage,
)
.map_err(|e| format!("world registries: {e}"))?;
// Warm the loot data with this world's packs before chunks decode
// (`save::roll_loaded_loot` reads the cached copy).
let _ = horizon_client::structure_blocks::world_loot_data(
@@ -12690,6 +12795,10 @@ impl Game<'_> {
}
pub(super) fn save_for_exit(&mut self) -> Result<()> {
if self.native_window() && !self.native_client_stopped {
self.native_client_stopped = true;
self.native_client_stopping();
}
// Native application termination can bypass CloseRequested. Finish any
// save screen synchronously here, before the process can go away.
while self.pending_save.is_some() {
@@ -12795,7 +12904,7 @@ impl Game<'_> {
}
/// Let go of every entity of the level (saved first): the next world
/// opened must not inherit them, nor save them as its own.
fn clear_entities(&mut self) {
pub(super) fn clear_entities(&mut self) {
self.mobs.clear();
self.dropped.clear();
self.orbs.clear();
@@ -12822,6 +12931,19 @@ impl Game<'_> {
self.riding = None;
self.persist = Default::default();
self.entity_attachments.clear();
self.clear_remote_objects();
self.clear_remote_projectiles();
self.levels.clear();
self.entity_vertices.clear();
self.see_through_vertices.clear();
self.glowing_vertices.get_mut().clear();
self.shadow_only_vertices.get_mut().clear();
self.entity_spans.borrow_mut().clear();
self.entity_motion.borrow_mut().clear();
self.skin_models
.borrow_mut()
.restart(&mut self.skin_frame.borrow_mut());
self.skin_frame.borrow_mut().clear_draws();
}
/// Equipment attribute modifiers (LivingEntity.collectEquipmentChanges).
fn apply_equipment(&mut self) {
@@ -12884,15 +13006,13 @@ impl Game<'_> {
}
let amount = ((damage / 4.0) as u32).max(1);
let name = self
.motion
.health
.health_resources
.damage
.definition(source)
.map(|d| d.name.clone())
.unwrap_or_default();
let fire = self
.motion
.health
.health_resources
.damage
.in_tag(source, "minecraft:is_fire");
let mut broke = false;
@@ -13536,9 +13656,14 @@ impl Game<'_> {
let want = sound.trim_start_matches("minecraft:");
let mut keep = Vec::with_capacity(capture.len());
for entry in capture.drain(..) {
let name = entry.0.trim_start_matches("minecraft:");
let name = entry.event.trim_start_matches("minecraft:");
if name == want || name == "block.fire.extinguish" {
self.guest_player_sounds.push(entry);
self.guest_player_sounds.push((
entry.event,
entry.position,
entry.volume,
entry.pitch,
));
} else {
keep.push(entry);
}
@@ -14330,8 +14455,13 @@ impl Game<'_> {
}
_ => ItemPose::Swing,
};
let m = to_world
let mut m = to_world
* horizon_client::first_person::item_pose(left, inverse_height, attack, pose);
// PvP & HUD's shield size (100% is vanilla's), toward the hand holding it.
let shield_size = self.settings.extras.pvp.tweaks.shield_size;
if shield_size != 100 && &*stack.item == "minecraft:shield" {
m *= glam::Mat4::from_scale(glam::Vec3::splat(f32::from(shield_size) / 100.0));
}
// Seeded by the player id plus the context ordinal.
let (context, seed) = if left {
(
@@ -14553,7 +14683,7 @@ impl Game<'_> {
alpha: f64,
) -> Vec<horizon_client::weather::Vertex> {
use horizon_client::weather;
let (intensity, _) = self.weather_levels(alpha as f32);
let (intensity, _) = self.view_weather(alpha as f32);
if intensity <= 0.0 {
return Vec::new();
}
@@ -15881,8 +16011,8 @@ impl Game<'_> {
);
// visual/water_fog_color: no dimension sets it, its default is #050533.
let water_fog = blend(|b| Some(b.water_fog), [0.0; 3]);
let mut sky = environment.sample(self.world.day_time, Some(biome_sky), Some(biome_fog));
let (rain, thunder) = self.weather_levels(alpha as f32);
let mut sky = environment.sample(self.view_day_time(), Some(biome_sky), Some(biome_fog));
let (rain, thunder) = self.view_weather(alpha as f32);
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
self.end_flash_sky(&mut sky, alpha);
if self.flash > 0 && !self.settings.file.bool("hideLightningFlashes") {
@@ -15917,6 +16047,11 @@ impl Game<'_> {
Vec::new()
};
let view = horizon_client::camera::view_matrix(view_yaw, view_pitch);
let world_primitives = if self.title.is_none() {
self.world_primitive_geometry(camera, alpha, view)
} else {
Default::default()
};
let at = camera.to_array();
// GameRenderer.render3dHud: the debug crosshair, first person with the HUD shown.
let mut crosshair = horizon_client::gizmos::Gizmos::default();
@@ -15996,8 +16131,6 @@ impl Game<'_> {
} else {
Vec::new()
};
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
self.flush_ime(&mut hud);
let title = self.shows_panorama();
let (yaw, pitch) = if title {
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
@@ -16016,8 +16149,6 @@ impl Game<'_> {
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
};
self.locator_camera =
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
let bob = if title {
glam::Mat4::IDENTITY
} else {
@@ -16028,7 +16159,6 @@ impl Game<'_> {
} else {
self.spinning_matrix(camera_alpha as f32)
};
let requested_post_effects = self.requested_post_effects();
let terrain_distance = self.terrain_distance();
let distance = (terrain_distance * 16) as f32;
let depth_far =
@@ -16059,6 +16189,14 @@ impl Game<'_> {
dimension: self.dim.id().to_string(),
}
});
// GUI hooks project against this frame's camera, including its bob/spin and FOV.
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
self.flush_ime(&mut hud);
self.native_gui_render_post(&mut hud, self.ui.hide_gui, self.focused);
self.resolve_inline_profiles(&hud.inline_profiles);
self.locator_camera =
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
let requested_post_effects = self.requested_post_effects();
self.view_frustum = self
.renderer
.as_ref()
@@ -16140,6 +16278,7 @@ impl Game<'_> {
if !title {
self.end_geometry(camera, &mut entities);
}
self.outline_fill(&outline, camera, &mut entities);
let weather = if !title && self.can_have_weather() && self.settings.horizon.weather {
horizon_client::spans::time("weather", || self.weather_geometry(camera, alpha))
} else {
@@ -16227,6 +16366,7 @@ impl Game<'_> {
outline,
outline_after_terrain,
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
pvp: self.settings.extras.pvp.frame(),
debug_lines: &debug_lines,
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
@@ -16265,6 +16405,7 @@ impl Game<'_> {
},
additive: &additive,
see_through: &see_through,
world_primitives: world_primitives.batch(),
shadow_only: &shadow_only,
celestial: if self.settings.horizon.sky || title {
&celestial
@@ -16428,7 +16569,9 @@ impl Game<'_> {
|| self.dialog_open()
|| (self.screen == Screen::Win && self.win_screen_pauses())));
let radius = self.settings.menu_blur as f32;
(blurred && radius >= 1.0).then_some(radius)
let mut radius = (blurred && radius >= 1.0).then_some(radius);
self.native_menu_background(&mut radius);
radius.filter(|r| r.is_finite() && (1.0..=64.0).contains(r))
}
/// `LevelExtractor` / `LevelRenderer`: the world border wall this frame.
fn border_draw(
@@ -18863,6 +19006,14 @@ impl Game<'_> {
event_loop.exit();
return;
}
// A Realm's address came back (GetServerDetailsTask hands over to ConnectScreen).
if let Some(action) = self.title.as_mut().and_then(|t| t.take_connect_request())
&& let Err(error) = self.title_action(event_loop, action)
{
self.error = Some(error);
event_loop.exit();
return;
}
horizon_client::spans::time("server_packs", || self.tick_server_packs());
horizon_client::spans::time("control", || self.poll_control(event_loop));
if self.pending_save.is_some() {
@@ -18971,7 +19122,7 @@ impl<'a> Game<'a> {
&gui.atlas.sprites,
&gui.atlas.translucent,
);
Ok(Game {
let mut game = Game {
world_biomes: None,
world_biomes_base: None,
res: res.clone(),
@@ -18990,6 +19141,8 @@ impl<'a> Game<'a> {
world_renderer_ready: false,
collision: res.collision(),
motion: res.motion(),
health_resources: res.motion().health.clone(),
damage_tag_generation: 1,
pack,
gui,
fog: Default::default(),
@@ -19300,6 +19453,7 @@ impl<'a> Game<'a> {
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
riding: None,
started_riding: Vec::new(),
boarding_cooldown: 0,
riding_at_tick: None,
rotation_forced: false,
@@ -19326,6 +19480,7 @@ impl<'a> Game<'a> {
hoppers: HashMap::new(),
mod_screen: None,
mod_container_viewers: HashMap::new(),
mod_chunk_tickets: HashMap::new(),
item_columns: None,
creative_screen: prepared.creative_screen,
control: None,
@@ -19400,6 +19555,7 @@ impl<'a> Game<'a> {
clipboard: Default::default(),
text_selection: Default::default(),
saved_for_exit: false,
native_client_stopped: false,
pending_save: None,
frame_drawn: true,
world_icon: None,
@@ -19471,7 +19627,11 @@ impl<'a> Game<'a> {
camera_type: Default::default(),
fangs: Default::default(),
player_model: None,
})
};
if game.native_window() {
game.native_client_start();
}
Ok(game)
}
}
@@ -19812,7 +19972,11 @@ impl Game<'_> {
/// Load the world's data packs' content tables (`None`: the world has no
/// packs, and `Resources.rest` stays in use). This re-reads the tables,
/// so it runs once per world open and on `/reload`.
fn build_world_tables(&self) -> Option<WorldTables> {
fn build_world_tables(
&self,
reload: bool,
enabled_features: &[String],
) -> Result<(Option<WorldTables>, horizon_client::health::HealthResources)> {
// The ask of this load, both here and on `/reload`: a native mod
// recomputes the pack files it contributes every time.
let mut contributed = Vec::new();
@@ -19821,31 +19985,27 @@ impl Game<'_> {
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
enabled_features,
contributed,
);
if files.is_empty() {
return None;
}
let overlay = horizon_world::loot::PackOverlay { files: &files };
let root = &self.options.resources;
let items = match Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay) {
Ok(items) => items,
Err(e) => {
log::warn!("Horizon: the world's data packs are not applied: {e}");
return None;
}
let base = if reload {
&self.health_resources
} else {
&self.motion.health
};
let health = base.with_packs(root, &overlay, reload)?;
if files.is_empty() {
return Ok((None, health));
}
let mut items = Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay)?;
// Item damage predicates use the same frozen registry as player hurt.
Arc::make_mut(&mut items.enchantments).bind_damage_catalog(&health.damage);
// The recipe tables, the stations and the recipe book read one folder:
// hold its files so it is read once.
let _recipe_files = horizon_client::json_folder::read(&root.join("data/minecraft/recipe"));
let recipes = match Recipes::load(root, &items, &overlay) {
Ok(recipes) => recipes,
Err(e) => {
log::warn!("Horizon: the world's data packs are not applied: {e}");
return None;
}
};
let recipes = Recipes::load(root, &items, &overlay)?;
let stations = horizon_client::stations::StationData::load(root, &items, &overlay);
let mut data_map_types = Vec::new();
self.native_data_map_types(&mut data_map_types);
@@ -19862,24 +20022,21 @@ impl Game<'_> {
.flatten()
.cloned()
.collect();
let suffocation = match horizon_client::mobs::suffocation::bind(
let suffocation = horizon_client::mobs::suffocation::bind(
self.res.core.collision.clone(),
&causes_suffocation,
) {
Ok(binding) => binding,
Err(e) => {
log::warn!("Horizon: the world's data packs are not applied: {e}");
return None;
}
};
Some(WorldTables {
suffocation,
files,
items,
recipes,
stations,
data_maps,
})
)?;
Ok((
Some(WorldTables {
suffocation,
files,
items,
recipes,
stations,
data_maps,
}),
health,
))
}
/// Reload the content tables with the world's packs, as
/// `/reload` reloads a server's (`ReloadableServerResources`).
@@ -19887,7 +20044,17 @@ impl Game<'_> {
if self.remote.is_some() {
return;
}
self.world_tables = self.build_world_tables();
match self.build_world_tables(true, &self.level.enabled_features) {
Ok((tables, health)) => {
self.world_tables = tables;
self.health_resources = health;
}
Err(e) => {
log::warn!("Horizon: the world's data packs are not applied: {e}");
return;
}
}
self.damage_tag_generation = self.damage_tag_generation.wrapping_add(1);
self.recipe_tables.categories = None;
horizon_client::structure_blocks::forget_loot_data();
// Before the players are sent the reloaded data below.
@@ -19910,7 +20077,13 @@ impl Game<'_> {
}
}
}
None => lan::forget_sync_data(),
None => {
if let Err(e) =
lan::reload_cached_sync_data(&self.options.resources, &self.pack_files())
{
log::warn!("Horizon: the cached world data is not reloaded: {e}");
}
}
}
let overlay = pack_files(&self.world_tables);
self.adv.tree = Arc::new(horizon_client::advancements::Tree::load_with(
@@ -20942,6 +21115,7 @@ fn loading_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: &[],
@@ -20964,6 +21138,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
@@ -21096,6 +21271,12 @@ fn start_game<'a>(
Some(folder) => {
let (seed, creative) = (game.options.seed, game.options.creative);
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some() {
// The loaded-command fixture pins the server's valid minimum.
// Client options reject simulationDistance2 (their UI starts at5),
// so an options.txt value cannot establish this prerequisite.
if std::env::var_os("HORIZON_HARNESS_LOADED_FIXTURE").is_some() {
game.settings.simulation_distance = 2;
}
// Headless, but with the same server and memory connection as play.
game.open_integrated(&folder, None, Some(seed), creative, None)?;
} else {
@@ -21395,6 +21576,7 @@ impl Loading {
event_loop.exit();
return;
}
self.poll_menu_realms(event_loop);
if let Some(w) = &self.window {
if self.options.as_ref().is_some_and(|o| o.offscreen) {
self.draw();
+222 -77
View File
@@ -137,6 +137,11 @@ impl adv::World for View<'_, '_> {
fn dimension(&self) -> &str {
self.0.dim.id()
}
/// `WeatherCheck.test`'s level queries, using dimension-aware effective levels.
fn weather(&self) -> (bool, bool) {
let (rain, thunder) = self.0.weather_levels(1.0);
(f64::from(rain) > 0.2, f64::from(thunder) > 0.9)
}
fn biome(&self, p: [i32; 3]) -> String {
self.0
.world
@@ -200,15 +205,9 @@ impl adv::World for View<'_, '_> {
let l = self.0.world.light(p[0], p[1], p[2]);
i32::from((l >> 4).max(l & 15))
}
/// `CampfireBlock.isSmokeyPos`: share the beehive smoke test.
fn smokey(&self, p: [i32; 3]) -> bool {
(1..=5).any(|d| {
self.0
.res
.facts()
.get(self.0.world.block(p[0], p[1] - d, p[2]))
.name
.ends_with("campfire")
})
horizon_client::beehive::smokey(&self.0.mob_world_base(&[]), p)
}
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
let items = &self.0.res.items();
@@ -219,12 +218,10 @@ impl adv::World for View<'_, '_> {
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
"damage_type" => self
.0
.res
.stations()
.enchantments
.damage_tags
.get(tag)
.is_some_and(|t| t.contains(id)),
.health_resources
.damage
.id(id)
.is_some_and(|source| self.0.health_resources.damage.in_tag(source, tag)),
_ => false,
}
}
@@ -439,17 +436,101 @@ impl Game<'_> {
&View(self),
)
}
/// The player as a command's predicates see it: with what it looks at.
pub(super) fn player_entity(&self) -> EntityView {
self.player_entity_looking(true)
}
/// [`Self::entity_view_of`] for testing `predicate` (a predicate, a list
/// of conditions or an item modifier): the player with the level,
/// statistics, advancements and recipes it may ask for, and nothing
/// copied when it asks for none.
pub(super) fn entity_view_asked(
&self,
t: super::commands::Target,
predicate: &Value,
) -> EntityView {
self.entity_view_asking(t, adv::facts_asked_in(predicate))
}
/// [`Self::entity_view_of`] with the player's records `asked` names.
pub(super) fn entity_view_asking(
&self,
t: super::commands::Target,
asked: adv::FactsAsked,
) -> EntityView {
use super::commands::Target;
let mut view = self.entity_view_of(t);
match t {
Target::Player => view.player_facts = self.player_facts(asked),
Target::Guest(conn) => view.player_facts = self.guest_facts(conn, asked),
_ => {}
}
view
}
/// [`Self::player_facts`] of a server's player who is not in place
/// (selectors leave out the one in place): its own records, kept on the
/// hub while another acts.
fn guest_facts(&self, conn: u32, asked: adv::FactsAsked) -> adv::PlayerFacts {
let Some(host) = self.lan.as_ref() else {
return adv::PlayerFacts::default();
};
let Some(g) = host
.hub
.guest(conn)
.filter(|_| self.lan_acting != Some(conn))
else {
return adv::PlayerFacts::default();
};
let stats = (host.play.get(&conn).map(|p| &p.stats)).filter(|s| s.loaded);
adv::PlayerFacts {
level: asked.level.then_some(g.xp_level),
stats: stats.filter(|_| asked.stats).map(|s| s.values.clone()),
advancements: asked
.advancements
.then(|| (self.adv.tree.clone(), g.advancements.clone())),
recipes: asked.recipes.then(|| g.recipe_book.recipes.clone()),
}
}
/// [`Self::player_entity`] for `trigger`'s criteria: what the player
/// looks at only when one of them may ask (a ray against every entity
/// each tick otherwise).
fn player_entity_for(&self, trigger: &str) -> EntityView {
self.player_entity_looking(self.adv.tree.may_look(trigger))
/// looks at, and its level, statistics, advancements and recipes, only
/// when one of them may ask (a ray against every entity and copies of
/// the player's records otherwise), and its compound only when one of
/// them may test `nbt`.
pub(super) fn player_entity_for(&mut self, trigger: &str) -> EntityView {
let mut me = self.player_entity_for_any(&[trigger]);
if self.adv.tree.may_test_nbt(trigger) {
me.nbt = self.player_nbt();
}
me
}
/// [`Self::player_entity_for`] for the criteria of several triggers,
/// without the player's compound (building it needs `&mut self`).
pub(super) fn player_entity_for_any(&self, triggers: &[&str]) -> EntityView {
let tree = &self.adv.tree;
let mut me = self.player_entity_looking(triggers.iter().any(|t| tree.may_look(t)));
let asked = (triggers.iter()).fold(adv::FactsAsked::default(), |all, t| {
all.or(tree.player_facts_asked(t))
});
me.player_facts = self.player_facts(asked);
me
}
/// The player's level, statistics, advancements and recipes that
/// `asked` names, for `PlayerPredicate`; records not loaded stay `None`.
fn player_facts(&self, asked: adv::FactsAsked) -> adv::PlayerFacts {
if self.player.is_none() {
return adv::PlayerFacts::default();
}
adv::PlayerFacts {
level: asked.level.then_some(self.experience.level),
stats: (asked.stats && self.stats.loaded).then(|| self.stats.values.clone()),
advancements: (asked.advancements && self.adv.loaded)
.then(|| (self.adv.tree.clone(), self.adv.progress.clone())),
recipes: asked
.recipes
.then(|| self.level.recipe_book.recipes.clone()),
}
}
/// The player as predicates see it, with what it looks at when `looking`.
fn player_entity_looking(&self, looking: bool) -> EntityView {
pub(super) fn player_entity_looking(&self, looking: bool) -> EntityView {
let Some(p) = self.player.as_ref() else {
return EntityView::default();
};
@@ -497,7 +578,7 @@ impl Game<'_> {
.collect(),
gamemode: Some(self.mode.name().to_owned()),
// Vanilla looks for it inside PlayerPredicate.matches whatever the
// trigger; skipped for a tick or location whose criteria can't ask.
// trigger; skipped for a trigger whose criteria can't ask.
looking_at: looking
.then(|| self.looking_at_entity().map(Box::new))
.flatten(),
@@ -559,6 +640,24 @@ impl Game<'_> {
armor.damage = m.extra.body_damage.max(0) as u32;
item_view(&armor)
});
let passengers: Vec<EntityView> = self
.mobs
.iter()
.filter(|r| r.extra.mount == Some(m.id))
.map(|r| self.mob_entity(r))
.chain(self.rider_entity(m.id))
.collect();
// `NbtPredicate.getEntityTagToCompare`: the mob as saved, with the
// mobs riding it under `Passengers`, when a criterion may ask.
let nbt = self.adv.tree.tests_nbt().then(|| {
let uuid = self.persist.uuids.get(&m.id).copied().unwrap_or_default();
let raw = self.persist.raw.get(&m.id);
let registry = Some(self.pack.assets.registry);
let mut tag = horizon_client::entity_nbt::mob_to_nbt(m, uuid, raw, registry);
let riders = passengers.iter().filter_map(|r| r.nbt.clone()).collect();
super::persist::add_passengers(&mut tag, riders);
tag
});
EntityView {
kind: m.kind.id.clone(),
position: [m.position.x, m.position.y, m.position.z],
@@ -570,13 +669,8 @@ impl Game<'_> {
.map(|(id, value)| (id.to_owned(), value))
.into_iter()
.collect(),
passengers: self
.mobs
.iter()
.filter(|r| r.extra.mount == Some(m.id))
.map(|r| self.mob_entity(r))
.chain(self.rider_entity(m.id))
.collect(),
passengers,
nbt,
..Default::default()
}
}
@@ -891,6 +985,10 @@ impl Game<'_> {
let tree = self.adv.tree.clone();
let index = self.inventory_index();
let list = index.candidates(stacks.iter().map(|s| s.id.as_str()));
if list.is_empty() {
return;
}
let me = self.player_entity_for("minecraft:inventory_changed");
self.trigger_criteria(&tree, &list, move |c, w| {
let slots = &c["slots"];
let range = |k: &str, v: f64| {
@@ -911,7 +1009,19 @@ impl Game<'_> {
for s in &stacks {
wanted.retain(|p| !adv::item(p, s, w));
}
// `InventoryChangeTrigger.TriggerInstance`'s `player`
// (`ContextAwarePredicate`), tested by `SimpleCriterionTrigger.trigger`.
wanted.is_empty()
&& adv::conditions(
&c["player"],
&adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
},
w,
)
});
}
/// `PlayerTrigger.LOCATION` every second, and the other per-tick checks.
@@ -969,7 +1079,7 @@ impl Game<'_> {
}
/// A player condition plus one entity (`player`, `entity` keys).
pub(super) fn trigger_entity(&mut self, name: &str, key: &str, target: &EntityView) {
let me = self.player_entity();
let me = self.player_entity_for(name);
let target = target.clone();
let key = key.to_owned();
self.trigger(name, move |c, w| {
@@ -1002,27 +1112,11 @@ impl Game<'_> {
target: &EntityView,
blow: Option<&adv::DamageView>,
) {
let me = self.player_entity();
let me = self.player_entity_for(name);
let target = target.clone();
let blow = blow.cloned();
self.trigger(name, move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let ectx = adv::Entities {
this: Some(&target),
..pctx
};
let blow_ok = c["killing_blow"].is_null()
|| blow
.as_ref()
.is_some_and(|d| adv::damage_source(&c["killing_blow"], d, w, me.position));
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["entity"], &ectx, w)
&& blow_ok
adv::killed(c, &me, &target, blow.as_ref(), w)
});
}
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
@@ -1031,7 +1125,7 @@ impl Game<'_> {
victims: &[EntityView],
weapon: Option<&ItemStack>,
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:killed_by_arrow");
let victims = victims.to_vec();
let weapon = weapon.map(item_view);
self.trigger("minecraft:killed_by_arrow", move |c, w| {
@@ -1041,7 +1135,7 @@ impl Game<'_> {
/// `LevitationTrigger`: how far the player rose from where it started
/// levitating, and for how long.
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:levitation");
self.trigger("minecraft:levitation", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1132,7 +1226,7 @@ impl Game<'_> {
/// `DistanceTrigger`: the player's `player` condition, `start_position`
/// at `start`, and the `distance` from `start` to the player.
fn trigger_distance(&mut self, name: &str, start: [f64; 3]) {
let me = self.player_entity();
let me = self.player_entity_for(name);
self.trigger(name, move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1150,7 +1244,7 @@ impl Game<'_> {
/// player is now. `matches` tests `cause` only when the instance has one,
/// and a missing cause fails it.
fn trigger_fall_after_explosion(&mut self, impact: [f64; 3], cause: Option<EntityView>) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:fall_after_explosion");
self.trigger("minecraft:fall_after_explosion", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1176,7 +1270,7 @@ impl Game<'_> {
/// `EffectsChangedTrigger`: the player's `effects`, and for an effect that
/// `source` gave, that entity (`source`).
fn trigger_effects_changed(&mut self, source: Option<EntityView>) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:effects_changed");
self.trigger("minecraft:effects_changed", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1213,7 +1307,7 @@ impl Game<'_> {
if self.remote.is_some() || self.no_player_here() {
return;
}
let me = self.player_entity();
let me = self.player_entity_for("minecraft:item_durability_changed");
let item = item_view(stack);
let max = self
.res
@@ -1241,7 +1335,7 @@ impl Game<'_> {
if self.remote.is_some() || self.no_player_here() {
return;
}
let me = self.player_entity();
let me = self.player_entity_for("minecraft:using_item");
let it = item_view(stack);
self.trigger("minecraft:using_item", move |c, w| {
let pctx = adv::Entities {
@@ -1314,7 +1408,7 @@ impl Game<'_> {
victim: &EntityView,
damage: &adv::DamageView,
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:player_hurt_entity");
let (victim, damage) = (victim.clone(), damage.clone());
self.trigger("minecraft:player_hurt_entity", move |c, w| {
let pctx = adv::Entities {
@@ -1342,7 +1436,7 @@ impl Game<'_> {
direct: Option<EntityView>,
amount: f32,
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:player_hurt_entity");
self.trigger_player_hurt_entity(
victim,
&adv::DamageView {
@@ -1358,7 +1452,7 @@ impl Game<'_> {
}
/// `EntityHurtPlayerTrigger`: the player and the damage it took.
pub(super) fn trigger_entity_hurt_player(&mut self, damage: &adv::DamageView) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:entity_hurt_player");
let damage = damage.clone();
self.trigger("minecraft:entity_hurt_player", move |c, w| {
let pctx = adv::Entities {
@@ -1373,7 +1467,7 @@ impl Game<'_> {
}
/// A player condition plus one item (`item` key).
pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) {
let me = self.player_entity();
let me = self.player_entity_for(name);
let it = item_view(stack);
self.trigger(name, move |c, w| {
let pctx = adv::Entities {
@@ -1391,7 +1485,7 @@ impl Game<'_> {
stack: &ItemStack,
target: &EntityView,
) {
let me = self.player_entity();
let me = self.player_entity_for(name);
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
@@ -1407,7 +1501,11 @@ impl Game<'_> {
thrower: [i32; 4],
) {
let target = if thrower == self.acting_uuid() {
let me = self.player_entity();
// The player is the entity of both triggers.
let me = self.player_entity_for_any(&[
"minecraft:thrown_item_picked_up_by_entity",
"minecraft:thrown_item_picked_up_by_player",
]);
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", stack, &me);
me
} else if let Some(id) = self.mob_by_uuid(thrower) {
@@ -1422,7 +1520,7 @@ impl Game<'_> {
}
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:construct_beacon");
self.trigger("minecraft:construct_beacon", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1435,7 +1533,7 @@ impl Game<'_> {
}
/// `EnchantedItemTrigger`: the enchanted item and the levels it cost.
pub(super) fn trigger_enchanted_item(&mut self, stack: &ItemStack, levels: i32) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:enchanted_item");
let it = item_view(stack);
self.trigger("minecraft:enchanted_item", move |c, w| {
let pctx = adv::Entities {
@@ -1452,7 +1550,7 @@ impl Game<'_> {
/// `BrewedPotionTrigger`: the potion contents of a stack taken from a
/// brewing stand.
pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:brewed_potion");
let potion = stack.potion().map(str::to_owned);
self.trigger("minecraft:brewed_potion", move |c, w| {
let pctx = adv::Entities {
@@ -1467,7 +1565,7 @@ impl Game<'_> {
}
/// `TradeTrigger`: the villager and the item the trade gave.
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:villager_trade");
let villager = villager.clone();
let it = item_view(stack);
self.trigger("minecraft:villager_trade", move |c, w| {
@@ -1494,7 +1592,7 @@ impl Game<'_> {
hooked: &EntityView,
items: &[ItemStack],
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:fishing_rod_hooked");
let rod = item_view(rod);
let hooked = hooked.clone();
let items: Vec<ItemView> = items.iter().map(item_view).collect();
@@ -1520,7 +1618,7 @@ impl Game<'_> {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
return;
};
let me = self.player_entity();
let me = self.player_entity_for("minecraft:enter_block");
self.trigger("minecraft:enter_block", move |c, w| {
let block_ok = adv::id_matches(&c["blocks"], "block", &definition.name, w);
let state_ok = c["state"]
@@ -1539,7 +1637,7 @@ impl Game<'_> {
}
/// `SlideDownBlockTrigger`: match the actual block and its state.
pub(super) fn trigger_honey_slide(&mut self, pos: [i32; 3]) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:slide_down_block");
self.trigger("minecraft:slide_down_block", move |c, w| {
let ctx = adv::Entities {
this: Some(&me),
@@ -1554,7 +1652,7 @@ impl Game<'_> {
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
/// the shooter's context, including distance from the shooter.
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:target_hit");
let projectile = projectile.clone();
self.trigger("minecraft:target_hit", move |c, w| {
let player = adv::Entities {
@@ -1575,7 +1673,7 @@ impl Game<'_> {
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
let me = self.player_entity();
let me = self.player_entity_for(name);
let it = item_view(stack);
self.trigger(name, move |c, w| {
let ctx = adv::Entities {
@@ -1599,7 +1697,7 @@ impl Game<'_> {
self.trigger_block_use("minecraft:any_block_use", pos, Some(stack));
}
fn trigger_block_use(&mut self, name: &str, pos: [i32; 3], stack: Option<&ItemStack>) {
let me = self.player_entity();
let me = self.player_entity_for(name);
let it = stack.map(item_view);
let centre = pos.map(|c| f64::from(c) + 0.5);
self.trigger(name, move |c, w| {
@@ -1622,7 +1720,7 @@ impl Game<'_> {
/// `LightningStrikeTrigger`: the bolt, and the mobs around it that it did
/// not hit (`bystander`), each tested from this player's position.
pub(super) fn trigger_lightning_strike(&mut self, bolt: &EntityView, viewers: &[EntityView]) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:lightning_strike");
let (bolt, viewers) = (bolt.clone(), viewers.to_vec());
self.trigger("minecraft:lightning_strike", move |c, w| {
let pctx = adv::Entities {
@@ -1653,7 +1751,7 @@ impl Game<'_> {
/// `ChanneledLightningTrigger`: each victim condition matched by some
/// mob the bolt hit, from the player who channelled it.
pub(super) fn trigger_channeled_lightning(&mut self, victims: &[EntityView]) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:channeled_lightning");
let victims = victims.to_vec();
self.trigger("minecraft:channeled_lightning", move |c, w| {
let pctx = adv::Entities {
@@ -1679,7 +1777,7 @@ impl Game<'_> {
}
/// `SpearMobsTrigger`: the living entities the spear stabbed in this use.
pub(super) fn trigger_spear_mobs(&mut self, stabbed: usize) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:spear_mobs");
self.trigger("minecraft:spear_mobs", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1702,7 +1800,7 @@ impl Game<'_> {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
return;
};
let me = self.player_entity();
let me = self.player_entity_for("minecraft:bee_nest_destroyed");
let tool = tool.map(item_view).unwrap_or_default();
self.trigger("minecraft:bee_nest_destroyed", move |c, w| {
let pctx = adv::Entities {
@@ -1723,9 +1821,21 @@ impl Game<'_> {
&& adv::conditions(&c["player"], &pctx, w)
});
}
/// `Entity.startRiding`'s `sendEventAndTriggers`: `START_RIDING_TRIGGER`
/// for every `ServerPlayer` in `entityToRide.getIndirectPassengersStream()`.
pub(super) fn trigger_started_riding(&mut self, vehicle: u32) {
let mounts: Vec<(u32, Option<u32>)> =
self.mobs.iter().map(|m| (m.id, m.extra.mount)).collect();
let aboard = super::boarding::passenger_vehicles(vehicle, &mounts);
self.for_each_player(|g| {
if g.riding.is_some_and(|v| aboard.contains(&v)) {
g.trigger_player("minecraft:started_riding");
}
});
}
/// A trigger with only the player condition (slept_in_bed, started_riding).
pub(super) fn trigger_player(&mut self, name: &str) {
let me = self.player_entity();
let me = self.player_entity_for(name);
self.trigger(name, move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1743,7 +1853,7 @@ impl Game<'_> {
recipe: &str,
ingredients: &[Option<ItemStack>],
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:recipe_crafted");
let used: Vec<ItemView> = ingredients
.iter()
.map(|s| {
@@ -1772,7 +1882,7 @@ impl Game<'_> {
/// `LootTableTrigger`: a container's loot table unpacked for this player
/// (`RandomizableContainer.unpackLootTable`).
pub(super) fn trigger_loot_table(&mut self, table: &str) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:player_generates_container_loot");
let table = table.to_owned();
self.trigger("minecraft:player_generates_container_loot", move |c, w| {
let pctx = adv::Entities {
@@ -1792,7 +1902,7 @@ impl Game<'_> {
partner: &EntityView,
child: Option<&EntityView>,
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:bred_animals");
let (parent, partner, child) = (parent.clone(), partner.clone(), child.cloned());
self.trigger("minecraft:bred_animals", move |c, w| {
let ctx = |e: &EntityView| {
@@ -2229,6 +2339,41 @@ mod tests {
assert!(!tree.may_look("minecraft:tick"));
}
#[test]
fn nbt_triggers_are_the_ones_that_ask() {
let criterion = |trigger: &str, conditions: serde_json::Value| adv::Criterion {
name: "c".into(),
trigger: trigger.into(),
conditions: horizon_world::lazy_json::LazyJson::new(&conditions),
};
let nbt = serde_json::json!({"entity": [{"condition": "minecraft:entity_properties",
"entity": "this", "predicate": {"minecraft:nbt": "{Variant:1}"}}]});
let reference = serde_json::json!({"player": [{"condition": "minecraft:reference",
"name": "pack:saved"}]});
let plain = serde_json::json!({"entity": [{"condition": "minecraft:entity_properties",
"entity": "this", "predicate": {"minecraft:flags": {"is_baby": true}}}]});
let mut tree = Tree::default();
tree.index_nbt();
assert!(!tree.tests_nbt());
tree.list.push(adv::Advancement {
id: "pack:a".into(),
parent: None,
display: None,
criteria: vec![
criterion("minecraft:player_interacted_with_entity", nbt),
criterion("minecraft:location", reference),
criterion("minecraft:tame_animal", plain),
],
requirements: Vec::new(),
rewards: Default::default(),
});
tree.index_nbt();
assert!(tree.tests_nbt());
assert!(tree.may_test_nbt("minecraft:player_interacted_with_entity"));
assert!(tree.may_test_nbt("minecraft:location"));
assert!(!tree.may_test_nbt("minecraft:tame_animal"));
}
#[test]
fn toast_labels_are_yellow_or_pink() {
// The batch takes colours as `rgba`: 0xFFFF_FF00 there is cyan (#5850).
@@ -518,9 +518,10 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let (bx, by) = ((w / 2.0 - 100.0).floor(), h - FOOTER + 6.0);
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
ui::button(
ui::action_button(
batch,
atlas,
horizon_client::widgets::Action::Done,
bx,
by,
200.0,
+1
View File
@@ -670,6 +670,7 @@ impl Game<'_> {
json!({ "mode": "multiplayer", "disconnected": reason })
}
Mode::Multiplayer(_) => json!({ "mode": "multiplayer" }),
Mode::Realms(_) => json!({ "mode": "realms" }),
Mode::MultiplayerWarning { check } => {
json!({ "mode": "multiplayer-warning", "check": check })
}
+76 -9
View File
@@ -11,6 +11,44 @@ use horizon_client::{
use horizon_core::random::RandomSource;
impl Game<'_> {
/// The client's `Allay.mobInteract` runs before the server's response.
pub(super) fn predict_allay_interaction(
&mut self,
index: usize,
hand: Hand,
offered: Option<ItemStack>,
) -> bool {
use allay::interaction::{Interaction, predict};
let duplicates = offered.as_ref().is_some_and(|s| {
self.items()
.item_tags
.get("minecraft:duplicates_allays")
.is_some_and(|tag| tag.contains(&*s.item))
});
let actor = self.acting_uuid();
let Some(result) = predict(
&mut self.mobs[index],
offered.as_ref(),
hand == Hand::Main,
duplicates,
actor,
) else {
return false;
};
self.play_mob_bound_sound(index, result.sound(), 6, 2.0, 1.0, false);
match result {
Interaction::Duplicate | Interaction::Give => {
self.with_hand(hand, Self::consume_held);
}
Interaction::Take(stack) => {
// Player.addItem returns leftovers on the client without
// spawning an item entity; the server owns any dropped stack.
let _ = self.give(stack);
}
}
true
}
pub(super) fn tick_allays(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
// The playing jukeboxes, found at the first allay: no allay starts
@@ -248,8 +286,14 @@ impl Game<'_> {
3.0, 3.75, 4.0,
];
let pitch = PITCHES[self.world.random.next_int_bound(16).unwrap_or(4) as usize];
self.audio
.play("minecraft:entity.allay.item_thrown", Some(from), 1.0, pitch);
self.play_mob_bound_sound(
i,
"minecraft:entity.allay.item_thrown",
6,
1.0,
pitch,
false,
);
}
}
/// `Allay.mobInteract`: duplicate when dancing with an amethyst shard,
@@ -260,7 +304,12 @@ impl Game<'_> {
return false;
}
let at = [m.position.x, m.position.y, m.position.z];
let shard = item.is_some_and(|s| &*s.item == "minecraft:amethyst_shard");
let shard = item.is_some_and(|s| {
self.items()
.item_tags
.get("minecraft:duplicates_allays")
.is_some_and(|tag| tag.contains(&*s.item))
});
if m.extra.flag && shard && m.extra.egg_time == 0 {
let pos = at;
self.mobs[index].extra.egg_time = 6000;
@@ -272,8 +321,14 @@ impl Game<'_> {
// Allay.duplicateAllay: entity event 18, three spawnHeartParticle.
let id = self.mobs[index].id;
self.mob_event_particles(id, 18);
self.audio
.play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0);
self.play_mob_bound_sound(
index,
"minecraft:block.amethyst_block.chime",
6,
2.0,
1.0,
true,
);
self.consume_held();
return true;
}
@@ -287,8 +342,14 @@ impl Game<'_> {
m.extra.tame = true;
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
m.extra.owner = Some(acting);
self.audio
.play("minecraft:entity.allay.item_given", Some(at), 2.0, 1.0);
self.play_mob_bound_sound(
index,
"minecraft:entity.allay.item_given",
6,
2.0,
1.0,
true,
);
self.consume_held();
return true;
}
@@ -305,8 +366,14 @@ impl Game<'_> {
let carried = m.extra.allay.as_mut().and_then(|st| st.inventory.take());
m.extra.tame = false;
m.extra.owner = None;
self.audio
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
self.play_mob_bound_sound(
index,
"minecraft:entity.allay.item_taken",
6,
2.0,
1.0,
true,
);
// Allay.mobInteract ends with swing(MAIN_HAND, ...).
self.mobs[index].swing();
if let Some(stack) = carried {
@@ -22,6 +22,20 @@ pub(super) type Pos = (i32, i32, i32);
/// `Screen.extractRenderState`'s grey for the labels: `-6250336`.
pub(super) const LABEL: u32 = 0xFFA0_A0A0;
/// The job of an edit screen's footer button `id`, given the ids of its Done and Cancel.
pub(super) fn footer_action(
id: u8,
done: u8,
cancel: u8,
) -> Option<horizon_client::widgets::Action> {
use horizon_client::widgets::Action;
match id {
_ if id == done => Some(Action::Done),
_ if id == cancel => Some(Action::Cancel),
_ => None,
}
}
/// A widget of an edit screen at GUI pixels.
pub(super) enum Item {
Label {
@@ -48,6 +62,8 @@ pub(super) enum Item {
w: f32,
label: Text,
active: bool,
/// Its job on the screen, for the controller.
action: Option<horizon_client::widgets::Action>,
},
/// A multi-line message (`FittingMultiLineTextWidget`), wrapped to `w`
/// and cut to what fits `h`.
@@ -271,10 +287,14 @@ impl Game<'_> {
w: bw,
label,
active,
action,
..
} = item
{
let hover = ui::hovered(cursor, *x, *y, *bw, 20.0);
if let Some(action) = *action {
horizon_client::widgets::declare_action(action, [*x, *y, *bw, 20.0], *active);
}
ui::button(batch, atlas, *x, *y, *bw, 20.0, label, *active, hover);
}
}
@@ -638,7 +638,7 @@ impl Game<'_> {
}
if short {
self.audio.play(
"minecraft:block.conduit.ambient_short",
"minecraft:block.conduit.ambient.short",
Some(centre),
1.0,
1.0,
@@ -43,7 +43,8 @@ impl Game<'_> {
for slot in self.world.block_entity_tickers().iter().copied() {
let pos = slot.0;
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
if !self.world.block_entities.is_live(slot)
if self.world.block_entities.is_asleep(&pos)
|| !self.world.block_entities.is_live(slot)
|| !ticking(pos.0, pos.2)
|| !self.border.contains(f64::from(pos.0), f64::from(pos.2))
{
@@ -264,6 +265,10 @@ impl Game<'_> {
self.campfire_drop(pos, stack);
}
let Some(lit) = lit else {
if matches!(self.world.block_entities.get(&pos), Some(BlockEntity::Furnace(f)) if f.idle())
{
self.world.block_entities.sleep(pos);
}
return Ok(());
};
let state = self.world.block(pos.0, pos.1, pos.2);
@@ -19,7 +19,51 @@ pub(super) fn after_leaving(cooldown: i32, was: Option<u32>, now: Option<u32>) -
}
}
/// `Entity.getIndirectPassengersStream` for `vehicle`: the vehicle and every
/// vehicle riding it, directly or through another rider. `mounts` holds
/// `(vehicle, its vehicle)` for every mob.
pub(super) fn passenger_vehicles(vehicle: u32, mounts: &[(u32, Option<u32>)]) -> Vec<u32> {
let mut ids = vec![vehicle];
let mut i = 0;
while i < ids.len() {
let v = ids[i];
i += 1;
for &(id, mount) in mounts {
if mount == Some(v) && !ids.contains(&id) {
ids.push(id);
}
}
}
ids
}
impl Game<'_> {
/// `Entity.startRiding` for the acting player: the rider link, then
/// `CriteriaTriggers.START_RIDING_TRIGGER` for every server player the
/// vehicle carries.
pub(super) fn start_riding(&mut self, vehicle: u32) {
self.riding = Some(vehicle);
self.note_started_riding(vehicle);
}
/// A rider link changed on `vehicle` (`sendEventAndTriggers`). Every
/// player aboard hears the trigger; a host's turn can fire it now, while
/// a guest's turn (the hub held by its action) leaves it for
/// [`Self::flush_started_riding`] once every player is back in place.
pub(super) fn note_started_riding(&mut self, vehicle: u32) {
self.started_riding.push(vehicle);
if self.lan.is_some() && self.lan_acting.is_none() {
self.flush_started_riding();
}
}
/// Fires the queued `started_riding` triggers with every player in place.
pub(super) fn flush_started_riding(&mut self) {
for vehicle in std::mem::take(&mut self.started_riding) {
self.trigger_started_riding(vehicle);
}
}
/// `Entity.canRide` for the player acting now (the host's, or the guest
/// whose turn it is): the cooldown has elapsed, and the player did not
/// leave a vehicle since the tick began (the tick that starts the
@@ -60,4 +104,17 @@ mod tests {
fn never_negative() {
assert_eq!(tick_down(0), 0);
}
#[test]
fn started_riding_reaches_every_rider_of_the_vehicle() {
// Entity.startRiding: START_RIDING_TRIGGER for every ServerPlayer in
// entityToRide.getIndirectPassengersStream(). A mob between two
// riders is still part of that stream, and another vehicle is not.
let mounts = [(7, Some(5)), (8, Some(7)), (9, Some(4))];
let aboard = passenger_vehicles(5, &mounts);
assert!(aboard.contains(&5), "the rider on the vehicle itself");
assert!(aboard.contains(&7), "the mob in the vehicle's seat");
assert!(aboard.contains(&8), "the rider of that mob");
assert!(!aboard.contains(&9), "a mob riding another vehicle");
assert!(!aboard.contains(&6), "a mob riding nothing");
}
}
@@ -269,6 +269,7 @@ impl Game<'_> {
count += 1;
// Mob.startRiding drops its lead.
self.break_leash(mob, true, false);
self.note_started_riding(id);
}
continue;
}
+6 -71
View File
@@ -2,6 +2,8 @@
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
//! saving and `AbstractBoatRenderer` with animated paddles.
mod placement;
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
use horizon_client::entity_rules::EntityRules;
use horizon_client::{
@@ -1251,48 +1253,7 @@ impl Game<'_> {
if !self.boat_models.has(wood, chest) {
return None;
}
let (eye, dir) = self.aim()?;
let reach = self.reach();
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
// block along the ray.
let block = self.pick().map(|h| {
(
h.point,
(0..3)
.map(|a| (h.point[a] - eye[a]).powi(2))
.sum::<f64>()
.sqrt(),
)
});
let mut fluid = None;
let mut t = 0.0;
while t < reach {
let p: [f64; 3] = std::array::from_fn(|a| eye[a] + dir[a] * t);
let (x, y, z) = (
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
);
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
&& p[1] <= f64::from(y) + f64::from(h)
{
fluid = Some((fluid_hit_point(p, y, h), t));
break;
}
t += 0.05;
}
let hit = match (block, fluid) {
(Some(b), Some(w)) => {
if w.1 < b.1 {
w.0
} else {
b.0
}
}
(Some(b), None) => b.0,
(None, Some(w)) => w.0,
(None, None) => return None,
};
let hit = self.boat_item_hit()?;
let mut boat = Boat::new(
self.next_entity + 1,
Arc::from(wood),
@@ -1387,10 +1348,10 @@ impl Game<'_> {
if !self.native_may_board(self.boats[i].id) {
return true;
}
self.riding = Some(self.boats[i].id);
let id = self.boats[i].id;
self.yaw = self.boats[i].yaw;
// ServerPlayer.startRiding.
self.trigger_player("minecraft:started_riding");
// Entity.startRiding (ServerPlayer.startRiding fires the trigger).
self.start_riding(id);
true
}
/// `ContainerEntity.interactWithContainerVehicle` (`AbstractChestBoat`):
@@ -1623,36 +1584,10 @@ impl Game<'_> {
}
}
/// Where a fluid pick lands in the cell the ray sample `p` is in. Vanilla's
/// `ClipContext.Fluid.ANY` clips against the fluid shape: a full-height cell
/// (the same fluid above it) is a full cube, so the hit is the ray's own
/// point and a boat placed under water stays at the eye; a partial cell is
/// clipped to its surface.
fn fluid_hit_point(p: [f64; 3], y: i32, h: f32) -> [f64; 3] {
if h >= 1.0 {
p
} else {
[p[0], f64::from(y) + f64::from(h), p[2]]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `VoxelShape.clip` on a full-cube fluid cell keeps the true hit point
/// (a ray starting inside hits at `from + delta * 0.001`); only a partial
/// cell is clipped to its surface.
#[test]
fn a_full_height_fluid_cell_keeps_the_ray_point_and_a_partial_one_snaps_to_its_surface() {
let eye = [0.5, 3.62, 0.5];
assert_eq!(fluid_hit_point(eye, 3, 1.0), eye);
assert_eq!(
fluid_hit_point(eye, 3, 8.0 / 9.0),
[0.5, 3.0 + f64::from(8.0f32 / 9.0), 0.5]
);
}
fn boat() -> Boat {
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
}
@@ -0,0 +1,106 @@
//! BoatItem's server POV clip; deliberately separate from renderer/mining picks.
use super::{Fluid, Game};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
position::BlockPos,
};
use horizon_world::{
registry::BlockStateId,
world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world},
};
use std::io;
struct BoatItemRay<'a, 'r>(&'a Game<'r>);
impl RayWorld for BoatItemRay<'_, '_> {
type Error = io::Error;
type Block = BlockStateId;
type Fluid = Option<(f32, bool, Fluid)>;
fn block_state(&mut self, p: BlockPos) -> io::Result<BlockStateId> {
Ok(self.0.world.block(p.x, p.y, p.z))
}
fn fluid_state(&mut self, p: BlockPos) -> io::Result<Self::Fluid> {
Ok(self
.0
.fluid_height_of(p.x, p.y, p.z, None)
.map(|(h, source)| {
(
h,
source,
self.0
.res
.facts()
.get(self.0.world.block(p.x, p.y, p.z))
.fluid,
)
}))
}
fn block_shape(
&mut self,
state: BlockStateId,
p: BlockPos,
_: RayBlockMode,
) -> io::Result<VoxelShape> {
// LiquidBlock.getShape is empty. Waterlogged blocks retain their outline.
if matches!(
&*self.0.res.facts().get(state).name,
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
) {
return Ok(VoxelShape::empty());
}
let mut shape = VoxelShape::empty();
for b in self.0.outline_boxes([p.x, p.y, p.z]) {
let b: [f64; 6] = std::array::from_fn(|i| b[i] - f64::from([p.x, p.y, p.z][i % 3]));
let part = VoxelShape::from_box(Aabb::new(
Vec3::new(b[0], b[1], b[2]),
Vec3::new(b[3], b[4], b[5]),
))
.map_err(|e| io::Error::other(format!("{e:?}")))?;
shape = shape
.join(&part, ShapeOp::OR)
.map_err(|e| io::Error::other(format!("{e:?}")))?;
}
Ok(shape)
}
fn interaction_shape(&mut self, _: BlockStateId, _: BlockPos) -> io::Result<VoxelShape> {
// Pinned vanilla's registered interaction shapes are all empty;
// interaction_shapes::InteractionShapes validates that same table.
Ok(VoxelShape::empty())
}
fn fluid_is_source(&mut self, s: Self::Fluid) -> io::Result<bool> {
Ok(s.is_some_and(|s| s.1))
}
fn fluid_is_empty(&mut self, s: Self::Fluid) -> io::Result<bool> {
Ok(s.is_none())
}
fn fluid_is_water(&mut self, s: Self::Fluid) -> io::Result<bool> {
Ok(s.is_some_and(|s| s.2 == Fluid::Water))
}
fn fluid_shape(&mut self, s: Self::Fluid, _: BlockPos) -> io::Result<VoxelShape> {
match s {
None => Ok(VoxelShape::empty()),
Some((h, _, _)) => {
VoxelShape::from_box(Aabb::new(Vec3::ZERO, Vec3::new(1.0, f64::from(h), 1.0)))
.map_err(|e| io::Error::other(format!("{e:?}")))
}
}
}
}
impl Game<'_> {
/// `Item.getPlayerPOVHitResult`: float view vector, OUTLINE and Fluid.ANY.
pub(super) fn boat_item_hit(&self) -> Option<[f64; 3]> {
let [x, y, z] = self.eye_position()?;
let start = Vec3::new(x, y, z);
let dir = horizon_world::view_direction::view_vector(self.pitch, self.yaw);
let end = start.add_vector(dir.scale(self.reach()));
let hit = clip_world(
&mut BoatItemRay(self),
start,
end,
RayBlockMode::Outline,
RayFluidMode::Any,
256,
)
.ok()?;
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
}
}
+32 -6
View File
@@ -1278,6 +1278,20 @@ impl Game<'_> {
hover(x, 196.0, bw, 20.0),
);
};
let action_button = |batch: &mut HudBatch, action, x: f32, bw: f32, label: &str| {
ui::action_button(
batch,
atlas,
action,
x,
196.0,
bw,
20.0,
&Text::literal(label),
true,
hover(x, 196.0, bw, 20.0),
);
};
match book {
BookScreen::Edit {
signing: true,
@@ -1381,12 +1395,12 @@ impl Game<'_> {
&tr("book.finalizeButton", "Sign and Close"),
!title.trim().is_empty(),
);
button(
action_button(
batch,
widgets::Action::Cancel,
w / 2.0 + 2.0,
98.0,
&tr("gui.cancel", "Cancel"),
true,
);
}
BookScreen::Edit {
@@ -1464,7 +1478,13 @@ impl Game<'_> {
&tr("book.signButton", "Sign"),
true,
);
button(batch, w / 2.0 + 2.0, 98.0, &tr("gui.done", "Done"), true);
action_button(
batch,
widgets::Action::Done,
w / 2.0 + 2.0,
98.0,
&tr("gui.done", "Done"),
);
}
BookScreen::View {
pages,
@@ -1495,7 +1515,13 @@ impl Game<'_> {
// LecternScreen.createMenuControls: Done and Take Book only with
// build permission, else BookViewScreen's wide Done.
if lectern.is_some() && self.may_build() {
button(batch, w / 2.0 - 100.0, 98.0, &tr("gui.done", "Done"), true);
action_button(
batch,
widgets::Action::Done,
w / 2.0 - 100.0,
98.0,
&tr("gui.done", "Done"),
);
button(
batch,
w / 2.0 + 2.0,
@@ -1504,12 +1530,12 @@ impl Game<'_> {
true,
);
} else {
button(
action_button(
batch,
widgets::Action::Done,
(w - 200.0) / 2.0,
200.0,
&tr("gui.done", "Done"),
true,
);
}
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
@@ -91,6 +91,7 @@ impl Game<'_> {
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
}
if !lit {
self.sleep_idle_campfire(pos, false);
return Ok(());
}
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
@@ -113,9 +114,17 @@ impl Game<'_> {
Some(state),
);
}
self.sleep_idle_campfire(pos, true);
Ok(())
}
fn sleep_idle_campfire(&mut self, pos: Pos, lit: bool) {
if matches!(self.world.block_entities.get(&pos), Some(BlockEntity::Campfire(c)) if c.idle(lit))
{
self.world.block_entities.sleep(pos);
}
}
/// `CampfireRenderer`: each cooking item lies on the fire, slot `i`
/// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat
/// (90° about X), at a corner and three eighths of its size, drawn as a
+193 -26
View File
@@ -195,7 +195,6 @@ impl UiState {
/// instead of at every world opening (thousands of files).
struct ResourceIds {
recipes: Vec<String>,
damage_types: Vec<String>,
loot_tables: Vec<String>,
templates: Vec<String>,
/// `entity-types.json`'s keys, sorted (empty when the file is missing).
@@ -287,7 +286,6 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
let (timelines, markers) = overworld_timelines(root);
let ids = Arc::new(ResourceIds {
recipes: ids_under(&data.join("recipe"), "json"),
damage_types: ids_under(&data.join("damage_type"), "json"),
// `/loot ... loot <id>` must find a content mod's tables too.
loot_tables: json_ids_of_every_namespace(root, "loot_table"),
templates: templates_under(&root.join("worldgen/structure")),
@@ -549,7 +547,12 @@ impl Game<'_> {
reg.permission = permission;
let ids = resource_ids(&self.options.resources);
self.ui.registries.recipes = ids.recipes.clone();
self.ui.registries.damage_types = ids.damage_types.clone();
self.ui.registries.damage_types = self
.health_resources
.damage
.entries()
.map(|(_, d)| d.name.clone())
.collect();
self.ui.registries.loot_tables = ids.loot_tables.clone();
self.ui.registries.templates = ids.templates.clone();
self.ui.registries.sounds = self.audio.event_names();
@@ -759,6 +762,16 @@ impl Game<'_> {
/// sent); blank text discards the draft. A draft still shown unchanged
/// stays as it is.
fn keep_chat_draft(&mut self, text: &str, reason: ChatExit) {
// During PvP & HUD's chat search the box holds the query: the draft is the text the
// search put aside, whichever way the chat closed.
let aside;
let text = if self.pvp.chat_search {
self.pvp.chat_search = false;
aside = std::mem::take(&mut self.pvp.chat_search_aside);
aside.as_str()
} else {
text
};
let unchanged =
self.ui.chat_draft_shown && self.ui.chat_draft.as_ref().is_some_and(|d| d.text == text);
if should_discard_draft(reason, self.settings.file.bool("saveChatDrafts"))
@@ -814,6 +827,40 @@ impl Game<'_> {
fn leave_bed_button(width: f32, height: f32) -> (i32, i32, i32, i32) {
(width as i32 / 2 - 100, height as i32 - 40, 200, 20)
}
/// The Leave Bed button's draws. It registers as a button
/// (`widgets::record`), so the pad's and the keyboard's focus reach it (the
/// pad's starts on it) and `horizon-ctl click-button` finds it.
fn leave_bed_draws(
m: &Measure,
mouse: (i32, i32),
width: f32,
height: f32,
) -> Vec<horizon_client::screen_draw::Draw> {
let (x, y, w, h) = Self::leave_bed_button(width, height);
let label = Text::literal(horizon_client::lang::get_or(
"multiplayer.stopSleeping",
"Leave Bed",
));
horizon_client::widgets::record(
&label.plain(),
[x as f32, y as f32, w as f32, h as f32],
true,
);
let mut button = Vec::new();
horizon_client::screen_draw::button(
&mut button,
m,
&label,
x,
y,
w,
h,
true,
horizon_client::screen_draw::inside(mouse, x, y, w, h),
horizon_client::hud_overlays::now_ms(),
);
button
}
/// `Minecraft.computeChatAbilities` from the Chat option.
pub(super) fn chat_abilities(&self) -> ChatAbilities {
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
@@ -890,6 +937,17 @@ impl Game<'_> {
// while a screen is open.
let ctrl = self.has_control_down_with_quirk();
let shift = self.has_shift_down();
// PvP & HUD's chat search: Ctrl+F starts and stops it, Enter stops it (sends nothing).
if self.settings.extras.pvp.chat.search
&& match key {
KeyCode::KeyF => ctrl,
KeyCode::Enter | KeyCode::NumpadEnter => self.pvp.chat_search,
_ => false,
}
{
self.toggle_chat_search();
return;
}
match key {
// ChatScreen.keyPressed: the chat scrolls a page.
KeyCode::PageUp | KeyCode::PageDown
@@ -917,7 +975,8 @@ impl Game<'_> {
};
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
ChatAction::Close => {
// Escape: the box still holds its text, which close_chat keeps as the draft.
// Escape: the box still holds its text, which close_chat keeps as the draft
// (a search's query gives way to the text it put aside: keep_chat_draft).
self.ui.chat_screen = Some(screen);
self.close_chat(ChatExit::Intentional);
}
@@ -1063,12 +1122,29 @@ impl Game<'_> {
} else {
return;
}
let Some(style) = self.ui.chat.style_at(
let style = self.ui.chat.style_at(
&self.gui.atlas.font,
height as i32,
cursor.0 as f64,
cursor.1 as f64,
) else {
);
// PvP & HUD's copy on click: a message where the click has nothing else to do.
if self.settings.extras.pvp.chat.copy
&& !self.has_shift_down()
&& style.as_ref().is_none_or(|s| s.click.is_none())
&& let Some(message) =
self.ui
.chat
.message_at(height as i32, cursor.0 as f64, cursor.1 as f64)
{
use horizon_client::clipboard::Clipboard;
self.clipboard.set(message);
let copied =
Text::translate_with_fallback("horizon.chat.copied", "Message copied", &[]);
self.titles.set_overlay(copied, false);
return;
}
let Some(style) = style else {
return;
};
// ChatScreen.handleComponentClicked: with Shift held only an
@@ -1163,8 +1239,9 @@ impl Game<'_> {
Text::translate("chat.copy", &[]),
Text::translate("gui.no", &[]),
];
self.link_prompt_layout(url, (w, h))
.draw(batch, atlas, self.cursor, &labels);
let layout = self.link_prompt_layout(url, (w, h));
layout.declare_no(2);
layout.draw(batch, atlas, self.cursor, &labels);
}
/// The prompt's answer: the link opens on yes; either way the chat is back.
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
@@ -1206,9 +1283,47 @@ impl Game<'_> {
self.run_command(command);
}
}
/// PvP & HUD's chat search on or off. The box holds the query: what was typed is put aside
/// when the search starts and comes back when it stops.
fn toggle_chat_search(&mut self) {
let typed = self.ui.chat_screen.as_ref().map(|s| s.value().to_owned());
let next = if self.pvp.chat_search {
std::mem::take(&mut self.pvp.chat_search_aside)
} else {
self.pvp.chat_search_aside = typed.unwrap_or_default();
String::new()
};
self.pvp.chat_search = !self.pvp.chat_search;
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
screen.suggest_command(&m, &c, &next);
}
}
/// Chat lines over the HUD (focused while the chat screen is open).
pub(super) fn draw_chat(&mut self, batch: &mut HudBatch) {
let focused = self.screen == Screen::Chat;
let options = self.settings.extras.pvp.chat;
self.ui.chat.set_qol(&self.gui.atlas, options.qol());
// A search ends with its option; a chat closed some other way ends it in keep_chat_draft.
if self.pvp.chat_search && self.ui.chat_screen.is_none() {
// The chat went without closing (leaving the world): nothing to give back.
self.pvp.chat_search = false;
self.pvp.chat_search_aside.clear();
} else if self.pvp.chat_search && !options.search {
self.toggle_chat_search();
}
let query = self
.ui
.chat_screen
.as_ref()
.filter(|_| focused && self.pvp.chat_search)
.map(|s| s.value());
self.ui.chat.set_search(&self.gui.atlas, query);
self.ui.chat.search_prompt = focused && self.pvp.chat_search;
if self.ui.hide_gui && !focused {
return;
}
@@ -1290,24 +1405,7 @@ impl Game<'_> {
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
self.ui.chat_screen = Some(screen);
if in_bed {
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
let label = Text::literal(horizon_client::lang::get_or(
"multiplayer.stopSleeping",
"Leave Bed",
));
let mut button = Vec::new();
horizon_client::screen_draw::button(
&mut button,
&m,
&label,
x,
y,
w,
h,
true,
horizon_client::screen_draw::inside(mouse, x, y, w, h),
horizon_client::hud_overlays::now_ms(),
);
let button = Self::leave_bed_draws(&m, mouse, batch.width, batch.height);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
}
// The hovered message part's tooltip (`renderComponentHoverEffect`),
@@ -1897,4 +1995,73 @@ mod tests {
assert_eq!(prefilled.summonable, vec!["minecraft:pig".to_string()]);
assert_eq!(prefilled.entity_types, vec!["minecraft:pig".to_string()]);
}
/// The sleeper's chat: Leave Bed is a button target for the pad and the
/// keyboard, right above the chat box, and the D-pad steps between the
/// two.
#[test]
fn leave_bed_is_a_focus_target_above_the_chat_box() {
use super::super::key_focus;
use horizon_client::{
font::Font,
gamepad::{Button, focus_step_strict},
widgets::Kind,
};
use std::collections::HashMap;
// The missing-glyph metrics measure without game assets.
let font = Font::load(
Path::new("/nonexistent-horizon-chat-test-resources"),
&HashMap::new(),
&[],
(1, 1),
(0, 0),
horizon_client::hud::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
);
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: horizon_client::command::vanilla::game_dispatcher(),
registries: &registries,
};
let (width, height) = (427.0, 240.0);
let mut screen = ChatScreen::new(
&m,
&c,
"",
0,
width as i32,
height as i32,
ChatAbilities::new(ChatVisibility::Full),
);
screen.draw(&m, (0, 0), 0, 0);
Game::leave_bed_draws(&m, (0, 0), width, height);
horizon_client::widgets::end_frame(u64::MAX);
let targets = key_focus::drawn_targets();
let (x, y, w, h) = Game::leave_bed_button(width, height);
let bed = targets
.iter()
.copied()
.find(|t| t.rect == [x as f32, y as f32, w as f32, h as f32])
.expect("Leave Bed is a focus target");
assert_eq!(bed.kind, Kind::Button);
assert!(key_focus::keyboard_targets(targets.clone(), false).contains(&bed));
let field = targets
.iter()
.copied()
.find(|t| t.kind == Kind::Field)
.expect("the chat box is a focus target");
let rects: Vec<_> = targets.iter().map(|t| t.rect).collect();
let step =
|from: [f32; 4], button| focus_step_strict(from, button, &rects).map(|i| targets[i]);
assert_eq!(step(field.rect, Button::DPadUp), Some(bed));
assert_eq!(step(bed.rect, Button::DPadDown), Some(field));
}
}
Loaded 100 of 560 files, more files were not shown because too many files have changed in this diff. Show more